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

# Customers and Identification

> Look up a customer at checkout with one read. The cashier-panel lookup returns a four-state identity, both money classes, activity, and a badge.

## Overview

You resolve a customer with `GET /customers/lookup`. Pass a phone or a POS customer id, and get back one at-a-glance projection: the identity state, the customer name, both money classes, lifetime activity, and a derived badge.

The single rule that shapes this page: a stranger is not an error. All four identity states resolve to HTTP 200 with a `status` field, never a 404. Your point-of-sale can render a "register this customer" call to action instead of routing a normal lookup through an error path.

You typically call this at the start of an order, before payment.

<Info>
  This page covers the cashier-panel read. To attach identity inside an active checkout, or to
  resolve a credential with no panel, see [Identity and credentials](/concepts/identity-and-credentials).
</Info>

## The four-state model

`GET /customers/lookup` returns one of four identity states in `data.status`. Branch your panel UI on the state, not on the HTTP code.

<Columns cols="2">
  <Card title="registered" icon="user-check">
    A verified customer. You get name, member-since, both money classes, lifetime activity, and a
    derived badge. Greet them and show the balance.
  </Card>

  <Card title="pending_proof" icon="lock">
    A proto-wallet that has accrued `LOCKED` cashback but has not verified a phone. The response
    carries `unclaimed_cashback`. Prompt the customer to register and claim it.
  </Card>

  <Card title="not_found" icon="user-plus">
    A stranger. `customer` is `null`. Render the register call to action. This is a valid outcome at
    HTTP 200, not an error.
  </Card>

  <Card title="disabled" icon="user-x">
    An operator-shut-off record. It surfaces as `not_found` with no PII and no register affordance,
    so only three values appear in `status`.
  </Card>
</Columns>

<Warning>
  `not_found` is HTTP 200 with `status` of `not_found`, never a 404. Returning a 404 would push a
  routine "who is this?" lookup through your error pipeline and deny the cashier the register
  affordance. Branch on `data.status`, not on the status code.
</Warning>

### Cross-enterprise reads resolve to not\_found

Every lookup is scoped to the enterprise resolved from the verified POS-terminal JWT, never from request input. The same phone at a different enterprise resolves a different record set, so a cross-enterprise read surfaces as `not_found` at HTTP 200, not a 403. A balance is scoped per merchant.

A 403 is reserved for a token that is valid but not authorized for the merchant or branch scope it claims.

## Look up a customer

`GET /customers/lookup` is callable today. Authenticate with a POS-terminal JWT. Pass at least one of `phone` or `provider_customer_id`.

<ParamField query="phone" param-type="string" required="false">
  E.164 phone to resolve the customer. The typed phone is authoritative. It is normalized (trimmed)
  the same way the top-up write path normalizes, so the read key byte-matches the stored record. At
  least one of `phone` or `provider_customer_id` is required.
</ParamField>

<ParamField query="provider_customer_id" param-type="string" required="false">
  The POS provider's own customer id. The no-phone fallback. Resolved within the calling
  integration's enterprise only.
</ParamField>

<CodeGroup>
  ```bash Request theme={null}
  curl -G https://api.feddi.io/v1/partner/customers/lookup \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    --data-urlencode "phone=+97433001122"
  ```

  ```json 200 registered theme={null}
  {
    "ok": true,
    "data": {
      "status": "registered",
      "customer": {
        "name": "Layla Hassan",
        "phone": "+97433001122",
        "state": "VERIFIED",
        "member_since": "2025-11-02T08:14:00Z",
        "balance": {
          "spendable_minor": 12500,
          "locked_promo_minor": 0,
          "currency": "QAR"
        },
        "activity": {
          "lifetime_spend_minor": 184000,
          "visit_count": 27,
          "visit_count_last_90d": 9,
          "last_visit_at": "2026-06-01T19:42:00Z"
        },
        "badge": "vip"
      },
      "unclaimed_cashback": null,
      "branch_id": "22222222-2222-2222-2222-222222222222",
      "fetched_at": "2026-06-05T09:10:00Z"
    },
    "error": null,
    "meta": {
      "request_id": "req_lk1",
      "idempotency_replayed": false,
      "api_version": "2026-06-01",
      "data_completeness_score": 80
    }
  }
  ```

  ```json 400 missing credential theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "Supply at least one of phone or provider_customer_id."
    },
    "meta": {
      "request_id": "req_lk2",
      "api_version": "2026-06-01"
    }
  }
  ```
</CodeGroup>

### Response fields

The lookup returns identity, both money classes, lifetime activity, and a badge in one payload.

<ResponseField name="status" field-type="string" required="true">
  The identity state. One of `registered`, `pending_proof`, `not_found`. A `disabled` record reports
  as `not_found`. Branch your panel on this.
</ResponseField>

<ResponseField name="customer" field-type="object">
  The customer projection, or `null` when `status` is `not_found`.
