Skip to main content

The one rule

A LOCKED promotional grant becomes spendable only when the customer is verified and the merchant’s claim-gate condition is met. Nothing else releases it. Internalize this before you wire any cashback or enrollment path. It is the boundary that lets promotional credit accrue for an unverified walk-up customer without putting that credit at risk.

What PENDING_PROOF is

When a customer is not yet verified, Feddi creates a wallet in the pending_proof state. It is a real wallet with a deliberate split:

Actual credits flow normally

A reload (top-up) credits the actual cashable balance immediately. Money the customer paid in is theirs right away, verified or not.

Promo grants stay LOCKED

Cashback and reload-bonus grants accrue as LOCKED promotional grants. They sit in escrow against the wallet, gated behind proof of phone ownership.
See Money: actual vs promotional for the two-class model.
A pending_proof wallet is not a stub. It holds a real reload balance and a real ledger of locked grants. The only thing it withholds is the ability to spend promotional credit.

The cashier signup flow

Two calls. The first creates the wallet and sends a verification link. The second proves the phone and releases everything in one transaction. The full lifecycle: a reload credits actual balance while the bonus stays LOCKED, a claim before verify is denied, and verify releases the grant.
1

Initiate enrollment

Call POST /enroll/initiate with the customer’s phone. Feddi normalizes it to E.164, creates a pending_proof wallet, and sends an SMS verification link. No money moves and no POS customer id is bound yet. Both happen at verify.
2

Verify the phone

Call POST /enroll/verify with the signed verification_token from the SMS link, or the keyed code. Feddi flips the wallet to verified and resolves all deferred side-effects in one transaction.

Step 1: initiate

POST /enroll/initiate returns the wallet id and confirms the SMS dispatch.
required
The customer’s phone. Normalized to E.164 server-side.
required
The POS provider’s customer id. Bound to the wallet at verify, not at initiate.
required
Explicit program when the customer could map to several. Omit or send null to use the integration’s current assignment. Ambiguity returns WALLET_PROGRAM_AMBIGUOUS with candidate ids in error.details.candidates.
required
Preferred SMS language, for example en or ar. Defaults to the program or branch default.

Initiate response fields

required
The wallet identity id.
required
One of pending_proof (new or unverified enrollment) or verified (the phone was already a verified customer, an idempotent no-op).
required
Whether a verification SMS was dispatched. false on an idempotent replay that did not re-send.
required
One of sms, whatsapp, or null.
required
When the verification link expires (date-time), or null.
required
Always false at initiate. The POS customer id is bound at verify.
required
true if this call created the wallet, false if it already existed.
Resends are rate-limited: a 60-second floor between sends and a cap of 3 per rolling 24h window. Use POST /enroll/resend, which returns sends_remaining_24h and next_send_allowed_at. If the phone is already a verified customer, initiate is an idempotent no-op that returns customer_state: "verified" and does not re-send.

Step 2: verify resolves everything atomically

POST /enroll/verify decodes the signed token, guards that it is not expired, not consumed, and that its wallet is still pending_proof, then runs one transaction that resolves four side-effects together.
required
The signed single-use token from the SMS link. Provide exactly one of this or code, never both.
required
The numeric code the cashier keyed, an alternative to the link token. Exactly 6 ASCII digits, for example 482913.
1

Bind the POS customer id

Binds the POS provider customer id to the wallet so future orders auto-identify.
2

Bridge to the canonical wallet

Find-or-creates the canonical wallet, so the customer keeps their reload balance plus the now-released bonus.
3

Release the LOCKED grants

Runs the LOCKED grants through the claim-gate. With verified now satisfied, they flip LOCKED to RELEASED and become spendable promo balance.
4

Arm offer eligibility

Turns on marketing and offer eligibility for the now-verified customer.
The response reports the released grants and the post-release balance split.

Verify response fields

required
The wallet identity id.
required
Always verified on success, including an idempotent replay of a prior verify.
required
When verification completed (date-time).
required
The canonical wallet bridged at verify, or null.
required
Whether a new POS customer binding was created. false on a replay.
required
Actual cashable balance after any escrowed credit was applied, in minor units.
required
Released promo balance after any escrowed grants were released, in minor units.
required
ISO-4217 currency code, for example QAR.
required
The LOCKED grants released by this verify, empty if none were escrowed. Each item carries promo_grant_id, released_minor, source (one of CASHBACK, RELOAD_BONUS, SKU_TOPUP_BONUS, GATEWAY_BONUS, SIGNUP_BONUS), state, and expires_at.

Atomicity: all or nothing

None of the four side-effects resolve partially. If any step fails, the whole flip rolls back and the verification link stays consumable. You never see a half-verified customer with a bound POS id but unreleased grants, or a released grant with no canonical wallet behind it.
Verify is a money path. Treat a non-200 as “nothing happened” and retry the same call with the same Idempotency-Key. The customer is still pending_proof and the link is still good.

