Skip to main content

Overview

Enrollment turns a phone number the cashier typed into a verified Feddi customer who can spend, in two calls. POST /enroll/initiate creates a wallet identity in the pending_proof state and sends an SMS verification link. POST /enroll/verify proves phone ownership and atomically resolves every deferred side-effect, including releasing locked promotional grants. The single rule that governs this page: a LOCKED promotional grant releases only when the customer reaches verified and the claim-gate is satisfied. Proof of phone is the one deliberate exception in the money model. A customer’s balance is scoped per merchant. The same phone number at a different merchant is a different customer with a different balance. Read PENDING_PROOF and promo release before you wire any cashback or signup path. This page assumes you know it.

Operations

Four operations make up the callable signup surface. All are stable and callable today.
Create the pending_proof wallet identity and send the verification SMS.
Prove phone ownership, flip to verified, and resolve side-effects atomically.
Resend the verification SMS. Rate-limited and attempt-capped.
Drive the claim release-engine directly. Used for ops re-drive; verify runs the same engine inline.

The signup flow

A promo grant lands LOCKED on the proto-wallet, the customer proves their phone by SMS, and verify releases the held promo in one transaction. POST /enroll/initiate find-or-creates a wallet identity in the pending_proof state for the merchant and normalized phone, mints a single-use signed verification link (a short-TTL JWT), and sends it by SMS. No money moves at this step and no provider-customer link is created yet. The verification SMS is transactional and needs no marketing consent opt-in. POST /enroll/verify decodes the signed token, guards that it is not expired, not consumed, and that the wallet identity is still pending_proof, then in one transaction flips pending_proof to verified and resolves every deferred side-effect atomically.
1

Bind the POS customer id

Create the provider-customer link so the POS provider’s customer id resolves to this wallet identity on every future order.
2

Bridge to the canonical wallet

Find-or-create the canonical wallet for the verified identity so a money ledger exists.
3

Release the locked promo

Run the claim-gate against every LOCKED cashback-class grant escrowed on the proto-wallet, flipping eligible grants to RELEASED and crediting spendable promo balance.
4

Arm offers eligibility

Mark the customer eligible for marketing and offers, subject to recorded consent.
None of these resolve partially. If any step fails, the whole flip rolls back and the link stays consumable. Domain events publish after the transaction commits, never inside it.
Verify is idempotent. A re-submit of an already-consumed token replays the original verified result with meta.idempotency_replayed: true. It does not re-release grants. A genuinely already-consumed token returns HTTP 200 with the replay flag, not a 422.

Initiate the signup

POST /enroll/initiate opens the flow. Supply the phone the cashier entered. Pass feddi_session_id in context to anchor the enrollment to a live checkout session, or omit it for a standalone signup. This call is idempotent on the Idempotency-Key header. A retry with the same key replays the original wallet identity and link without sending a new SMS and without resetting the resend rate-limit window.
required
A partner-generated UUID. Required on every mutating call.
required
Request context. merchant_id is required; branch_id, terminal_id, cashier_id, partner_session_id, and feddi_session_id are optional. See Conventions.
required
The customer phone number. Feddi normalizes it to E.164. An invalid or unsupported country code returns VALIDATION_ERROR.
required
The POS provider’s customer id. Bound to the wallet identity at verify, not here.
required
Disambiguate when the phone resolves to more than one program. Omitting it when the phone is ambiguous returns WALLET_PROGRAM_AMBIGUOUS with the candidate program ids in error.details.candidates.
required
Preferred SMS language for the verification link, for example en or ar. Defaults to the program or branch default.
required
The id of the wallet identity. Persist it to address the same customer on verify, resend, and claims.
required
pending_proof for a new or unverified enrollment. verified if the phone was already a verified customer (an idempotent no-op enroll).
The normalized E.164 phone.
The resolved program id.
required
Whether a verification SMS was dispatched. false on an idempotent replay that did not re-send.
One of sms, whatsapp, or null.
When the verification link expires. After this, the customer needs a resend.
Always false at initiate. The provider-customer link is created at verify.
true if this call created the proto-wallet, false if it already existed.
The checkout session this enrollment anchored to, when feddi_session_id was supplied.

Verify the phone

POST /enroll/verify proves phone ownership. The customer clicks the SMS link, or the cashier keys the code. Supply verification_token or code. This call releases promo, so treat it as a money path. It is idempotent on the Idempotency-Key header.
required
A partner-generated UUID.
required
Request context. merchant_id is required.
required
The signed single-use token from the SMS link. Supply this or code.
required
The numeric code the cashier keyed, as an alternative to the link token.
required
The id of the now-verified wallet identity.
required
Always verified on success or an idempotent replay of a prior verify.
When verification completed.
The canonical wallet bridged at verify.
The resolved program id.
Whether a new provider-customer link was created. false on a replay.
The verified customer’s actual (real-money) balance in minor units. Pair with currency.
Released, spendable promotional balance in minor units, after the claim-gate ran.
ISO-4217 currency for the balance fields.
The grants this verify released, empty if none were escrowed. Each carries promo_grant_id, released_minor, and source (one of CASHBACK, RELOAD_BONUS, SKU_TOPUP_BONUS, GATEWAY_BONUS, SIGNUP_BONUS).
Money is always minor units with an explicit currency. balance_minor: 5000 with currency: 'QAR' means 50.00 QAR. The server is authoritative. Never compute money client-side.

