> ## Documentation Index
> Fetch the complete documentation index at: https://docs.feddi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# PENDING_PROOF & promo release

> How locked promotional credit releases. One verify call flips a PENDING_PROOF wallet to VERIFIED and resolves every deferred side-effect atomically, or none of them.

## The one rule

A `LOCKED` promotional grant becomes spendable only when the customer is `verified` and the merchant's claim-gate condition is met. Nothing else releases it.

Internalize this before you wire any cashback or enrollment path. It is the boundary that lets promotional credit accrue for an unverified walk-up customer without putting that credit at risk.

## What PENDING\_PROOF is

When a customer is not yet verified, Feddi creates a wallet in the `pending_proof` state. It is a real wallet with a deliberate split:

<Columns cols="2">
  <Card title="Actual credits flow normally" icon="banknote" horizontal="false">
    A reload (top-up) credits the actual cashable balance immediately. Money the customer paid in is theirs right away, verified or not.
  </Card>

  <Card title="Promo grants stay LOCKED" icon="lock" horizontal="false">
    Cashback and reload-bonus grants accrue as `LOCKED` promotional grants. They sit in escrow against the wallet, gated behind proof of phone ownership.
  </Card>
</Columns>

See [Money: actual vs promotional](/concepts/money-classes) for the two-class model.

<Info>
  A `pending_proof` wallet is not a stub. It holds a real reload balance and a real ledger of locked grants. The only thing it withholds is the ability to spend promotional credit.
</Info>

## The cashier signup flow

Two calls. The first creates the wallet and sends a verification link. The second proves the phone and releases everything in one transaction.

The full lifecycle: a reload credits actual balance while the bonus stays `LOCKED`, a claim before verify is denied, and verify releases the grant.

```mermaid theme={null}
sequenceDiagram
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API
    participant Cust as Customer

    POS->>Feddi: POST /enroll/initiate (phone)
    Feddi-->>POS: 200 customer_state pending_proof, verification_sent
    Feddi--)Cust: SMS verification link

    POS->>Feddi: POST /topup/confirm (reload)
    Feddi-->>POS: 200 actual balance credited, bonus LOCKED

    Note over POS: Attempt to release the bonus early
    POS->>Feddi: POST /claims (wallet still pending_proof)
    Feddi-->>POS: 403 FORBIDDEN claim-gate denies release

    Cust->>Feddi: Open SMS link, verify phone
    POS->>Feddi: POST /enroll/verify (verification_token)
    Feddi-->>POS: 200 customer_state verified, released_grants

    POS->>Feddi: POST /claims (re-drive a later accrual)
    Feddi-->>POS: 200 state released, promo balance spendable
```

<Steps>
  <Step title="Initiate enrollment">
    Call `POST /enroll/initiate` with the customer's `phone`. Feddi normalizes it to E.164, creates a `pending_proof` wallet, and sends an SMS verification link. No money moves and no POS customer id is bound yet. Both happen at verify.
  </Step>

  <Step title="Verify the phone">
    Call `POST /enroll/verify` with the signed `verification_token` from the SMS link, or the keyed `code`. Feddi flips the wallet to `verified` and resolves all deferred side-effects in one transaction.
  </Step>
</Steps>

### Step 1: initiate

`POST /enroll/initiate` returns the wallet id and confirms the SMS dispatch.

<ParamField body="phone" param-type="string" required="true">
  The customer's phone. Normalized to E.164 server-side.
</ParamField>

<ParamField body="provider_customer_id" param-type="string" required="false">
  The POS provider's customer id. Bound to the wallet at verify, not at initiate.
</ParamField>

<ParamField body="wallet_program_id" param-type="string" required="false">
  Explicit program when the customer could map to several. Omit or send `null` to use the integration's current assignment. Ambiguity returns `WALLET_PROGRAM_AMBIGUOUS` with candidate ids in `error.details.candidates`.
</ParamField>

<ParamField body="language" param-type="string" required="false">
  Preferred SMS language, for example `en` or `ar`. Defaults to the program or branch default.
</ParamField>

