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.
registered
A VERIFIED customer. You get name, member-since, both money classes, lifetime activity, and a derived badge. Greet them, show the balance, surface offers.
pending_proof
A proto-wallet that has accrued LOCKED cashback but has never verified a phone. The response
carries unclaimed_cashback: the "money waiting for you" hook. Drive register-to-claim.
not_found
A stranger. customer is null. Render the register CTA. This is a valid outcome at HTTP 200,
not an error.
identified-in-session
Already resolved on the open checkout_session via session identify. You do not re-look-up; you
read the CustomerContext the session already holds.
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.
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.
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"
{
"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
}
}
The projection
The lookup returns identity, both money classes, lifetime activity, and a badge in one payload.
The 4-state identity: registered | pending_proof | not_found. A disabled record reports as
not_found. Branch your panel on this.
The customer record. null when status is not_found.
Actual (cashable) balance the customer can spend now, in minor units. Paired with an explicit
currency.
LOCKED promotional credit awaiting register-to-claim. Surfaced, never spendable yet. See Money: Actual vs Promotional.
A derived classification: new | regular | vip | lapsed. Computed from the same activity
aggregates already in the response.
Present (non-null) when status is pending_proof. The register-to-claim hook for a proto-wallet.
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.
VALIDATION_ERROR (400) when neither phone nor provider_customer_id is supplied, or a value
is malformed. INVALID_API_KEY (401) when the terminal JWT is missing, expired, malformed, or has
the wrong audience. FORBIDDEN (403) only when 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. Full set: Errors.
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:
POST /identify (sessionless)
The thin-partner fallback. Resolves a customer for a balance-check panel with no active session.
Returns a CustomerContext, never a debit token.
Session identify (preferred)
POST /checkout/sessions/{id}/identify attaches identity to an open session and merges the
anonymous session into the customer. The richer path.
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.
| Operation | Method + path | Availability |
|---|---|---|
| Cashier-panel lookup (4-state) | GET /customers/lookup | Beta |
Folded identify (use /identify instead) | POST /customers/identify | Planned (folded) |
| Read stored preferences | GET /customers/{phone}/preferences | Planned |
| Cached summary projection | GET /customers/{phone}/summary-cached | Planned |
| Admin customer search | POST /customers/search | Planned |
| Merchant dashboard stats | GET /customers/stats | Planned |
| Badge reason + next-tier gap | GET /customers/{phone}/badge-reason | Planned |
| Log a non-purchase event | POST /customers/{phone}/events | Planned |
| Customer-twin context read | GET /customers/{id}/context | Planned |
| GDPR/PDPL data export | GET /customers/{id}/export | Preview |
| GDPR/PDPL erasure | DELETE /customers/{customerId} | Preview |
| Correct a typo'd name/phone | PATCH /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
Identity & Credentials
How a session resolves who the customer is, and why identify never returns a debit token.
Enrollment & Claims
Turn a not_found stranger or a pending_proof proto-wallet into a verified, claimed customer.
Money: Actual vs Promotional
The two-class balance the lookup surfaces: spendable actual and LOCKED promo.
The Decisioning Model
Why lookup is the first call, and why non-purchase events feed the Brain.