Payments & Redemption
Debit a Feddi wallet with the single atomic, promo-first payment endpoint. One credential, one explicit debit resource, never a side-effect of redeeming an offer.
One endpoint debits the wallet
You debit a Feddi wallet with POST /v1/partner/payments. It is a first-class resource, not a side-effect of redeeming an offer. One call collects a customer credential, debits the balance atomically, and returns a PaymentResult that splits the debit into promo and actual money.
The debit is atomic and promo-first. Feddi spends released promotional credit first (FIFO by expiry), then real money, 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 route to recovery and retry.
POST /payments is Beta: live and callable today on the OTP and QR credential paths. The short-code and pass-tap credentials are Planned (shape-frozen, not yet mounted). Confirm the credential types enabled for your integration at GET /v1/partner/capabilities (supported_credential_types), and the live mounted route set at GET /v1/partner/openapi. See Availability & Maturity.
Operations
This table reflects the contract. Where this page and openapi.yaml disagree, the spec wins.
| Operation | Method | Path | Availability |
|---|---|---|---|
paymentAuthorize | POST | /v1/partner/payments | Beta (OTP + QR live; short-code, pass-tap Planned) |
paymentGet | GET | /v1/partner/payments/{id} | Beta |
paymentCapture | POST | /v1/partner/payments/{id}/capture | Beta (v1 OTP second-leg; two-phase hold reserved for v2) |
qrMint | POST | /v1/partner/qr/mint | Beta |
redeemBalanceCheck | POST | /v1/partner/redeem/balance-check | Planned |
redeemVoid | POST | /v1/partner/redeem/void | Planned |
refundCreate | POST | /v1/partner/refund/create | Planned |
Most calls authenticate with the terminal-scoped JWT (PosTerminalJWT). The two integration-scoped operations, paymentGet and refundCreate, carry the integration x-api-key instead. The terminal-driven operations (paymentAuthorize, paymentCapture, qrMint, redeemBalanceCheck, redeemVoid) all use the terminal JWT. Refresh the terminal JWT before a money call: it has a 600s TTL. See Authentication.
Debit the wallet
POST /v1/partner/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.
Bearer <terminal-jwt>. The POS terminal JWT you exchanged your x-api-key for at POST /auth/token. 600s TTL.
A partner-generated UUID, stable for this one logical payment and unique across different ones. 24h TTL. A replay returns the original result byte-identical with meta.idempotency_replayed: true. Do not re-debit on a replay.
The customer credential, discriminated on credential.type: otp, qr (live), or short_code, pass_tap (Planned). One credential block per call.
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.
ISO-4217 currency code, for example QAR.
Your order or receipt reference, stored for reconciliation. Falls back to context.partner_session_id if absent.
Item lines for data maximization. Optional, but each line raises your data_completeness_score and feeds the Brain. Line totals are captured, never the debit basis: amount_minor is the debit.
The credential is a discriminated union. Adding a new credential type is an enum extension plus a validator, never a new route.
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",
"feddi_session_id": "33333333-3333-3333-3333-333333333333"
},
"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", "qty": 2, "unit_price_minor": 1500, "ext_price_minor": 3000 },
{ "sku": "PASTRY-007", "qty": 1, "unit_price_minor": 500, "ext_price_minor": 500 }
]
}
}'
{
"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",
"feddi_session_id": "33333333-3333-3333-3333-333333333333"
},
"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", "qty": 2, "unit_price_minor": 1500, "ext_price_minor": 3000 },
{ "sku": "PASTRY-007", "qty": 1, "unit_price_minor": 500, "ext_price_minor": 500 }
]
}
}
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.
Feddi-assigned payment id. Pass it to GET /payments/{id} for reconciliation or to /refund/create later.
completed (debit landed), voided (reversed), or pending (async edge case only).
Total amount debited (promo plus actual).
Portion drawn from released promo grants, spent first (FIFO by expiry). Zero if no released promo was available.
Portion drawn from the actual (real-money) balance.
The full two-class Balance after the debit (actual, released promo, locked promo, pending top-ups): actual_minor, promo_available_minor, promo_locked_minor, pending_topups_minor, currency, promo_grants.
{
"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
}
}
debited_promo_minor + debited_actual_minor == amount_minor, always. Promotional credit is spent first to keep the customer's real money intact and make promo use-it-or-lose-it. The two money classes are never one bucket. See Money: Actual vs Promotional.
v1 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 two-phase authorize → capture flow (reserve funds without debiting, capture or release later) is a card-style pre-auth hold reserved for a future payment_mode: hold in v2. Do not build against a held-money state in v1: it does not exist yet.
The two-phase hold semantics on POST /payments/{id}/capture are Planned (v2). What is Beta today is the OTP second-leg use of that same endpoint, 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 call mints and sends the OTP. The customer enters it on the PIN-pad. Then POST /v1/partner/payments/{id}/capture carries the OTP and authorizes the debit.
The action field discriminates the capture:
capture debits the wallet and completes the payment. close releases the payment without debiting.
Amount to capture, must be less than or equal to the authorized amount. Captures the full amount if absent.
A capture returns the same PaymentResult with status: completed. A close returns status: voided. The call is idempotent on the Idempotency-Key header.
Mint a QR or short-code
When the customer pays from their wallet app, mint a rotating credential with POST /v1/partner/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.
The customer to resolve the wallet for: credential_type (phone or provider_customer_id) plus the value.
If true, also generate a five-character alphanumeric short-code alongside the QR payload. Defaults to false.
The nonce is bound to the customer's wallet with a five-minute TTL. Re-minting before the TTL expires invalidates the old nonce and returns a new one. The QR session is single-use: a replayed nonce in POST /payments returns CREDENTIAL_EXPIRED_OR_REPLAYED (400).
{
"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" }
}
Insufficient funds is a recovery path, not a dead end
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. This is the highest-intent moment in the whole interaction: the customer wants to spend and cannot. Model it as a recovery path.
{
"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" }
}
On INSUFFICIENT_FUNDS, offer an inline reload anchored to the same checkout session, then retry the debit. Read details.shortfall_minor to size the prompt. For the top-up mechanics, see Top-up & Reload Bonus.
PENDING_PROOF wallets may not pay
A PENDING_PROOF wallet is a proto-wallet that 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 & Verification and The PENDING_PROOF Invariant.
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 enterprise. 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 /capabilities, use a supported type from details. |
RATE_LIMITED | 429 | OTP send (3 per order in 5 minutes) or verify attempts (3 wrong locks the session) exceeded. | Back off per the Retry-After header. |
The full set is in Error Reference.
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 & Errors.
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 (no new debit).
Planned: balance-check, void, and refund
Three operations are Planned (shape-frozen, not yet mounted). Wire them, but treat them as not-callable until they flip. Confirm at GET /capabilities.
POST /v1/partner/redeem/balance-checkis a non-debiting pre-flight that returns the two-classBalanceplus anaffordableboolean. It is advisory only: a TOCTOU window exists between it and the debit, so never use it as the sole insufficient-funds gate. The atomic gate isPOST /paymentsitself.POST /v1/partner/redeem/voidis a cashier void within a grace window. It reverses the debit and restores spent promo grants in FIFO-reverse order. Outside the window it returnsVOID_WINDOW_EXPIRED.POST /v1/partner/refund/createis a merchant-initiated refund after the grace window. It records the refund as PENDING and settles the wallet credit asynchronously within 24 to 48 hours, then fires arefund.settledwebhook.
Where to go next
Money: Actual vs Promotional
The two-class balance model behind the promo-first debit split.
Top-up & Reload Bonus
Crediting actual balance, the recovery path after insufficient funds.
The PENDING_PROOF Invariant
Why an unverified wallet cannot pay, and how to release it.
Idempotency & Errors
The dedup key and typed error model that govern the debit.