> ## Documentation Index
> Fetch the complete documentation index at: https://docs.feddi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity and Credentials

> Resolve any POS credential to a CustomerContext. Four credential types, one discriminated-union shape, and the strict line between identity and money.

## Resolve a credential 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. An identity call never returns a debit token, an authorization token, or any artifact that permits a ledger mutation. Identity and money 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 payment endpoint with its own credential. See [Payments and redemption](/guides/payments).

The sequence below shows a presented credential resolving to a `CustomerContext`. The credential is an opaque input and the resolution is an opaque output; the POS sends one and reads the other.

```mermaid theme={null}
sequenceDiagram
    participant Cust as Customer
    participant POS as POS / Cashier
    participant Feddi as Feddi Partner API

    Cust->>POS: Present credential (phone / QR nonce / short code)
    POS->>Feddi: POST /v1/partner/identify (one credential block)
    Feddi-->>POS: CustomerContext (resolution_state, wallet, balance)
    Note over POS: Branch on resolution_state; no debit token returned
    POS-->>Cust: Show wallet state and balance
```

The canonical resolver is `POST /v1/partner/identify`. 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.

<Info>
  A customer's balance is scoped per merchant. The same phone at two merchants resolves to two distinct balances.
</Info>

## Credential types

The point of sale always has one signal. Feddi resolves any supported credential type to the same `CustomerContext`. Each call sends exactly one credential, keyed by `credential_type`.

The `credential_type` enum is `phone`, `provider_customer_id`, `card_fingerprint`, `short_code`, and `qr`. The set enabled for your integration is capabilities-driven, never a fixed list. Read it from `GET /capabilities` under `supported_identify_credential_types` before you assume a type is callable.

<ResponseField name="phone" field-type="string">
  E.164 phone number. Feddi-native. OTP and proof flows attach to the identified phone.
</ResponseField>

<ResponseField name="provider_customer_id" field-type="string">
  The POS platform's own customer id.
</ResponseField>

<ResponseField name="card_fingerprint" field-type="string">
  Tokenised card fingerprint from a card-tap or NFC read, never the full PAN. The link is probationary until the phone is verified. A masked-PAN collision never auto-acts on money.
</ResponseField>

<ResponseField name="short_code" field-type="string">
  A 4-8 alphanumeric code the customer displays from the wallet app. Already verified by Feddi.
</ResponseField>

<ResponseField name="qr" field-type="string">
  Raw QR payload scanned from the wallet app. Single-use.
</ResponseField>

Each call carries one credential block:

```json theme={null}
{ "credential_type": "phone", "phone": "+97433001122" }
```

```json theme={null}
{ "credential_type": "card_fingerprint", "card_fingerprint": "fp_a1b2c3d4e5f6" }
```

```json theme={null}
{ "credential_type": "short_code", "short_code": "F3A9" }
```

<Warning>
  A `card_fingerprint` link is probationary. Feddi recognizes the card to surface a likely customer, but it does not release locked promotional credit or treat the customer as verified until the phone is proven. See [PENDING\_PROOF and promo release](/concepts/pending-proof).
</Warning>

### The discriminated-union shape

`credential_type` is the discriminator. You send one credential block per call, and Feddi reads the discriminator to pick the validator. New credential types are added additively: a new type appears in `GET /capabilities` without any route change, and your existing `/identify` integration keeps working unchanged. This is why you read capabilities rather than hardcode the list.

## Resolve a customer

<Steps>
  <Step title="Read capabilities once at startup" icon="list-checks">
    Call `GET /v1/partner/capabilities`. It returns `supported_identify_credential_types`, `supported_payment_credential_types`, `supported_currencies`, `features`, `rate_limits`, and `api_version`. Identity resolution reads `supported_identify_credential_types`. Cache the result, keyed on `api_version`, and re-fetch when the `api_version` in a response `meta` changes.
  </Step>

  <Step title="Send the credential you have" icon="user-search">
    Call `POST /v1/partner/identify` with `meta`, `context`, and one `credential` block. The standalone resolver fits balance-check panels, cashier lookup screens, and POS displays where there is no active checkout session.
  </Step>

  <Step title="Branch on resolution_state" icon="git-branch">
    Inspect `data.resolution_state`. Handle `registered`, `pending_proof`, and `not_found` explicitly. `not_found` is returned as HTTP `200`, not `404`, so a polling UI can tell 'no wallet' from a server error.
  </Step>

  <Step title="Re-fetch capabilities on CREDENTIAL_TYPE_UNSUPPORTED" icon="refresh-cw">
    A `CREDENTIAL_TYPE_UNSUPPORTED` error means your cached set is stale. Re-read `GET /capabilities` and route to a supported type.
  </Step>
</Steps>

The standalone `/identify` request body carries three required blocks: `meta`, `context`, and `credential`.

