# Contact accounts

Contacts 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:

1. **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 the `contacts:write` scope for all POST, PATCH, and DELETE endpoints and the `contacts:read` scope for the GET endpoints. A key minted before the rename carries `customers:write` / `customers:read` instead, and is still accepted. A missing scope returns `403 {"message":"Invalid ability provided."}`.
2. **Contact session token** (required on contact-authenticated endpoints). `POST /v1/api/contacts/register` and `POST /v1/api/contacts/login` return a `token` string. Send it on subsequent requests in the `X-Customer-Token` header. A missing or invalid contact token returns `401 {"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 with `422 "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_at` if 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. |
