# Add customer accounts

URL: /docs/guides/customer-accounts

Sign-up, sign-in, email verification, password reset, address book, order history and account deletion for shoppers.

Shoppers can create an account on your storefront to save addresses, see their orders and check out faster. The engine issues a bearer token at sign-up and sign-in; your server keeps it and sends it as `Authorization: Bearer` on the shopper's behalf.

## Keep the token on your server [#keep-the-token-on-your-server]

The engine sets no cookies and has no sessions. Store the token in an httpOnly, `Secure`, `SameSite=Lax` cookie on your own domain, and have your server add it to engine calls. Browser JavaScript should never be able to read it, so never put it in `localStorage`.

```ts title="app/api/account/login/route.ts"
import { cookies } from 'next/headers'
import { ENGINE, engineHeaders } from '@/lib/engine'

export async function POST(req: Request) {
  const { email, password } = await req.json()
  const jar = await cookies()
  const cartToken = jar.get('cart')?.value

  const res = await fetch(`${ENGINE}/auth/login`, {
    method: 'POST',
    headers: {
      ...engineHeaders(),
      'Content-Type': 'application/json',
      // Merge the guest basket into the customer's cart.
      ...(cartToken ? { 'X-Cart-Token': cartToken } : {}),
    },
    body: JSON.stringify({ email, password }),
  })
  const body = await res.json()
  if (!res.ok) return Response.json(body, { status: res.status })

  const expires = body.data.expires_at ? new Date(body.data.expires_at) : undefined
  jar.set('session', body.data.token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', expires })

  // Send the user to the browser, never the token.
  return Response.json({ data: body.data.user })
}
```

Tokens expire (30 days by default; `expires_at` says when). When any call answers `401 UNAUTHENTICATED`, clear the cookie and ask the shopper to sign in again.

## Sign up [#sign-up]

```bash tab="cURL"
curl -X POST https://api.elaraone.app/api/v1/auth/register \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Grace Hopper",
    "email": "grace@example.com",
    "password": "correct horse battery",
    "password_confirmation": "correct horse battery"
  }'
```

```ts tab="TypeScript"
const res = await fetch(`${ENGINE}/auth/register`, {
  method: 'POST',
  headers: { ...engineHeaders(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ name, email, password, password_confirmation: passwordConfirmation }),
})
```

The password needs at least 8 characters. The answer is `201` with the token, so the shopper is signed in straight away:

```json title="Response"
{
  "data": {
    "user": {
      "id": 88,
      "name": "Grace Hopper",
      "email": "grace@example.com",
      "email_verified_at": null,
      "customer_id": 214
    },
    "token": "88|…",
    "expires_at": "2026-11-23T10:00:00+00:00"
  }
}
```

An address that is already registered gets `422 VALIDATION_FAILED` on `email`. Send `X-Cart-Token` with sign-up or sign-in to carry the guest basket over; an unknown or expired cart token is ignored, never an error.

## Sign in and out [#sign-in-and-out]

`POST /v1/auth/login` takes `email` and `password` and answers `200` with the same shape as sign-up. A wrong password and an unknown address get the same `422 VALIDATION_FAILED`, so the endpoint never confirms whether an address is registered.

`POST /v1/auth/logout` revokes the token that made the request and answers `{ "data": { "status": "revoked" } }`. Delete your cookie at the same time.

Sign-up, sign-in and both password-reset steps share a budget of 10 requests a minute per store and visitor. Send `X-Client-IP` with the shopper's IP from your server so the limit applies per shopper rather than to your server's address.

## Verify the email address [#verify-the-email-address]

Verification is optional: the token works before it. If the store has set its **Email verification page URL** in Store settings, the engine emails a link to that page once, after sign-up. The URL template contains `{query}`, for example `https://shop.example.com/verify-email?{query}`.

When the shopper opens the link, your page posts the query string to the engine **exactly as received**: same order, same encoding, nothing added. The link is signed, so any change makes it invalid.

```ts title="app/api/verify-email/route.ts"
// The verify page calls this with its own query string, unchanged:
// fetch(`/api/verify-email${location.search}`, { method: 'POST' })
export async function POST(req: Request) {
  const query = new URL(req.url).search // "?expires=…&hash=…&id=…&signature=…"
  const res = await fetch(`${ENGINE}/auth/verify${query}`, { method: 'POST', headers: engineHeaders() })
  return Response.json(await res.json(), { status: res.status })
}
```

Success is `{ "data": { "status": "verified" } }`. An invalid or expired link gets `403 FORBIDDEN`. A signed-in shopper can ask for a new link with `POST /v1/auth/verify/resend`, which answers `verification-sent` or `already-verified` in `data.status`.

Have the page post on a button press rather than on load, because some mail scanners open links before the shopper does.

## The signed-in shopper [#the-signed-in-shopper]

`GET /v1/auth/me` returns the user: `id`, `name`, `email`, `email_verified_at` and `customer_id`.

`PATCH /v1/auth/me` changes `name`, `email` or both. Changing the email needs `current_password`; afterwards `email_verified_at` is `null` and a verification email goes to the new address.

`POST /v1/auth/password` changes the password with `current_password`, `password` and `password_confirmation`. The token that made the request stays valid and every other token is revoked:

```json
{ "data": { "status": "password-changed", "revoked_tokens": 2 } }
```

## Reset a forgotten password [#reset-a-forgotten-password]

