Domain GuidesEnrollment & Signup

Enrollment & Signup

Sign a customer up at the counter: create a PENDING_PROOF wallet, prove the phone, release locked promo, and record consent.

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 PENDING_PROOF proto-wallet and texts a verification link, then POST /enroll/verify proves phone ownership and atomically resolves every deferred side-effect. The single rule that governs everything on this page: a LOCKED promotional grant releases only when the customer reaches VERIFIED and the merchant claim-gate is satisfied. Proof of phone is the one deliberate exception in the money model, and it is non-negotiable.

The signup flow is the acquisition flywheel. Cashback accrues on every transaction (even a cash or card purchase) as a LOCKED PromoGrant. That locked promo is the "money waiting for you" that pulls the customer into the wallet. Enrollment is how they unlock it.

Read The PENDING_PROOF Invariant before you wire any cashback or signup path. This page assumes you know it.

What this flow does

Enrollment binds three things that were previously loose:

  • A phone number to a verified WalletUser (the POS-facing identity).
  • The POS provider's customer-id to that WalletUser (a ProviderCustomerMap), so future orders auto-identify.
  • Any LOCKED promo escrowed against the proto-wallet to spendable promo balance.

WalletUser is unique per (enterprise, normalized_phone). The same phone at a different merchant is a different customer, with a different balance, by design. This is the closed-loop isolation that keeps each merchant's stored value its own. See Identity & Credentials.

Operations

The three live signup calls are Beta: callable today, shape may still tighten. POST /claims is Beta and exists for ops re-drive (the common path runs the claim engine inline inside verify). Everything else in this tag is Planned: shape-frozen in the spec, not yet mounted, treat as not-callable until it flips.

OperationPathMethodAvailability
Initiate signup (create proto-wallet, send SMS)/enroll/initiatePOSTBeta
Verify phone (flip to VERIFIED, resolve side-effects)/enroll/verifyPOSTBeta
Resend verification SMS (rate-limited)/enroll/resendPOSTBeta
Drive the claim release-engine (ops re-drive)/claimsPOSTBeta
Read a claim's state/claims/{claimId}GETPlanned
Read full customer identity + provenance/customer/{customerId}/identityGETPlanned
Record marketing/personalization consent/customer/{customerId}/consentPOSTPlanned
Merge a duplicate / recycled-SIM proto-wallet/customers/{customerId}/mergePOSTPlanned
Attach a finalized cash/card order post-hoc/customer/{customerId}/attach-orderPOSTPlanned (v1.5)
Open an identify-only session/customer/session/initiatePOSTPlanned

The consent endpoint was reclassified from Preview to Planned. The per-channel, append-only ledger SHAPE is stable; the remaining build work is reconciling the live SMS sending gate to honor WalletUser-level marketing consent. Treat it as not-callable until it flips. The 95 Planned endpoints across the API are PROVISIONAL: designed, not implementation-verified. Confirm the live, mounted set at runtime via GET /capabilities and GET /openapi.

The PENDING_PROOF signup flow

POST /enroll/initiate find-or-creates a WalletUser in 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 ProviderCustomerMap is created yet. The transactional SMS needs no consent opt-in: it is triggered by the customer's own counter activity.

POST /enroll/verify decodes the signed token, guards that it is not expired, not consumed, and that the WalletUser is still PENDING_PROOF, then in one transaction flips PENDING_PROOF → VERIFIED and resolves every deferred side-effect atomically:

Bind the POS customer-id

Create the ProviderCustomerMap so the POS provider's customer-id resolves to this WalletUser on every future order.

Bridge to the canonical wallet

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

Release the locked promo

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

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 (wallet_user.verified, promo_grant.released, provider_customer_map.created) publish after commit, never inside the transaction.

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 of this verify returns 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 hang the enrollment off the live checkout_session; omit it for a standalone signup. Idempotent on the Idempotency-Key header: a retry replays the original proto-wallet and link without re-sending a fresh SMS and without resetting the resend window.

header
Idempotency-Keystring
Required

A partner-generated UUID. Required on every mutating call. 24h TTL.

body
phonestring
Required

The customer phone number. Feddi normalizes it to E.164. An invalid or unsupported country code returns VALIDATION_ERROR.

body
provider_customer_idstring

