Customers & Customer Accounts
Two distinct concepts:
- Merchant-managed customers (
customers:read/customers:write, secret key) — CRM-style records. - Customer accounts (
customer_accountsscope, usable with a publishable key) — storefront self-service login, used by end-shoppers. Authenticated via a bearer-like session token sent asX-Customer-Token(separate from the API key).
A customers row may or may not have a password_hash; the API exposes this as has_account: boolean and strips password_hash from all responses.
Merchant-managed customers
GET /customers
Scope: customers:read, secret required.
Query params:
| Param | Type | Notes |
|---|---|---|
page,limit | int | pagination |
q | string | searches email/first_name/last_name/company (ILIKE) |
email | string | ILIKE exact |
tag | string | contains tag |
accepts_marketing | "true"/other | boolean filter |
min_orders | number | orders_count >= |
min_spent | number | total_spent >= |
Returns array of customer objects + pagination meta.
GET /customers/:id
Scope: customers:read, secret required. Returns customer plus addresses: CustomerAddress[].
POST /customers
Scope: customers:write, secret required.
Body:
| Field | Type | Required | Constraints |
|---|---|---|---|
email | string | yes | valid email; stored lowercased |
first_name | string|null | no | max 100 |
last_name | string|null | no | max 100 |
phone | string|null | no | max 40 |
company | string|null | no | max 200 |
vat_id | string|null | no | max 30 |
language | enum | no | pl|en, default pl |
accepts_marketing | bool | no | |
tags | string[] | no | max 30, each max 60 |
note | string|null | no | max 5000 |
409 duplicate (with details.id) if a customer with that email already exists in the store. Fires customer.created automation. Response 201.
PATCH /customers/:id
Scope: customers:write, secret required. Same fields, all optional/partial. Email re-lowercased if present. 422 update_failed on DB error.
DELETE /customers/:id
Scope: customers:write, secret required. Hard deletes. Returns { "id", "deleted": true }.
GET /customers/:id/orders
Scope: orders:read, secret required. Paginated order history for this customer (secret serialization).
POST /customers/:id/addresses
Scope: customers:write, secret required. Body: address schema + is_default?: bool. Requires line1, city, postal_code (else 422 invalid_address). If is_default, clears default flag on other addresses first. country_code defaults to "PL" if omitted. Response 201 with the address row.
DELETE /customers/:id/addresses/:addressId
Scope: customers:write, secret required. Returns { "deleted": true } (no existence check — always "succeeds").
Customer accounts (storefront self-service)
Session tokens: format cst_<random>, stored hashed, valid 30 days, sent back by the client as header X-Customer-Token: <token> on all /customer-auth/me* routes. There's no refresh mechanism; client re-logs in after expiry.
POST /customer-auth/register
Scope: customer_accounts.
Body: { email: string(email), password: string(8-200), first_name?: string(max100)|null, last_name?: string(max100)|null, phone?: string(max40)|null, accepts_marketing?: bool, language?: "pl"|"en" }.
If a customer record with that email exists but has no password yet (e.g. created via an order), it's "claimed" (password attached, missing fields filled in). If it already has a password: 409 account_exists. New/claimed customer triggers customer_welcome notification and (for brand-new customers) customer.created automation. Response 201:
{
"data": {
"customer": { "...": "serialized customer (has_account:true)" },
"session": { "token": "cst_xxx...", "expires_at": "2024-06-01T00:00:00.000Z" }
}
}POST /customer-auth/login
Scope: customer_accounts. Body: { email, password }. 401 invalid_credentials on mismatch or missing password hash. Response: { customer, session }.
POST /customer-auth/logout
Scope: customer_accounts. Reads X-Customer-Token header (optional) and deletes that session. Always returns { "ok": true }.
POST /customer-auth/password-reset
Scope: customer_accounts. Body: { email: string(email), reset_url?: string(url) }. Always returns { "ok": true } regardless of whether the email exists (no enumeration). If it exists, creates a reset token (1 hour expiry) and sends password_reset notification with reset_url variable = <reset_url or store.storefront_url or origin>/reset-password?token=<token> (uses & if the base already has a ?).
POST /customer-auth/password-reset/confirm
Scope: customer_accounts. Body: { token: string(min 20), password: string(8-200) }. 400 invalid_token if token missing/used/expired. On success: updates password hash, marks reset used, invalidates all existing sessions for that customer, and returns a fresh session: { "ok": true, "session": {...} }.
GET /customer-auth/me
Scope: customer_accounts. Requires X-Customer-Token. Returns the logged-in customer (note/tags explicitly stripped/undefined in this response) + addresses.
PATCH /customer-auth/me
Scope: customer_accounts. Body (all optional): first_name, last_name, phone, company, vat_id, accepts_marketing, language, plus optional password change via current_password + new_password (min 8). 401 invalid_credentials if current_password doesn't match when changing password. Returns updated customer.
POST /customer-auth/me/addresses
Scope: customer_accounts. Same address rules as the merchant endpoint. Response 201.
DELETE /customer-auth/me/addresses/:addressId
Scope: customer_accounts. Deletes only if it belongs to the authenticated customer.
GET /customer-auth/me/orders
Scope: customer_accounts. Paginated order history (non-secret serialization) for the logged-in customer.
GET /customer-auth/me/orders/:id
Scope: customer_accounts. :id may be a UUID or a numeric order number. 404 not_found if it doesn't belong to this customer.
Public order tracking (no customer account needed)
GET /orders/track
See Orders — no scope needed (any valid key), query params number + email.