Skip to content
Elara One docs

Add 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

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.

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

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": "[email protected]",
    "password": "correct horse battery",
    "password_confirmation": "correct horse battery"
  }'

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

Response
{
  "data": {
    "user": {
      "id": 88,
      "name": "Grace Hopper",
      "email": "[email protected]",
      "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

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

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.

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

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:

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

Reset a forgotten password

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

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.

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.

Address book

All address routes need the bearer token.

MethodPathNotes
GET/v1/account/addressesThe shopper's addresses, newest first.
POST/v1/account/addressesCreate. 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" } }.
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

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.

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.

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

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.

Account deletion

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

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" }'

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.
StatusCodeWhen
422VALIDATION_FAILEDcurrent_password is missing or wrong.
409ACCOUNT_SHAREDThe store linked this account's customer record to other accounts. Only the store can erase it; nothing was changed.
429RATE_LIMITEDMore than 3 attempts a minute or 10 an hour for the account, or 20 an hour from one IP.