Core ConceptsThe PENDING_PROOF Invariant

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:

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"
  }'

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.

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
LOCKEDstate

Accrued but gated. Cashback and reload-bonus grants land here while the customer is unverified. Not spendable.

RELEASEDstate

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.

CLAWED_BACKstate

Reversed (for example, reversing a top-up claws back the reload bonus it earned). Terminal.

EXPIREDstate

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.

OperationPathMaturity
enrollInitiatePOST /enroll/initiateBeta
enrollVerifyPOST /enroll/verifyBeta
enrollResendPOST /enroll/resendBeta
claimsReleasePOST /claimsBeta (explicit ops re-drive)
listCustomerGrantsGET /customers/{customerId}/grantsBeta
clawbackGrantPOST /grants/{id}/clawbackBeta (operator-gated)
customerConsentRecordPOST /customer/{customerId}/consentPlanned

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