Core ConceptsIdentity & Credentials

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_typeWhat it isNotes
phoneE.164 phone numberFeddi-native. OTP and proof flows attach to the identified phone.
card_fingerprintMasked-PAN hash from the terminalThe link is probationary until the phone is verified. A masked-PAN collision never auto-acts on money.
short_code5-character consumer-minted codeAlready 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.

CREDENTIAL_TYPE_UNSUPPORTEDstring

The credential type is not enabled for this integration. error.details lists the supported set. Recovery: read GET /capabilities and use a supported type.

REQUIRES_DYNAMIC_CREDENTIALstring

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.

WALLET_PROGRAM_AMBIGUOUSstring

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.

resolution_statestring

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).

wallet_program_idstring

First-class program binding. Null when not_found. Ambiguity raises WALLET_PROGRAM_AMBIGUOUS instead of returning this field.

balanceobject

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.

identity_trace_idstring

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.

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

OperationPathMaturity
Resolve a credentialPOST /v1/partner/identifyBeta
Identify within a sessionPOST /v1/partner/checkout/sessions/{id}/identifyBeta
Read enabled credential setGET /v1/partner/capabilitiesGA
Self-validate mounted routesGET /v1/partner/openapiGA

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