</ResponseField>

<ResponseField name="customer.name" field-type="string">
  Customer name, or `null` when unknown.
</ResponseField>

<ResponseField name="customer.phone" field-type="string" required="true">
  The E.164 phone the summary resolved to.
</ResponseField>

<ResponseField name="customer.state" field-type="string" required="true">
  The underlying identity state machine value. One of `VERIFIED`, `PENDING_PROOF`. A `DISABLED`
  identity is never returned in a customer body, it surfaces as top-level `status` of `not_found`
  with a null customer.
</ResponseField>

<ResponseField name="customer.member_since" field-type="string" required="true">
  ISO-8601 date-time of when this customer first became known at the merchant.
</ResponseField>

<ResponseField name="customer.balance.spendable_minor" field-type="integer" required="true">
  Actual (cashable) balance the customer can spend now, in minor units. The customer's claim against
  the merchant's deferred-revenue liability.
</ResponseField>

<ResponseField name="customer.balance.locked_promo_minor" field-type="integer" required="true">
  Sum of `LOCKED` promotional credit awaiting register-to-claim, in minor units. Surfaced, not
  spendable until the customer verifies. See [Money: actual vs promotional](/concepts/money-classes).
</ResponseField>

<ResponseField name="customer.balance.currency" field-type="string">
  ISO-4217 code for the money above, or `null` when the customer has no money of either kind.
</ResponseField>

<ResponseField name="customer.activity.lifetime_spend_minor" field-type="integer" required="true">
  Lifetime completed spend (payments out of the wallet), in minor units.
</ResponseField>

<ResponseField name="customer.activity.visit_count" field-type="integer" required="true">
  Lifetime count of completed purchases.
</ResponseField>

<ResponseField name="customer.activity.visit_count_last_90d" field-type="integer" required="true">
  Completed purchases in the last 90 days.
</ResponseField>

<ResponseField name="customer.activity.last_visit_at" field-type="string">
  ISO-8601 date-time of the most recent completed activity (pay or top-up), or `null`.
</ResponseField>

<ResponseField name="customer.badge" field-type="string" required="true">
  A derived classification. One of `new`, `regular`, `vip`, `lapsed`. Precedence is fixed: the lapse
  check is evaluated before the VIP check, so a high-value but inactive customer reads `lapsed`
  rather than `vip`.
</ResponseField>

<ResponseField name="unclaimed_cashback" field-type="object">
  Present (non-null) only when `LOCKED` cashback is waiting, typically with `status` of
  `pending_proof`. Carries `amount_minor` (integer, minor units) and `currency` (string or `null`).
</ResponseField>

<ResponseField name="branch_id" field-type="string">
  Echoed from the POS-terminal JWT scope, never from request input. `null` for enterprise or
  brand-scoped tokens.
</ResponseField>

<ResponseField name="fetched_at" field-type="string" required="true">
  ISO-8601 date-time of when this projection was read.
</ResponseField>

<Info>
  This read surfaces a proto-wallet. It never credits, releases, or moves money. The `pending_proof`
  state gates every money side effect elsewhere, lookup is read-only.
</Info>

### Badge values

<Expandable title="How each badge is derived">
  * `new`: no purchases, or a single purchase within the new-member window.
  * `vip`: lifetime spend at or above the configured threshold, or visit count at or above the
    configured threshold.
  * `lapsed`: no activity for at least the configured lapse-days threshold. Outranks `vip`.
  * `regular`: any customer not matching the preceding cases.
</Expandable>

### Errors

<ResponseField name="VALIDATION_ERROR" field-type="400">
  Neither `phone` nor `provider_customer_id` was supplied, or a value is malformed. Send at least one
  valid credential.
</ResponseField>

<ResponseField name="INVALID_API_KEY" field-type="401">
  The terminal JWT is missing, expired, malformed, or has the wrong audience. Re-mint the token. See
  [Authentication](/api-reference/authentication).
</ResponseField>

<ResponseField name="FORBIDDEN" field-type="403">
  The token is valid but not authorized for the merchant or branch scope it claims. A cross-enterprise
  customer is not a 403, it resolves to `not_found` at HTTP 200 to avoid a PII leak.
</ResponseField>

Full set: [Errors](/api-reference/errors).

## Resolving identity without the panel

`GET /customers/lookup` is the cashier-panel read. To resolve a customer credential directly, or to attach identity to a checkout, use one of the identify resources. Both return a `CustomerContext` and never return a debit or authorize token. Resolving who the customer is never grants the right to move their balance.

<Columns cols="2">
  <Card title="Standalone identify" icon="scan-line" href="/guides/platform">
    `POST /identify` resolves a customer for a balance-check or POS-display panel with no active
    checkout. It accepts a discriminated credential and returns a `CustomerContext`.
  </Card>

  <Card title="Session identify" icon="link" href="/concepts/the-checkout-session">
    `POST /checkout/sessions/{id}/identify` attaches identity to an open session and merges the
    anonymous session into the customer.
  </Card>
