Contacts
Contact accounts
How storefront contact accounts, session tokens, and the guest lifecycle work.
View as MarkdownContacts give your storefront contact accounts: registration, login, password reset, a profile, and saved addresses. All routes live under /v1/api/contacts, with an identical alias at /v1/api/customers for integrations built before the rename. Contacts are part of every store, so there is no module to enable first.
Alias: /v1/api/customers
Every route below is also served, unchanged, at the equivalent /v1/api/customers/... path (for example POST /v1/api/customers/register alongside POST /v1/api/contacts/register). Request and response shapes are identical on both. This alias is permanent, not a deprecation window: existing integrations never need to migrate, though new integrations should use /v1/api/contacts.
Two tokens, two headers
Contact endpoints use a layered authentication model:
- Tenant API key (always required). Every request carries your store's API key as
Authorization: Bearer {api_key}, exactly like the rest of the storefront API. The key needs thecontacts:writescope for all POST, PATCH, and DELETE endpoints and thecontacts:readscope for the GET endpoints. A key minted before the rename carriescustomers:write/customers:readinstead, and is still accepted. A missing scope returns403 {"message":"Invalid ability provided."}. - Contact session token (required on contact-authenticated endpoints).
POST /v1/api/contacts/registerandPOST /v1/api/contacts/loginreturn atokenstring. Send it on subsequent requests in theX-Customer-Tokenheader. A missing or invalid contact token returns401 {"message":"Invalid customer token."}.
The two headers are independent and both required on contact-authenticated routes:
Authorization: Bearer {api_key}
X-Customer-Token: {token}Which endpoints need which:
| Endpoint | API key scope | X-Customer-Token |
|---|---|---|
POST /register | contacts:write (customers:write accepted) | No |
POST /login | contacts:write (customers:write accepted) | No |
POST /forgot-password | contacts:write (customers:write accepted) | No |
POST /reset-password | contacts:write (customers:write accepted) | No |
POST /logout | contacts:write (customers:write accepted) | Yes |
GET /me | contacts:read (customers:read accepted) | Yes |
PATCH /me | contacts:write (customers:write accepted) | Yes |
GET /me/addresses | contacts:read (customers:read accepted) | Yes |
POST /me/addresses | contacts:write (customers:write accepted) | Yes |
PATCH /me/addresses/{id} | contacts:write (customers:write accepted) | Yes |
DELETE /me/addresses/{id} | contacts:write (customers:write accepted) | Yes |
Contact session tokens do not expire on a timer. They are revoked by POST /logout (one token) and by a successful password reset (all of the contact's tokens).
The public contact object
Contact-facing endpoints return this shape (internal fields such as is_guest, type, and customer_group_id are never exposed on the storefront API):
| Field | Type | Description |
|---|---|---|
id | string (uuid) | Contact id. Stable across the guest-to-account upgrade. |
name | string | Full name. |
email | string or null | Email address, always lowercase when present. Unique per store. Registering through the storefront always requires an email; a contact can only have a null email when it was created by the merchant (for example a company contact added through the client dashboard or CSV import) without one. |
phone | string or null | Phone number. |
kind | string | person or company. Read-only through this API: set by the merchant, not by the storefront. |
company_name | string or null | Company name. Set only when kind is company. Read-only through this API. |
registration_number | string or null | Company registration number (for example a Dutch KvK number). Read-only through this API. |
vat_id | string or null | VAT id. Read-only through this API. |
external_id | string or null | Merchant-assigned identifier, for example an ERP or accounting system id. Read-only through this API. |
display_name | string | The company name when there is one, the contact's name otherwise. Derived, not stored. |
created_at | string (ISO 8601) | Creation timestamp, for example 2026-08-14T10:32:11+00:00. |
kind, company_name, registration_number, vat_id, and external_id are readable on every contact but not writable through register or PATCH /me: a storefront account cannot turn itself into a company contact. The merchant sets these fields from the client dashboard's Contacts pages.
Guest lifecycle
Cart email capture in the Commerce module can create a contact row with only an email and no password (a guest). This matters in three places:
- Registration upgrades guests. Registering with an email that belongs to a guest row upgrades that row in place: same
id, order history preserved. Only an email that belongs to a real (non-guest) account fails with422 "Email already registered.". - Guests cannot log in. They have no password, so login fails with the generic
422 "Invalid credentials.". - Password reset also upgrades. Completing a reset sets a password, marks the account non-guest, and sets
email_verified_atif it was unset. This is a second path from guest checkout to a full account.
Rate limits
All contact endpoints share the storefront limit of 120 requests per minute per store (429 with a Retry-After header when exceeded). The four credential endpoints (register, login, forgot-password, reset-password) carry an additional limit of 10 requests per minute per client IP to slow brute force attempts.
Errors
All error envelopes are the standard storefront shapes:
| Status | Body | Cause |
|---|---|---|
| 401 | {"message":"Unauthenticated."} | Missing or invalid tenant API key. |
| 401 | {"message":"Invalid customer token."} | Missing or invalid X-Customer-Token on a contact-authenticated route. |
| 403 | {"message":"Invalid ability provided."} | API key lacks the required scope. |
| 403 | {"message":"Tenant suspended.","code":"tenant_suspended"} | Store suspended. |
| 404 | {"message":"..."} | Resource not found (or not owned by the authenticated contact). |
| 422 | {"message":"...","errors":{"field":["..."]}} | Validation failure. |
| 429 | {"message":"Too Many Attempts."} | Rate limit exceeded. |