<CodeGroup>
  ```bash cURL wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/enroll/initiate \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: 7c1f0a4e-1b2c-4d3e-9f8a-0b1c2d3e4f50" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "1a2b3c4d-0001-4f9b-9d1e-1a2b3c4d5e6f", "occurred_at": "2026-06-05T09:40:00Z", "sent_at": "2026-06-05T09:40:01Z", "api_version": "2026-06-01" },
      "context": { "merchant_id": "11111111-1111-1111-1111-111111111111", "branch_id": "22222222-2222-2222-2222-222222222222", "terminal_id": "POS-360-0007" },
      "phone": "+97433001122"
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "wallet_user_id": "wu_aa01",
      "customer_state": "pending_proof",
      "phone": "+97433001122",
      "wallet_program_id": "wp_77",
      "verification_sent": true,
      "verification_channel": "sms",
      "verification_expires_at": "2026-06-05T10:10:00Z",
      "provider_customer_map_created": false,
      "is_new": true
    },
    "error": null,
    "meta": { "request_id": "req_en1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 422 Wallet program ambiguous theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "WALLET_PROGRAM_AMBIGUOUS",
      "message": "Phone resolves to multiple wallet programs; specify wallet_program_id.",
      "details": { "candidates": ["wp_77", "wp_91"] }
    },
    "meta": { "request_id": "req_en9", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

#### Initiate response fields

<ResponseField name="wallet_user_id" field-type="string" required="true">
  The wallet identity id.
</ResponseField>

<ResponseField name="customer_state" field-type="string" required="true">
  One of `pending_proof` (new or unverified enrollment) or `verified` (the phone was already a verified customer, an idempotent no-op).
</ResponseField>

<ResponseField name="verification_sent" field-type="boolean" required="true">
  Whether a verification SMS was dispatched. `false` on an idempotent replay that did not re-send.
</ResponseField>

<ResponseField name="verification_channel" field-type="string" required="false">
  One of `sms`, `whatsapp`, or `null`.
</ResponseField>

<ResponseField name="verification_expires_at" field-type="string" required="false">
  When the verification link expires (date-time), or `null`.
</ResponseField>

<ResponseField name="provider_customer_map_created" field-type="boolean" required="true">
  Always `false` at initiate. The POS customer id is bound at verify.
</ResponseField>

<ResponseField name="is_new" field-type="boolean" required="false">
  `true` if this call created the wallet, `false` if it already existed.
</ResponseField>

<Info>
  Resends are rate-limited: a 60-second floor between sends and a cap of 3 per rolling 24h window. Use `POST /enroll/resend`, which returns `sends_remaining_24h` and `next_send_allowed_at`. If the phone is already a verified customer, initiate is an idempotent no-op that returns `customer_state: "verified"` and does not re-send.
</Info>

### Step 2: verify resolves everything atomically

`POST /enroll/verify` decodes the signed token, guards that it is not expired, not consumed, and that its wallet is still `pending_proof`, then runs one transaction that resolves four side-effects together.

<ParamField body="verification_token" param-type="string" required="false">
  The signed single-use token from the SMS link. Provide exactly one of this or `code`, never both.
</ParamField>

<ParamField body="code" param-type="string" required="false">
  The numeric code the cashier keyed, an alternative to the link token. Exactly 6 ASCII digits, for example `482913`.
</ParamField>

<Steps>
  <Step title="Bind the POS customer id">
    Binds the POS provider customer id to the wallet so future orders auto-identify.
  </Step>

  <Step title="Bridge to the canonical wallet">
    Find-or-creates the canonical wallet, so the customer keeps their reload balance plus the now-released bonus.
  </Step>

  <Step title="Release the LOCKED grants">
    Runs the `LOCKED` grants through the claim-gate. With `verified` now satisfied, they flip `LOCKED` to `RELEASED` and become spendable promo balance.
  </Step>

  <Step title="Arm offer eligibility">
    Turns on marketing and offer eligibility for the now-verified customer.
  </Step>
</Steps>

The response reports the released grants and the post-release balance split.

<CodeGroup>
  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "wallet_user_id": "wu_aa01",
      "customer_state": "verified",
      "verified_at": "2026-06-05T09:45:02Z",
      "wallet_id": "wal_5512",
      "wallet_program_id": "wp_77",
      "provider_customer_map_created": true,
      "balance_minor": 5000,
      "promo_balance_minor": 250,
      "currency": "QAR",
      "released_grants": [
        { "promo_grant_id": "pg_bb02", "released_minor": 250, "source": "SKU_TOPUP_BONUS", "state": "RELEASED" }
      ]
    },
    "error": null,
    "meta": { "request_id": "req_ev1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 422 Token expired or already left PENDING_PROOF theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "Verification token is expired.",
      "details": {}
    },
    "meta": { "request_id": "req_ev9", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

#### Verify response fields

<ResponseField name="wallet_user_id" field-type="string" required="true">
  The wallet identity id.
</ResponseField>

<ResponseField name="customer_state" field-type="string" required="true">
  Always `verified` on success, including an idempotent replay of a prior verify.
</ResponseField>

<ResponseField name="verified_at" field-type="string" required="false">
  When verification completed (date-time).
</ResponseField>

<ResponseField name="wallet_id" field-type="string" required="false">
  The canonical wallet bridged at verify, or `null`.
</ResponseField>

<ResponseField name="provider_customer_map_created" field-type="boolean" required="false">
  Whether a new POS customer binding was created. `false` on a replay.
</ResponseField>

<ResponseField name="balance_minor" field-type="integer" required="false">
  Actual cashable balance after any escrowed credit was applied, in minor units.
</ResponseField>

<ResponseField name="promo_balance_minor" field-type="integer" required="false">
  Released promo balance after any escrowed grants were released, in minor units.
</ResponseField>

<ResponseField name="currency" field-type="string" required="false">
  ISO-4217 currency code, for example `QAR`.
</ResponseField>

<ResponseField name="released_grants" field-type="array" required="false">
  The `LOCKED` grants released by this verify, empty if none were escrowed. Each item carries `promo_grant_id`, `released_minor`, `source` (one of `CASHBACK`, `RELOAD_BONUS`, `SKU_TOPUP_BONUS`, `GATEWAY_BONUS`, `SIGNUP_BONUS`), `state`, and `expires_at`.
</ResponseField>

## Atomicity: all or nothing

None of the four side-effects resolve partially. If any step fails, the whole flip rolls back and the verification link stays consumable. You never see a half-verified customer with a bound POS id but unreleased grants, or a released grant with no canonical wallet behind it.

<Warning>
  Verify is a money path. Treat a non-`200` as "nothing happened" and retry the same call with the same `Idempotency-Key`. The customer is still `pending_proof` and the link is still good.
</Warning>

## Replay never re-releases

Verify is idempotent. A re-submit of an already-consumed token replays the original verified result with `meta.idempotency_replayed: true`. It does not release the grants a second time. Bind your logic to the response, not to the fact that you made the call. See [Idempotency & errors](/concepts/idempotency-and-errors).

## What does NOT release a grant

Two paths look like they should unlock promo and deliberately do not. Both exist so that softer identity signals never move promotional credit on their own.

<Columns cols="2">
  <Card title="An anonymous-to-identity merge" icon="git-merge" horizontal="false">
    Merging an anonymous session into a resolved customer carries grants over, but a merge never releases a `LOCKED` grant on its own. Release stays gated on `verified` plus the claim-gate.
  </Card>

  <Card title="A card-fingerprint identify" icon="credit-card" horizontal="false">
    Identifying by card fingerprint links the session, but the link is probationary (reversible) until the phone is verified. Masked card numbers can collide, so a fingerprint match never auto-acts on money.
  </Card>
</Columns>

The principle: identity resolution and money movement are strictly separated. A customer can be recognized by card or merged from an anonymous session and still hold zero spendable promo until they prove phone ownership. See [Identity & credentials](/concepts/identity-and-credentials).

## The promo-grant state machine

A promotional grant moves through a small, one-way set of states.

```text theme={null}
LOCKED ──(claim-gate: verified + merchant condition)──▶ RELEASED ──▶ CLAWED_BACK / EXPIRED
```

<ResponseField name="LOCKED" field-type="state" required="false">
  Accrued but gated. Cashback and reload-bonus grants land here while the customer is unverified. Not spendable.
</ResponseField>

<ResponseField name="RELEASED" field-type="state" required="false">
  Spendable promo balance. Reached only through the claim-gate at verify, or an ops re-drive via `POST /claims`. Spent promo-first, FIFO by expiry.
</ResponseField>

<ResponseField name="CLAWED_BACK" field-type="state" required="false">
  Reversed, for example reversing a top-up claws back the reload bonus it earned. Terminal.
</ResponseField>

<ResponseField name="EXPIRED" field-type="state" required="false">
  Past its own expiry clock, on a merchant-configured schedule independent of actual balance. Terminal.
</ResponseField>

### Re-driving a release with POST /claims

`POST /enroll/verify` invokes the claim engine inline for the common path. `POST /claims` is the explicit re-drive: use it when verification already happened but a later accrual still needs releasing (a top-up bonus that landed after the customer registered).

Resolve the customer by `wallet_user_id` or by an identity credential. The call is idempotent on the `Idempotency-Key` header and converges regardless of the order in which register, accrual, and claim arrive: a duplicate claim is a no-op replay, a claim after clawback releases `0`, and concurrent claims collapse to exactly one creditor.

<CodeGroup>
  ```bash cURL wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/claims \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: 4d5e6f70-0004-4f9b-9d1e-1a2b3c4d5e6f" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "4d5e6f70-0004-4f9b-9d1e-1a2b3c4d5e6f", "occurred_at": "2026-06-05T09:55:00Z", "sent_at": "2026-06-05T09:55:00Z", "api_version": "2026-06-01" },
      "context": { "merchant_id": "11111111-1111-1111-1111-111111111111", "branch_id": "22222222-2222-2222-2222-222222222222" },
      "wallet_user_id": "wu_aa01"
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "claim_id": "clm_01",
      "wallet_user_id": "wu_aa01",
      "state": "released",
      "customer_state": "verified",
      "wallet_id": "wal_5512",
      "released_minor": 250,
      "promo_balance_after_minor": 250,
      "currency": "QAR",
      "released_grants": [
        { "promo_grant_id": "pg_bb02", "released_minor": 250, "source": "SKU_TOPUP_BONUS", "state": "RELEASED", "expires_at": "2026-09-05T00:00:00Z" }
      ],
      "skipped_grants": []
    },
    "error": null,
    "meta": { "request_id": "req_cl1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 403 Customer not verified theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "FORBIDDEN",
      "message": "Customer is not verified; the claim-gate denies release.",
      "details": {}
    },
    "meta": { "request_id": "req_cl9", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

<ResponseField name="state" field-type="string" required="true">
  The claim-state-machine value. One of `pending` (awaiting verify or gate), `released` (grants credited), `expired` (grants lapsed before claim), or `clawed_back` (grants reversed before claim, releases `0`).
</ResponseField>

<ResponseField name="released_minor" field-type="integer" required="false">
  Total promo released by this claim, `0` when nothing was claimable, in minor units.
</ResponseField>

<ResponseField name="released_grants" field-type="array" required="false">
  Grants released by this claim. Each carries `promo_grant_id`, `released_minor`, `source`, `state`, and `expires_at`.
</ResponseField>

<ResponseField name="skipped_grants" field-type="array" required="false">
  Grants not released, each with a `promo_grant_id` and a `reason` (one of `already_clawed_back`, `expired`, `gate_not_met`, `already_released`).
</ResponseField>

## A clawback never touches the customer's cash

When a clawback hits promo that is already spent, Feddi books the spent portion as a merchant marketing loss. It does not, and cannot, pull from the customer's actual cash balance. Promotional credit is a contingent merchant cost. Actual balance is the customer's real money. The clawback respects that wall.

Clawback is `POST /grants/{id}/clawback`. It is operator-gated (API-key auth), not a cashier action. A `LOCKED` grant claws back its full amount. A `RELEASED` grant claws back only the remaining unspent amount; any already-spent portion is recorded in `spent_loss_logged`. The call is idempotent on the `Idempotency-Key` header: clawing back an already-`CLAWED_BACK` grant replays as a no-op.

<ParamField body="reason" param-type="string" required="true">
  Why the grant is being clawed back. One of `SOURCE_ORDER_VOIDED`, `SOURCE_REFUNDED`, `FRAUD`, `DUPLICATE`, `OTHER`.
</ParamField>

<ParamField body="note" param-type="string" required="false">
  Free-text note for the audit trail.
</ParamField>

<CodeGroup>
  ```bash cURL wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/grants/pg_7781/clawback \
    -H "x-api-key: $PARTNER_API_KEY" \
    -H "Idempotency-Key: 2b3c4d5e-6f70-8192-0314-253647586a7b" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "2b3c4d5e-6f70-8192-0314-253647586a7b", "occurred_at": "2026-06-05T11:10:00Z", "sent_at": "2026-06-05T11:10:01Z", "api_version": "2026-06-01" },
      "reason": "SOURCE_ORDER_VOIDED",
      "note": "POS order 5512 refunded"
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "grant_id": "pg_7781",
      "state": "CLAWED_BACK",
      "clawed_back": { "amount_minor": 600, "currency": "QAR" },
      "spent_loss_logged": { "amount_minor": 400, "currency": "QAR" },
      "clawed_at": "2026-06-05T11:10:02Z"
    },
    "error": null,
    "meta": { "request_id": "req_cc22dd33", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 400 Not in a clawback-able state theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "Grant is already EXPIRED or CLAWED_BACK.",
      "details": {}
    },
    "meta": { "request_id": "req_cc99", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

<ResponseField name="grant_id" field-type="string" required="true">
  The promotional grant id.
</ResponseField>

<ResponseField name="state" field-type="string" required="true">
  Always `CLAWED_BACK` on success.
</ResponseField>

<ResponseField name="clawed_back" field-type="object" required="true">
  The amount clawed back, as `amount_minor` plus `currency`.
</ResponseField>

<ResponseField name="spent_loss_logged" field-type="object" required="false">
  Any already-spent promotional portion booked as a merchant loss, as `amount_minor` plus `currency`. Never pulled from the customer's actual cash.
</ResponseField>

## Inspecting a customer's grants

Read the full promotional-grant ledger with `GET /customers/{customerId}/grants`. The path parameter `customerId` is the wallet identity id. Results are cursor-paginated and tenant-scoped.

<ParamField query="state" param-type="string" required="false">
  Filter by grant state. One of `LOCKED`, `RELEASED`, `CLAWED_BACK`, `EXPIRED`.
</ParamField>

<ParamField query="cursor" param-type="string" required="false">
  Opaque pagination cursor from a prior response's `next_cursor`.
</ParamField>

<ParamField query="limit" param-type="integer" required="false">
  Max grants per page. Default 50, max 100.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.feddi.io/v1/partner/customers/wu_4421/grants?state=LOCKED \
    -H "Authorization: Bearer $TERMINAL_JWT"
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "customer_id": "wu_4421",
      "grants": [
        {
          "grant_id": "pg_7781",
          "source": "CASHBACK",
          "state": "LOCKED",
          "amount": { "amount_minor": 1000, "currency": "QAR" },
          "remaining": { "amount_minor": 1000, "currency": "QAR" },
          "accrued_at": "2026-06-01T12:00:00Z",
          "expires_at": "2026-09-01T12:00:00Z",
          "source_event_ref": "pos:order:5512"
        }
      ],
      "next_cursor": null,
      "has_more": false
    },
    "error": null,
    "meta": { "request_id": "req_bb11cc22", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 404 Customer not found theme={null}
  {
    "ok": false,
    "data": null,
    "error": { "code": "NOT_FOUND", "message": "Customer not found.", "details": {} },
    "meta": { "request_id": "req_bb99", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

#### Grant fields

<ResponseField name="grant_id" field-type="string" required="true">
  The grant ledger row id.
</ResponseField>

<ResponseField name="source" field-type="string" required="true">
  What funded the grant. One of `CASHBACK`, `RELOAD_BONUS`, `SKU_TOPUP_BONUS`, `GATEWAY_BONUS`, `SIGNUP_BONUS`.
</ResponseField>

<ResponseField name="state" field-type="string" required="true">
  One of `LOCKED`, `RELEASED`, `CLAWED_BACK`, `EXPIRED`.
</ResponseField>

<ResponseField name="amount" field-type="object" required="true">
  The original grant amount, as `amount_minor` plus `currency`.
</ResponseField>

<ResponseField name="remaining" field-type="object" required="false">
  The unspent remainder, as `amount_minor` plus `currency`.
</ResponseField>

<ResponseField name="accrued_at" field-type="string" required="false">
  When the grant accrued (date-time), or `null`.
</ResponseField>

<ResponseField name="expires_at" field-type="string" required="false">
  When the grant expires unclaimed or unspent (date-time), or `null`.
</ResponseField>

<ResponseField name="source_event_ref" field-type="string" required="false">
  Server-derived reference to the finalized source event, for example `pos:order:5512`.
</ResponseField>

<Info>
  The response `meta` includes `data_completeness_score`, an opaque integer Feddi may use internally. No action required.
</Info>

## Where to go next

<Columns cols="2">
  <Card title="Money: actual vs promotional" href="/concepts/money-classes" icon="coins" horizontal="false">
    The two-class model and why locked promo is never a real-money liability.
  </Card>

  <Card title="Enrollment & signup" href="/guides/enrollment" icon="user-plus" horizontal="false">
    The initiate, verify, resend, and claims endpoints in detail.
  </Card>

  <Card title="Identity & credentials" href="/concepts/identity-and-credentials" icon="fingerprint" horizontal="false">
    Why a card-fingerprint match is probationary until the phone is verified.
  </Card>

  <Card title="Incentives" href="/guides/incentives" icon="gift" horizontal="false">
    Reading the grant ledger and managing clawback.
  </Card>
</Columns>