<ParamField body="meta" param-type="object" required="true">
  Request metadata. Carries `partner_request_id` (a correlation id, not the idempotency basis) and `api_version`. Optional `occurred_at` and `sent_at` timestamps. Send both timestamps when you have them.
</ParamField>

<ParamField body="context" param-type="object" required="true">
  Request context. `merchant_id` is required and key-enforced to a merchant your credential is authorized for, never a free-text trust field. Optional `branch_id`, `terminal_id`, `cashier_id`, `partner_session_id`, `feddi_session_id`.
</ParamField>

<ParamField body="credential" param-type="object" required="true">
  Exactly one credential block, keyed by `credential_type`. See the credential types above.
</ParamField>

The `Idempotency-Key` header is optional on this call: identify is a side-effect-free resolution. Sending it is allowed, and a retry with the same key and the same payload replays the original response.

<CodeGroup>
  ```bash request.sh wrap="true" theme={null}
  curl -X POST https://api.feddi.io/v1/partner/identify \
    -H "x-api-key: $FEDDI_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 7f3a1e20-1b2c-4d5e-8f90-a1b2c3d4e5f6" \
    -d '{
      "meta": {
        "partner_request_id": "7f3a1e20-1b2c-4d5e-8f90-a1b2c3d4e5f6",
        "api_version": "2026-06-01"
      },
      "context": { "merchant_id": "11111111-1111-1111-1111-111111111111" },
      "credential": { "credential_type": "phone", "phone": "+97433001122" }
    }'
  ```

  ```json 200 OK theme={null}
  {
    "ok": true,
    "data": {
      "resolution_state": "registered",
      "wallet_user_id": "wu-aabbccdd-1234",
      "wallet_program_id": "wp-55667788-0001",
      "wallet_id": "wlt-99001122-ffff",
      "identity_trace_id": "idt-trace-xyzabc",
      "display_name": "Ahmed Al-Rashid",
      "badge": "Gold",
      "balance": {
        "actual_minor": 15000,
        "promo_available_minor": 500,
        "promo_locked_minor": 1200,
        "pending_topups_minor": 0,
        "currency": "QAR",
        "promo_grants": [
          { "source": "CASHBACK", "state": "RELEASED", "remaining_minor": 500, "expires_at": "2026-09-05T00:00:00Z" },
          { "source": "RELOAD_BONUS", "state": "LOCKED", "remaining_minor": 1200, "expires_at": "2026-09-10T00:00:00Z" }
        ]
      }
    },
    "error": null,
    "meta": {
      "request_id": "req_b2c3d4",
      "idempotency_replayed": false,
      "api_version": "2026-06-01",
      "data_completeness_score": 88,
      "decision_trace_id": null
    }
  }
  ```

  ```json 422 WALLET_PROGRAM_AMBIGUOUS theme={null}
  {
    "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"
    }
  }
  ```
</CodeGroup>

## The CustomerContext response

A successful resolution returns a `CustomerContext`. The shape is the same from `/identify` and from the session-anchored identify. `resolution_state` and `identity_trace_id` are always present. The customer fields and `balance` are null when `resolution_state` is `not_found`.

<ResponseField name="resolution_state" field-type="string" required="true">
  One of `registered` (fully enrolled, balance readable and spendable), `pending_proof` (enrolled, balance visible, spend may be gated by the POS), or `not_found` (no wallet user for this credential under this enterprise, returned as HTTP `200`).
</ResponseField>

<ResponseField name="wallet_user_id" field-type="string">
  The customer's POS-facing identity id. Null when `resolution_state` is `not_found`.
</ResponseField>

<ResponseField name="wallet_program_id" field-type="string">
  First-class program binding. Null when `not_found`. Ambiguity raises HTTP `422` `WALLET_PROGRAM_AMBIGUOUS` instead of returning this field.
</ResponseField>

<ResponseField name="wallet_id" field-type="string">
  The ledger id. Null when `not_found`, or when the customer's wallet has not yet been created (no credits yet).
</ResponseField>

<ResponseField name="display_name" field-type="string">
  Customer display name. Null when unknown.
</ResponseField>

<ResponseField name="badge" field-type="string">
  Current loyalty tier label, for example `Gold` or `Silver`. Null when the program has no tiering.
</ResponseField>

<ResponseField name="balance" field-type="object">
  The two-class balance breakdown: `actual_minor`, `promo_available_minor`, `promo_locked_minor`, `pending_topups_minor`, `currency`, and a `promo_grants` array. Null when `not_found`. See [Money: actual vs promotional](/concepts/money-classes).
</ResponseField>

<ResponseField name="identity_trace_id" field-type="string" required="true">
  An opaque correlation id for this resolution. Persist it and echo it where a call accepts one.