Resend the verification SMS

POST /enroll/resend mints a fresh single-use token and invalidates the prior one. Resolve the target by wallet_user_id or phone. The target must be in pending_proof; a verified customer needs no link and returns VALIDATION_ERROR. This is a transactional message and needs no consent gate. Send limits protect the customer from SMS bombing: a maximum of 3 sends per rolling 24-hour window, with a minimum interval of 60 seconds between sends. A send that exceeds the cap is rejected with RATE_LIMITED (HTTP 429) and error.details.retry_after_seconds. A retry within the 60-second floor replays the prior dispatch rather than counting as a new send.
required
A partner-generated UUID.
required
Request context. merchant_id is required.
required
The pending_proof wallet identity to resend to. Supply this or phone.
required
E.164 phone, normalized identically to initiate. An alternative to wallet_user_id.
required
The wallet identity the SMS was sent to.
required
Whether the SMS was dispatched.
When the new verification link expires.
Sends used in the rolling 24-hour window, including the initiate send.
Remaining sends before the 3-per-24h cap.
The earliest time the 60-second floor allows another send.

Drive claims directly

POST /claims is the explicit, idempotent driver for the claim release-engine: it converts a verified customer’s LOCKED cashback-class grants into spendable promo balance. The common signup path does not need it, because POST /enroll/verify invokes the same engine inline. Reach for POST /claims for ops re-drive, and for the top-up-before-register ordering where verification already happened but a later accrual still needs releasing. For the top-up-before-register ordering, verify already ran with nothing to release, a later accrual escrows a fresh LOCKED grant, and POST /claims releases it. The engine converges across event orderings. Register-before-accrual and accrual-before-register both converge to the same released balance. A claim after clawback releases 0. A duplicate claim is an idempotent no-op replay. Concurrent claims collapse to exactly one creditor. Resolve the customer by wallet_user_id or an identity credential in customer. At least one is required. The call is idempotent on the Idempotency-Key header.
The claim-gate requires verified. A LOCKED grant is never released by an anonymous-to-identity merge alone; release stays gated on proof of phone. A claim against an unverified customer is denied with FORBIDDEN (HTTP 403). A claim that finds nothing claimable returns state: released with released_minor: 0.
required
A partner-generated UUID.
required
Request context. merchant_id is required.
required
The verified wallet identity whose LOCKED grants to release. Supply this or customer.
required
An identity credential resolving the customer, as an alternative to wallet_user_id.
required
The id of the claim resource.
required
The resolved wallet identity.
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).
One of verified, pending_proof, or null.
The canonical wallet credited.
Total promo released by this claim in minor units. 0 when nothing was claimable.
The spendable promo balance after the claim, in minor units.
ISO-4217 currency for the money fields.
The grants released by this claim. Each carries promo_grant_id, released_minor, source, state, and expires_at.
Grants not released, each with promo_grant_id and a reason. The reason is one of already_clawed_back, expired, gate_not_met, or already_released.
meta.decision_trace_id is an opaque correlation id. Persist it and echo it where a call accepts one.

Errors

Every error is a typed string code in error.code, never a bare HTTP status number. Branch on error.code, not the HTTP status. See Idempotency and errors and the Error reference.
Malformed body, invalid or unsupported phone, expired token, or a wallet identity not in the required state. details carries the field errors.
Missing or invalid x-api-key or terminal JWT (expired, malformed, or wrong audience).
Authorized key but wrong merchant or branch scope, a token belonging to another integration, or a claim against an unverified customer (the claim-gate denies).
Unknown customer, or a customer in another tenant. Cross-tenant requests resolve to NOT_FOUND, never a leak.
The phone resolves to multiple programs and none was specified. Candidates are listed in error.details.candidates.
Same Idempotency-Key, different payload.
Resend exceeded the 60-second floor or the 3-per-24h cap. error.details.retry_after_seconds tells you when to retry.

Where to go next

PENDING_PROOF and promo release

Why locked promo releases only on verified plus the claim-gate, and how grants escrow.

Money: actual vs promotional

The two-class balance model that enrollment unlocks.

Top-up and reload bonus

Where the locked promo grants that signup releases come from.

Identity and credentials

How a customer is resolved before you enroll them.