Skip to main content

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:

Identify the customer

POST /identify resolves a phone (or other credential) to a CustomerContext with the customer’s balance. This call never returns a debit token.

Debit the wallet

POST /payments presents a customer credential (otp, qr, short_code, or pass_tap) and debits the wallet atomically, promo balance first, then actual balance.

Recover from insufficient funds

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

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 }`.
required
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.
required
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.
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.

Response fields

required
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.
The POS-facing customer id. null when resolution_state is not_found.
The wallet program this customer is bound to. null when not_found. An ambiguous match returns WALLET_PROGRAM_AMBIGUOUS (422) instead of this field.
The ledger id for this wallet. null when not_found or when no wallet has been created yet.
required
An opaque correlation id for this identity resolution. Persist it and echo it where a call accepts one.
The wallet balance, or null when resolution_state is not_found. The money classes are documented below.

The balance classes

Balance is split into distinct classes and is never summed into one number. See Money: actual vs promotional.
required
Real money, funded by top-ups. Spent after promotional balance is exhausted.
required
Released promotional credit, spendable now. Spent before actual_minor, FIFO by expiry.
required
Locked promotional credit, gated on an activation condition such as signup or first top-up. Not yet spendable.
required
Top-ups in flight, not yet confirmed. Informational only.
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.
not_found is HTTP 200 with resolution_state: not_found, not a 404. Render a register CTA rather than treating it as an error.

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.

Request fields

required
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.
required
Amount to debit in the currency’s minor unit (for example fils for QAR). Server-authoritative: the debit is exactly this value. Minimum 1.
required
ISO-4217 currency code, for example QAR.
Your order or receipt reference, stored for reconciliation. Maps to context.partner_session_id if absent.
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.

Response fields

required
Feddi-assigned payment id. Read it back with GET /payments/{id}.
required
One of completed (debit landed), voided (reversed), pending (async edge case only).
required
Total debited, equal to debited_promo_minor plus debited_actual_minor.
required
Portion drawn from released promo grants, FIFO by expiry. Zero if no promo was available.
required
Portion drawn from the actual (real-money) balance.
required
The wallet balance after the debit, with the same money classes returned by identify.
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.
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.

Payment errors

Inspect error.code for programmatic branching, never the HTTP status. See 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.
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.
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.

Read the shortfall

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.

Confirm the top-up

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.

Retry the payment

Re-call POST /payments with a new Idempotency-Key. The original credential was never consumed, so the retry is clean.

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.

Request fields

required
The provider that processed the charge. One of SADAD, STRIPE.
required
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.
required
ISO-4217 currency code. Returns CURRENCY_NOT_SUPPORTED (422) when the currency is not enabled for the integration.
The prepared provider session reference, when you have one.
The expected amount. The credited amount is derived server-side from the finalized provider event and reconciled against this value.

Response fields

required
The deposit transaction id.
required
One of pending, completed, failed, escrowed. completed means credited. escrowed means held for a pending_proof customer until they register.
required
Actual (cashable) balance credited, in minor units.
Promotional bonus accrued, in minor units. Zero if none.
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.
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.

Where to go next

Payments and redemption

The full debit reference: credential types, the capture leg, and the error table.

Top-up and reload bonus

Crediting actual balance, the reload bonus, and SKU top-ups.

Money: actual vs promotional

The two money classes, promo-first spend ordering, and the grant state machine.

Idempotency and errors

The dedup key and every typed error code on the money paths.