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

# Top-up and reload bonus

> Credit a Feddi wallet from an online gateway or a cashier-sold SKU, accrue a reload bonus as a separate promotional grant, and keep both money classes distinct.

## Overview

A top-up credits a customer's wallet with actual, spendable money. Feddi credits the ledger after the payment settles; Feddi never holds the cash. You can credit a wallet two ways:

* Confirm an online-gateway top-up with `POST /topup/confirm`.
* Report a cashier-sold reload SKU with `POST /topup/sku/{skuId}`.

Each top-up can accrue a reload bonus. The bonus is a separate, lighter money class: a promotional grant that is locked, expirable, and clawback-able. It never merges into the actual balance. Read [Money: actual vs promotional](/concepts/money-classes) before you build this flow.

<Info>
  Money is always a minor-units integer paired with an explicit ISO-4217 currency (for example `amount_minor: 5000`, `currency: QAR`). Call `GET /capabilities` and `GET /openapi` at runtime for the authoritative list of operations, currencies, and credential types available to your integration.
</Info>

## How a top-up settles

<Steps>
  <Step title="The customer pays" icon="credit-card">
    For an online top-up, the customer pays on the provider's hosted page and the cash settles to the merchant's bank. For a cashier-sold reload, the customer pays the reload SKU at the till.
  </Step>

  <Step title="You report the settled payment" icon="wallet">
    Call `POST /topup/confirm` (online) or `POST /topup/sku/{skuId}` (SKU). Feddi credits the actual balance and accrues any configured reload bonus as a promotional grant in the same atomic transaction. Both effects apply together or neither does.
  </Step>

  <Step title="Feddi returns both money classes" icon="receipt">
    The response carries `credited_minor` (actual money) and `bonus_minor` (promotional grant) as distinct fields, plus the balances after the credit.
  </Step>
</Steps>

The two settlement paths look like this:

**Online gateway top-up (e.g. Sadad)**

```mermaid theme={null}
sequenceDiagram
    participant Cust as Customer
    participant GW as Sadad Gateway
    participant POS as POS / Partner
    participant Feddi as Feddi Partner API
    Cust->>GW: pays on the hosted page
    GW-->>POS: settled payment reference (provider_payment_ref)
    POS->>Feddi: POST /topup/confirm (provider=SADAD, ref, amount)
    Feddi-->>POS: completed, credited_minor + bonus_minor, balance_after
```

**Cashier-sold reload SKU**

```mermaid theme={null}
sequenceDiagram
    participant Cust as Customer
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API
    Cust->>POS: buys a reload SKU (e.g. "QAR 50 load") and gives phone
    Note over POS: POS finalizes the order
    POS->>Feddi: POST /topup/sku/{skuId} (pos_order_ref, customer phone)
    Feddi-->>POS: credited_minor + bonus_minor (SKU_TOPUP_BONUS), balance_after
```

A standalone top-up entry point that resolves a provider session before payment is not yet available. Today, confirm a top-up against a settled provider payment reference (`provider_payment_ref`), or report a cashier-sold SKU sale against a finalized POS order reference (`pos_order_ref`).

## Idempotency

Both write endpoints are idempotent on the `Idempotency-Key` header, a partner-generated UUID with a 24-hour TTL. Send it on every mutating call.

* Same key, same payload: byte-identical replay. `meta.idempotency_replayed` is `true`.
* Same key, different payload: the call returns `IDEMPOTENCY_KEY_REUSED` (HTTP 422).

`POST /topup/confirm` deduplicates a second way: on the provider settlement reference (`provider_payment_ref`). A duplicate provider notification plus a partner retry credit the wallet exactly once.

`POST /topup/sku/{skuId}` deduplicates a second way: on the POS order reference (`pos_order_ref`). A duplicate sale report never double-credits.

## Confirm an online-gateway top-up

`POST /topup/confirm` credits the customer's actual balance and accrues any configured reload bonus, then settles both in one atomic transaction. The credited amount is server-authoritative: Feddi derives it from the finalized provider settlement event and reconciles it against the `amount_minor` you send. Because the customer is already identified at top-up time, the reload bonus accrues directly to `released` with no claim gate.

Authenticate with a POS terminal JWT.

### Request