</Columns>

### POST /identify

The standalone identify accepts one credential, discriminated by `credential_type`, and returns a `CustomerContext`. Supported `credential_type` values are `phone`, `card_fingerprint`, `short_code`, `provider_customer_id`, and `qr`. Call `GET /capabilities` for the enabled set. Authenticate with an API key or a POS-terminal JWT. Send the `Idempotency-Key` header on this call.

<ParamField body="meta" param-type="object" required="false">
  Request metadata. See [Conventions](/api-reference/conventions).
</ParamField>

<ParamField body="context" param-type="object" required="true">
  The merchant, branch, terminal, and cashier context for this call.
</ParamField>

<ParamField body="credential" param-type="object" required="true">
  Exactly one credential, discriminated by `credential.credential_type`. A `credential_type` not in
  the supported set returns `CREDENTIAL_TYPE_UNSUPPORTED` listing the supported values.
</ParamField>

<CodeGroup>
  ```bash Request theme={null}
  curl https://api.feddi.io/v1/partner/identify \
    -H "Authorization: Bearer $TERMINAL_JWT" \
    -H "Idempotency-Key: 7f3a1e20-1b2c-4d5e-8f90-a1b2c3d4e5f6" \
    -H "Content-Type: application/json" \
    -d '{
      "context": {
        "merchant_id": "11111111-1111-1111-1111-111111111111",
        "branch_id": "22222222-2222-2222-2222-222222222222",
        "terminal_id": "POS-360-0001",
        "cashier_id": "cashier-7"
      },
      "credential": {
        "credential_type": "phone",
        "phone": "+97433001122"
      }
    }'
  ```

  ```json 200 registered 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"
      }
    },
    "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 ambiguous program theme={null}
  {
    "ok": false,
    "data": null,
    "error": {
      "code": "WALLET_PROGRAM_AMBIGUOUS",
      "message": "Credential matches multiple active programs.",
      "details": {
        "candidates": ["wp-55667788-0001", "wp-55667788-0002"]
      }
    },
    "meta": {
      "request_id": "req_b2c3e1",
      "api_version": "2026-06-01"
    }
  }
  ```
</CodeGroup>

<ResponseField name="resolution_state" field-type="string" required="true">
  One of `registered` (fully enrolled, balance readable and spendable), `pending_proof` (enrolled but
  not yet identity-verified, balance visible, spend may be gated by the POS), `not_found` (no customer
  for this credential under this enterprise). `not_found` is HTTP 200 with a `null` balance, not a
  404, so polling UIs can distinguish "no wallet" from a server error.
</ResponseField>

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

<ResponseField name="wallet_program_id" field-type="string">
  The wallet program binding. `null` when `resolution_state` is `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 `resolution_state` is `not_found` or the wallet has no credits yet.
</ResponseField>

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

<ResponseField name="display_name" field-type="string">
  Customer display name, or `null` when unknown.
</ResponseField>

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

<ResponseField name="balance" field-type="object">
  Wallet balance breakdown, or `null` when `resolution_state` is `not_found`.
</ResponseField>

<Warning>
  `POST /identify` is rate-limited per integration. A 429 `RATE_LIMITED` response carries
  `Retry-After` and `X-RateLimit-*` headers. Back off and retry after the indicated delay.
</Warning>

<Expandable title="identify error codes">
  * `VALIDATION_ERROR` (400): malformed body, unrecognized `credential_type`, or a missing required
    credential field.
  * `INVALID_API_KEY` (401): missing or invalid API key or POS-terminal JWT.
  * `FORBIDDEN` (403): the credential is valid but not authorized for this merchant scope.
  * `WALLET_PROGRAM_AMBIGUOUS` (422): the credential matches a customer in multiple active programs.
    `error.details.candidates` lists `wallet_program_id` values so you can present a disambiguator.
  * `RATE_LIMITED` (429): per-integration identify quota exceeded.
</Expandable>

## Where to go next

<Columns cols="2">
  <Card title="Identity and credentials" href="/concepts/identity-and-credentials" icon="fingerprint">
    The credential model, and why identify never returns a debit token.
  </Card>

  <Card title="Enrollment and signup" href="/guides/enrollment" icon="user-plus">
    Turn a not\_found stranger or a pending\_proof proto-wallet into a verified customer.
  </Card>

  <Card title="Money: actual vs promotional" href="/concepts/money-classes" icon="coins">
    The two-class balance the lookup surfaces: spendable actual and LOCKED promo.
  </Card>

  <Card title="The checkout session" href="/concepts/the-checkout-session" icon="receipt">
    Group the calls of one customer interaction and attach identity to it.
  </Card>
</Columns>
