Domain GuidesPayments & Redemption

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.

OperationMethodPathAvailability
paymentAuthorizePOST/v1/partner/paymentsBeta (OTP + QR live; short-code, pass-tap Planned)
paymentGetGET/v1/partner/payments/{id}Beta
paymentCapturePOST/v1/partner/payments/{id}/captureBeta (v1 OTP second-leg; two-phase hold reserved for v2)
qrMintPOST/v1/partner/qr/mintBeta
redeemBalanceCheckPOST/v1/partner/redeem/balance-checkPlanned
redeemVoidPOST/v1/partner/redeem/voidPlanned
refundCreatePOST/v1/partner/refund/createPlanned

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.

header
Authorizationstring
Required

Bearer <terminal-jwt>. The POS terminal JWT you exchanged your x-api-key for at POST /auth/token. 600s TTL.

header
Idempotency-Keystring
Required

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.

body
credentialobject
Required

The customer credential, discriminated on credential.type: otp, qr (live), or short_code, pass_tap (Planned). One credential block per call.

body
amount_minorinteger
Required

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.

body
currencystring
Required

ISO-4217 currency code, for example QAR.

body
order_refstring

Your order or receipt reference, stored for reconciliation. Falls back to context.partner_session_id if absent.

body
basketobject

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 }
      ]
    }
  }'

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.

payment_idstring

Feddi-assigned payment id. Pass it to GET /payments/{id} for reconciliation or to /refund/create later.

statusstring

completed (debit landed), voided (reversed), or pending (async edge case only).

amount_minorinteger

Total amount debited (promo plus actual).

debited_promo_minorinteger

Portion drawn from released promo grants, spent first (FIFO by expiry). Zero if no released promo was available.

debited_actual_minorinteger

Portion drawn from the actual (real-money) balance.

balance_afterobject

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:

body
actionstring
Required

capture debits the wallet and completes the payment. close releases the payment without debiting.

body
amount_minorinteger

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.

body
customerobject
Required

The customer to resolve the wallet for: credential_type (phone or provider_customer_id) plus the value.

body
include_short_codeboolean

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.

CodeHTTPMeaningWhat to do
INVALID_API_KEY401Missing or invalid terminal JWT (expired, malformed, wrong audience).Re-exchange your x-api-key at POST /auth/token.
INSUFFICIENT_FUNDS402Actual plus released promo cannot cover the debit.Route to top-up. Credential not consumed, retry after reload.
FORBIDDEN403Terminal 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_FOUND404Wallet 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_ERROR400Malformed 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_REUSED422Same key, different payload.Fix key generation: stable per logical payment, unique across different ones.
WALLET_PROGRAM_AMBIGUOUS422Customer maps to multiple wallet programs.Pass wallet_program_id from details.candidates.
CREDENTIAL_TYPE_UNSUPPORTED422The credential type is not enabled for this integration.Read /capabilities, use a supported type from details.
RATE_LIMITED429OTP 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.

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-check is a non-debiting pre-flight that returns the two-class Balance plus an affordable boolean. 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 is POST /payments itself.
  • POST /v1/partner/redeem/void is a cashier void within a grace window. It reverses the debit and restores spent promo grants in FIFO-reverse order. Outside the window it returns VOID_WINDOW_EXPIRED.
  • POST /v1/partner/refund/create is 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 a refund.settled webhook.

Where to go next