<ParamField header="Idempotency-Key" param-type="string" required="true">
  Partner-generated UUID. The dedup key for the call. 24-hour TTL.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  The request context. `merchant_id` is required and is enforced against the credential's authority; a `merchant_id` outside that authority is rejected. Optional members: `branch_id`, `terminal_id`, `cashier_id`, `partner_session_id`.
</ParamField>

<ParamField body="provider" param-type="string" required="true">
  The provider that processed the charge. One of `SADAD`, `STRIPE`.
</ParamField>

<ParamField body="provider_payment_ref" param-type="string" required="true">
  The provider's settled payment reference (for example a Stripe PaymentIntent id or a Sadad transaction number). Used as a secondary idempotency anchor.
</ParamField>

<ParamField body="currency" param-type="string" required="true">
  ISO-4217 code, 3 characters. A currency with no active provider returns `CURRENCY_NOT_SUPPORTED`.
</ParamField>

<ParamField body="amount_minor" param-type="integer" required="false">
  Expected paid amount in minor units, minimum `1`. The credited amount is derived server-side from the finalized provider event and reconciled against this value. Nullable.
</ParamField>

<ParamField body="session_ref" param-type="string" required="false">
  A prepared provider session reference, when you have one. Nullable; omit it to confirm against `provider_payment_ref` alone.
</ParamField>

<ParamField body="meta" param-type="object" required="false">
  Request metadata. `partner_request_id` (UUID, correlation only, not the dedup key) and `api_version` are required when `meta` is present. Optional `occurred_at` and `sent_at` timestamps (RFC3339); send both when you have them.
</ParamField>

### Response

<ResponseField name="transaction_id" field-type="string" required="true">
  The deposit transaction id.
</ResponseField>

<ResponseField name="status" field-type="string" required="true">
  One of `pending`, `completed`, `failed`, `escrowed`. `completed` means the wallet was credited. `escrowed` means the credit is held for an unverified customer until they register.
</ResponseField>

<ResponseField name="wallet_id" field-type="string" required="true">
  The credited wallet id.
</ResponseField>

<ResponseField name="credited_minor" field-type="integer" required="true">
  Actual (cashable) balance credited, in minor units.
</ResponseField>

<ResponseField name="bonus_minor" field-type="integer" required="false">
  Promotional bonus accrued, in minor units. `0` when no bonus applied.
</ResponseField>

<ResponseField name="balance_after_minor" field-type="integer" required="false">
  Actual balance after the credit. Nullable; `null` when the credit is escrowed.
</ResponseField>

<ResponseField name="promo_balance_after_minor" field-type="integer" required="false">
  Released promotional balance after the accrual. Nullable.
</ResponseField>

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

<ResponseField name="reload_bonus" field-type="object" required="false">
  The reload bonus accrued by this top-up, if any. See the fields below.
</ResponseField>

<Expandable title="reload_bonus fields">
  <ResponseField name="applied" field-type="boolean" required="true">
    Whether a reload bonus was accrued.
  </ResponseField>

  <ResponseField name="bonus_minor" field-type="integer" required="false">
    Bonus amount in minor units.
  </ResponseField>

  <ResponseField name="promo_grant_id" field-type="string" required="false">
    The promotional grant ledger row id. Nullable.
  </ResponseField>

  <ResponseField name="promo_grant_state" field-type="string" required="false">
    Initial grant state. One of `locked`, `released`, `clawed_back`, `expired`. Nullable. A grant for an identified customer accrues `released`; a grant for an unverified customer accrues `locked`.
  </ResponseField>

  <ResponseField name="source" field-type="string" required="false">
    Which lane accrued the grant. One of `RELOAD_BONUS`, `SKU_TOPUP_BONUS`, `GATEWAY_BONUS`, `CASHBACK`. Nullable.
  </ResponseField>

  <ResponseField name="expires_at" field-type="string" required="false">
    When the grant expires if unspent (RFC3339). Nullable.
  </ResponseField>
</Expandable>

