> ## 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.

# Make your first payment

> Identify a customer by phone, debit their wallet with the promo-first split, recover from insufficient funds, and retry. The callable payment loop, end to end.

## The payment loop

This page walks one wallet payment from identification to a completed debit, with the exact request and response bodies you send and receive. The loop is three calls:

<Steps>
  <Step title="Identify the customer" icon="user-search">
    `POST /identify` resolves a phone (or other credential) to a `CustomerContext` with the customer's balance. This call never returns a debit token.
  </Step>

  <Step title="Debit the wallet" icon="credit-card">
    `POST /payments` presents a customer credential (`otp`, `qr`, `short_code`, or `pass_tap`) and debits the wallet atomically, promo balance first, then actual balance.
  </Step>

  <Step title="Recover from insufficient funds" icon="wallet">
    If the wallet cannot cover the amount, `POST /payments` returns `INSUFFICIENT_FUNDS` (402) without consuming the credential. Credit the wallet with `POST /topup/confirm`, then retry the payment.
  </Step>
</Steps>

Confirm the live, enabled operations for your integration at runtime with `GET /capabilities` and `GET /openapi`. Do not assume an endpoint or credential type is enabled because this page names it.

End to end, the loop exchanges your key for a terminal JWT, identifies the customer, confirms the amount, then debits with a credential and reads back the promo-first split.

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

    POS->>Feddi: POST /auth/token (x-api-key)
    Feddi-->>POS: terminal JWT (600s)

    POS->>Feddi: POST /identify (phone)
    Feddi-->>POS: CustomerContext + balance

    Note over POS,Cust: Confirm amount, collect credential (OTP)
    POS->>Feddi: POST /payments (credential, amount_minor)
    Feddi-->>POS: completed: debited_promo + debited_actual, balance_after
