Domain GuidesTop-up & Reload Bonus

Top-up & Reload Bonus

Credit a Feddi wallet with the two-step top-up, accrue a reload bonus as a separate promotional grant, and reverse cleanly when needed.

Overview

You top up a Feddi wallet in two steps: prepare moves no money, confirm credits the wallet. The cash goes to the merchant's bank at the gateway. Feddi credits the ledger only after confirm succeeds. Feddi never holds the money.

A top-up credits actual balance, the customer's real money. A reload bonus accrues in the same transaction as a promotional grant, a separate, lighter class that is locked, expirable, and clawback-able. The two classes never merge into one number. Read Money: Actual vs Promotional before you build this flow.

This domain mixes maturities. POST /topup/confirm and POST /topup/sku/{skuId} are Beta (callable today). The reload-bonus config endpoint is Beta. POST /topup/prepare, the wallet balance read, status polling, reverse, the provider webhook, and the phase-2 settlement surface are Planned (shape-frozen, not yet mounted). Confirm the live set at runtime with GET /capabilities and GET /openapi. The 95 Planned endpoints are provisional: designed, not implementation-verified.

The two-step model

A top-up is split so that no wallet credit ever races a gateway payment.

Prepare a provider session

POST /v1/partner/topup/prepare resolves the payment provider server-side (QAR routes to Sadad, card markets to Stripe) and returns the provider artifact to render. No money moves. Planned.

Customer pays at the gateway

The customer pays on the provider's hosted page. The cash settles to the merchant's bank, not to Feddi.

Confirm to credit the wallet

POST /v1/partner/topup/confirm flips the deposit PENDING to COMPLETED, credits actual balance, and accrues any configured reload bonus as a PromoGrant in the same $transaction. Beta.

Confirm is idempotent on the Idempotency-Key header and defensively deduped on the provider settlement reference (provider_payment_ref). A duplicate provider webhook plus a partner poll credit exactly once.

Prepare a top-up

POST /v1/partner/topup/prepare is Planned. It creates a provider session and returns the redirect or client artifact. The credited amount is server-authoritative: amount_minor and currency are the basis, and the partner never sets the final credited amount.

header
Idempotency-Keystring
Required

A partner-generated UUID. Required on every mutating top-up call. 24h TTL.

body
amount_minorinteger
Required

The top-up amount in minor units. Paired with currency.

body
currencystring
Required

ISO-4217 code. Resolves the provider. A currency with no active provider returns CURRENCY_NOT_SUPPORTED listing the supported set.

body
customerobject
Required

The identity credential (for example { "credential_type": "phone", "phone": "+97433001122" }).

body
return_urlstring

Where the gateway returns the customer after payment.

curl -X POST https://api.feddi.io/v1/partner/topup/prepare \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  -H "Idempotency-Key: 8d2f6e10-2c4a-4f9b-9d1e-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{
    "meta": { "partner_request_id": "8d2f...", "occurred_at": "2026-06-05T09:12:00Z", "sent_at": "2026-06-05T09:12:01Z", "api_version": "2026-06-01" },
    "context": { "merchant_id": "1111...", "branch_id": "2222...", "terminal_id": "POS-360-0007", "cashier_id": "cashier-42", "partner_session_id": "ord-99812" },
    "customer": { "credential_type": "phone", "phone": "+97433001122" },
    "amount_minor": 5000,
    "currency": "QAR",
    "return_url": "https://pos.example.qa/topup/return"
  }'

Confirm a top-up

POST /v1/partner/topup/confirm is Beta. It credits actual balance and accrues the reload bonus, then publishes wallet.balance_changed, topup.confirmed, and promo_grant.accrued after the transaction commits.

The reload bonus accrues as a GATEWAY_BONUS grant. Because the customer is already identified at top-up time, that grant accrues RELEASED (no claim gate). The credited amount is derived from the finalized provider event, never from POS-submitted totals.

body
session_refstring

The session_ref returned by prepare. Optional and nullable: omit it when you confirm against an existing provider settlement reference rather than a prepared session.

body
providerstring
Required

The resolved provider (for example SADAD).

body
provider_payment_refstring
Required

The provider's settlement id. Used as a defensive dedup basis so a webhook plus a poll never double-credit.

body
amount_minorinteger
Required

