Domain GuidesCustomers & Identification

Customers & Identification

Resolve who is standing at the counter with one read. The cashier-panel lookup returns a 4-state identity, both money classes, lifetime activity, and a badge, and a stranger is a 200, not a 404.

Overview

You resolve a customer with GET /customers/lookup: pass a phone or a POS customer id, get back one at-a-glance projection. The single rule that shapes everything on this page: a stranger is not an error. All four identity states resolve to HTTP 200 with a status field, never a 404, so your point-of-sale can render a "register this customer" call-to-action instead of routing a normal lookup through an error path.

This is the most-leveraged read in the surface. You hit it at the start of nearly every order, before basket, before offers, before payment.

Looking for a single identity-resolution call inside a session? That lives in the checkout_session spine, not here. This page covers the cashier-panel read model and the customer-record lifecycle (preferences, events, GDPR). For how a session learns who the customer is, see Identity & Credentials.

The 4-state cashier-panel model

GET /customers/lookup returns one of four identity states. You branch your panel UI on data.status. The state, not the HTTP code, is the business answer.

A fifth case folds into the third on purpose. A disabled identity (an operator-shut-off record) surfaces as not_found: no PII, no register affordance for a deliberately disabled flow.

not_found is HTTP 200 with status: "not_found", never a 404. Returning 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.

Why a cross-enterprise customer is also not_found

Tenant isolation is structural. Every lookup query scopes to the enterprise resolved from the verified POS-terminal JWT, never from request input. The same phone at a different enterprise resolves a different row set, so a cross-enterprise read is impossible: it surfaces as not_found (HTTP 200), not a 403. A 403 is reserved for a token that is not authorized for the merchant or branch scope it claims.

Look up a customer

GET /customers/lookup is Beta: callable today, shape may still tighten. Authenticate with a POS-terminal JWT. Pass at least one of phone or provider_customer_id.

query
phonestring

E.164 phone to resolve the customer. The typed phone is authoritative. Normalized (trimmed) the same way the top-up write path normalizes, so the read key byte-matches the stored record.

query
provider_customer_idstring

The POS provider's own customer id. The no-phone fallback. Resolved within the calling integration's enterprise only.

curl -G https://api.feddi.io/v1/partner/customers/lookup \
  -H "Authorization: Bearer $TERMINAL_JWT" \
  --data-urlencode "phone=+97433001122"

The projection

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

statusstring

The 4-state identity: registered | pending_proof | not_found. A disabled record reports as not_found. Branch your panel on this.

customerobject | null

The customer record. null when status is not_found.

balance.spendable_minorinteger

Actual (cashable) balance the customer can spend now, in minor units. Paired with an explicit currency.

balance.locked_promo_minorinteger

LOCKED promotional credit awaiting register-to-claim. Surfaced, never spendable yet. See Money: Actual vs Promotional.

badgestring

A derived classification: new | regular | vip | lapsed. Computed from the same activity aggregates already in the response.

unclaimed_cashbackobject | null

Present (non-null) when status is pending_proof. The register-to-claim hook for a proto-wallet.

branch_idstring

Echoed unspoofably from the JWT scope, never from request input.

This read merely surfaces a proto-wallet. It never credits, releases, or moves money. PENDING_PROOF gates every money side-effect elsewhere; lookup is read-only.

Identity resolution is one resource, not this one

There is no standalone POST /customers/identify. Identity resolution is deliberately a single resource in the contract, and it lives elsewhere. The folded path exists in the spec only so an integrator who searches for /customers/identify finds the redirect.

Use one of these instead:

Both return a CustomerContext and never a debit or authorize token. Identity and money are strictly separated: resolving who the customer is never grants the right to move their balance. That invariant is covered in Identity & Credentials.

Operations in this domain

Availability is per-operation and load-bearing. Build against Beta today. Everything marked Planned is shape-frozen but not yet mounted, and Preview is reserved pending a founder and legal call. The authoritative, live list of mounted routes is always GET /capabilities and GET /openapi, never this page alone.