<CodeGroup>
  ```bash Request wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/topup/confirm \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: 9f01a2b3-c4d5-4e6f-8a90-1b2c3d4e5f60" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "f3a1c2d3-0000-4000-8000-000000000001", "occurred_at": "2026-06-05T09:15:00Z", "sent_at": "2026-06-05T09:15: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", "partner_session_id": "ord-99812" },
      "session_ref": "sadad-sess-7f3a91",
      "provider": "SADAD",
      "provider_payment_ref": "sadad-trans-0099812",
      "amount_minor": 5000,
      "currency": "QAR"
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "transaction_id": "tx_77c1",
      "status": "completed",
      "wallet_id": "wal_5512",
      "credited_minor": 5000,
      "bonus_minor": 500,
      "balance_after_minor": 12500,
      "promo_balance_after_minor": 500,
      "currency": "QAR",
      "reload_bonus": {
        "applied": true,
        "bonus_minor": 500,
        "promo_grant_id": "pg_aa01",
        "promo_grant_state": "released",
        "source": "GATEWAY_BONUS",
        "expires_at": "2026-09-05T00:00:00Z"
      }
    },
    "error": null,
    "meta": { "request_id": "req_c4d5", "idempotency_replayed": false, "api_version": "2026-06-01", "data_completeness_score": 70 }
  }
  ```

  ```json 422 IDEMPOTENCY_KEY_REUSED theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "IDEMPOTENCY_KEY_REUSED",
      "message": "Idempotency-Key was reused with a different payload."
    },
    "meta": { "request_id": "req_c4d6", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

<Info>
  `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.
</Info>

### Errors

| HTTP | `code`                   | Recovery                                                                                                                                            |
| ---- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `VALIDATION_ERROR`       | A required field is missing or a domain rule was violated. `details` carries the field errors. Fix the body and retry with a new `Idempotency-Key`. |
| 401  | `INVALID_API_KEY`        | The terminal JWT is missing, expired, malformed, or has the wrong audience. Re-mint the token and retry.                                            |
| 403  | `FORBIDDEN`              | The credential is valid but not authorized for this merchant or branch scope.                                                                       |
| 404  | `NOT_FOUND`              | The session or provider payment reference does not resolve to a pending deposit for this integration.                                               |
| 422  | `IDEMPOTENCY_KEY_REUSED` | Same `Idempotency-Key`, different payload. Use a new key for a new request, or resend the identical payload to replay.                              |
| 422  | `CURRENCY_NOT_SUPPORTED` | No active provider for the currency. `details` lists the supported set.                                                                             |

## 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"). When a registered top-up SKU is sold, report it with `POST /topup/sku/{skuId}`.

Feddi credits the resolved wallet by the SKU's registered `wallet_credits_minor` and accrues any configured SKU bonus as a promotional grant with `source` `SKU_TOPUP_BONUS`. The credited amount and bonus are derived server-side from the SKU registration and the finalized POS sale; POS-submitted money fields are ignored. The bonus accrues `released` because the customer is identified at point of sale.

If the customer is not yet a verified Feddi customer, the sale creates an unverified proto-wallet: the customer keeps the reload (actual balance credits) and the bonus stays `locked` behind proof-of-phone until they register. In that case `status` is `escrowed` and `customer_state` is `pending_proof`. See [PENDING\_PROOF and promo release](/concepts/pending-proof).

Authenticate with a POS terminal JWT.

### Request

<ParamField header="Idempotency-Key" param-type="string" required="true">
  Partner-generated UUID. 24-hour TTL.
</ParamField>

<ParamField path="skuId" param-type="string" required="true">
  The registered top-up SKU id (UUID). A SKU that does not exist, is retired, or belongs to another integration resolves to `NOT_FOUND`.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  The request context. `merchant_id` is required; optional `branch_id`, `terminal_id`, `cashier_id`, `partner_session_id`.
</ParamField>

<ParamField body="pos_order_ref" param-type="string" required="true">
  The finalized POS order reference for this sale. Used as the server-side derivation source and the secondary idempotency anchor.
</ParamField>

<ParamField body="customer" param-type="object" required="false">
  The identity credential, discriminated by `credential_type` (one of `phone`, `provider_customer_id`, `card_fingerprint`, `short_code`, `qr`). For example a `phone` credential carries an E.164 `phone` value. The enabled set is gated by `GET /capabilities`.
</ParamField>

<ParamField body="quantity" param-type="integer" required="false">
  Number of SKU units sold, minimum `1`, default `1`. The credit is `wallet_credits_minor` multiplied by `quantity`.
</ParamField>

