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

# Payments and redemption

> Debit a Feddi wallet with one atomic, promo-first payment call. Collect a credential, debit the balance, and read back the promo and actual split.

## Three ways to redeem

A wallet debit is one `POST /payments` call. What changes per method is the credential the customer presents; the debit that follows is identical. There are two authentication modes: a typed phone plus OTP, or a dynamic credential the wallet shows as a linked QR and short code.

**1. Phone + OTP (two-leg)**

```mermaid theme={null}
sequenceDiagram
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API
    participant Cust as Customer
    POS->>Feddi: POST /payments/otp/send (phone, amount, order ref)
    Feddi--)Cust: SMS one-time code
    Feddi-->>POS: otp_session_id (no money moves)
    Cust->>POS: reads OTP to cashier
    POS->>Feddi: POST /payments (credential.type=otp, otp_code, otp_session_id)
    Feddi-->>POS: completed, promo + actual split, balance_after
```

**2. Dynamic credential: QR or short code (no OTP)**

The wallet shows a rotating QR and a short code that are two forms of the same single-use nonce. They refresh together on the same clock, so the customer can scan the QR or, if the scan fails, read the short code out loud. Both are dynamic, so neither needs an OTP.

```mermaid theme={null}
sequenceDiagram
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API
    participant Cust as Customer
    Note over Cust: wallet app shows a rotating QR, 5-min TTL, single-use
    Cust->>POS: presents QR / barcode (scanner active)
    POS->>Feddi: POST /payments (credential.type=qr, qr_payload, amount)
    Feddi-->>POS: completed, promo + actual split, balance_after
```