The POS provider's customer-id, bound to the WalletUser at verify (not here).

body
wallet_program_idstring

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 listed in error.details.candidates.

body
languagestring

The SMS language for the verification link.

curl -X POST https://api.feddi.io/v1/partner/enroll/initiate \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  -H "Idempotency-Key: 1a2b3c4d-0001-4f9b-9d1e-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{
    "meta": { "partner_request_id": "1a2b3c4d-0001-4f9b-9d1e-1a2b3c4d5e6f", "occurred_at": "2026-06-05T09:40:00Z", "sent_at": "2026-06-05T09:40: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", "cashier_id": "cashier-42", "partner_session_id": "ord-99841", "feddi_session_id": "33333333-3333-3333-3333-333333333333" },
    "phone": "+97433001122",
    "provider_customer_id": "odoo-cust-5521",
    "language": "en"
  }'
customer_statestring

pending_proof for a new or unverified enrollment; verified if the phone was already a verified customer.

provider_customer_map_createdboolean

Always false at initiate. The map is created at verify.

verification_expires_atstring

When the verification link expires. After this, the customer needs a resend.

Verify the phone

POST /enroll/verify proves phone ownership. The customer clicks the SMS link, or the cashier keys the code. Pass the signed verification_token (or the code). This is a Tier 1 money path: it releases promo. Idempotent on the Idempotency-Key header.

body
verification_tokenstring

The signed token from the SMS link. Supply this or code.

body
codestring

The verification code, when the cashier keys it manually instead of using the link.

curl -X POST https://api.feddi.io/v1/partner/enroll/verify \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  -H "Idempotency-Key: 2b3c4d5e-0002-4f9b-9d1e-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{
    "meta": { "partner_request_id": "2b3c4d5e-0002-4f9b-9d1e-1a2b3c4d5e6f", "occurred_at": "2026-06-05T09:45:00Z", "sent_at": "2026-06-05T09:45: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" },
    "verification_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.proof.signature"
  }'
balance_minorinteger

The verified customer's actual (real-money) balance in minor units. Pair with currency.

promo_balance_minorinteger

Released, spendable promotional balance in minor units, after the claim-gate ran.

released_grantsarray

The PromoGrants the verify flip released, each with released_minor and source.

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. It is rate-limited to protect the customer from SMS bombing: max 3 sends per 24h, minimum 60s between sends. The 4th send in a window is rejected with RATE_LIMITED (HTTP 429) and error.details.retry_after_seconds. Only valid while the WalletUser is still PENDING_PROOF; a verified customer needs no link and returns VALIDATION_ERROR. Resolve the target by wallet_user_id or by phone. Transactional message, so no consent gate.

curl -X POST https://api.feddi.io/v1/partner/enroll/resend \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  -H "Idempotency-Key: 3c4d5e6f-0003-4f9b-9d1e-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{
    "meta": { "partner_request_id": "3c4d5e6f-0003-4f9b-9d1e-1a2b3c4d5e6f", "occurred_at": "2026-06-05T09:50:00Z", "sent_at": "2026-06-05T09:50: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" },
    "wallet_user_id": "wu_aa01"
  }'
sends_remaining_24hinteger

How many resends remain in the rolling 24h window before the SMS-bomb guard blocks further sends.

next_send_allowed_atstring

The earliest time the next resend is allowed (the 60s floor).

Claims: drive the release-engine 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 needs releasing.

The engine is the day-1 riskiest money path because it must converge 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 idempotent. Concurrent claims collapse to exactly one creditor.

The claim-gate requires VERIFIED. A LOCKED grant is NEVER released by a mere anonymous-to-identity merge: release stays gated on proof of phone. This is the schema's one deliberate exception, and it is the rule that protects the merchant's promotional liability.

Resolve the customer by wallet_user_id or an identity credential. Idempotent on the Idempotency-Key header. A claim against an unverified customer returns FORBIDDEN (HTTP 403). A claim that finds nothing claimable returns state: released with released_minor: 0.