Replay never re-releases

Verify is idempotent. A re-submit of an already-consumed token replays the original verified result with meta.idempotency_replayed: true. It does not release the grants a second time. Bind your logic to the response, not to the fact that you made the call. See Idempotency & errors.

What does NOT release a grant

Two paths look like they should unlock promo and deliberately do not. Both exist so that softer identity signals never move promotional credit on their own.

An anonymous-to-identity merge

Merging an anonymous session into a resolved customer carries grants over, but a merge never releases a LOCKED grant on its own. Release stays gated on verified plus the claim-gate.

A card-fingerprint identify

Identifying by card fingerprint links the session, but the link is probationary (reversible) until the phone is verified. Masked card numbers can collide, so a fingerprint match never auto-acts on money.
The principle: identity resolution and money movement are strictly separated. A customer can be recognized by card or merged from an anonymous session and still hold zero spendable promo until they prove phone ownership. See Identity & credentials.

The promo-grant state machine

A promotional grant moves through a small, one-way set of states.
required
Accrued but gated. Cashback and reload-bonus grants land here while the customer is unverified. Not spendable.
required
Spendable promo balance. Reached only through the claim-gate at verify, or an ops re-drive via POST /claims. Spent promo-first, FIFO by expiry.
required
Reversed, for example reversing a top-up claws back the reload bonus it earned. Terminal.
required
Past its own expiry clock, on a merchant-configured schedule independent of actual balance. Terminal.

Re-driving a release with POST /claims

POST /enroll/verify invokes the claim engine inline for the common path. POST /claims is the explicit re-drive: use it when verification already happened but a later accrual still needs releasing (a top-up bonus that landed after the customer registered). Resolve the customer by wallet_user_id or by an identity credential. The call is idempotent on the Idempotency-Key header and converges regardless of the order in which register, accrual, and claim arrive: a duplicate claim is a no-op replay, a claim after clawback releases 0, and concurrent claims collapse to exactly one creditor.
required
The claim-state-machine value. One of pending (awaiting verify or gate), released (grants credited), expired (grants lapsed before claim), or clawed_back (grants reversed before claim, releases 0).
required
Total promo released by this claim, 0 when nothing was claimable, in minor units.
required
Grants released by this claim. Each carries promo_grant_id, released_minor, source, state, and expires_at.
required
Grants not released, each with a promo_grant_id and a reason (one of already_clawed_back, expired, gate_not_met, already_released).

A clawback never touches the customer’s cash

When a clawback hits promo that is already spent, Feddi books the spent portion as a merchant marketing loss. It does not, and cannot, pull from the customer’s actual cash balance. Promotional credit is a contingent merchant cost. Actual balance is the customer’s real money. The clawback respects that wall. Clawback is POST /grants/{id}/clawback. It is operator-gated (API-key auth), not a cashier action. A LOCKED grant claws back its full amount. A RELEASED grant claws back only the remaining unspent amount; any already-spent portion is recorded in spent_loss_logged. The call is idempotent on the Idempotency-Key header: clawing back an already-CLAWED_BACK grant replays as a no-op.
required
Why the grant is being clawed back. One of SOURCE_ORDER_VOIDED, SOURCE_REFUNDED, FRAUD, DUPLICATE, OTHER.
required
Free-text note for the audit trail.
required
The promotional grant id.
required
Always CLAWED_BACK on success.
required
The amount clawed back, as amount_minor plus currency.
required
Any already-spent promotional portion booked as a merchant loss, as amount_minor plus currency. Never pulled from the customer’s actual cash.

Inspecting a customer’s grants

Read the full promotional-grant ledger with GET /customers/{customerId}/grants. The path parameter customerId is the wallet identity id. Results are cursor-paginated and tenant-scoped.
required
Filter by grant state. One of LOCKED, RELEASED, CLAWED_BACK, EXPIRED.
required
Opaque pagination cursor from a prior response’s next_cursor.
required
Max grants per page. Default 50, max 100.

Grant fields

required
The grant ledger row id.
required
What funded the grant. One of CASHBACK, RELOAD_BONUS, SKU_TOPUP_BONUS, GATEWAY_BONUS, SIGNUP_BONUS.
required
One of LOCKED, RELEASED, CLAWED_BACK, EXPIRED.
required
The original grant amount, as amount_minor plus currency.
required
The unspent remainder, as amount_minor plus currency.
required
When the grant accrued (date-time), or null.
required
When the grant expires unclaimed or unspent (date-time), or null.
required
Server-derived reference to the finalized source event, for example pos:order:5512.
The response meta includes data_completeness_score, an opaque integer Feddi may use internally. No action required.

Where to go next

Money: actual vs promotional

The two-class model and why locked promo is never a real-money liability.

Enrollment & signup

The initiate, verify, resend, and claims endpoints in detail.

Identity & credentials

Why a card-fingerprint match is probationary until the phone is verified.

Incentives

Reading the grant ledger and managing clawback.