```

## Before each call: auth and the envelope

Every mutating request carries the canonical envelope (`meta` plus `context`) and an `Idempotency-Key` header. Every response is `` `{ ok, data, error, meta }` ``.

<ParamField header="Authorization" param-type="string" required="true">
  `Bearer <jwt>`, the short-lived POS terminal JWT (600 second TTL) you exchange your `x-api-key` for at `POST /auth/token`. Refresh it before a money call. See [Authentication](/api-reference/authentication).
</ParamField>

<ParamField header="Idempotency-Key" param-type="string" required="true">
  A partner-generated UUID, unique per logical operation, with a 24 hour TTL. A same-key, same-payload retry replays the original byte-identical response with `meta.idempotency_replayed: true`. A same-key, different-payload request returns `IDEMPOTENCY_KEY_REUSED` (422). See [Idempotency and errors](/concepts/idempotency-and-errors).
</ParamField>

`meta.partner_request_id` is your correlation id, not the dedup key. The `Idempotency-Key` header is the one dedup basis. There is no `meta.idempotency_key` field.

## Step 1: Identify the customer

`POST /identify` resolves a credential to a `CustomerContext`. Use this standalone endpoint for cashier lookup and balance-check panels. The credential is a discriminated union on `credential_type`: one of `phone`, `card_fingerprint`, `short_code`, `provider_customer_id`, or `qr`. Send exactly one credential field alongside `credential_type`.

<CodeGroup>
  ```bash request theme={null}
  curl https://api.feddi.io/v1/partner/identify \
    -H "Authorization: Bearer $JWT" \
    -H "Idempotency-Key: 7f3a1e20-1b2c-4d5e-8f90-a1b2c3d4e5f6" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": {
        "partner_request_id": "7f3a1e20-1b2c-4d5e-8f90-a1b2c3d4e5f6",
        "occurred_at": "2026-06-05T10:00:00Z",
        "sent_at": "2026-06-05T10:00: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",
        "cashier_id": "cashier-42"
      },
      "credential": { "credential_type": "phone", "phone": "+97433001122" }
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "resolution_state": "registered",
      "wallet_user_id": "wu-aabbccdd-1234",
      "wallet_program_id": "wp-55667788-0001",
      "wallet_id": "wlt-99001122-ffff",
      "identity_trace_id": "idt-trace-xyzabc",
      "display_name": "Ahmed Al-Rashid",
      "badge": "Gold",
      "balance": {
        "actual_minor": 15000,
        "promo_available_minor": 500,
        "promo_locked_minor": 1200,
        "pending_topups_minor": 0,
        "currency": "QAR",
        "promo_grants": [
          { "source": "CASHBACK", "state": "RELEASED", "remaining_minor": 500, "expires_at": "2026-09-05T00:00:00Z" }
        ]
      }
    },
    "error": null,
    "meta": { "request_id": "req_b2c3d4", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 422 ambiguous theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "WALLET_PROGRAM_AMBIGUOUS",
      "message": "Credential matches a customer in multiple active wallet programs.",
      "details": { "candidates": ["wp-55667788-0001", "wp-55667788-0002"] }
    },
    "meta": { "request_id": "req_amb1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

### Response fields

<ResponseField name="resolution_state" field-type="string" required="true">
  One of `registered`, `pending_proof`, `not_found`. `registered` means fully enrolled, balance readable and spendable. `pending_proof` means enrolled but not yet identity-verified: balance is visible but spend is gated. `not_found` means no wallet for this credential under this merchant.
</ResponseField>

<ResponseField name="wallet_user_id" field-type="string">
  The POS-facing customer id. `null` when `resolution_state` is `not_found`.
</ResponseField>

<ResponseField name="wallet_program_id" field-type="string">
  The wallet program this customer is bound to. `null` when `not_found`. An ambiguous match returns `WALLET_PROGRAM_AMBIGUOUS` (422) instead of this field.
</ResponseField>

<ResponseField name="wallet_id" field-type="string">
  The ledger id for this wallet. `null` when `not_found` or when no wallet has been created yet.
</ResponseField>

<ResponseField name="identity_trace_id" field-type="string" required="true">
  An opaque correlation id for this identity resolution. Persist it and echo it where a call accepts one.
</ResponseField>

<ResponseField name="balance" field-type="object">
  The wallet balance, or `null` when `resolution_state` is `not_found`. The money classes are documented below.
</ResponseField>

### The balance classes

Balance is split into distinct classes and is never summed into one number. See [Money: actual vs promotional](/concepts/money-classes).

<ResponseField name="actual_minor" field-type="integer" required="true">
  Real money, funded by top-ups. Spent after promotional balance is exhausted.
</ResponseField>

<ResponseField name="promo_available_minor" field-type="integer" required="true">
  Released promotional credit, spendable now. Spent before `actual_minor`, FIFO by expiry.
</ResponseField>

<ResponseField name="promo_locked_minor" field-type="integer" required="true">
  Locked promotional credit, gated on an activation condition such as signup or first top-up. Not yet spendable.
</ResponseField>

<ResponseField name="pending_topups_minor" field-type="integer" required="true">
  Top-ups in flight, not yet confirmed. Informational only.
</ResponseField>

<Info>
  Identify never returns a debit token. Identity resolution and money movement are strictly separated. A `card_fingerprint` link is probationary until the phone is verified, so a masked-PAN collision never auto-acts on money.
</Info>

<Tip>
  `not_found` is HTTP 200 with `resolution_state: not_found`, not a 404. Render a register CTA rather than treating it as an error.
</Tip>

<Expandable title="Handle WALLET_PROGRAM_AMBIGUOUS">
  When a credential matches a customer enrolled in multiple active wallet programs, you get `WALLET_PROGRAM_AMBIGUOUS` (422). The `error.details.candidates` array lists the candidate `wallet_program_id` values. Present a disambiguator and resend with the chosen program.
</Expandable>

## Step 2: Debit the wallet

`POST /payments` presents a customer credential and debits the wallet. There is no reservation or hold: the debit is immediate. The credential is a discriminated union on `credential.type`: one of `otp`, `qr`, `short_code`, or `pass_tap`. Adding a credential type extends the enum without changing the route.

<CodeGroup>
  ```bash request theme={null}
  curl https://api.feddi.io/v1/partner/payments \
    -H "Authorization: Bearer $JWT" \
    -H "Idempotency-Key: a1b2c3d4-0001-0001-0001-000000000001" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": {
        "partner_request_id": "a1b2c3d4-0001-0001-0001-000000000001",
        "occurred_at": "2026-06-05T10:00:00Z",
        "sent_at": "2026-06-05T10:00: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",
        "cashier_id": "cashier-42",
        "partner_session_id": "ord-88812"
      },
      "credential": {
        "type": "otp",
        "phone": "+97433001122",
        "otp_code": "483921",
        "otp_session_id": "otps_cc01"
      },
      "amount_minor": 3500,
      "currency": "QAR",
      "order_ref": "ord-88812",
      "basket": {
        "lines": [
          { "sku": "COFFEE-001", "quantity": 2, "unit_price_minor": 1500, "extended_price_minor": 3000 },
          { "sku": "PASTRY-007", "quantity": 1, "unit_price_minor": 500, "extended_price_minor": 500 }
        ]
      }
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "payment_id": "pay_77c1",
      "status": "completed",
      "wallet_id": "wal_5512",
      "amount_minor": 3500,
      "debited_promo_minor": 500,
      "debited_actual_minor": 3000,
      "currency": "QAR",
      "order_ref": "ord-88812",
      "balance_after": {
        "actual_minor": 9500,
        "promo_available_minor": 0,
        "promo_locked_minor": 200,
        "pending_topups_minor": 0,
        "currency": "QAR",
        "promo_grants": []
      },
      "completed_at": "2026-06-05T10:00:01Z"
    },
    "error": null,
    "meta": { "request_id": "req_p1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 402 insufficient theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "INSUFFICIENT_FUNDS",
      "message": "Wallet balance is insufficient for this payment.",
      "details": {
        "shortfall_minor": 1200,
        "available_actual_minor": 1800,
        "available_promo_minor": 500,
        "currency": "QAR"
      }
    },
    "meta": { "request_id": "req_p2", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

### Request fields

<ParamField body="credential" param-type="object" required="true">
  A discriminated union on `credential.type`. For `otp`, send `phone`, `otp_code`, and the `otp_session_id` returned by `POST /payments/otp/send` (the trigger leg that SMSes the code; no money moves there). For `qr`, send the rotating nonce. For `short_code`, send the 5-character code. The enabled set is reported by `GET /capabilities`. See [Identity and credentials](/concepts/identity-and-credentials).
</ParamField>

<ParamField body="amount_minor" param-type="integer" required="true">
  Amount to debit in the currency's minor unit (for example fils for QAR). Server-authoritative: the debit is exactly this value. Minimum 1.
</ParamField>

<ParamField body="currency" param-type="string" required="true">
  ISO-4217 currency code, for example `QAR`.
</ParamField>

<ParamField body="order_ref" param-type="string">
  Your order or receipt reference, stored for reconciliation. Maps to `context.partner_session_id` if absent.
</ParamField>

<ParamField body="basket" param-type="object">
  Optional basket lines. Line totals are captured but are not the debit basis; `amount_minor` is. Each line accepts `sku`, `quantity`, `unit_price_minor`, `extended_price_minor` (all required per line), plus optional `category` and `discount_minor`. Sending the lines your POS has is recommended.
</ParamField>

### Response fields

<ResponseField name="payment_id" field-type="string" required="true">
  Feddi-assigned payment id. Read it back with `GET /payments/{id}`.
</ResponseField>

<ResponseField name="status" field-type="string" required="true">
  One of `completed` (debit landed), `voided` (reversed), `pending` (async edge case only).
</ResponseField>

<ResponseField name="amount_minor" field-type="integer" required="true">
  Total debited, equal to `debited_promo_minor` plus `debited_actual_minor`.
</ResponseField>

<ResponseField name="debited_promo_minor" field-type="integer" required="true">
  Portion drawn from released promo grants, FIFO by expiry. Zero if no promo was available.
</ResponseField>

<ResponseField name="debited_actual_minor" field-type="integer" required="true">
  Portion drawn from the actual (real-money) balance.
</ResponseField>

<ResponseField name="balance_after" field-type="object" required="true">
  The wallet balance after the debit, with the same money classes returned by identify.
</ResponseField>

<Info>
  Promo-first invariant. Feddi spends `promo_available_minor` first (FIFO by expiry across all released grants), then `actual_minor`. The two parts always sum to `amount_minor`. Here `debited_promo_minor: 500` plus `debited_actual_minor: 3000` equals `amount_minor: 3500`.
</Info>

<Info>
  The OTP is consumed atomically with the debit. A failed debit does not consume the OTP, so it stays retryable. QR nonces are single-use; a replayed nonce returns `CREDENTIAL_EXPIRED_OR_REPLAYED`.
</Info>

<Expandable title="When the wallet is PENDING_PROOF">
  A `pending_proof` wallet may not pay. The call returns `FORBIDDEN` (403) because the customer's phone is not yet verified. Redirect to enrollment, where `POST /enroll/verify` releases the locked grants and flips the wallet to verified. See [PENDING\_PROOF and promo release](/concepts/pending-proof) and [Enrollment and signup](/guides/enrollment).
</Expandable>

### Payment errors

| Status | Code                          | Recovery                                                                                                                                                  |
| ------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `VALIDATION_ERROR`            | Fix the body. Covers expired OTP (`OTP_EXPIRED`), wrong OTP (`OTP_INVALID`), replayed QR nonce (`CREDENTIAL_EXPIRED_OR_REPLAYED`), or invalid short code. |
| 401    | `INVALID_API_KEY`             | Missing, invalid, or expired POS terminal JWT. Re-exchange your key.                                                                                      |
| 402    | `INSUFFICIENT_FUNDS`          | Balance below `amount_minor`. Credential not consumed. Top up and retry (Step 3), or retry with a lower amount.                                           |
| 403    | `FORBIDDEN`                   | Cross-tenant `merchant_id`, or the wallet is `pending_proof`. Verify identity before paying.                                                              |
| 404    | `NOT_FOUND`                   | No wallet for this identity in this merchant.                                                                                                             |
| 422    | `IDEMPOTENCY_KEY_REUSED`      | Same key, different payload. Use a fresh `Idempotency-Key`.                                                                                               |
| 422    | `WALLET_PROGRAM_AMBIGUOUS`    | Customer maps to multiple programs. Disambiguate, then resend.                                                                                            |
| 422    | `CREDENTIAL_TYPE_UNSUPPORTED` | Credential type not enabled. `details` lists the supported set.                                                                                           |
| 429    | `RATE_LIMITED`                | OTP send or verify limit hit (3 sends per order in 5 minutes; 3 wrong attempts lock the session). Honor `Retry-After`.                                    |

Inspect `error.code` for programmatic branching, never the HTTP status. See [Errors](/api-reference/errors).

## Step 3: Recover from insufficient funds

On `INSUFFICIENT_FUNDS` (402), the credential is not consumed. Credit the wallet, then retry the original payment with a new `Idempotency-Key`.

<Check>
  The OTP or QR credential is not consumed on `INSUFFICIENT_FUNDS`. You can retry with a lower amount, or credit the wallet and retry the original amount.
</Check>

A standalone top-up entry point is not yet available. Today, confirm a top-up against a provider settlement reference with `POST /topup/confirm`: the customer pays through your online gateway, and you confirm the settled charge to credit the wallet.

<Steps>
  <Step title="Read the shortfall" icon="search">
    Branch on `error.code === "INSUFFICIENT_FUNDS"`. Read `details.shortfall_minor`, `details.available_actual_minor`, and `details.available_promo_minor` to know how much to reload.
  </Step>

  <Step title="Confirm the top-up" icon="wallet">
    Call `POST /topup/confirm` with the provider settlement reference. The credited amount is server-authoritative, derived from the finalized provider settlement event, not from POS-submitted totals.
  </Step>

  <Step title="Retry the payment" icon="repeat">
    Re-call `POST /payments` with a new `Idempotency-Key`. The original credential was never consumed, so the retry is clean.
  </Step>
</Steps>

### Confirm the top-up

`POST /topup/confirm` credits the customer's cashable balance and accrues any configured reload bonus as a released promotional grant, in one atomic operation: both effects apply or neither does.

<CodeGroup>
  ```bash request theme={null}
  curl https://api.feddi.io/v1/partner/topup/confirm \
    -H "Authorization: Bearer $JWT" \
    -H "Idempotency-Key: f3a10000-0000-0000-0000-000000000001" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": {
        "partner_request_id": "f3a10000-0000-0000-0000-000000000001",
        "occurred_at": "2026-06-05T10:00:30Z",
        "sent_at": "2026-06-05T10:00:31Z",
        "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",
        "partner_session_id": "ord-88812"
      },
      "session_ref": "gw-sess-7f3a91",
      "provider": "SADAD",
      "provider_payment_ref": "gw-trans-0088812",
      "amount_minor": 5000,
      "currency": "QAR"
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "transaction_id": "tx_77c1",
      "status": "completed",
      "wallet_id": "wal_5512",
      "credited_minor": 5000,
      "bonus_minor": 500,
      "balance_after_minor": 12500,
      "promo_balance_after_minor": 500,
      "currency": "QAR",
      "reload_bonus": {
        "applied": true,
        "bonus_minor": 500,
        "promo_grant_id": "pg_aa01",
        "promo_grant_state": "released",
        "source": "GATEWAY_BONUS",
        "expires_at": "2026-09-05T00:00:00Z"
      }
    },
    "error": null,
    "meta": { "request_id": "req_c4d5", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

### Request fields

<ParamField body="provider" param-type="string" required="true">
  The provider that processed the charge. One of `SADAD`, `STRIPE`.
</ParamField>

<ParamField body="provider_payment_ref" param-type="string" required="true">
  The provider's settled payment reference. A secondary idempotency anchor: a duplicate provider webhook and a partner confirm carrying the same reference still credit exactly once.
</ParamField>

<ParamField body="currency" param-type="string" required="true">
  ISO-4217 currency code. Returns `CURRENCY_NOT_SUPPORTED` (422) when the currency is not enabled for the integration.
</ParamField>

<ParamField body="session_ref" param-type="string">
  The prepared provider session reference, when you have one.
</ParamField>

<ParamField body="amount_minor" param-type="integer">
  The expected amount. The credited amount is derived server-side from the finalized provider event and reconciled against this value.
</ParamField>

### Response fields

<ResponseField name="transaction_id" field-type="string" required="true">
  The deposit transaction id.
</ResponseField>

<ResponseField name="status" field-type="string" required="true">
  One of `pending`, `completed`, `failed`, `escrowed`. `completed` means credited. `escrowed` means held for a `pending_proof` customer until they register.
</ResponseField>

<ResponseField name="credited_minor" field-type="integer" required="true">
  Actual (cashable) balance credited, in minor units.
</ResponseField>

<ResponseField name="bonus_minor" field-type="integer">
  Promotional bonus accrued, in minor units. Zero if none.
</ResponseField>

<ResponseField name="reload_bonus" field-type="object">
  The accrued reload bonus, when configured: `applied`, `bonus_minor`, `promo_grant_id`, `promo_grant_state`, `source`, and `expires_at`. The bonus is promotional money: never cashable and always carries an expiry.
</ResponseField>

<Info>
  `POST /topup/confirm` credits exactly once. Confirmation flips the deposit from pending to completed atomically, so concurrent confirmations (a provider webhook and a partner confirm arriving together) still credit a single time. Idempotency is enforced on the `Idempotency-Key` header and, defensively, the `provider_payment_ref`.
</Info>

## Where to go next

<Columns cols="2">
  <Card title="Payments and redemption" href="/guides/payments" icon="credit-card">
    The full debit reference: credential types, the capture leg, and the error table.
  </Card>

  <Card title="Top-up and reload bonus" href="/guides/topup" icon="wallet">
    Crediting actual balance, the reload bonus, and SKU top-ups.
  </Card>

  <Card title="Money: actual vs promotional" href="/concepts/money-classes" icon="coins">
    The two money classes, promo-first spend ordering, and the grant state machine.
  </Card>

  <Card title="Idempotency and errors" href="/concepts/idempotency-and-errors" icon="triangle-alert">
    The dedup key and every typed error code on the money paths.
  </Card>
</Columns>