{
  "ok": true,
  "data": {
    "claim_id": "clm_01",
    "wallet_user_id": "wu_aa01",
    "state": "released",
    "customer_state": "verified",
    "wallet_id": "wal_5512",
    "released_minor": 250,
    "promo_balance_after_minor": 250,
    "currency": "QAR",
    "released_grants": [
      { "promo_grant_id": "pg_bb02", "released_minor": 250, "source": "SKU_TOPUP_BONUS", "state": "released", "expires_at": "2026-09-05T00:00:00Z" }
    ],
    "skipped_grants": []
  },
  "error": null,
  "meta": { "request_id": "req_cl1", "idempotency_replayed": false, "api_version": "2026-06-01", "data_completeness_score": 80, "decision_trace_id": "dt_claim_7af2" }
}

GET /claims/{claimId} reads a claim's state (pending, released, expired, clawed_back) plus the released and skipped grant breakdown. It is Planned, read-only, and tenant-scoped: a claim belonging to another integration resolves to NOT_FOUND.

POST /customer/{customerId}/consent records a marketing or personalization consent decision as an append-only ConsentRecord ledger row, not an upsert. The latest row per (customer, channel, enterprise) wins for the live gate; older rows stay for audit. Consent is per-channel (SMS, WHATSAPP, EMAIL, PUSH) and captures the exact prompt text version the customer saw.

The consent model has three tiers. The API only records the second and third:

The gate is real. A recorded opt-in is honored by the actual SMS sending gate, which reads the latest ConsentRecord. A v1-to-v2 bump in the prompt text means marketing messages stay gated until the customer re-consents under v2. A transactional message may carry light promotional language (for example, "You earned 5 SAR cashback, balance 25 SAR. Top up 50, get 10 bonus.") while staying primarily transactional. That is not marketing and needs no opt-in.

Planned, reclassified from Preview. The per-channel append-only SHAPE is stable. The default opt-in vs opt-out state and the cross-merchant honoring semantics are a founder and legal call (GCC PDPL leans explicit opt-in) and may still change. The build work is reconciling the live SMS gate to honor WalletUser-level marketing consent.

Reading identity and resolving duplicates (Planned)

These operations are Planned. Build against the shape, treat as not-callable until they flip.

GET /customer/{customerId}/identity reads the full identity record: state machine value, verified_at, resolved wallet_id and wallet_program_id, balance and released promo, the ProviderCustomerMap entries, enrollment provenance (which credential identified, when, by which branch and cashier), and any unclaimed locked grant total. A PENDING_PROOF customer surfaces only state, created-at, and the locked-grant total, because no canonical wallet exists yet.

POST /customers/{customerId}/merge folds a duplicate or recycled-SIM proto-wallet into a surviving customer. Balances and released promo sum into the survivor; LOCKED grants carry over but stay LOCKED (a merge never releases a locked grant on its own); the source's ProviderCustomerMap entries re-point to the survivor. Merge is one-way and idempotent.

POST /customer/{customerId}/attach-order (Planned, deferred to v1.5) links an already-finalized cash or card order to an identified customer after the fact, so the spend still feeds history and any order-level cashback. The cashback is derived server-side from the finalized order, never from POS-submitted money fields.

POST /customer/session/initiate (Planned) opens an identify-only checkout_session. It is an alias for POST /checkout/sessions with no basket and no tender, giving a terminal an audit-linked identification window before a sale. Prefer a full session when a basket exists.

Errors

Every error is a typed string code in error.code, never a bare HTTP number. See Idempotency & Errors and the Error Reference.

CodeWhen it happens on this flow
VALIDATION_ERRORMalformed body, invalid or unsupported phone, expired token, or a WalletUser not in the required state.
INVALID_API_KEYMissing or invalid x-api-key or terminal JWT (expired, malformed, wrong audience).
FORBIDDENAuthorized key but wrong merchant or branch scope; or a claim against an unverified customer (the claim-gate denies).
NOT_FOUNDUnknown customer, or a customer in another enterprise. Cross-tenant requests resolve to NOT_FOUND, never a leak.
WALLET_PROGRAM_AMBIGUOUSThe phone resolves to multiple programs and none was specified. Candidates are listed in error.details.candidates.
IDEMPOTENCY_KEY_REUSEDSame Idempotency-Key, different payload (HTTP 422).
RATE_LIMITEDResend exceeded the 60s floor or the 3-per-24h cap (HTTP 429). error.details.retry_after_seconds tells you when to retry.

Where to go next