**Short code (the QR's typed twin)**

```mermaid theme={null}
sequenceDiagram
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API
    participant Cust as Customer
    Note over Cust: wallet app shows a single-use short code
    Cust->>POS: reads short code to cashier
    POS->>Feddi: POST /payments (credential.type=short_code, amount)
    Feddi-->>POS: completed, promo + actual split, balance_after
```

The short\_code type returns CREDENTIAL\_TYPE\_UNSUPPORTED unless enabled for your integration; check GET /capabilities.

## Debit the wallet with one call

You debit a Feddi wallet with `POST /payments`. One call collects a customer credential, debits the balance atomically, and returns a `PaymentResult` that splits the debit into promotional and actual money.

The debit is atomic and promo-first. Feddi spends released promotional credit first (FIFO by expiry), then actual balance, in a single transaction. The two parts always sum to the amount you asked for. If the combined balance cannot cover the amount, nothing is debited and the credential is not consumed, so you can route to recovery and retry.

The base URL is `https://api.feddi.io/v1/partner`. Paths below are relative to it.

### Operations

`POST /payments` and `POST /qr/mint` and `POST /payments/{id}/capture` authenticate with the terminal-scoped JWT (`PosTerminalJWT`). `GET /payments/{id}` authenticates with the integration `x-api-key`. Refresh the terminal JWT before a money call: it has a 600 second TTL. See [Authentication](/api-reference/authentication).

<ParamField path="id" param-type="string" required="true">
  On `GET /payments/{id}` and `POST /payments/{id}/capture`, the `payment_id` returned by `POST /payments`.
</ParamField>

## Debit the wallet

`POST /payments` collects the credential, debits the balance, and returns the result in one round trip. Send the full envelope, an `Idempotency-Key` header, and a `credential` block discriminated on `credential.type`.

<ParamField header="Authorization" param-type="string" required="true">
  `Bearer <terminal-jwt>`. The POS terminal JWT you exchanged your `x-api-key` for at `POST /auth/token`. 600 second TTL.
</ParamField>

<ParamField header="Idempotency-Key" param-type="string" required="true">
  A partner-generated UUID, stable for this one logical payment and unique across different ones. A replay returns the original result byte-identical with `meta.idempotency_replayed: true`. Do not re-debit on a replay.
</ParamField>

<ParamField body="meta" param-type="object" required="true">
  Request metadata. Carries `partner_request_id` (UUID, required, a correlation id only, not the dedup basis), `api_version` (string, required), and the optional `occurred_at` and `sent_at` timestamps (RFC3339). Send both timestamps.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  Request context. Carries `merchant_id` (UUID, required, key-enforced to a merchant your credential is authorized for) plus optional `branch_id`, `terminal_id`, `cashier_id`, `partner_session_id`, and `feddi_session_id`.
</ParamField>

<ParamField body="credential" param-type="object" required="true">
  The customer credential, discriminated on `credential.type`. One credential block per call. See the credential shapes below.
</ParamField>

<ParamField body="amount_minor" param-type="integer" required="true">
  Amount to debit in the currency's minor unit (fils for QAR, halalas for SAR). 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" required="false">
  Your order or receipt reference, stored for reconciliation. Maps to `context.partner_session_id` if absent.
</ParamField>

<ParamField body="basket" param-type="object" required="false">
  Item lines for the order. Optional. Line totals are captured for reconciliation, never the debit basis: `amount_minor` is the debit. We recommend sending the lines your POS has. See the basket line fields below.
</ParamField>

### Credential types

`credential` is a discriminated union on `credential.type`. The `otp` and `qr` types are callable today. The `short_code` and `pass_tap` types are accepted by the schema but return `CREDENTIAL_TYPE_UNSUPPORTED` unless enabled for your integration. Call `GET /capabilities` and read `supported_payment_credential_types` for the set enabled for you. Adding a new credential type extends the enum and adds a validator, never a new route.

<Expandable title="otp credential">
  <ParamField body="type" param-type="string" required="true">
    The literal `otp`.
  </ParamField>

  <ParamField body="phone" param-type="string" required="true">
    Customer phone in E.164 format, used to resolve the wallet. For example `+97433001122`.
  </ParamField>

  <ParamField body="otp_code" param-type="string" required="true">
    The 6-digit OTP the customer received via SMS.
  </ParamField>

  <ParamField body="otp_session_id" param-type="string" required="true">
    The OTP session id returned when the OTP was triggered. Bound to this order, amount, and integration, so it prevents cross-order OTP replay.
  </ParamField>
</Expandable>

<Expandable title="qr credential">
  <ParamField body="type" param-type="string" required="true">
    The literal `qr`.
  </ParamField>

  <ParamField body="qr_payload" param-type="string" required="true">
    The raw QR payload string (the `feddi://` URI or signed nonce) scanned from the customer's wallet app. Mint it with `POST /qr/mint`.
  </ParamField>
</Expandable>

<Expandable title="short_code credential">
  <ParamField body="type" param-type="string" required="true">
    The literal `short_code`.
  </ParamField>

  <ParamField body="short_code" param-type="string" required="true">
    The 5-character alphanumeric short-code displayed in the customer's wallet app, for manual entry at the terminal. Returns `CREDENTIAL_TYPE_UNSUPPORTED` unless enabled for your integration.
  </ParamField>
</Expandable>

<Expandable title="pass_tap credential">
  <ParamField body="type" param-type="string" required="true">
    The literal `pass_tap`.
  </ParamField>

  <ParamField body="pass_token" param-type="string" required="true">
    NFC or contactless pass token from a pass tap. Returns `CREDENTIAL_TYPE_UNSUPPORTED` unless enabled for your integration.
  </ParamField>
</Expandable>

### Basket line fields

Each entry in `basket.lines` carries these fields. We recommend sending the ones your POS has.

<ParamField body="sku" param-type="string" required="true">
  Your stock-keeping identifier for the line.
</ParamField>

<ParamField body="quantity" param-type="number" required="true">
  Units of this line.
</ParamField>

<ParamField body="unit_price_minor" param-type="integer" required="true">
  Per-unit price in minor units.
</ParamField>

<ParamField body="extended_price_minor" param-type="integer" required="true">
  Line total in minor units (quantity times unit price, less line discount).
</ParamField>

<ParamField body="category" param-type="string" required="false">
  Merchant category for the line, for example `beverage` or `bakery`.
</ParamField>

<ParamField body="discount_minor" param-type="integer" required="false">
  Discount already applied to this line in minor units.
</ParamField>

<CodeGroup>
  ```bash cURL wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/payments \
    -H "Authorization: Bearer $TERMINAL_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": 3402,
      "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": 3402,
      "debited_promo_minor": 500,
      "debited_actual_minor": 2902,
      "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",
      "data_completeness_score": 80
    }
  }
  ```
</CodeGroup>

## The PaymentResult

A successful debit returns a `PaymentResult` inside the standard envelope. The result splits the debit into `debited_promo_minor` and `debited_actual_minor`, and the two always sum to `amount_minor`.

<ResponseField name="payment_id" field-type="string" required="true">
  Feddi-assigned payment id. Pass it to `GET /payments/{id}` for reconciliation.
</ResponseField>

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

<ResponseField name="wallet_id" field-type="string" required="true">
  The wallet that was debited.
</ResponseField>

<ResponseField name="amount_minor" field-type="integer" required="true">
  Total amount debited (promo plus actual), in minor units.
</ResponseField>

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

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

<ResponseField name="currency" field-type="string" required="true">
  ISO-4217 currency code of the debit.
</ResponseField>

<ResponseField name="order_ref" field-type="string" required="false">
  Echoes the `order_ref` you sent.
</ResponseField>

<ResponseField name="balance_after" field-type="object" required="true">
  The full balance after the debit: `actual_minor`, `promo_available_minor`, `promo_locked_minor`, `pending_topups_minor`, `currency`, and a `promo_grants` array.
</ResponseField>

<ResponseField name="completed_at" field-type="string" required="false">
  RFC3339 timestamp when the debit completed.
</ResponseField>

<Info>
  `debited_promo_minor + debited_actual_minor == amount_minor`, always. Promotional credit is spent first, so the customer's real money stays intact and promo is use-it-or-lose-it. The two money classes are never one bucket. See [Money: actual vs promotional](/concepts/money-classes).
</Info>

The `meta.data_completeness_score` field is an opaque integer Feddi may use internally. No action required.

## A v1 payment is a single atomic debit

In v1 a wallet payment is one atomic debit. There is no reservation, no held-money state, no authorized balance you capture later. The ledger debits immediately and returns the result. The `POST /payments/{id}/capture` endpoint has one callable role today: the OTP second leg described below.

## The OTP second leg (capture)

The OTP path can run in two legs when your POS separates credential collection from debit authorization. The first leg mints and sends the OTP. The customer enters it on the PIN-pad. Then `POST /payments/{id}/capture` carries the OTP and authorizes the debit.

<ParamField body="meta" param-type="object" required="true">
  Request metadata, same shape as on `POST /payments`.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  Request context, same shape as on `POST /payments`.
</ParamField>

<ParamField body="action" param-type="string" required="true">
  One of `capture` (debit the wallet and complete the payment) or `close` (release without debiting).
</ParamField>

<ParamField body="amount_minor" param-type="integer" required="false">
  Amount to capture. Must be ≤ the authorized amount. Captures the full authorized amount if absent.
</ParamField>

<ParamField body="currency" param-type="string" required="false">
  ISO-4217 currency code. Must match the authorization.
</ParamField>

A `capture` returns the same `PaymentResult` with `status: completed`. A `close` returns `status: voided`. The call is idempotent on the `Idempotency-Key` header.

<CodeGroup>
  ```bash cURL wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/payments/pay_77c1/capture \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: a1b2c3d4-0002-0002-0002-000000000002" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": {
        "partner_request_id": "a1b2c3d4-0002-0002-0002-000000000002",
        "api_version": "2026-06-01"
      },
      "context": {
        "merchant_id": "11111111-1111-1111-1111-111111111111",
        "terminal_id": "POS-360-0007",
        "partner_session_id": "ord-88812"
      },
      "action": "capture",
      "amount_minor": 3402,
      "currency": "QAR"
    }'
  ```

  ```json 400 VALIDATION_ERROR theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "Payment is not in a capturable state."
    },
    "meta": {
      "request_id": "req_cap_err",
      "idempotency_replayed": false,
      "api_version": "2026-06-01"
    }
  }
  ```
</CodeGroup>

## Mint a QR or short-code

When the customer pays from their wallet app, mint a rotating credential with `POST /qr/mint`, render the `qr_payload` as a QR code, and optionally display the `short_code` for manual entry. The POS then scans the QR and passes it back as `credential.type: qr` in `POST /payments`.

<ParamField body="meta" param-type="object" required="true">
  Request metadata, same shape as on `POST /payments`.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  Request context, same shape as on `POST /payments`.
</ParamField>

<ParamField body="customer" param-type="object" required="true">
  The customer to resolve the wallet for. Carries `credential_type` (one of `phone` or `provider_customer_id`) plus the matching `phone` or `provider_customer_id` value.
</ParamField>

<ParamField body="include_short_code" param-type="boolean" required="false">
  If true, also generate a 5-character alphanumeric short-code alongside the QR payload. Defaults to false.
</ParamField>

<ParamField body="wallet_program_id" param-type="string" required="false">
  Disambiguate when the customer maps to multiple wallet programs.
</ParamField>

The nonce is bound to the customer's wallet with a 5 minute TTL. Re-minting before the TTL expires returns a new nonce and invalidates the previous one. The QR session is single-use: a replayed nonce in `POST /payments` returns `CREDENTIAL_EXPIRED_OR_REPLAYED` (400). Mint is idempotent on the `Idempotency-Key` header: a retried mint with the same key returns the same nonce while it is within its TTL.

<ResponseField name="qr_session_id" field-type="string" required="true">
  The QR session id.
</ResponseField>

<ResponseField name="qr_payload" field-type="string" required="true">
  The full QR payload string. Encode this as a QR code in the wallet app.
</ResponseField>

<ResponseField name="short_code" field-type="string" required="false">
  5-character alphanumeric short-code, present only if `include_short_code` was true. Single-use, same TTL as the QR nonce.
</ResponseField>

<ResponseField name="expires_at" field-type="string" required="true">
  Nonce expiry time. After this, presenting the QR at the POS returns `CREDENTIAL_EXPIRED_OR_REPLAYED`.
</ResponseField>

<ResponseField name="ttl_seconds" field-type="integer" required="true">
  Seconds until the nonce expires.
</ResponseField>

<CodeGroup>
  ```bash cURL wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/qr/mint \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: a1b2c3d4-0006-0006-0006-000000000006" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": {
        "partner_request_id": "a1b2c3d4-0006-0006-0006-000000000006",
        "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"
      },
      "customer": {
        "credential_type": "phone",
        "phone": "+97433001122"
      },
      "include_short_code": true
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "qr_session_id": "qrs_dd01",
      "qr_payload": "feddi://pay?nonce=eyJhbGciOiJFUzI1NiJ9...&wallet=wal_5512",
      "short_code": "A7X3Q",
      "expires_at": "2026-06-05T10:03:00Z",
      "ttl_seconds": 300
    },
    "error": null,
    "meta": { "request_id": "req_qr1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

## Insufficient funds is a recovery path

When the wallet balance (actual plus released promo) cannot cover the amount, `POST /payments` returns `INSUFFICIENT_FUNDS` (402). Nothing is debited. The OTP or QR credential is not consumed, so the customer can retry after a reload.

```mermaid theme={null}
sequenceDiagram
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API
    POS->>Feddi: POST /payments (amount)
    Feddi-->>POS: 402 INSUFFICIENT_FUNDS (shortfall_minor), nothing debited
    Note over POS: sell a reload SKU, or take an online top-up
    POS->>Feddi: POST /topup/sku/{skuId} or POST /topup/confirm
    Feddi-->>POS: credited, balance_after
    POS->>Feddi: POST /payments (retry, fresh credential)
    Feddi-->>POS: completed
```

<CodeGroup>
  ```json 402 INSUFFICIENT_FUNDS 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>

On `INSUFFICIENT_FUNDS`, read `details.shortfall_minor` to size a reload prompt, credit the wallet, then retry the debit. To credit the wallet, confirm a top-up against a provider settlement reference with `POST /topup/confirm`. A standalone top-up entry point is not yet available, so today you confirm a top-up against a finalized provider settlement reference. For the mechanics, see [Top-up and reload bonus](/guides/topup).

## A PENDING\_PROOF wallet may not pay

A `PENDING_PROOF` wallet holds reloads but gates promo release and payment behind phone proof. A payment against one returns `FORBIDDEN` (403). Redirect the customer to enrollment to verify their phone, then retry. See [Enrollment and signup](/guides/enrollment) and [PENDING\_PROOF and promo release](/concepts/pending-proof).

## Errors

Errors are typed string codes in `error.code`, never a bare HTTP number. Branch on the code and read `error.details` for the actionable fields.

| Code                          | HTTP | Meaning                                                                                                                    | What to do                                                                    |
| ----------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `INVALID_API_KEY`             | 401  | Missing or invalid terminal JWT (expired, malformed, wrong audience).                                                      | Re-exchange your `x-api-key` at `POST /auth/token`.                           |
| `INSUFFICIENT_FUNDS`          | 402  | Actual plus released promo cannot cover the debit.                                                                         | Route to top-up. Credential not consumed, retry after reload.                 |
| `FORBIDDEN`                   | 403  | Terminal JWT not authorized for this merchant or branch, or a `PENDING_PROOF` wallet on a payment.                         | Verify tenant scope, or send the customer to enroll.                          |
| `NOT_FOUND`                   | 404  | Wallet not found for this identity in this tenant. Cross-tenant lookups collapse to 404 (no PII leak).                     | Verify the identity and ids belong to your tenant.                            |
| `VALIDATION_ERROR`            | 400  | Malformed body, expired OTP (`OTP_EXPIRED`), replayed QR (`CREDENTIAL_EXPIRED_OR_REPLAYED`), or wrong OTP (`OTP_INVALID`). | Read `details`, fix, retry with a new idempotency key.                        |
| `IDEMPOTENCY_KEY_REUSED`      | 422  | Same key, different payload.                                                                                               | Fix key generation: stable per logical payment, unique across different ones. |
| `WALLET_PROGRAM_AMBIGUOUS`    | 422  | Customer maps to multiple wallet programs.                                                                                 | Pass `wallet_program_id` from `details.candidates`.                           |
| `CREDENTIAL_TYPE_UNSUPPORTED` | 422  | The credential type is not enabled for this integration.                                                                   | Read `GET /capabilities`, use a supported type from `details`.                |
| `RATE_LIMITED`                | 429  | OTP send (3 per order in 5 minutes) or verify attempts (3 wrong attempts locks the session) exceeded.                      | Back off per the `Retry-After` header.                                        |

The full set is in [Errors](/api-reference/errors).

## Idempotency on the debit

Every `POST /payments` call carries an `Idempotency-Key` header. A retried call with the same key and same payload replays the original `PaymentResult` byte-identical with `meta.idempotency_replayed: true`: it does not re-debit. A reused key with a different payload returns `IDEMPOTENCY_KEY_REUSED` (422).

The debit defends in depth beyond the key. The OTP is consumed atomically with the debit, so a failed debit such as `INSUFFICIENT_FUNDS` leaves the OTP retryable. QR nonces are single-use. See [Idempotency and errors](/concepts/idempotency-and-errors).

<Expandable title="Why is my payment_id stable across retries">
  A retry with the same `Idempotency-Key` replays the original response, so the same `payment_id` comes back with `meta.idempotency_replayed: true`. `meta.partner_request_id` is your correlation id, not the dedup basis: a fresh `partner_request_id` with a reused key is still a replay, with no new debit.
</Expandable>

## Where to go next

<Columns cols="2">
  <Card title="Money: actual vs promotional" href="/concepts/money-classes" icon="coins">
    The two-class balance model behind the promo-first debit split.
  </Card>

  <Card title="Top-up and reload bonus" href="/guides/topup" icon="wallet">
    Crediting actual balance, the recovery path after insufficient funds.
  </Card>

  <Card title="PENDING_PROOF and promo release" href="/concepts/pending-proof" icon="lock">
    Why an unverified wallet cannot pay, and how to release it.
  </Card>

  <Card title="Idempotency and errors" href="/concepts/idempotency-and-errors" icon="repeat">
    The dedup key and typed error model that govern the debit.
  </Card>
</Columns>