</ResponseField>

<Danger>
  There is no debit token anywhere in this response. Identity tells you who the customer is and what they hold. It does not authorize spending any of it.
</Danger>

## Identify within a checkout session

The session-anchored form attaches an identity to an open checkout session, upgrading it from `OPEN` to `IDENTIFIED`, and merges the session's anonymous activity into the resolved customer. Use `POST /v1/partner/checkout/sessions/{id}/identify`. It returns the same `CustomerContext`.

The request body differs from the standalone resolver: the credential block is named `customer`, and an optional top-level `wallet_program_id` disambiguates a multi-program match.

<ParamField body="context" param-type="object" required="true">
  Request context. `feddi_session_id` is redundant with the path `{id}` and must match it when both are present.
</ParamField>

<ParamField body="customer" param-type="object" required="true">
  Exactly one credential block, keyed by `credential_type`. Same credential types as the standalone resolver.
</ParamField>

<ParamField body="wallet_program_id" param-type="string" required="false">
  Disambiguates when the identity resolves to multiple wallet programs. Omit it to receive `WALLET_PROGRAM_AMBIGUOUS` with the candidate list.
</ParamField>

The merge has three properties you can rely on:

<Steps>
  <Step title="One-way" icon="arrow-right">
    The anonymous session folds into the customer. The customer is never demoted back to anonymous.
  </Step>

  <Step title="Idempotent" icon="repeat">
    Re-running the merge with the same inputs produces the same result. The call is idempotent on the `Idempotency-Key` header: a retry replays the original `CustomerContext`.
  </Step>

  <Step title="Never releases a LOCKED promo grant" icon="lock">
    The merge moves session activity onto the customer. It does not unlock promotional credit. Release happens only on verified phone proof. See [PENDING\_PROOF and promo release](/concepts/pending-proof).
  </Step>
</Steps>

## Identity errors

Each error is a string code in `error.code`, never a bare HTTP number. The typed errors specific to credential resolution echo the valid set so you can recover without a second round trip.

<ResponseField name="CREDENTIAL_TYPE_UNSUPPORTED" field-type="string">
  HTTP `422`. The credential type is not enabled for this integration. `error.details.supported` lists the supported set. Recovery: re-read `GET /capabilities` and use a supported type.
</ResponseField>

<ResponseField name="WALLET_PROGRAM_AMBIGUOUS" field-type="string">
  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. Recovery: present a disambiguator and re-call with `wallet_program_id` set.
</ResponseField>

<ResponseField name="VALIDATION_ERROR" field-type="string">
  HTTP `400`. Malformed body, unrecognized `credential_type`, or a missing required credential field. `error.details` carries the field errors.
</ResponseField>

<ResponseField name="INVALID_API_KEY" field-type="string">
  HTTP `401`. Missing or invalid `x-api-key` or POS terminal JWT.
</ResponseField>

<ResponseField name="FORBIDDEN" field-type="string">
  HTTP `403`. The key is valid but not authorized for the merchant or branch scope of the call.
</ResponseField>

<ResponseField name="RATE_LIMITED" field-type="string">
  HTTP `429`. The per-integration identify quota is exceeded. The response carries `Retry-After` and `X-RateLimit-*` headers.
</ResponseField>

<Expandable title="Recover from WALLET_PROGRAM_AMBIGUOUS">
  Read `error.details.candidates`, present the candidate `wallet_program_id` values to the customer or cashier, then re-call with the chosen `wallet_program_id`. On the session-anchored form, send it as the top-level `wallet_program_id` field. On the standalone resolver, the candidate list is in the error body so you never have to guess.
</Expandable>

## The enabled set is tenant-specific

Not every credential type is enabled for every integration. The authoritative, per-tenant list lives in `GET /v1/partner/capabilities` under `supported_identify_credential_types`. `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 right now, capabilities plus openapi answer it, not this page. Never assume an endpoint is callable because this page names it.

The session-anchored identify shares the contract and credential model of the standalone resolver. Confirm it is enabled for your integration via `GET /capabilities` before you build against it.

## Where to go next

<Columns cols="2">
  <Card title="PENDING_PROOF and promo release" href="/concepts/pending-proof" icon="lock">
    Why a card link is probationary and how locked promo releases only on verified phone proof.
  </Card>

  <Card title="Customers and identification" href="/guides/customers" icon="user-search">
    The cashier-panel lookup, the four-state model, and the projection fields.
  </Card>

  <Card title="Idempotency and errors" href="/concepts/idempotency-and-errors" icon="shield-alert">
    The response envelope, the idempotency rule, and the full typed-error model.
  </Card>

  <Card title="Money: actual vs promotional" href="/concepts/money-classes" icon="coins">
    The two-class balance that a CustomerContext returns.
  </Card>
</Columns>
