Customers — profile, addresses, documents
The signed-in customer surface. Every endpoint here requires a customer
session: authorization: Bearer <supabase jwt> (minted by the
passwordless flow — see auth.md — or a client-side supabase
password sign-in) plus the x-client-id tenant header the storefront
always sends. Missing/invalid JWT → 401 {code: "unauthenticated"} on every
route below.
Lazy registration: /customers/me resolves the customer by
(client_id, JWT email) and creates the row on first authenticated call
(has_account=true, account_status per the store's approval policy) — a
fresh login never 404s on "me".
customer.account_status (pending | approved) is on every customer
payload; when the store's B2B approval policy is on, hide B2B content until
approved.
The executable blocks below prove the auth boundary (401 contract) — the docs harness is anonymous, so happy paths are shown as
jsoncshapes and are pinned bytests/contract/customers.contract.test.tsandtests/store/customer-accounts-*.test.tswith real sessions.
POST /api/store/customers — guest-to-registered conversion
- Purpose: bind a password sign-up to a customer row. Flow:
supabase.auth.signUp({email, password})client-side → this call with the same email. NOT needed for the passwordless code flow (verify already lazy-creates the customer). - Auth: Bearer JWT (+
x-client-id). Body email MUST equal the JWT email. - Request:
// POST /api/store/customers
{
"email": "maria@example.com", // must match the session email
"first_name": "Maria", // optional
"last_name": "Petrova", // optional
"phone": "+359888123456", // optional
"company_name": "Acme OOD" // optional
}- Response:
{customer}— the full customer withaddressesembedded (empty on first create). See the shape under/customers/mebelow. - Errors:
401 unauthenticated·403 email_mismatch(body email ≠ session email) ·400 validation_failed. - SDK:
customers.createCustomer(client, input) - Components: account registration form (account pages family).
- Settings: store approval policy decides
account_statusof a NEW row (pendingwhen B2B approval is required, elseapproved).
# No session → the documented 401 contract (the auth boundary, executable).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/customers" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email": "doc-'$RUN'@doc.test"}')
test "$STATUS" = 401GET /api/store/customers/me — the signed-in customer
- Purpose: hydrate account state after login / on account pages. Lazy-creates the customer row on first call for this (tenant, email).
- Auth: Bearer JWT (+
x-client-id). - Request: no params.
- Response:
{
"customer": {
"id": "uuid",
"client_id": "uuid",
"email": "maria@example.com",
"first_name": "Maria",
"last_name": "Petrova",
"phone": "+359888123456",
"company_name": null,
"company_eik": null, // Bulgarian company id (ЕИК) — invoice checkout
"vat_number": null,
"has_account": true,
"account_status": "approved", // "pending" | "approved" — B2B gating
"tags": [], // admin-only labels; read-only here
"metadata": null,
"created_by": null,
"created_at": "ISO-8601",
"updated_at": "ISO-8601",
"deleted_at": null,
"addresses": [ /* CustomerAddress[], created_at asc — shape below */ ]
}
}- Errors:
401 unauthenticated·400 missing_client_id. - SDK:
customers.getMe(client) - Components: account dashboard / header account state.
- Settings: approval policy (
account_statuson lazy create).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/customers/me" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401
# The error envelope carries the stable code:
curl -s "$BASE/api/store/customers/me" -H "x-client-id: $CLIENT_ID" \
| grep -q '"code":"unauthenticated"'POST /api/store/customers/me — update own profile
- Purpose: profile edit (names, phone, company/VAT master data).
- Auth: Bearer JWT (+
x-client-id). - Request (all optional; nullable fields clear with
null):
{
"first_name": "Maria",
"last_name": "Petrova",
"phone": "+359888123456",
"company_name": "Acme OOD",
"company_eik": "123456789", // loose format — foreign B2B customers exist
"vat_number": "BG123456789",
"metadata": {}
// "tags" are admin-only — the server STRIPS them from this body.
}- Response:
{customer}— the refreshed customer (shape above). - Errors:
401 unauthenticated·400 validation_failed. - SDK:
customers.updateMe(client, input) - Components: account profile form; invoice-details step in checkout.
- Settings:
company_eik/vat_numberfeed invoice-required checkout (order-documents).
GET /api/store/customers/me/addresses — list addresses
- Purpose: the address book. Returns every address (created_at asc)
— the envelope is nominal:
offsetis always 0 andlimitequalscount; no query params are read. - Auth: Bearer JWT (+
x-client-id). - Response:
{
"addresses": [
{
"id": "uuid",
"client_id": "uuid",
"customer_id": "uuid",
"address_name": "Home",
"first_name": "Maria",
"last_name": "Petrova",
"company": null,
"address_1": "ul. Ivan Vazov 1",
"address_2": null,
"city": "Sofia",
"country_code": "bg", // stored lower-case
"province": null,
"postal_code": "1000",
"phone": "+359888123456",
"is_default_billing": false,
"is_default_shipping": true,
"metadata": null,
"created_at": "ISO-8601",
"updated_at": "ISO-8601",
"deleted_at": null
}
],
"count": 1,
"offset": 0,
"limit": 1
}- Errors:
401 unauthenticated. - SDK:
customers.listAddresses(client) - Components: address book; checkout address picker.
POST /api/store/customers/me/addresses — create address
- Purpose: add to the address book.
is_default_billing/is_default_shipping: trueclears the flag on every sibling first (one default per kind). - Auth: Bearer JWT (+
x-client-id). - Request: all fields optional — same keys as the address shape above
(minus id/customer_id/timestamps).
country_codeis lower-cased. - Response:
{customer}— the full refreshed customer (addresses embedded), NOT the created address alone; find it incustomer.addresses. - Errors:
401 unauthenticated·400 validation_failed. - SDK:
customers.createAddress(client, input)
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
"$BASE/api/store/customers/me/addresses" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"city": "Sofia"}')
test "$STATUS" = 401GET /api/store/customers/me/addresses/:id — one address
- Purpose: read one owned address (edit-form hydrate).
- Auth: Bearer JWT (+
x-client-id). - Response:
{address}— ONE address object (the only address endpoint that returns{address}instead of{customer}). - Errors:
401 unauthenticated·404 not_found— unknown OR another customer's address; ownership violations 404, they never 403. - SDK:
customers.getAddress(client, addressId)
POST /api/store/customers/me/addresses/:id — update address
- Purpose: partial update; default flags clear siblings.
- Auth: Bearer JWT (+
x-client-id). - Request: same keys as create, nullable to clear.
- Response:
{customer}— full refreshed customer. - Errors:
401 unauthenticated·404 not_found·400 validation_failed. - SDK:
customers.updateAddress(client, addressId, input)
DELETE /api/store/customers/me/addresses/:id — delete address
- Purpose: soft-delete an owned address.
- Auth: Bearer JWT (+
x-client-id). - Response:
{customer}— full refreshed customer (address gone). - Errors:
401 unauthenticated·404 not_found. - SDK:
customers.deleteAddress(client, addressId)
GET /api/store/customers/me/documents — my documents (invoices)
- Purpose: the account "Invoices" page — the customer's issued
(non-void) order documents,
issued_atdesc. Ownership is INNER-join enforced (session customer id AND client_id) — cross-customer reads are impossible (adversarial-tested). - Auth: Bearer JWT (+
x-client-id). - Request: query
{limit? (≤200, default 20), offset?, doc_type?}. - Response:
{
"documents": [
{
"id": "uuid",
"order_id": "order_…",
"order_display_id": 1042,
"doc_type": "invoice",
"number": "0000000042",
"issued_at": "ISO-8601",
"pdf_url": "https://…/invoice.pdf",
"due_date": null,
"paid_at": "ISO-8601"
}
],
"count": 1,
"offset": 0,
"limit": 20
}- Errors:
401 unauthenticated·400 validation_failed. - SDK:
customers.listMyDocuments(client, query?) - Components: account documents/invoices list.
- Settings: order-documents issuing config (doc types, numbering).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/customers/me/documents" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401