The PENDING_PROOF Invariant
Locked promotional credit releases only when the customer proves phone ownership. One verify call flips PENDING_PROOF to VERIFIED and resolves every deferred side-effect atomically, or none of them.
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 safety boundary that lets cashback accrue on an anonymous walk-up customer without ever putting promotional money at risk.
What PENDING_PROOF is
When a customer is not yet a verified Feddi customer, Feddi creates a PENDING_PROOF proto-wallet. It is a real wallet with a deliberate split:
Actual credits flow normally
A reload (top-up) credits actual_minor immediately. Real money the customer paid in is theirs
right away, verified or not.
Promo grants stay LOCKED
Cashback and reload-bonus grants accrue as LOCKED PromoGrants. They sit in escrow against the
proto-wallet, gated behind proof-of-phone.
This is the acquisition flywheel. Cashback accrues on every transaction (even cash and card purchases) as locked promo, so there is real "money waiting for you" pulling the customer into the wallet. The customer only collects it by registering. See Money: Actual vs Promotional for the two-class model.
A PENDING_PROOF wallet is not a stub. It holds 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 proto-wallet and sends a verification link. The second proves the phone and releases everything in one transaction. Both are Beta (callable today, shape may still tighten).
Initiate enrollment
POST /v1/partner/enroll/initiate with the customer's phone. Feddi normalizes it to E.164,
creates a PENDING_PROOF WalletUser, and SMSes a verification link. No money moves and no POS
customer-id is bound yet (that happens at verify).
Verify the phone
POST /v1/partner/enroll/verify with the signed verification_token from the SMS link (or the
keyed code). Feddi flips PENDING_PROOF to VERIFIED and resolves all deferred side-effects in
one transaction.
Step 1: initiate
POST /v1/partner/enroll/initiate returns the proto-wallet id and confirms the SMS dispatch.
curl -X POST https://api.feddi.io/v1/partner/enroll/initiate \
-H "Authorization: Bearer $TERMINAL_JWT" \
-H "Idempotency-Key: 7c1f0a4e-1b2c-4d3e-9f8a-0b1c2d3e4f50" \
-H "Content-Type: application/json" \
-d '{
"meta": { "partner_request_id": "uuid", "occurred_at": "2026-06-05T09:40:00Z", "sent_at": "2026-06-05T09:40:01Z", "api_version": "2026-06-01" },
"context": { "merchant_id": "uuid", "branch_id": "uuid", "terminal_id": "POS-360-0007" },
"phone": "+97455512345"
}'
{
"ok": true,
"data": {
"wallet_user_id": "wu_aa01",
"customer_state": "pending_proof",
"phone": "+97455512345",
"verification_sent": true,
"verification_channel": "sms",
"verification_expires_at": "2026-06-05T10:40:00Z",
"provider_customer_map_created": false,
"is_new": true
},
"error": null,
"meta": { "request_id": "req_aa01", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
Resends are rate-limited: a 60-second floor between sends and a cap of 3 per rolling 24h window.
Use POST /v1/partner/enroll/resend (Beta), 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 /v1/partner/enroll/verify decodes the signed token, guards that it is not expired, not consumed, and that its WalletUser is still PENDING_PROOF, then runs one transaction that resolves four side-effects together:
Bind the POS customer-id
Creates the ProviderCustomerMap (the POS provider customer-id to WalletUser binding) so future
orders auto-identify.
Bridge to the canonical wallet
Find-or-create the canonical Wallet, so the customer keeps their reload balance plus the
now-released bonus.
Release the LOCKED grants
Runs the LOCKED PromoGrants through the claim-gate. With VERIFIED now satisfied, they flip
LOCKED to RELEASED and become spendable promo balance.
Arm offer eligibility
Turns on marketing and offers eligibility for the now-verified customer.
The response reports the released grants and the post-release balance split:
{
"ok": true,
"data": {
"wallet_user_id": "wu_aa01",
"customer_state": "verified",
"verified_at": "2026-06-05T09:45:02Z",
"wallet_id": "wal_5512",
"wallet_program_id": "wp_77",
"provider_customer_map_created": true,
"balance_minor": 5000,
"promo_balance_minor": 250,
"currency": "QAR",
"released_grants": [
{ "promo_grant_id": "pg_bb02", "released_minor": 250, "source": "SKU_TOPUP_BONUS" }
]
},
"error": null,
"meta": { "request_id": "req_aa02", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
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 Tier-1 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 the result 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 money 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 PANs 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 PromoGrant state machine
A promotional grant moves through a small, one-way set of states:
LOCKED ──(claim-gate: VERIFIED + merchant condition)──▶ RELEASED ──▶ CLAWED_BACK / EXPIRED
Accrued but gated. Cashback and reload-bonus grants land here while the customer is unverified. Not spendable.
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.
Reversed (for example, reversing a top-up claws back the reload bonus it earned). Terminal.
Past its own clock, on a merchant-configured schedule independent of actual balance. Terminal.
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 /v1/partner/grants/{id}/clawback (Beta, operator-gated). It is not a cashier
action.
Inspecting a customer's grants
Read the full PromoGrant ledger with GET /v1/partner/customers/{customerId}/grants (Beta). Each grant carries its source, state, amount, remaining, accrued_at, and expires_at. Filter by state to see exactly what is locked versus spendable.
{
"ok": true,
"data": {
"customer_id": "wu_aa01",
"grants": [
{
"grant_id": "pg_bb02",
"source": "CASHBACK",
"state": "LOCKED",
"amount": { "amount_minor": 250, "currency": "QAR" },
"remaining": { "amount_minor": 250, "currency": "QAR" },
"accrued_at": "2026-06-01T12:00:00Z",
"expires_at": "2026-09-01T12:00:00Z"
}
],
"next_cursor": null,
"has_more": false
},
"error": null,
"meta": { "request_id": "req_aa03", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
Maturity at a glance
This flow is mostly Beta, with one Planned surface. Confirm the live, mounted set at runtime via GET /v1/partner/capabilities and GET /v1/partner/openapi, never this page alone.
| Operation | Path | Maturity |
|---|---|---|
enrollInitiate | POST /enroll/initiate | Beta |
enrollVerify | POST /enroll/verify | Beta |
enrollResend | POST /enroll/resend | Beta |
claimsRelease | POST /claims | Beta (explicit ops re-drive) |
listCustomerGrants | GET /customers/{customerId}/grants | Beta |
clawbackGrant | POST /grants/{id}/clawback | Beta (operator-gated) |
customerConsentRecord | POST /customer/{customerId}/consent | Planned |
Marketing-consent capture is Planned (shape-frozen in the spec, not yet mounted), pending a product and legal decision. Do not build against it as callable today. The transactional path (sending a verification SMS, releasing a grant) needs no separate marketing consent, so this gap does not block the enrollment flow.
Where to go next
Money: Actual vs Promotional
The two-class model. Why locked promo is never a real-money liability.
Enrollment & Cashback
The initiate, verify, resend, and claims endpoints in detail.
Identity & Credentials
Why a card-fingerprint match is probationary until the phone is verified.
Incentives & Offers
Where LOCKED promotional grants come from and how clawback works.