<Steps>
<Step>

### Ask for a link [#ask-for-a-link]

`POST /v1/auth/password/forgot` with `{ "email": "grace@example.com" }`. It always answers `200` with `{ "data": { "status": "reset-link-sent" } }`, whether or not the address has an account.

</Step>
<Step>

### The shopper opens your reset page [#the-shopper-opens-your-reset-page]

The email links to the store's **Password reset page URL** (Store settings), with `{token}` and `{email}` filled in, for example `https://shop.example.com/reset-password?token={token}&email={email}`. If the store has not set this URL, no reset email is sent.

</Step>
<Step>

### Set the new password [#set-the-new-password]

Your server posts `token`, `email`, `password` and `password_confirmation` to `POST /v1/auth/password/reset`. Success is `{ "data": { "status": "password-reset" } }`. A bad or expired token gets `422 VALIDATION_FAILED` on `token`; offer a new link.

</Step>
</Steps>

## Address book [#address-book]

All address routes need the bearer token.

| Method | Path | Notes |
|---|---|---|
| `GET` | `/v1/account/addresses` | The shopper's addresses, newest first. |
| `POST` | `/v1/account/addresses` | Create. `first_name`, `last_name`, `line_one` and `city` are required. Answers `201`. |
| `PATCH` | `/v1/account/addresses/{id}` | Change any fields. |
| `DELETE` | `/v1/account/addresses/{id}` | Answers `{ "data": { "status": "deleted" } }`. |

```json title="GET /v1/account/addresses"
{
  "data": [
    {
      "id": 42,
      "label": "Home",
      "title": null,
      "first_name": "Grace",
      "last_name": "Hopper",
      "company_name": null,
      "line_one": "4 Walnut Row",
      "line_two": null,
      "line_three": null,
      "city": "York",
      "state": null,
      "postcode": "YO1 7HH",
      "country_id": 235,
      "delivery_instructions": null,
      "contact_email": null,
      "contact_phone": "07700 900456",
      "shipping_default": true,
      "billing_default": true
    }
  ]
}
```

- `label` is an optional nickname such as "Home" or "Work" (up to 50 characters). `title` is the salutation, not a nickname.
- Setting `shipping_default` or `billing_default` to `true` moves that default to this address. Deleting a default moves it to the newest remaining address.
- Another shopper's address id gets `404 NOT_FOUND`.

### Use saved addresses at checkout [#use-saved-addresses-at-checkout]

On `PUT /v1/cart/addresses`, a signed-in shopper can send `{ "address_id": 42 }` for either side instead of the full fields. When they type a new address, send `save_to_account: true` with it to add it to their book once the order is placed. Show that as a "Save to my account" box, ticked by default.

```json title="PUT /v1/cart/addresses"
{
  "shipping": { "address_id": 42 },
  "billing": {
    "first_name": "Grace", "last_name": "Hopper",
    "line_one": "1 Office Park", "city": "York", "postcode": "YO1 9AA",
    "country_id": 235,
    "save_to_account": true
  }
}
```

The cart must belong to the shopper: create it with the bearer token, or merge it at sign-in. Nothing is saved until the order is placed, an address that is already in the book is not added twice, and guest orders never save anything.

| Status | Code | When |
|---|---|---|
| `422` | `ADDRESS_NOT_FOUND` with `details.reason: "sign_in_required"` | `address_id` sent without a valid bearer token. |
| `422` | `ADDRESS_NOT_FOUND` with `details.reason: "not_found"` | The id is not one of this shopper's addresses. |
| `422` | `VALIDATION_FAILED` | The saved address lacks something checkout needs, such as a postcode or country. |

## Order history [#order-history]

`GET /v1/orders` lists the signed-in shopper's placed orders, newest first, paginated with `page` and `perPage` (default 24, maximum 100). `GET /v1/orders/{reference}` returns one order with its lines, addresses, totals and shipments. Both need the store's Shop feature and the bearer token.

Guest orders are separate: see [Show the order to a guest](/docs/guides/stripe-checkout#show-the-order-to-a-guest).

## Account deletion [#account-deletion]

Shoppers can erase their own account. Ask for their password and confirm the choice clearly, because it cannot be undone.

```bash tab="cURL"
curl -X DELETE https://api.elaraone.app/api/v1/account \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "Authorization: Bearer $CUSTOMER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "current_password": "correct horse battery" }'
```

```ts tab="TypeScript"
const res = await fetch(`${ENGINE}/account`, {
  method: 'DELETE',
  headers: { ...engineHeaders(), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ current_password: password }),
})
if (res.ok) (await cookies()).delete('session')
```

Success is `{ "data": { "status": "erased" } }`. What happens:

- The account can never sign in again, and every token is revoked, including the one that made the request.
- Saved addresses and open carts are deleted.
- Orders are kept as the store's records, with names, street, email and phone removed.
- The verified email address is kept only as a one-way hash on the store's do-not-contact list, so automated messages never reach it again.

| Status | Code | When |
|---|---|---|
| `422` | `VALIDATION_FAILED` | `current_password` is missing or wrong. |
| `409` | `ACCOUNT_SHARED` | The store linked this account's customer record to other accounts. Only the store can erase it; nothing was changed. |
| `429` | `RATE_LIMITED` | More than 3 attempts a minute or 10 an hour for the account, or 20 an hour from one IP. |
