Identity & Credentials
How a checkout_session resolves who the customer is. Four credential types, one discriminated-union shape, and the strict line between identity and money.
Identity resolves a signal to a CustomerContext
You send the credential the point of sale has. Feddi resolves it to a CustomerContext: the customer's wallet state, program binding, and balance breakdown. Identity never returns a debit token, an authorization token, or any artifact that permits a ledger mutation. Money and identity are strictly separated.
That separation is the load-bearing rule of this page. Resolving who a customer is tells you nothing you can spend. To move money, you call a money endpoint with its own credential (see Payments & Redemption).
The canonical resolver is POST /v1/partner/identify (Beta). Inside a checkout flow you use the session-anchored form, POST /v1/partner/checkout/sessions/{id}/identify, which also merges the anonymous session into the resolved customer.
The customer credential types
The point of sale always has one signal. Feddi resolves any supported credential type to the same CustomerContext. The three core types below cover the common POS surfaces. Additional types exist in the contract under the same discriminated-union pattern (provider_customer_id, and qr in QR-capable contexts), so treat the enabled set as capabilities-driven, never a fixed list.
credential_type | What it is | Notes |
|---|---|---|
phone | E.164 phone number | Feddi-native. OTP and proof flows attach to the identified phone. |
card_fingerprint | Masked-PAN hash from the terminal | The link is probationary until the phone is verified. A masked-PAN collision never auto-acts on money. |
short_code | 5-character consumer-minted code | Already pre-verified by Feddi. The customer minted it in the wallet app. |
Additional contract-supported types include provider_customer_id (a POS-platform-minted customer id) and, in QR-capable contexts, qr (a single-use, wallet-minted QR nonce, compare-and-swap so a replay fails). The authoritative enabled set for your integration is always GET /capabilities plus the openapi schema, not this page.
Each call sends exactly one credential block, keyed by credential_type:
{ "credential_type": "phone", "phone": "+97433001122" }
{ "credential_type": "card_fingerprint", "card_fingerprint": "8a1f...e09c" }
{ "credential_type": "short_code", "short_code": "X7K2P" }
A card_fingerprint link is probationary. Feddi will recognize the card to surface a likely customer, but it will not release locked promotional credit or treat the customer as verified until the phone is proven. See The PENDING_PROOF Invariant.
The discriminated-union extension point
credential_type is a discriminator. The partner sends one credential block per call; Feddi reads the discriminator to pick the validator. This is the stable extension point for the whole identity surface.
Adding a new credential type (for example, a provider-minted customer id from a specific POS platform) is an enum extension plus a validator. It is never a new route. Your existing /identify integration keeps working unchanged, and the new type appears in GET /capabilities once it is enabled.
That design choice is why you read capabilities rather than hardcode the list. The enabled set is tenant-specific.
The enabled set is tenant-specific: read GET /capabilities
Not every credential type is enabled for every integration. The authoritative, per-tenant list lives in GET /v1/partner/capabilities (GA) under supported_credential_types. Read it before you assume any type is callable.
Fetch capabilities once at startup
GET /v1/partner/capabilities returns supported_credential_types, supported_currencies, enabled features, rate limits, and api_version. It is authenticated and tenant-scoped.
Cache it, keyed on api_version
Store the result. Re-fetch when the api_version you see in a response meta changes.
Re-fetch on a typed credential error
If a call returns CREDENTIAL_TYPE_UNSUPPORTED, your cached set is stale. Re-fetch and route accordingly.
GET /v1/partner/openapi is the second runtime source of truth: the CI-validated spec of every mounted route. When you need to know what exists at this moment, capabilities plus openapi answer it, not this page.
Identity errors
Three typed errors are specific to credential resolution. Each is a string code in error.code, never a bare HTTP number, and each echoes the valid set so you can recover without a second round trip.
The credential type is not enabled for this integration. error.details lists the supported set. Recovery: read GET /capabilities and use a supported type.
The path you called requires a dynamic, single-use credential (a qr nonce), but you sent a static one. Recovery: have the customer present a fresh QR.
HTTP 422. The credential matches a customer enrolled in more than one active wallet program under this enterprise, and you did not specify which. error.details.candidates lists the candidate wallet_program_id values.
{
"ok": false,
"data": null,
"error": {
"code": "WALLET_PROGRAM_AMBIGUOUS",
"message": "Customer resolves to multiple wallet programs; specify wallet_program_id.",
"details": { "candidates": ["wp_77", "wp_92"] }
},
"meta": { "request_id": "req_a1b2c3", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
When you get WALLET_PROGRAM_AMBIGUOUS, present a disambiguator from the listed candidates and re-call with wallet_program_id set. The candidate list is in the error body so you never have to guess.
What you get back: the CustomerContext
A successful resolution returns a CustomerContext. The shape is the same from /identify and from the session-anchored identify. It carries a resolution_state so you handle the not-found and pending-proof outcomes explicitly.
registered (fully enrolled, balance spendable), pending_proof (enrolled, balance visible, spend gated by the POS), or not_found (no wallet user for this credential under this enterprise; returned as HTTP 200 to distinguish from a server error).
First-class program binding. Null when not_found. Ambiguity raises WALLET_PROGRAM_AMBIGUOUS instead of returning this field.
The two-class balance breakdown (actual_minor, promo_available_minor, promo_locked_minor, and the rest). Null when not_found. See Money: Actual vs Promotional.
Opaque trace id for this resolution event. Persist it to correlate downstream audit logs and decision traces.
{
"ok": true,
"data": {
"resolution_state": "registered",
"wallet_user_id": "wu-aabbccdd-1234",
"wallet_program_id": "wp_77",
"wallet_id": "wlt-99001122-ffff",
"display_name": "Ahmed Al-Rashid",
"badge": "Gold",
"balance": { "actual_minor": 9500, "promo_available_minor": 500, "promo_locked_minor": 200, "currency": "QAR" },
"identity_trace_id": "idt-trace-xyzabc"
},
"error": null,
"meta": { "request_id": "req_55ff", "idempotency_replayed": false, "api_version": "2026-06-01", "decision_trace_id": "dt_88a1" }
}
There is no debit token anywhere in this response, and there never will be. Identity tells you who the customer is and what they hold. It does not authorize spending a cent of it.
The identity topology
Two rows back every customer. Knowing which is which prevents a whole class of modeling mistakes.
WalletUser: the POS identity
The point-of-sale-facing identity row, unique on (enterpriseId, normalizedPhone). This is what /identify resolves and what a checkout_session links to. It carries the state machine, including PENDING_PROOF.
User + Wallet: the canonical ledger
The money ledger, reached from a WalletUser by phone at credit time. The wallet_id in a CustomerContext points here. It is null until the first credit creates the bridge.
The uniqueness key, (enterpriseId, normalizedPhone), is why a customer's closed-loop balance is per-merchant. The same phone at two retailers is two distinct WalletUser rows with two distinct balances. That is the closed-loop money model, not an accident (see The Decisioning Model).
There is no CustomerTwin model. The "customer twin" the platform refers to is the resolved User. When you read older material that names a twin, it means the canonical User + Wallet pair.
Anonymous continuity and merge
Every session gets an anonymous_session_id the moment it opens, with or without a credential. Telemetry starts before identity does, so an offer-only or abandoned session is still a fully captured outcome.
When identity attaches later (typically at tender), the anonymous session merges into the resolved customer. The merge has three properties you can rely on:
One-way
The anonymous session folds into the customer. The customer is never demoted back to anonymous.
Idempotent
Re-running the merge with the same inputs produces the same result. A conflicting credential link resolves to a deterministic winner.
Never releases a LOCKED PromoGrant
A merge moves session telemetry onto the customer. It does not unlock promotional credit. Release happens only on verified phone proof.
The merge is a telemetry and identity operation, never a money operation. A LOCKED PromoGrant stays locked through a merge. Unlocking it is governed by The PENDING_PROOF Invariant.
Availability at a glance
| Operation | Path | Maturity |
|---|---|---|
| Resolve a credential | POST /v1/partner/identify | Beta |
| Identify within a session | POST /v1/partner/checkout/sessions/{id}/identify | Beta |
| Read enabled credential set | GET /v1/partner/capabilities | GA |
| Self-validate mounted routes | GET /v1/partner/openapi | GA |
Both identify endpoints are Beta: contracted with a frozen shape, shape may still tighten. The standalone /identify is the canonical resolver. The session-anchored identify carries the same maturity in the contract, so confirm it is mounted for your integration before you build against it. Confirm the live set at runtime via GET /capabilities and GET /openapi. Never assume an endpoint is callable because this page names it.
Where to go next
The PENDING_PROOF Invariant
Why a card link is probationary and how locked promo releases only on verified phone proof.
Identify a Customer
The identity endpoints in operational detail, with the session-anchored flow.
Idempotency & Errors
The envelope, the idempotency rule, and the full typed-error model.
Money: Actual vs Promotional
The two-class balance that a CustomerContext returns.