OperationMethod + pathAvailability
Cashier-panel lookup (4-state)GET /customers/lookupBeta
Folded identify (use /identify instead)POST /customers/identifyPlanned (folded)
Read stored preferencesGET /customers/{phone}/preferencesPlanned
Cached summary projectionGET /customers/{phone}/summary-cachedPlanned
Admin customer searchPOST /customers/searchPlanned
Merchant dashboard statsGET /customers/statsPlanned
Badge reason + next-tier gapGET /customers/{phone}/badge-reasonPlanned
Log a non-purchase eventPOST /customers/{phone}/eventsPlanned
Customer-twin context readGET /customers/{id}/contextPlanned
GDPR/PDPL data exportGET /customers/{id}/exportPreview
GDPR/PDPL erasureDELETE /customers/{customerId}Preview
Correct a typo'd name/phonePATCH /customers/{customerId}Planned

Two Planned reads are deliberately redundant with lookup. summary-cached is a performance variant (same shape, precomputed projection), and badge-reason extends the badge classifier. Both are recommended to fold into lookup rather than a second round-trip, since the same aggregates already computed produce them for free.

Non-purchase signals (Planned)

POST /customers/{phone}/events records a check-in, a survey response, a loyalty-card scan, or a feedback submission so the Brain captures the full interaction graph, not just transactions. This is the counterfactual surface for the customer domain: the things a customer does that are not a purchase.

The event is idempotent on the Idempotency-Key header, so a retried report replays the original recorded event and never double-logs.

{
  "event_type": "check_in",
  "attributes": {
    "source": "loyalty_card_scan",
    "note": "Walked in, browsed, did not buy"
  }
}

An unknown event_type is a VALIDATION_ERROR that lists the supported set. The event publishes to the domain-event bus only after the write commits. This operation is Planned: wire it, but treat it as not-callable until it flips.

Correcting and merging identity (Planned)

PATCH /customers/{customerId} corrects a mistyped name or phone. A phone correction re-triggers verification: the record returns to (or stays in) PENDING_PROOF and a fresh verification link is sent to the new number. A typo'd proto-wallet that accrued a LOCKED grant keeps its claimable promo, which follows the corrected identity, so it is never lost. A name-only correction does not re-verify.

Because a phone change moves the (enterprise, normalized_phone) identity key, the new number must not already resolve to a different existing customer. If it does, you get a VALIDATION_ERROR, and you use the merge resource for a genuine duplicate. This operation is Planned.

Data subject rights: export and erasure (Preview)

The GDPR/PDPL surface is Preview: the request and response shapes are stable, but whether these ship day-one or in v1.5 is an open founder and legal call. The shape will not shift under you, so you can wire against it now.

Export a customer's data

GET /customers/{id}/export produces a portable export of everything Feddi holds for one customer (identity, balances, ledger, promo grants, transactions, consent, events) and returns a signed, time-limited download URL rather than inlining a large payload. Enterprise-admin RBAC (customer EXPORT permission).

Erase a customer (state-aware)

DELETE /customers/{customerId} is state-aware. A PENDING_PROOF proto-wallet (never verified, holds only unclaimed cashback) is hard-deleted. A VERIFIED customer is anonymized: PII is scrubbed and lookups thereafter return not_found, but the transaction audit trail is preserved for the merchant's deferred-revenue and financial-audit obligations. Idempotent on the Idempotency-Key header. Enterprise-admin RBAC (customer DELETE permission).

{
  "customer_id": "usr_5512",
  "outcome": "anonymized",
  "prior_state": "VERIFIED",
  "transactions_retained": true,
  "pii_scrubbed": true,
  "erased_at": "2026-06-05T09:20:00Z"
}

Erasing a customer who holds a non-zero spendable balance is a documented edge. The call either blocks (the balance must be settled first, returning VALIDATION_ERROR) or escalates. It never silently destroys a live monetary claim, because the customer's actual balance is a real merchant liability, not marketing data.

Where to go next