<ParamField body="meta" param-type="object" required="false">
  Request metadata. `partner_request_id` and `api_version` are required when `meta` is present; optional `occurred_at` and `sent_at`.
</ParamField>

### Response

The response is the same `TopupResult` shape as `POST /topup/confirm`, with one additional field surfaced on the SKU path:

<ResponseField name="customer_state" field-type="string" required="false">
  The resolved customer identity state at credit time. One of `verified`, `pending_proof`. Nullable.
</ResponseField>

<CodeGroup>
  ```bash Request wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/topup/sku/3f29c1a0-0000-4000-8000-000000000099 \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: dd01e2f3-a4b5-4c6d-8e9f-001122334455" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "dd01e2f3-0000-4000-8000-000000000002", "occurred_at": "2026-06-05T09:30:00Z", "sent_at": "2026-06-05T09:30:00Z", "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", "partner_session_id": "ord-99830" },
      "customer": { "credential_type": "phone", "phone": "+97433009988" },
      "pos_order_ref": "ord-99830",
      "quantity": 1
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "transaction_id": "tx_sku_01",
      "status": "completed",
      "wallet_id": "wal_8842",
      "credited_minor": 5000,
      "bonus_minor": 250,
      "balance_after_minor": 5000,
      "promo_balance_after_minor": 250,
      "currency": "QAR",
      "customer_state": "verified",
      "reload_bonus": {
        "applied": true,
        "bonus_minor": 250,
        "promo_grant_id": "pg_bb02",
        "promo_grant_state": "released",
        "source": "SKU_TOPUP_BONUS",
        "expires_at": "2026-09-05T00:00:00Z"
      }
    },
    "error": null,
    "meta": { "request_id": "req_sk1", "idempotency_replayed": false, "api_version": "2026-06-01", "data_completeness_score": 75 }
  }
  ```

  ```json 404 NOT_FOUND theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "NOT_FOUND",
      "message": "SKU does not exist, is retired, or belongs to another integration."
    },
    "meta": { "request_id": "req_sk2", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

### Errors

| HTTP | `code`                     | Recovery                                                                                                                                     |
| ---- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `VALIDATION_ERROR`         | A required field is missing or a domain rule was violated. `details` carries the field errors.                                               |
| 401  | `INVALID_API_KEY`          | The terminal JWT is missing, expired, malformed, or has the wrong audience.                                                                  |
| 403  | `FORBIDDEN`                | The credential is not authorized for this merchant or branch scope.                                                                          |
| 404  | `NOT_FOUND`                | The SKU does not exist, is retired, or belongs to another integration.                                                                       |
| 422  | `IDEMPOTENCY_KEY_REUSED`   | Same `Idempotency-Key`, different payload.                                                                                                   |
| 422  | `WALLET_PROGRAM_AMBIGUOUS` | The customer maps to multiple wallet programs and the SKU has no explicit binding. `details` lists the candidate `wallet_program_id` values. |

## Configure the reload bonus

`POST /topup/reload-bonus/config` sets the amount-banded bonus a wallet program grants on a top-up (for example "top up QAR 50 or more, get a 10% bonus"). The config is forward-looking only: it governs future top-ups and never re-grants retroactively. The bonus it produces accrues as a promotional grant, distinct from the customer's actual cashable balance.

Authenticate with an API key. This call requires the enterprise-admin `integrations` permission.

### Request

<ParamField header="Idempotency-Key" param-type="string" required="true">
  Partner-generated UUID. 24-hour TTL.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  The request context. `merchant_id` is required.
</ParamField>

<ParamField body="wallet_program_id" param-type="string" required="true">
  The wallet program these tiers govern.
</ParamField>

<ParamField body="currency" param-type="string" required="true">
  ISO-4217 code, 3 characters.
</ParamField>

<ParamField body="tiers" param-type="array" required="true">
  One or more non-overlapping bonus bands. Each tier carries `min_topup_minor`, optional `max_topup_minor`, `bonus_type`, and `bonus_value`. See the tier fields below.
</ParamField>

<ParamField body="expiry_days" param-type="integer" required="false">
  How long an accrued bonus grant lives before it expires, in days, minimum `1`. Drives the grant's `expires_at`. Nullable.
</ParamField>

<ParamField body="meta" param-type="object" required="false">
  Request metadata. `partner_request_id` and `api_version` are required when `meta` is present.
</ParamField>

<Expandable title="tier fields">
  <ParamField body="min_topup_minor" param-type="integer" required="true">
    Inclusive lower bound of the top-up band, in minor units, minimum `0`.
  </ParamField>

  <ParamField body="max_topup_minor" param-type="integer" required="false">
    Inclusive upper bound, in minor units. Nullable; `null` means open-ended (the highest tier).
  </ParamField>

  <ParamField body="bonus_type" param-type="string" required="true">
    One of `PERCENTAGE`, `FIXED_AMOUNT`. `PERCENTAGE` grants a percent of the top-up; `FIXED_AMOUNT` grants a fixed minor-unit amount.
  </ParamField>

  <ParamField body="bonus_value" param-type="integer" required="true">
    The bonus. When `bonus_type` is `PERCENTAGE`, a value from `0` to `100`. When `bonus_type` is `FIXED_AMOUNT`, a minor-unit amount. Minimum `0`.
  </ParamField>
</Expandable>

<Info>
  Tiers must not overlap, and percentage values stay within the `0` to `100` range. An overlapping or out-of-range tier returns `VALIDATION_ERROR`.
</Info>

### Response

<ResponseField name="config_id" field-type="string" required="true">
  The persisted config id (UUID).
</ResponseField>

<ResponseField name="wallet_program_id" field-type="string" required="true">
  The wallet program the tiers govern.
</ResponseField>

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

<ResponseField name="status" field-type="string" required="true">
  One of `active`, `inactive`.
</ResponseField>

<ResponseField name="tiers" field-type="array" required="true">
  The saved bonus bands, each with `min_topup_minor`, `max_topup_minor`, `bonus_type`, and `bonus_value`.
</ResponseField>

<ResponseField name="expiry_days" field-type="integer" required="false">
  Grant lifetime in days. Nullable.
</ResponseField>

<ResponseField name="updated_at" field-type="string" required="false">
  When the config was last saved (RFC3339).
</ResponseField>

<CodeGroup>
  ```bash Request wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/topup/reload-bonus/config \
    -H "x-api-key: $API_KEY" \
    -H "Idempotency-Key: ee01f2a3-b4c5-4d6e-8f90-aabbccdd0011" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "ee01f2a3-0000-4000-8000-000000000003", "api_version": "2026-06-01" },
      "context": { "merchant_id": "11111111-1111-1111-1111-111111111111" },
      "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 }
      ]
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "config_id": "rbc_01",
      "wallet_program_id": "wp_77",
      "currency": "QAR",
      "expiry_days": 90,
      "status": "active",
      "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 }
      ],
      "updated_at": "2026-06-05T07:00:00Z"
    },
    "error": null,
    "meta": { "request_id": "req_rb1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json 400 VALIDATION_ERROR theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "Reload-bonus tiers overlap.",
      "details": { "tiers": "min_topup_minor of tier 2 falls inside tier 1" }
    },
    "meta": { "request_id": "req_rb2", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

## Insufficient-funds recovery

When a wallet pay fails with `INSUFFICIENT_FUNDS`, top up the wallet and retry the charge. Take the customer's gateway payment or sell a reload SKU, then call `POST /topup/confirm` or `POST /topup/sku/{skuId}` to credit the wallet before re-attempting the payment. See [Payments and redemption](/guides/payments) for the charge retry.

## Response metadata

Every response carries a uniform `meta` block. Two fields you may see:

* `data_completeness_score`: an opaque integer Feddi may use internally. No action required.
* `decision_trace_id`: an opaque correlation id. Persist it and echo it where a call accepts one.

## Where to go next

<Columns cols="2">
  <Card title="Money: actual vs promotional" icon="banknote" href="/concepts/money-classes">
    Why actual balance and reload-bonus promo are never one number.
  </Card>

  <Card title="PENDING_PROOF and promo release" icon="lock" href="/concepts/pending-proof">
    How a reload credits while the bonus stays locked until phone proof.
  </Card>

  <Card title="Payments and redemption" icon="credit-card" href="/guides/payments">
    The promo-first debit and the insufficient-funds moment that triggers a reload.
  </Card>

  <Card title="Incentives" icon="gift" href="/guides/incentives">
    The other sources of promotional grants beyond the reload bonus.
  </Card>
</Columns>