The paid amount in minor units, paired with currency.

curl -X POST https://api.feddi.io/v1/partner/topup/confirm \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  -H "Idempotency-Key: 9f01...-confirm" \
  -H "Content-Type: application/json" \
  -d '{
    "meta": { "partner_request_id": "f3a1...", "occurred_at": "2026-06-05T09:15:00Z", "sent_at": "2026-06-05T09:15:01Z", "api_version": "2026-06-01" },
    "context": { "merchant_id": "1111...", "branch_id": "2222...", "terminal_id": "POS-360-0007", "partner_session_id": "ord-99812" },
    "session_ref": "sadad-sess-7f3a91",
    "provider": "SADAD",
    "provider_payment_ref": "sadad-trans-0099812",
    "amount_minor": 5000,
    "currency": "QAR"
  }'

credited_minor is the actual-money credit. bonus_minor is the separate promotional grant. They are returned as distinct fields and accounted distinctly. Never sum them into one balance.

Top up mid-checkout (insufficient-funds recovery)

When a wallet pay against a session fails with INSUFFICIENT_FUNDS, you do not dead-end the customer. You offer an inline reload anchored to the same checkout session.

POST /v1/partner/checkout/sessions/{id}/topup is Planned. It is the session-scoped equivalent of prepare then confirm: pass step: "prepare" for the gateway artifact, then step: "confirm" (or let the provider webhook confirm) to credit and accrue the bonus. The prepared top-up, the basket, the identity, and the recovery pay all attach to one telemetry anchor, so the Brain learns the recovery counterfactual.

The session must be OPEN or IDENTIFIED and owned by the calling integration. A cross-tenant session resolves to 403 or 404. Crediting an unverified PENDING_PROOF identity follows the same gate as the standalone flow: actual balance credits, cashback-class promo stays LOCKED. See The PENDING_PROOF Invariant.

Cashier-sold reloads (top-up SKUs)

Some merchants sell a reload as a line item at the till (for example a "QAR 50 wallet load" SKU). Register the SKU once, then report each sale.

POST /v1/partner/topup/sku-register is Planned. It registers a top-up SKU with its wallet_credits_minor and bonus configuration.

POST /v1/partner/topup/sku/{skuId} is Beta. The POS reports that a registered SKU was sold at tender. Feddi credits the wallet by the SKU's registered wallet_credits_minor and accrues any SKU bonus as a SKU_TOPUP_BONUS grant in the same transaction. The credited amount and bonus are derived server-side from the SKU registration and the finalized POS sale, never from POS-submitted money fields. The call is idempotent on the Idempotency-Key header and on the POS order reference (pos_order_ref), so a duplicate sale report never double-credits.

GET /v1/partner/topup/skus is Planned. It lists the registered SKUs for the cashier dropdown, tenant-scoped to your integration.

If the customer is not yet a verified Feddi customer, the SKU sale creates a PENDING_PROOF proto-wallet: the customer keeps the reload (actual balance credits), and the bonus stays LOCKED behind proof-of-phone until they register.

Configure the reload bonus

POST /v1/partner/topup/reload-bonus/config is Beta. It sets the amount-banded bonus a wallet program grants on a top-up (for example "top up QAR 50+, get 10% bonus").

Each tier is { min_topup_minor, max_topup_minor?, bonus_type (PERCENTAGE | FIXED_AMOUNT), bonus_value }. Tiers must not overlap and percentages stay within 0 to 100. Config is forward-looking only: it governs future top-ups and never re-grants retroactively. The bonus it produces is the merchant's deferred-revenue marketing liability, accrued as a PromoGrant, not Feddi-custodied money.

Marked Beta because the PromoGrant primitive exists. The accrual-timing and liability-settlement semantics (whether a pre-registration bonus sits in net-payable or is held until the customer registers) are still under founder review. The tier shape is stable. Treat the surrounding settlement semantics as subject to tightening.

{
  "wallet_program_id": "wp_77",
  "currency": "QAR",
  "expiry_days": 90,
  "tiers": [
    { "min_topup_minor": 5000, "max_topup_minor": 9999, "bonus_type": "PERCENTAGE", "bonus_value": 10 },
    { "min_topup_minor": 10000, "max_topup_minor": null, "bonus_type": "PERCENTAGE", "bonus_value": 15 }
  ]
}

Read the balance

GET /v1/partner/wallet/{walletId}/balance is Planned. It returns both money classes: balance_minor (actual, cashable), promo_balance_minor (RELEASED, non-expired promotional credit), and pending_topup_minor (in-flight top-ups). Resolve the customer by wallet_id or by identity credential (phone or provider_customer_id) via query params. wallet_program_id is first-class in the response. If an identity resolves to multiple programs and none is specified, the call returns WALLET_PROGRAM_AMBIGUOUS listing the candidates. Cross-enterprise lookups resolve to NOT_FOUND so no PII leaks.

Poll an async top-up

GET /v1/partner/topup/status/{transactionId} is Planned. When the gateway confirm is asynchronous (a Sadad webhook), poll the status by transaction id or by the Idempotency-Key used at confirm. It returns pending, completed, or failed, plus the credited and bonus amounts once complete. The lookup is tenant-scoped with a 24h retention window.

Reverse a top-up

POST /v1/partner/topups/{id}/reverse is Planned and operator-gated for the pilot. It reverses a confirmed top-up (mistake, fraud, chargeback) and claws back any reload bonus it accrued, as a single idempotent compensation saga: debit the credited actual balance back out, claw back the associated PromoGrant(s), then attempt the provider refund.

Because promo may already be partially spent, the reversal resolves to one of three explicit terminal outcomes.

outcomestring

One of the terminal states below.

  • REVERSED: clean. The actual credit was debited back, the bonus was clawed back in full, and the provider refund was attempted.
  • REVERSED_WITH_PROMO_LEAK: the bonus was already spent. The spent portion is logged as a merchant marketing loss and not recovered. bonus_leaked_minor reports the amount. The customer's actual cash is never touched to cover it.
  • REVERSAL_BLOCKED_MANUAL_REVIEW: the reversal would drive the actual balance negative (or otherwise needs a human). It escalates to ops with no partial mutation.

A double-reverse replays the original terminal outcome. This is a Tier 1 money path: build it last, and only behind an operator credential.

{
  "transaction_id": "tx_77c1",
  "outcome": "REVERSED_WITH_PROMO_LEAK",
  "reversed_minor": 5000,
  "bonus_clawed_back_minor": 200,
  "bonus_leaked_minor": 300,
  "provider_refund_status": "initiated",
  "balance_after_minor": 7500,
  "currency": "QAR"
}

Provider webhook and settlement (Planned)

POST /v1/partner/topup/webhook is Planned. It is the inbound completion webhook from the payment provider (Sadad). It is a separate contract from the outbound envelope: it carries no Idempotency-Key header, deduplicates on the provider settlement id (provider, provider_payment_ref) plus a transaction lock, verifies an RSA signature, filters by enterprise server-side before crediting, and returns the provider's literal ACK (Sadad expects success). It is not wrapped in the standard partner response shape.

Two phase-2 reconciliation surfaces are also Planned: POST /v1/partner/topup/settlement-report (ingest the provider's settlement report) and POST /v1/partner/topup/webhook/reconciliation (the inbound daily-settlement webhook). Neither is in day-1 scope.

Operations

OperationPathMethodAvailability
Prepare top-up/topup/preparePOSTPlanned
Confirm top-up/topup/confirmPOSTBeta
Top up mid-checkout/checkout/sessions/{id}/topupPOSTPlanned
Register top-up SKU/topup/sku-registerPOSTPlanned
Report SKU sale/topup/sku/{skuId}POSTBeta
List top-up SKUs/topup/skusGETPlanned
Set reload-bonus config/topup/reload-bonus/configPOSTBeta
Read wallet balance/wallet/{walletId}/balanceGETPlanned
Poll top-up status/topup/status/{transactionId}GETPlanned
Reverse top-up/topups/{id}/reversePOSTPlanned (operator-gated)
Provider webhook/topup/webhookPOSTPlanned (provider-shaped)
Ingest settlement report/topup/settlement-reportPOSTPlanned (phase 2)
Reconciliation webhook/topup/webhook/reconciliationPOSTPlanned (phase 2)

This table reflects the contract at authoring time. The live, mounted set is whatever GET /capabilities and GET /openapi return at runtime. Always read them before assuming a Planned operation is callable.

Where to go next