# Elara One documentation > Elara One is commerce, CRM, automation and customer conversations in one platform: one customer record, one admin and one REST API. Bring your own storefront (headless) or start from the MIT-licensed Next.js, Nuxt or Astro + Alpine starters. The API base URL is https://api.elaraone.app/api/v1; every call carries the store key in X-Tenant-Key and server calls also carry X-Tenant-Secret. Money is always { amount, currency, formatted } with amount in minor units (pence). --- # Introduction URL: https://elaraone.app/docs/introduction What Elara One is, how a store and its keys fit together, and where to start building. Elara One is commerce, CRM, automation and customer conversations in one platform. A store has one admin, one customer record and one REST API behind it. You build the storefront your way, in your framework and on your hosting, and call the API for everything the shop and the CRM know. ## What you can build [#what-you-can-build] - **A storefront.** Catalogue, search, cart and checkout with Stripe or PayPal on the store's own accounts. Stock is locked while an order is placed, promotions and tax are calculated by the API, and customers can have accounts. - **Lead capture for a service business.** Forms with spam protection that open a lead in the CRM, send email notifications and start automation flows. - **Integrations.** The Merchant API reads and writes CRM contacts, companies, tasks and notes, manages orders, forms, flows and CMS pages, and signed webhooks tell your systems when CRM records change. ## How it fits together [#how-it-fits-together] ```text Shopper's browser ──▶ Your storefront server ──▶ https://api.elaraone.app/api/v1 (Next.js, Nuxt, Astro…) X-Tenant-Key + X-Tenant-Secret │ Store admin ◀─────────┘ one database per store ``` - **A store** is one business on Elara One. Each store has its own database, its own admin at `{slug}.admin.elaraone.app` and its own keys. - **The store key** (`X-Tenant-Key`, starting `pk_live_`) identifies the store on every call. It is public: it is safe in a browser. - **The server secret** (`X-Tenant-Secret`) proves a call comes from your server. It never goes to a browser. - **Bearer tokens** sign in a customer (from `POST /v1/auth/login`) or a merchant integration (a Merchant API key with scoped abilities). Read [Stores and keys](/docs/concepts/stores-and-keys) for the full model, and [Authentication](/docs/authentication) for every header. ## Conventions [#conventions] | | | |---|---| | Base URL | `https://api.elaraone.app/api/v1` | | Format | JSON in and out. Successful responses wrap their payload in `data`. | | Money | Integer minor units: `{ "amount": 1299, "currency": "GBP", "formatted": "£12.99" }`. See [Money](/docs/concepts/money). | | Times | ISO 8601, in UTC. | | Errors | `{ "error": { "code", "message", "details" } }` with a stable `code`. See [Errors](/docs/concepts/errors). | | Retries | Send an `Idempotency-Key` on writes. See [Idempotency keys](/docs/concepts/idempotency). | ## Start here [#start-here] ## For AI tools [#for-ai-tools] Every page in these docs has a Markdown copy at its URL plus `.md`, for example [`/docs/quickstart.md`](/docs/quickstart.md). [`/llms.txt`](/llms.txt) lists every page, and [`/llms-full.txt`](/llms-full.txt) has the whole of the docs in one file. Use **Copy page** at the top of any page to paste it into an assistant. --- # Quickstart URL: https://elaraone.app/docs/quickstart Call the API with your store key, list products, create a cart, add a line and take a test payment, in about five minutes. This walk-through makes the same calls a storefront makes: read the catalogue, start a cart, put something in it and check out. Every request runs from a terminal or a server, never from a browser, because it carries your server secret. You need an Elara One store. If you do not have one yet, [get started](/signup) or [book a demo](/contact). ## Copy your keys [#copy-your-keys] In your store admin, open **Settings → Storefront API**. Copy the **store key** (it starts `pk_live_`, or `pk_test_` on a staging store) and reveal the **server secret**. Put both in your shell or your server's environment: ```bash title="Terminal" export ELARAONE_TENANT_KEY="pk_live_…" export ELARAONE_TENANT_SECRET="…" ``` The server secret lets a caller act as your storefront server. Never put it in browser code or in a public environment variable such as `NEXT_PUBLIC_*`. See [Authentication](/docs/authentication). ## Check the connection [#check-the-connection] `GET /v1/ping` answers for your store when the key and secret are right. ```bash tab="cURL" curl https://api.elaraone.app/api/v1/ping \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" ``` ```ts tab="TypeScript" const API = 'https://api.elaraone.app/api/v1' // Every call from your server carries both headers. const headers = { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, } const ping = await fetch(`${API}/ping`, { headers }) console.log(ping.status, await ping.json()) ``` A wrong or missing key answers with an [error](/docs/concepts/errors) such as `TENANT_KEY_MISSING` (400) or `TENANT_NOT_FOUND` (404). ## List products [#list-products] Ask for variants and their prices with `include`. Prices are [money objects](/docs/concepts/money) in pence. ```bash tab="cURL" curl -G https://api.elaraone.app/api/v1/products \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ --data-urlencode "include=variants.prices,images" \ --data-urlencode "perPage=3" ``` ```ts tab="TypeScript" const params = new URLSearchParams({ include: 'variants.prices,images', perPage: '3' }) const res = await fetch(`${API}/products?${params}`, { headers }) const { data: products } = await res.json() const variant = products[0].variants[0] console.log(products[0].name, variant.sku, variant.price.formatted) ``` ```json title="200 OK (trimmed)" { "data": [ { "id": 101, "status": "published", "name": "Oak desk lamp", "slug": "oak-desk-lamp", "variants": [ { "id": 1543, "sku": "LAMP-OAK-NAT", "stock": 12, "shippable": true, "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" } } ], "images": [{ "id": 55, "url": "https://…/oak-desk-lamp.webp" }] } ], "links": { "first": "…", "last": "…", "prev": null, "next": "…" }, "meta": { "current_page": 1, "per_page": 3, "total": 24 } } ``` Note a variant's `id` or `sku`: you add variants, not products, to a cart. Filters, sorting and paging are in [Catalogue](/docs/concepts/catalogue). ## Create a cart [#create-a-cart] `POST /v1/cart` returns a `cart_token`. Send it as `X-Cart-Token` on every cart and checkout call. Keep it on your server, for example in an httpOnly cookie. ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/cart \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" ``` ```ts tab="TypeScript" const created = await fetch(`${API}/cart`, { method: 'POST', headers }) const { data } = await created.json() const cartToken: string = data.cart_token ``` ```json title="201 Created" { "data": { "cart_token": "01JB9K3N7Q2W5X8Z4C6V1T0R3M", "cart": { "id": 812, "currency": "GBP", "lines": [], "total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" } } } } ``` ## Add a line [#add-a-line] Add a variant by `variant_id` or `sku`. The `Idempotency-Key` makes a retried request safe: the line is added once, however many times the request is sent. ```bash tab="cURL" export CART_TOKEN="01JB9K3N7Q2W5X8Z4C6V1T0R3M" curl -X POST https://api.elaraone.app/api/v1/cart/lines \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "variant_id": 1543, "quantity": 1 }' ``` ```ts tab="TypeScript" const added = await fetch(`${API}/cart/lines`, { method: 'POST', headers: { ...headers, 'X-Cart-Token': cartToken, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ variant_id: variant.id, quantity: 1 }), }) const { data: cart } = await added.json() console.log(cart.total.formatted) ``` Every cart write returns the whole cart, recalculated: lines, promotions, tax and totals. See [Carts and the cart token](/docs/concepts/carts) for coupons, addresses and shipping. ## Take a test payment [#take-a-test-payment] A checkout sets the addresses and a shipping option, then pays and places the order: 1. `PUT /v1/cart/addresses`, then `GET /v1/cart/shipping-options` and `PUT /v1/cart/shipping-option`. 2. `POST /v1/checkout/payment-intent` returns a Stripe `client_secret`. Your page mounts Stripe's Payment Element with it, and the shopper confirms the payment in the browser. 3. Your server calls `POST /v1/checkout/complete` with the PaymentIntent and an `Idempotency-Key`. It answers `201` with the placed order. ```bash title="Place the order" curl -X POST https://api.elaraone.app/api/v1/checkout/complete \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_…" } }' ``` When `GET /v1/checkout/config` reports `"gateway_mode": "test"`, payments go to Stripe's test mode: pay with Stripe's test card `4242 4242 4242 4242`, any future expiry date and any CVC. A declined card answers `402 PAYMENT_FAILED` with a `decline_code` you can show the shopper. The [Checkout with Stripe](/docs/guides/stripe-checkout) guide builds this end to end, including the browser code. ## Next steps [#next-steps] --- # Authentication URL: https://elaraone.app/docs/authentication The store key, the server secret, registered websites, bearer tokens for customers and merchant integrations, and how to rotate credentials. Every call names its store with a public key. Calls from your servers also prove where they come from with the store's server secret, and calls from a browser are accepted only from websites you have registered. Customer and merchant routes add a bearer token on top. ## The headers [#the-headers] | Header | When | Example | |---|---|---| | `X-Tenant-Key` | Every call. It is the only thing that identifies the store. | `pk_live_…` | | `X-Tenant-Secret` | Every call from a server. Never from a browser. | your server secret | | `Authorization` | Customer routes (a token from `POST /v1/auth/login`) and Merchant API routes (a merchant API key). | `Bearer …` | | `X-Client-IP` | Optional on server calls: the shopper's IP address, so rate limits apply per shopper. Trusted only with a valid secret. | `203.0.113.7` | | `X-Cart-Token` | Cart and checkout routes. | from `POST /v1/cart` | | `X-Order-Access-Token` | Viewing a guest's order. | from checkout | | `Idempotency-Key` | Writes you might retry. See [Idempotency keys](/docs/concepts/idempotency). | a UUID | All of these are in **Settings → Storefront API** in your store admin, apart from customer tokens (which the API issues) and merchant API keys. ## Server calls and browser calls [#server-calls-and-browser-calls] The API tells the two apart by the `Origin` header, which browsers add and servers do not. | | Server call | Browser call | |---|---|---| | Made by | Your storefront server, a script, an integration | JavaScript on a web page | | Must carry | `X-Tenant-Key` and `X-Tenant-Secret` | `X-Tenant-Key` only, from a registered website | | Can use | Every route its credentials allow, and `X-Client-IP` | The public routes. Routes that need the secret, such as consent, refuse it. | We recommend calling the API only from your own server, as a backend-for-frontend. The [starters](/docs/starters) all work this way, and refuse to start in production without the secret. ```ts title="lib/elaraone.ts (server only)" const API = 'https://api.elaraone.app/api/v1' export async function elaraone(path: string, init: RequestInit = {}, shopperIp?: string) { return fetch(`${API}${path}`, { ...init, headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, ...(shopperIp ? { 'X-Client-IP': shopperIp } : {}), ...init.headers, }, }) } ``` Server-side `fetch` in Node.js sends no `Origin`. If you copy the shopper's request headers onto your API call, their `Origin` comes too and turns a server call into a browser call. Build the headers yourself, as above. Keep the secret out of any environment variable your framework copies into browser code: `NEXT_PUBLIC_*` in Next.js, `NUXT_PUBLIC_*` and `VITE_*` in Nuxt, `PUBLIC_*` in Astro. ## Registered websites [#registered-websites] A browser call is accepted only from a website the store has registered in **Settings → Storefront API → Your websites**. Enter each origin with its scheme and host, such as `https://shop.example.com`, and no path. Only `https://` websites can be registered; register `www.` and any separate checkout host as well. If your storefront never calls the API from a browser, switch **Browser calls** off on the same page. The store then refuses every call without the secret. CORS is set per store. A response to an accepted browser call echoes your website in `Access-Control-Allow-Origin`, never `*`, and credentials (cookies) are never allowed. It stops other websites' JavaScript from using your public key. A program that is not a browser can send any `Origin` it likes, so browser calls only ever get the public tier. Anything private needs the secret, and so a server. ## The API lock [#the-api-lock] A new store starts in **report-only** for 14 days: nothing is refused, but every call the lock would refuse is counted and shown in **Settings → Storefront API → API lock**, by website and by kind. After 14 days the lock turns on by itself. You can turn it on sooner from the same page. Once the lock is on, these calls are refused with `403`: | `error.code` | Why | Fix | |---|---|---| | `TENANT_SECRET_REQUIRED` | A server call without `X-Tenant-Secret`. | Send the secret on every server call. | | `TENANT_SECRET_INVALID` | A server call with the wrong secret, or a secret-only route called without it. | Copy the current secret from Settings → Storefront API. | | `ORIGIN_NOT_ALLOWED` | A browser call from a website the store has not registered. | Register the website, or call from your server. | | `BROWSER_CALLS_DISABLED` | A browser call while browser calls are switched off. | Call from your server with the secret. | None of these is worth retrying: the same call gets the same answer until your code or the store's settings change. **Before the lock turns on**, check that every website is registered, that every server, job and integration sends the secret, and that the report shows no calls without the secret for the last few days. ## Customer tokens [#customer-tokens] `POST /v1/auth/login` (or `POST /v1/auth/register`) returns a bearer token for the customer. Send it as `Authorization: Bearer …`, together with your store key and secret, on account routes such as `GET /v1/auth/me` and `GET /v1/orders`. Keep the token on your server, for example in an httpOnly cookie on your own domain. See [Customers and accounts](/docs/concepts/customers). ## Merchant API keys [#merchant-api-keys] The Merchant API (`/v1/merchant/…`) is for back-office integrations: CRM sync, order fulfilment, forms, flows and pages. A merchant API key is sent as a bearer token, alongside the store key and secret, and carries only the abilities it was given: | Ability | Allows | |---|---| | `crm:read`, `crm:write` | Reading and writing contacts, companies, tasks, notes and associations | | `read_orders`, `manage_orders` | Listing orders; dispatching, marking delivered and recording offline refunds | | `manage_forms`, `manage_flows`, `manage_pages` | Forms, automation flows and CMS pages | | `manage_consent` | The cookie consent banner and consent records | | `view_conversations` | Inbox conversations | A key without the ability a route needs gets `403 FORBIDDEN`. A missing or revoked key gets `401 UNAUTHENTICATED`. ## Rotating credentials [#rotating-credentials] **The store key.** Settings → Storefront API → **Rotate store key**. The new key keeps the old prefix. The old key keeps working for 7 days, and responses to calls made with it carry `X-Tenant-Key-Deprecated: true`: log that header, so a forgotten deployment shows up. **End overlap now** stops the old key at once, for a key that has leaked. After the overlap, the old key gets `404 TENANT_NOT_FOUND`. **The server secret.** Rotate it when it may have leaked: it was seen in a browser, committed to a repository, or someone who knew it has left. The change is immediate, with no overlap, so update every server's environment and restart. To keep a locked shop working throughout, rotate just before you deploy. Rotating either asks for your admin password and authenticator code, and is recorded in the activity log. --- # Choosing a starter URL: https://elaraone.app/docs/starters Three starter storefronts, in Next.js, Nuxt and Astro + Alpine. What they share, how they differ and how to run one. You can build a storefront from scratch against the API, or start from one of ours. There are three starters, one per framework family. Each is a complete shop: catalogue, search, cart, Stripe checkout, customer accounts and a cookie banner, wired to the API the way these docs describe. The starters are MIT-licensed and their repositories become public at launch. Until then, [contact us](/contact) for early access. ## Pick one [#pick-one] | | Next.js | Nuxt | Astro + Alpine | |---|---|---|---| | Framework | Next.js 16 and React | Nuxt 3 and Vue 3 | Astro 7 with Alpine.js 3 islands | | Choose it when | Your team writes React | Your team writes Vue | You want the lightest pages: mostly HTML, a little JavaScript | | Instant search script | | under 15 KB gzipped | under 8 KB gzipped | | Dev port | 3004 | 3002 | 3005 | They behave the same way. Choose by the framework your team already knows. ## What every starter does [#what-every-starter-does] - **Calls the API only from its own server** (a backend-for-frontend). The store key and server secret never reach the browser, and the starters send `X-Tenant-Key`, `X-Tenant-Secret` and the shopper's `X-Client-IP` on every call. - **Refuses to start in production without the server secret**, and stops the build if the secret is copied into a public environment variable. `npm run check:bundle` also scans the built browser files for the key and the secret. - **Takes card payments with Stripe** through the Payment Element. PayPal is supported by the API but not in the starters' UI yet. - **Includes a cookie consent banner** and only loads Google Analytics 4, if you set an ID, after the shopper consents. See [Cookie consent under PECR](/docs/guides/cookie-consent). - **Has unit tests (Vitest) and end-to-end tests (Playwright).** `E2E_API_LOCK=enforce npm run test:e2e` first proves your store's [API lock](/docs/authentication#the-api-lock) is on, then runs the shopping journeys against it. ## Run one locally [#run-one-locally] Each starter reads the store key and secret from its environment. Copy the example file, fill in the values from **Settings → Storefront API**, install and start. ```bash tab="Next.js" cp .env.example .env.local # ELARAONE_ENGINE_URL=https://api.elaraone.app # ELARAONE_TENANT_KEY=pk_live_… # ELARAONE_TENANT_SECRET=… npm ci npm run dev # http://localhost:3004 ``` ```bash tab="Nuxt" cp .env.example .env # NUXT_ENGINE_URL=https://api.elaraone.app # NUXT_TENANT_KEY=pk_live_… # NUXT_TENANT_SECRET=… npm ci npm run dev # http://localhost:3002 ``` ```bash tab="Astro + Alpine" cp .env.example .env # ELARAONE_ENGINE_URL=https://api.elaraone.app # ELARAONE_TENANT_KEY=pk_live_… # ELARAONE_TENANT_SECRET=… npm install npm run dev # http://localhost:3005 ``` In development the secret is optional and a warning is logged on the first API call. In production a missing secret stops the server with a message naming the variable. ## Deploying [#deploying] The starters are ordinary Node.js applications: deploy them wherever you run Node, behind your own domain. Then add that domain to **Settings → Storefront API → Your websites** if anything on it calls the API from the browser, and keep the secret in the host's server-side environment settings. ## Building your own instead [#building-your-own-instead] Everything the starters do is in these docs. Start with the [Quickstart](/docs/quickstart) and [Authentication](/docs/authentication), then follow the guides for [product listings](/docs/guides/product-listing), [checkout](/docs/guides/stripe-checkout) and [customer accounts](/docs/guides/customer-accounts). --- # Stores and API keys URL: https://elaraone.app/docs/concepts/stores-and-keys How the API knows which store you mean, the public store key and server secret, registered websites, and how to rotate credentials safely. Every request to the Elara One API is made on behalf of one store. This page covers how a store is identified, which credentials to send from a browser and from a server, and how to change them without taking your shop offline. ## A store [#a-store] A store is one shop or business on Elara One, such as Hearth & Oak. Each store has its own database: its catalogue, customers, orders, CRM records and settings live there and are never shared with another store. Search indexes, cache entries and media paths are kept apart per store too. | What | Where | |---|---| | API base URL | `https://api.elaraone.app/api/v1` | | Store admin | `{slug}.admin.elaraone.app` | | Keys, secret and websites | Store admin, **Settings → Storefront API** | ## The public store key [#the-public-store-key] `X-Tenant-Key` identifies the store, and it is the only thing that does. Send it on every call. - Live store keys start with `pk_live_`. A staging store, where your plan includes one, has its own key starting with `pk_test_`. - The key is public in the same way a Stripe publishable key is: it can appear in browser code. On its own it only works from your registered websites (browser calls) or together with the server secret (server calls). - Find it under **Store key** in **Settings → Storefront API**. A missing key returns `400 TENANT_KEY_MISSING`. An unknown key, a suspended store and a rotated key whose overlap has ended all return the same `404 TENANT_NOT_FOUND`. The API never echoes the key back. ## The server secret [#the-server-secret] `X-Tenant-Secret` proves a call comes from your server. Send it on every server-to-server call, together with the store key. Anyone who visits the site can read browser code and call the API as your server. Keep the secret in a server-only environment variable, never under a prefix your framework copies into the browser bundle: `NEXT_PUBLIC_*` (Next.js), `NUXT_PUBLIC_*` and `VITE_*` (Nuxt and Vite), `PUBLIC_*` (Astro, SvelteKit), `REACT_APP_*` (Create React App). The secret is shown in **Settings → Storefront API**. Revealing or rotating it asks for your admin password and authenticator code. ## Browser calls and server calls [#browser-calls-and-server-calls] The API tells the two kinds of caller apart by the `Origin` header, which browsers add to cross-site requests. | | Browser call | Server call | |---|---|---| | How the API tells | The request has an `Origin` header | The request has no `Origin` header | | Who makes it | JavaScript on a web page | Your storefront server, a script, a job, an integration | | Must carry | `X-Tenant-Key`, from a registered website | `X-Tenant-Key` and `X-Tenant-Secret` | | Gets | The public tier only | Everything the route allows, including per-shopper rate limits through `X-Client-IP` | The `Origin` check is not authentication. It stops other websites' JavaScript from using your key, but a program that is not a browser can send any `Origin` it likes. That is why a browser call only gets the public tier: `X-Client-IP` is ignored, and routes that need the secret (such as consent) refuse it whatever `Origin` it claims. We recommend a backend-for-frontend: your storefront's own server calls the API, and the browser only talks to your server. All the starter storefronts work this way. ```bash tab="cURL" curl https://api.elaraone.app/api/v1/products \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Client-IP: 203.0.113.7" ``` ```ts tab="TypeScript" // Server-side only (a route handler, server action or API route). const shopperIp = '203.0.113.7' // read it from the incoming request in your framework const res = await fetch('https://api.elaraone.app/api/v1/products', { headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, // Optional: the shopper's IP, so rate limits apply per shopper. 'X-Client-IP': shopperIp, }, }) const { data } = await res.json() ``` ```php tab="PHP" // Laravel's HTTP client; any PHP HTTP client works the same way. $response = Http::withHeaders([ 'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'), 'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'), ])->get('https://api.elaraone.app/api/v1/products'); $products = $response->json('data'); ``` Server-side `fetch` in Node.js does not add an `Origin` header, so your server's calls are server calls. Do not copy the incoming browser request's headers onto the API call wholesale: the browser's `Origin` would turn a server call into a browser call. ## Register your websites [#register-your-websites] Browser calls are accepted only from the websites you register in **Settings → Storefront API → Your websites**. Each entry is an origin: scheme, host and, when it is not the default, port. No path. | You enter | Stored as | |---|---| | `https://Shop.Example.com/` | `https://shop.example.com` | | `https://shop.example.com:443` | `https://shop.example.com` | | `https://shop.example.com:8443` | `https://shop.example.com:8443` | | `https://shop.example.com/products` | Refused: no path, query string or fragment | | `http://shop.example.com` | Refused: `https://` only | | `https://*.example.com` | A wildcard, on plans with wildcard website domains | - A wildcard matches one or more labels below its base, never the bare domain itself. Register `https://example.com` separately if the shop runs there too. - How many websites you can register depends on your plan. The admin shows how many you have used. - `Origin: null` (a sandboxed iframe, a page opened from a file) counts as an unregistered website. So does a page served from `localhost` on a live store: develop against the API from your own server instead. Staging stores, where your plan includes them, accept `http://localhost` and `http://127.0.0.1` on any port. - Registering a website never identifies a store; the key does. Two stores can register the same website. ### CORS [#cors] - Preflight (`OPTIONS`) requests are answered for any origin, because the store cannot be identified before the real request. They are cached for 600 seconds and never allow credentials (cookies). A successful preflight does not mean the call will be accepted. - The real response carries `Access-Control-Allow-Origin` set to your origin, never `*`, and every response carries `Vary: Origin`. - Your page's JavaScript can read these response headers: `Idempotent-Replayed`, `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-Request-Id`, `X-Search-Id`, `X-Search-Total` and `X-Tenant-Key-Deprecated`. If your storefront calls the API only from its server, switch **Browser calls** off in **Settings → Storefront API**. Every call without the secret is then refused, with or without an `Origin`. ## Report-only, then enforcing [#report-only-then-enforcing] A new store starts in report-only mode. Nothing is refused, but every call the lock would refuse is counted and shown in **Settings → Storefront API → API lock**, per website, with the first example of each kind (path, method, time and browser family). No header values or IP addresses are kept. After 14 days the lock turns on by itself. The admin shows the date and counts down. You can choose **Turn on the lock now** earlier, and until the original date you can go **Back to report-only**. Once the lock is on, the API answers: | Status | `error.code` | When | Fix | |---|---|---|---| | 403 | `ORIGIN_NOT_ALLOWED` | A browser call from a website you have not registered | Register the website, or call from your server | | 403 | `BROWSER_CALLS_DISABLED` | A browser call while browser calls are switched off | Call from your server with the secret, or switch browser calls back on | | 403 | `TENANT_SECRET_REQUIRED` | A server call without `X-Tenant-Secret` | Send the secret on every server call | | 403 | `TENANT_SECRET_INVALID` | A server call with the wrong secret, or a secret-only route called without it | Copy the current secret from Settings → Storefront API | None of these is worth retrying: the same call gets the same answer until your integration or the store's settings change. Before the lock date, check that: 1. Every website the shop runs on is registered, including `www.` and any separate checkout or landing-page host. 2. Every server that calls the API sends `X-Tenant-Secret`: the storefront server and any integration, scheduled job or feed generator. 3. The report shows no calls without the secret and none from unregistered sites for the last few days. 4. If the report shows no browser calls at all, switch **Browser calls** off. ## Rotate the public key [#rotate-the-public-key] Rotate the store key when it has been copied onto a site you do not control, or when an agency hands over. Go to **Settings → Storefront API → Danger zone → Rotate store key**. Rotating asks for your admin password and authenticator code. - The new key keeps the old one's prefix (`pk_live_` or `pk_test_`) and is shown straight after the rotation. - **The old key keeps working for 7 days.** Responses to calls made with it carry `X-Tenant-Key-Deprecated: true`. Log this header in your integration so a forgotten deployment shows up. - **End overlap now** stops the old key at once, for a key that has leaked. - After the overlap, calls with the old key get `404 TENANT_NOT_FOUND`. - Only one rotation runs at a time. Deploy the new key everywhere during the overlap. ## Rotate the server secret [#rotate-the-server-secret] Rotate the secret when it may have leaked: it was seen in a browser, committed to a repository, or someone who knew it has left. The change is immediate, with no overlap. Once the store is enforcing, calls with the old secret get `403 TENANT_SECRET_INVALID`. To keep a locked shop working, rotate just before you deploy the new value to every server. If a call ever carries both an `Origin` header and the secret, the secret has leaked to every visitor of that website. The API still serves the call, but the admin shows an urgent notice. Rotate the secret and move those calls to your server. --- # Money URL: https://elaraone.app/docs/concepts/money How the API represents prices and totals as integer minor units, how to display them, and how tax-inclusive and tax-exclusive prices are labelled. Every price, total and discount in the API is a money object with an integer amount in minor units. There are no floats and no decimal strings to parse. This page explains the shape, how to display it and how to send amounts back. ## The money object [#the-money-object] ```json title="A money object" { "amount": 8900, "currency": "GBP", "formatted": "£89.00" } ``` | Field | Type | Meaning | |---|---|---| | `amount` | integer | The value in the currency's minor unit: pence for GBP, cents for EUR and USD. `8900` is £89.00. | | `currency` | string | The ISO 4217 currency code, such as `GBP`. | | `formatted` | string | The amount ready to show, formatted for the UK (`£89.00`). | The same shape is used everywhere: product prices, cart lines, shipping options, discounts, tax, order totals and search results. A money field that has no value is `null`, never `0` and never a missing object. For example, a variant has no price when the store has not set one in its currency, and cart totals are `null` until the cart has been calculated. Merchant CRM money custom fields use the same idea without `formatted`: `{ "amount": 250000, "currency": "GBP" }`. ## Never use floats [#never-use-floats] Floating-point numbers cannot hold most decimal fractions exactly, so `0.1 + 0.2` is not `0.3`. Keep amounts as integers from the moment they arrive until you display them. - **Show `formatted`.** It is correct for the currency and already rounded. - **Do not add up totals yourself.** Promotions, shipping discounts and tax all change the numbers. Read `sub_total`, `discount_total`, `tax_total` and `total` from the cart or order. - **Compare and sort on `amount`**, never on `formatted`. - **Send integers back.** Query parameters that take money, such as `filter[price_min]` and `filter[price_max]`, take integer minor units. Send `2500` for £25.00, never `25` or `25.00`. ## Display amounts in another locale [#display-amounts-in-another-locale] If your shop shows prices in a format other than the UK one, format `amount` yourself. Divide by the currency's number of minor units, which `Intl.NumberFormat` already knows: ```ts title="Format a money object for any locale" type Money = { amount: number; currency: string; formatted: string } export function formatMoney(money: Money, locale = 'en-GB'): string { const formatter = new Intl.NumberFormat(locale, { style: 'currency', currency: money.currency }) const digits = formatter.resolvedOptions().maximumFractionDigits ?? 2 return formatter.format(money.amount / 10 ** digits) } formatMoney({ amount: 8900, currency: 'GBP', formatted: '£89.00' }, 'de-DE') // "89,00 £" ``` Division here is only for display. Never feed the result back into arithmetic or into a request. ## Currency [#currency] Each store trades in its own currency. You can read it from: - `currency` on `GET /v1/checkout/config`; - `currency` on every money object; - `meta.currency` on search responses. Do not assume GBP. Read the currency from the response and pass it to your formatter. ## Percentages [#percentages] Percentages that describe money are integers in basis points, so they never need floats either. `percent_bp: 1250` is 12.5%. You will see them on a variant's `pricing.saving` and in a bulk-price table's rows. Tax rates in a tax breakdown are the exception: `percentage` is a string, such as `"20"`, meant for display. ## Tax-inclusive and tax-exclusive prices [#tax-inclusive-and-tax-exclusive-prices] Stores set, per tax zone, whether prices are shown including or excluding tax. A cart tells you which applies through `tax_zone`: ```json title="tax_zone on a cart" { "tax_zone": { "id": 1, "name": "UK", "price_display": "tax_inclusive", "prices_include_tax": true } } ``` | Field | Values | |---|---| | `price_display` | `tax_inclusive` ("Including VAT") or `tax_exclusive` ("Excluding VAT") | | `prices_include_tax` | The same, as a boolean | The zone is the shipping address's, or the store's default zone before an address is set. `tax_zone` is `null` when the store has no tax zone. This setting is for labelling. The amounts are already correct either way: `total` is always what the shopper pays, `tax_total` is the tax within it, and `tax_breakdown` lists each rate: ```json title="tax_breakdown on a cart" [ { "identifier": "vat-standard", "description": "VAT", "percentage": "20", "price": { "amount": 2050, "currency": "GBP", "formatted": "£20.50" } } ] ``` Use `price_display` to choose the label next to prices ("inc. VAT" or "ex. VAT"), and show `tax_total` as its own line when prices exclude tax. --- # Catalogue URL: https://elaraone.app/docs/concepts/catalogue Products, variants, options, collections, images and prices, and how to filter, sort, include and page through the product list. The catalogue endpoints give your storefront everything it needs to render listing and product pages. They are read-only, and only published products are ever returned. ## Endpoints [#endpoints] | Endpoint | Returns | |---|---| | `GET /v1/products` | A page of products, with filters, sorts and includes | | `GET /v1/products/price-bounds` | The lowest and highest price for the current filters, for a price slider | | `GET /v1/products/{slug}` | One product, with everything included | | `GET /v1/collections` | A page of collections | | `GET /v1/collections/{slug}` | One collection | | `GET /v1/collections/{slug}/products` | A page of the collection's products, with the same filters, sorts and includes as `GET /v1/products` | | `GET /v1/promotions` | The store's live public offers, for an offers strip or banner | | `GET /v1/countries` | The countries the store sells and ships to, for address forms | Catalogue responses are cached by the engine and cleared when the merchant changes the catalogue, so you can call them on every page view. ## The building blocks [#the-building-blocks] - **Products** have a `name`, `description`, `slug` and `status`. Link product pages by `slug`. - **Variants** are what a shopper actually buys. Each has an `id`, a `sku`, its `stock` and whether it is `shippable`. Add a variant to a cart by its `id` or its `sku`. - **Options** describe how variants differ, such as Colour or Size. A product's `options` list the options its live variants use, in the merchant's order, each with the values in use. Each variant's own `options` name its value per option. That is all an option picker needs. - **Collections** group products. They nest: each has a `parent_id`, and may list its `children`. - **Attributes** are the merchant's own product fields, such as brand or material. Filterable attributes can narrow a listing with `filter[attr]`. - **Images** have a `url` and a `thumbnail_url`. - **Prices** are per variant, as [money objects](/docs/concepts/money). ## A product [#a-product] ```json title="GET /v1/products/oak-desk-lamp (trimmed)" { "data": { "id": 101, "status": "published", "name": "Oak desk lamp", "description": "Turned solid oak base with a linen shade.", "slug": "oak-desk-lamp", "options": [ { "id": 3, "handle": "finish", "name": "Finish", "label": "Finish", "values": [{ "id": 7, "name": "Natural" }, { "id": 8, "name": "Smoked" }] } ], "variants": [ { "id": 1543, "sku": "LAMP-OAK-NAT", "stock": 12, "shippable": true, "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }, "pricing": { "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }, "sale_price": null, "compare_price": null, "on_sale": false, "saving": null, "badges": [] }, "options": [{ "option_id": 3, "option": "Finish", "value_id": 7, "value": "Natural" }] } ], "images": [ { "id": 55, "name": "oak-desk-lamp", "url": "https://…/oak-desk-lamp.webp", "thumbnail_url": "https://…/oak-desk-lamp-thumb.webp" } ], "collections": [{ "id": 4, "parent_id": null, "name": "Lighting", "slug": "lighting" }], "offers": [] } } ``` `GET /v1/products/{slug}` always returns the full product, whatever you pass in `include`. Listings return only what you ask for. ### Prices on a variant [#prices-on-a-variant] | Field | Meaning | |---|---| | `pricing.price` | The price for one unit. `price` on the variant is the same amount, kept for older clients. | | `pricing.sale_price` | The promotional sale price, when a catalogue sale applies and the store shows sale prices. Otherwise `null`. | | `pricing.compare_price` | The merchant's "was" price, only when it is above the price. | | `pricing.on_sale` | Whether to present the item as on sale. | | `pricing.saving` | What a promotion saves, as `amount` and `percent_bp` (basis points). | | `pricing.badges` | Badges to show, each with a `label` and `kind`. | When the request carries a signed-in customer's bearer token, prices are that customer's customer-group prices. Otherwise they are the default prices a guest sees. ## List products [#list-products] ```bash tab="cURL" curl -G https://api.elaraone.app/api/v1/products \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ --data-urlencode "filter[collection]=lighting" \ --data-urlencode "filter[price_max]=10000" \ --data-urlencode "sort=-price" \ --data-urlencode "include=variants.prices,images" \ --data-urlencode "perPage=24" ``` ```ts tab="TypeScript" const params = new URLSearchParams({ 'filter[collection]': 'lighting', 'filter[price_max]': '10000', // £100.00 in pence sort: '-price', include: 'variants.prices,images', perPage: '24', }) const res = await fetch(`https://api.elaraone.app/api/v1/products?${params}`, { headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, }, }) const { data, links, meta } = await res.json() ``` ```php tab="PHP" $response = Http::withHeaders([ 'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'), 'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'), ])->get('https://api.elaraone.app/api/v1/products', [ 'filter' => ['collection' => 'lighting', 'price_max' => 10000], 'sort' => '-price', 'include' => 'variants.prices,images', 'perPage' => 24, ]); ``` ### Filters [#filters] | Parameter | Example | Matches | |---|---|---| | `filter[collection]` | `filter[collection]=lighting` | Products in the collection with this slug | | `filter[search]` | `filter[search]=mug` | Products whose name contains the text, ignoring case | | `filter[price_min]` | `filter[price_min]=2000` | Products with a variant priced at or above this, in integer minor units | | `filter[price_max]` | `filter[price_max]=5000` | Products with a variant priced at or below this, in integer minor units | | `filter[attr][handle]` | `filter[attr][material]=linen` | Products whose attribute has this value, ignoring case. Repeat for more attributes; they all have to match. | Filters combine: every one you send has to match. `filter[search]` is a simple name match. For ranked, typo-tolerant search with facets, use [search](/docs/concepts/search). ### Sorts [#sorts] `sort` takes one of `name`, `created_at` or `price`. Put `-` in front for descending order, for example `sort=-created_at` for newest first or `sort=price` for cheapest first. ### Includes [#includes] `include` takes a comma-separated list: | Include | Adds | |---|---| | `variants` | The variants, without prices | | `variants.prices` | Each variant's `price` and `pricing` | | `variants.options` | The product's `options` and each variant's `options` | | `images` | The product's images | | `collections` | The collections the product is in | | `offers` | The promotions the product advertises, for badges, banners and bulk-price tables | Ask only for what the page shows: a listing grid usually needs `variants.prices,images`. An unknown filter, sort or include is refused with `400`. ### Pages [#pages] `perPage` sets the page size, from 1 to 100 (default 24), and `page` picks the page. Responses carry `links` and `meta`. See [pagination](/docs/concepts/pagination). ## Price bounds [#price-bounds] `GET /v1/products/price-bounds` returns the price range of the products a listing would show with the same `filter[collection]`, `filter[search]` and `filter[attr]`. Forward your listing's query string unchanged: `filter[price_min]` and `filter[price_max]` are accepted but ignored, so the slider's range never shrinks to the range the shopper just picked. ```json title="Response" { "data": { "currency": "GBP", "min": { "amount": 1800, "currency": "GBP", "formatted": "£18.00" }, "max": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" } } } ``` ## Promotions [#promotions] `GET /v1/promotions` lists the store's live public offers, unpaginated, for an offers strip or banners. ```json title="Response (trimmed)" { "data": [ { "id": 12, "kind": "free_shipping", "label": "Free delivery over £50", "badge": null, "banner": { "title": "Free delivery over £50", "body": "On every order to the UK mainland." }, "ends_at": "2026-11-30T23:59:59+00:00", "target": null, "scope": "all" } ] } ``` `kind` is the rule kind: `product_adjustment`, `cart_adjustment`, `bulk_tiered`, `bundle_set`, `buy_x_get_y` or `free_shipping`. `scope` says what the rule covers: `all` (store-wide), `collection` or `product`. Offers on a single product come back in its `offers` (always on `GET /v1/products/{slug}`, and on listings with `include=offers`). A bulk rule adds a `bulk_table` of quantity bands, each with `min_qty`, `max_qty`, `unit_price` and `percent_bp`. ## Countries [#countries] `GET /v1/countries` lists the countries the store sells and ships to. Use it to build the country picker in address forms: the country's `id` is the `country_id` that cart and account addresses take. ```json title="Response (trimmed)" { "data": [ { "id": 235, "iso2": "GB", "iso3": "GBR", "name": "United Kingdom", "phonecode": "44" } ], "meta": { "default_country": "GB" } } ``` --- # Carts and the cart token URL: https://elaraone.app/docs/concepts/carts Create a cart, add and change lines, apply coupons, set addresses and shipping, and read totals, tax, gifts and nudges. A cart holds what a shopper is about to buy. You create one, get back a `cart_token`, and send that token as `X-Cart-Token` on every cart and checkout call. There are no cookies or sessions on the API side: the token is the cart's identity. ## The flow [#the-flow] 1. `POST /v1/cart` creates a cart and returns its `cart_token`. 2. `POST /v1/cart/lines` adds variants. `PATCH` and `DELETE` on `/v1/cart/lines/{id}` change or remove them. 3. `POST /v1/cart/coupon` applies a promotion code (optional). 4. `PUT /v1/cart/addresses` sets the shipping and billing addresses. 5. `GET /v1/cart/shipping-options` lists the delivery and collection options for that address, and `PUT /v1/cart/shipping-option` picks one. 6. [Checkout](/docs/concepts/checkout) takes payment and places the order. Every write returns the whole cart, recalculated, so you never need a second call to refresh it. ## Create a cart [#create-a-cart] ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/cart \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" ``` ```ts tab="TypeScript" const res = await fetch('https://api.elaraone.app/api/v1/cart', { method: 'POST', headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, }, }) const { data } = await res.json() // Keep data.cart_token server-side, for example in an httpOnly cookie. ``` ```php tab="PHP" $response = Http::withHeaders([ 'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'), 'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'), ])->post('https://api.elaraone.app/api/v1/cart'); $cartToken = $response->json('data.cart_token'); ``` ```json title="201 Created" { "data": { "cart_token": "01JB9K3N7Q2W5X8Z4C6V1T0R3M", "cart": { "id": 812, "currency": "GBP", "lines": [], "sub_total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" }, "tax_total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" }, "total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" } } } } ``` - A cart token lasts 30 days from when it is issued. - An unknown or expired token, or the token of a cart that has become an order, returns `404 CART_NOT_FOUND`. Create a new cart. - Create the cart with a signed-in customer's bearer token and it belongs to that customer. A signed-in customer can also call `GET /v1/cart` without `X-Cart-Token` to resume their current cart; the token comes back in the `X-Cart-Token` response header and in `meta.cart_token`. - Send `X-Cart-Token` with `POST /v1/auth/login` or `POST /v1/auth/register` to merge a guest's cart into the customer's. See [customers](/docs/concepts/customers). ## Add a line [#add-a-line] Send either a `variant_id` or a `sku`, with a `quantity` of at least 1. Send an [`Idempotency-Key`](/docs/concepts/idempotency) so a retried request never adds the line twice. ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/cart/lines \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "sku": "MUG-STONE", "quantity": 2 }' ``` ```ts tab="TypeScript" const res = await fetch('https://api.elaraone.app/api/v1/cart/lines', { method: 'POST', headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, 'X-Cart-Token': cartToken, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ variant_id: 1543, quantity: 1 }), }) const { data: cart } = await res.json() ``` To change the quantity, send `PATCH /v1/cart/lines/{id}` with `{ "quantity": 3 }`. To remove a line, send `DELETE /v1/cart/lines/{id}`. Both accept an `Idempotency-Key` too. The `{id}` is the line's `id` from the cart, not the variant's. A line id that is not in this cart returns `404 CART_LINE_NOT_FOUND`. ## The cart object [#the-cart-object] | Field | Meaning | |---|---| | `id`, `currency` | The cart's id and currency | | `lines` | The lines, described below | | `shippable` | `false` when nothing in the cart ships (a digital-only or empty cart), so you can skip the delivery step | | `addresses` | `shipping` and `billing` addresses | | `shipping` | The chosen `option` and its `sub_total`, `tax_total`, `total` and `breakdown` | | `coupon_code`, `coupon` | The code on the cart and its state | | `discounts` | Each promotion applied, with its `label`, `kind`, `amount`, and how much came off the items and off the delivery | | `sub_total` | The lines before promotions | | `discount_total` | What promotions took off the lines | | `shipping_discount` | What promotions took off the delivery, already applied to `shipping` and `total` | | `savings` | "You saved": the promotion discounts plus the shipping discount | | `tax_total`, `tax_breakdown`, `tax_zone` | Tax, per rate, and how prices should be labelled. See [money](/docs/concepts/money). | | `nudges` | Messages that tell the shopper how to qualify for an offer | | `rewards` | Free gifts and "choose a free product" offers the cart has earned | | `total` | What the shopper pays | Each line has an `id`, `quantity`, `variant` (`id`, `sku`, `name`), `unit_price`, `sub_total`, `discount_total`, `sub_total_discounted`, `discounts`, `gift`, `tax_amount` and `total`. All amounts are [money objects](/docs/concepts/money). Show them as they are; do not recompute them. ## Coupons [#coupons] `POST /v1/cart/coupon` with `{ "code": "WELCOME10" }` applies a code. `DELETE /v1/cart/coupon` removes it. A code that cannot go on the cart is refused with `422`, and the cart is left exactly as it was: | `error.code` | When | `error.details.reason` | |---|---|---| | `COUPON_INVALID` | No such code | `unknown` | | `COUPON_EXPIRED` | The code or its offers have not started yet, or have ended | `not_started`, `ended` | | `COUPON_USED_UP` | The code has reached its usage limit | `used_up` | | `COUPON_LIMIT_REACHED` | This customer (or, for a guest, this email) has used it as often as allowed | `limit_reached` | | `COUPON_NOT_APPLICABLE` | Nothing in this basket qualifies | `threshold`, `customer`, `items` | For `threshold`, `error.details.remaining` is the money still to spend, and `error.message` says it in words ("Spend £20.00 more to use this code."). Show `error.message` next to the code field. A code that was applied can stop qualifying later, for example when the shopper removes items. It stays on the cart, and `coupon.status` changes from `applied` to `not_applicable`, with `coupon.reason_code` and, for a spend threshold, `coupon.remaining`. ## Addresses [#addresses] `PUT /v1/cart/addresses` sets both addresses in one call. `billing` is required; `shipping` can be left out for a click-and-collect order, whose shipping address is the collection point. Set `billing_same_as_shipping: true` to copy the shipping address. ```json title="PUT /v1/cart/addresses" { "shipping": { "first_name": "Grace", "last_name": "Hopper", "line_one": "12 Mill Lane", "city": "York", "postcode": "YO1 7HH", "country_id": 235, "contact_email": "grace@example.com", "contact_phone": "+447700900123" }, "billing_same_as_shipping": true } ``` Address fields are `title`, `first_name`, `last_name`, `company_name`, `line_one`, `line_two`, `line_three`, `city`, `state`, `postcode`, `country_id`, `delivery_instructions`, `contact_email` and `contact_phone`. `country_id` comes from `GET /v1/countries`. For a signed-in customer you can send `{ "address_id": 42 }` instead of the fields to use a saved address, or add `"save_to_account": true` to a new address to save it to their address book once the order is placed. A guest cannot use `address_id`: it returns `422 ADDRESS_NOT_FOUND`. If a shipping option was already chosen, changing the address re-prices it for the new destination or clears it. `meta.shipping_option_cleared` tells you which. ## Shipping options [#shipping-options] `GET /v1/cart/shipping-options` lists exactly what `PUT /v1/cart/shipping-option` will accept for the cart's current address. Before a shipping address is set it returns `422 SHIPPING_ADDRESS_REQUIRED`. ```json title="GET /v1/cart/shipping-options (trimmed)" { "data": [ { "identifier": "standard", "name": "Standard delivery", "description": "2 to 4 working days", "price": { "amount": 495, "currency": "GBP", "formatted": "£4.95" }, "discounted_price": null, "collect": false, "kind": "delivery", "collection_location": null } ] } ``` Pick one with `PUT /v1/cart/shipping-option` and `{ "identifier": "standard" }`. A click-and-collect option has `collect: true` and a `collection_location` with the address and opening hours. `discounted_price` shows the price after a free-delivery or delivery-discount promotion. The engine remembers the price you showed. If the merchant changes a rate, or the basket crosses a weight or spend band, before the shopper pays, checkout refuses with `409 SHIPPING_OPTION_CHANGED` rather than charge a price the shopper never saw. See [checkout](/docs/concepts/checkout). ## Gifts, rewards and nudges [#gifts-rewards-and-nudges] Promotions can add to the cart as well as take off it. - **Free gifts.** A gift line has `gift: true`. Its quantity belongs to the promotion, so `PATCH` returns `422 GIFT_LINE_LOCKED`. The shopper can remove it with `DELETE`, and it is then not offered again on that cart. - **"Choose a free product" offers.** `rewards` lists them with `type: "choice"`. Add one of the listed variants or products as a normal line and the promotion makes it free. `choose_qty` is how many free units the cart has earned and `chosen_qty` how many are already in it. `items` gives picker rows when the offer lists specific products. - **Nudges.** Each entry in `nudges` has a `message`, such as "Spend £12.00 more for free delivery", and the `remaining` amount. Show them near the basket total. ## Stock [#stock] Adding a line checks that the variant can be bought in that quantity, and refuses with `422 VALIDATION_FAILED` when it cannot. A cart does not reserve stock: two shoppers can have the last item in their carts at the same time. Stock is locked when it matters: as the order is placed. Checkout locks the stock of every variant in the cart, checks it again, and takes it off in the same step that creates the order, so the last item can only be sold once. If the stock has gone, checkout returns `422 VALIDATION_FAILED` and no order is created. --- # Checkout and orders URL: https://elaraone.app/docs/concepts/checkout Set up payment with Stripe or PayPal, place the order exactly once, give guests access to their order, and follow an order through its states. Checkout turns a ready cart into an order. Your storefront sets up the payment with the store's own Stripe or PayPal account, the shopper pays in the provider's own form, and one call places the order. With an `Idempotency-Key`, that call can be retried safely and still places exactly one order. ## Before you start [#before-you-start] A cart is ready for checkout when it has at least one line, a billing address, and, if anything in it ships, a shipping address and a chosen shipping option. See [carts](/docs/concepts/carts). A cart that is not ready is refused with `422 VALIDATION_FAILED`. ## The steps [#the-steps] ### Read the checkout configuration [#read-the-checkout-configuration] `GET /v1/checkout/config` describes the store's payment set-up. It needs no cart. ```json title="Response" { "data": { "gateways": ["stripe", "paypal"], "currency": "GBP", "country": "GB", "gateway_mode": "live", "stripe_publishable_key": "pk_live_…", "stripe_capture_method": "manual", "paypal_client_id": "AX…" } } ``` | Field | Use it to | |---|---| | `gateways` | Decide which ways to pay to offer | | `gateway_mode` | `test` or `live`: show a "test payments" banner in test mode, and load PayPal's script for the same mode | | `stripe_publishable_key` | Initialise Stripe.js | | `stripe_capture_method` | `manual` or `automatic`. Pass it as Elements' `captureMethod` if you mount the Payment Element before creating the PaymentIntent. | | `paypal_client_id` | The `client-id` of PayPal's JavaScript SDK | ### Set up the payment [#set-up-the-payment] **Stripe.** `POST /v1/checkout/payment-intent` (with `X-Cart-Token`) creates a PaymentIntent for the cart's current total and returns its `client_secret` and the `publishable_key`. Mount Stripe's Payment Element with them, and let the shopper confirm the payment in the browser. Each cart has one open PaymentIntent. Calling the endpoint again after the cart changes reuses it with the new total when that is possible, so call it again whenever the total changes before payment. **PayPal.** `POST /v1/checkout/paypal/order` (with `X-Cart-Token`) creates a PayPal order and returns its `paypal_order_id`. Return it from the PayPal buttons' `createOrder` callback. ### Place the order [#place-the-order] For Stripe, once the shopper has confirmed the payment, call `POST /v1/checkout/complete` with the PaymentIntent's id. For PayPal, once the shopper approves, call `POST /v1/checkout/paypal/capture` with the PayPal order id. Both place the order and return `201 Created`. Send an `Idempotency-Key` on both. ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/checkout/complete \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: 9b2c6f0e-4a7d-4c1e-9f3b-2d8a5e7c1b04" \ -H "Content-Type: application/json" \ -d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_3QxYz…" } }' ``` ```ts tab="TypeScript" // Make the key once per "Pay" click and keep it for every retry of that click. const idempotencyKey = crypto.randomUUID() const res = await fetch('https://api.elaraone.app/api/v1/checkout/complete', { method: 'POST', headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, 'X-Cart-Token': cartToken, 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ payment: { gateway: 'stripe', payment_intent: paymentIntentId } }), }) if (res.status === 201) { const { data: order, meta } = await res.json() // meta.order_access_token is present for a guest order. } ``` ```php tab="PHP" $response = Http::withHeaders([ 'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'), 'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'), 'X-Cart-Token' => $cartToken, 'Idempotency-Key' => $idempotencyKey, ])->post('https://api.elaraone.app/api/v1/checkout/complete', [ 'payment' => ['gateway' => 'stripe', 'payment_intent' => $paymentIntentId], ]); ``` For PayPal, the body of `POST /v1/checkout/paypal/capture` is `{ "paypal_order_id": "5O190127TN364715T" }`. `POST /v1/checkout/complete` also accepts `{ "payment": { "gateway": "paypal", "paypal_order_id": "…" } }`. ## The order [#the-order] ```json title="201 Created (trimmed)" { "data": { "id": 3051, "reference": "00003051", "status": "payment-received", "status_label": "Payment Received", "placed_at": "2026-10-24T10:12:31+00:00", "dispatched_at": null, "delivered_at": null, "cancelled_at": null, "lines": [ { "id": 9120, "type": "physical", "description": "Linen cushion", "identifier": "CUSH-LIN-NAT", "option": null, "quantity": 2, "unit_price": { "amount": 3400, "currency": "GBP", "formatted": "£34.00" }, "total": { "amount": 6800, "currency": "GBP", "formatted": "£68.00" } } ], "shipping_method": { "identifier": "standard", "name": "Standard delivery", "kind": "delivery", "collection_location": null }, "shipments": [], "sub_total": { "amount": 6800, "currency": "GBP", "formatted": "£68.00" }, "shipping_total": { "amount": 495, "currency": "GBP", "formatted": "£4.95" }, "tax_total": { "amount": 1216, "currency": "GBP", "formatted": "£12.16" }, "total": { "amount": 7295, "currency": "GBP", "formatted": "£72.95" } }, "meta": { "order_access_token": "eyJ…", "order_access_token_expires_at": "2026-10-24T11:12:31+00:00" } } ``` After a successful checkout the cart token stops working (`404 CART_NOT_FOUND`). Create a new cart for the shopper's next order. The store's order confirmation email is sent automatically, if the store has it switched on. ## Exactly once [#exactly-once] Send an `Idempotency-Key` on `POST /v1/checkout/complete` and `POST /v1/checkout/paypal/capture`. If the response is lost (a timeout, a dropped connection, a double click), retry with the same key and the same body: - if the first request placed the order, you get the same `201` back, with `Idempotent-Replayed: true`, and no second order is created; - if the first request is still running, you get `409 IDEMPOTENCY_IN_PROGRESS`; wait a moment and retry with the same key; - if the first request failed, the retry runs again, so a shopper who fixes their card can pay with the same key. A PayPal capture is recorded before the order is placed, so a retry never captures the payment twice. See [idempotency keys](/docs/concepts/idempotency). ## What can go wrong [#what-can-go-wrong] | Status | `error.code` | What happened | What to do | |---|---|---|---| | 402 | `PAYMENT_FAILED` | The payment was declined or could not be taken. The cart and stock are unchanged. | Show the reason from `details.decline_code` and let the shopper try again. See [payments](/docs/concepts/payments). | | 409 | `SHIPPING_OPTION_CHANGED` | The chosen shipping option is no longer offered, or its price changed since it was chosen. Nothing was charged. | Show `details.options` (the options on offer now), let the shopper choose again with `PUT /v1/cart/shipping-option`, then set up the payment again. | | 409 | `PROMOTIONS_CHANGED` | The basket's discounts changed since the payment was set up (an offer ended, ran out of uses, or a first-order offer no longer applies). Nothing was charged. | Show the new total from `details.cart` and set up the payment again. `details.reason` is `changed`, `used_up`, `limit_reached` or `first_order`. | | 409 | `PAYMENT_REFUNDED` | The payment was taken but the order could no longer be placed (stock gone, cart changed), so the payment was refunded. | Tell the shopper, and start a new checkout. | | 409 | `PAYMENT_REFUND_PENDING` | As above, but the automatic refund is still being retried. The store has been told. | Tell the shopper their payment will be refunded. Do not ask them to pay again for this cart. | | 409 | `PAYMENT_ALREADY_USED` | This payment already paid for another checkout. | Set up a new payment. | | 409 | `PAYMENT_SETUP_IN_PROGRESS` | Another request is setting up this cart's PaymentIntent. | Retry shortly; you get the same intent. | | 422 | `VALIDATION_FAILED` | The cart is not ready, or an item no longer has enough stock. | Show `details` and send the shopper back to fix the cart. | | 422 | `PAYMENT_GATEWAY_UNAVAILABLE` | The gateway is unknown or not set up for this store. | Only offer gateways listed in `checkout/config`. | `SHIPPING_OPTION_CHANGED` can also come back from `POST /v1/checkout/payment-intent` and `POST /v1/checkout/paypal/order`: the engine checks the shipping price before any payment is set up. ## Order states [#order-states] | `status` | Label | Moves to | |---|---|---| | `awaiting-payment` | Awaiting Payment | `payment-offline`, `payment-received`, `cancelled` | | `payment-offline` | Payment Offline | `payment-received`, `cancelled` | | `payment-received` | Payment Received | `dispatched`, `cancelled` | | `dispatched` | Dispatched | `delivered` | | `delivered` | Delivered | Nothing (final) | | `cancelled` | Cancelled | Nothing (final) | Branch on `status`, which never changes meaning, and show `status_label`, which is the store's own wording. `dispatched_at`, `delivered_at` and `cancelled_at` are filled in when the order reaches that state, and `shipments` lists the parcels with their carrier and tracking details once the order is dispatched. A cancelled order's stock is put back. ## Guest orders [#guest-orders] A guest has no account, so a guest order comes with a short-lived access token in `meta.order_access_token`, and `meta.order_access_token_expires_at` says when it stops working. Use it straight away to show the confirmation page: ```bash curl https://api.elaraone.app/api/v1/orders/guest/00003051 \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Order-Access-Token: $ORDER_ACCESS_TOKEN" ``` To let a guest look up an order later, ask for the order reference and the email used at checkout, and call `POST /v1/orders/lookup`: ```json title="POST /v1/orders/lookup" { "reference": "00003051", "email": "alan@example.com" } ``` It returns the order and a fresh `meta.order_access_token`. A reference and email that do not match return `404`. Lookups share the sign-in rate limit (10 a minute per store and IP), because a reference and email together act like a password. A missing, tampered or expired access token returns `403 ORDER_ACCESS_TOKEN_INVALID`. `details.reason` is `expired` (ask for the email again) or `invalid`. Signed-in customers see their orders with `GET /v1/orders` and `GET /v1/orders/{reference}`. See [customers](/docs/concepts/customers). --- # Customer accounts URL: https://elaraone.app/docs/concepts/customers Register and sign in shoppers with bearer tokens, verify email, reset passwords, manage the address book, show order history and delete an account. Shoppers can create an account with the store to save addresses and see their orders. Accounts belong to one store: the same email can have separate accounts at two stores. The API uses bearer tokens, never cookies, so your storefront server keeps the token and sends it as `Authorization: Bearer …`. ## Endpoints [#endpoints] | Endpoint | Does | |---|---| | `POST /v1/auth/register` | Creates an account and returns a token | | `POST /v1/auth/login` | Signs in and returns a token | | `POST /v1/auth/logout` | Revokes the current token | | `GET /v1/auth/me` | The signed-in account | | `PATCH /v1/auth/me` | Changes the name or email | | `POST /v1/auth/verify` | Confirms an email address from the verification link | | `POST /v1/auth/verify/resend` | Sends the verification email again | | `POST /v1/auth/password/forgot` | Emails a password reset link | | `POST /v1/auth/password/reset` | Sets a new password with the token from that link | | `POST /v1/auth/password` | Changes the password of a signed-in account | | `GET`, `POST /v1/account/addresses` | Lists or adds saved addresses | | `PATCH`, `DELETE /v1/account/addresses/{id}` | Changes or deletes a saved address | | `GET /v1/orders`, `GET /v1/orders/{reference}` | Order history | | `DELETE /v1/account` | Erases the account | ## Register and sign in [#register-and-sign-in] ```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 "X-Cart-Token: $CART_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Katherine Johnson", "email": "katherine@example.com", "password": "correct-horse-battery", "password_confirmation": "correct-horse-battery" }' ``` ```ts tab="TypeScript" const res = await fetch('https://api.elaraone.app/api/v1/auth/login', { method: 'POST', headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, 'X-Cart-Token': guestCartToken, // optional: merges the guest's cart 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), }) const { data } = await res.json() // Store data.token in an httpOnly cookie on your own domain, set to expire at data.expires_at. ``` ```json title="Response" { "data": { "user": { "id": 77, "name": "Katherine Johnson", "email": "katherine@example.com", "email_verified_at": null, "customer_id": 204 }, "token": "12|q8V…", "expires_at": "2026-11-23T10:12:31+00:00" } } ``` - Registration returns `201` and login returns `200`, both with the same shape. - Passwords need at least 8 characters, and `password_confirmation` must match. - Wrong credentials, or an email already registered, return `422 VALIDATION_FAILED` with the field in `details`. - The token can be used straight away, before the email is verified. `expires_at` says when it stops working. - Send the guest's `X-Cart-Token` with login or registration to merge their basket into the customer's cart. An unknown or expired cart token is ignored; it never stops the sign-in. - Register, login, password reset, profile changes and password changes share a tight rate limit: 10 a minute per store and IP. See [rate limits](/docs/concepts/rate-limits). Treat the bearer token like a password. Keep it in an httpOnly, secure cookie on your storefront's domain, and have your server add it to API calls. Never put it in `localStorage`. Send the token on every call made for that customer, together with the store key and secret: ```bash curl https://api.elaraone.app/api/v1/auth/me \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` A missing, expired or revoked token returns `401 UNAUTHENTICATED`. `POST /v1/auth/logout` revokes the token it was sent with. ## Change the name or email [#change-the-name-or-email] `PATCH /v1/auth/me` takes `name`, `email` or both. Changing the email also needs `current_password`. ## Verify the email address [#verify-the-email-address] When an account is created, the store sends a verification email, if the store has set its storefront's verify-email page in **Store settings**. The link opens that page on your storefront with a signed query string. Your page sends that query string to the API exactly as it arrived, as the query string of `POST /v1/auth/verify`: ```ts title="On your verify-email page (server-side)" // incomingUrl is the URL the shopper opened, e.g. https://hearthandoak.example/verify?expires=…&hash=…&id=…&signature=… const query = new URL(incomingUrl).search // keep it byte for byte: same order, same encoding const res = await fetch(`https://api.elaraone.app/api/v1/auth/verify${query}`, { method: 'POST', headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, }, }) // 200 { "data": { "status": "verified" } }, or 403 when the link is invalid or has expired ``` The signature is checked against the exact query string, so do not re-encode, reorder or add parameters. `POST /v1/auth/verify/resend` (signed in) sends a fresh link, or answers `already-verified`. ## Reset a forgotten password [#reset-a-forgotten-password] 1. `POST /v1/auth/password/forgot` with `{ "email": "…" }`. It always answers `200` with `reset-link-sent`, whether or not the address has an account, so it cannot be used to find out who shops at the store. 2. The store emails a link to the reset page you set in **Store settings**, with the token and email filled in. The token works once, for 60 minutes. If the store has not set a reset page, no email is sent. 3. Your reset page sends `POST /v1/auth/password/reset` with `token`, `email`, `password` and `password_confirmation`. A bad or expired token returns `422 VALIDATION_FAILED` on `token`. ## Change the password [#change-the-password] `POST /v1/auth/password` (signed in) takes `current_password`, `password` and `password_confirmation`. The token that made the request keeps working; every other token on the account is revoked, and `revoked_tokens` says how many. ## The address book [#the-address-book] ```json title="GET /v1/account/addresses" { "data": [ { "id": 42, "label": "Home", "title": null, "first_name": "Ada", "last_name": "Lovelace", "company_name": null, "line_one": "221B Baker Street", "line_two": null, "line_three": null, "city": "London", "state": null, "postcode": "NW1 6XE", "country_id": 235, "delivery_instructions": null, "contact_email": null, "contact_phone": "+447700900123", "shipping_default": true, "billing_default": true } ] } ``` - `GET` lists the customer's addresses, newest first. - `POST` creates one (`201`). `first_name`, `last_name`, `line_one` and `city` are required. - `PATCH /v1/account/addresses/{id}` changes any field. `DELETE` removes the address. - `label` is an optional nickname such as "Home" or "Work", up to 50 characters. `title` is the salutation. - Setting `shipping_default` or `billing_default` to `true` moves that default to this address, so only one address holds each. Deleting a default moves it to the newest remaining address. - Another customer's address id returns `404 NOT_FOUND`. At checkout, a signed-in customer can use a saved address with `address_id`, or save a new one with `save_to_account`. See [carts](/docs/concepts/carts#addresses). ## Order history [#order-history] `GET /v1/orders` lists the signed-in customer's orders, newest first, with `page` and `perPage` (1 to 100, default 24). `GET /v1/orders/{reference}` returns one order in the same shape checkout returns. See [checkout](/docs/concepts/checkout#the-order). When a customer verifies their email, orders they placed earlier as a guest with that address become part of their account. ## Delete the account [#delete-the-account] `DELETE /v1/account` lets a shopper erase their own account. It needs the bearer token and the account password as `current_password`: ```bash curl -X DELETE "https://api.elaraone.app/api/v1/account?current_password=correct-horse-battery" \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "Authorization: Bearer $CUSTOMER_TOKEN" ``` On success it answers `{ "data": { "status": "erased" } }`, and: - the account can never sign in again, and every token on it 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; - guest orders, carts and form submissions made with the account's verified email are anonymised the same way; - the verified email and phone numbers are kept only as one-way hashes on the store's do-not-contact list, so automated messages never reach them again. A missing or wrong password returns `422 VALIDATION_FAILED`. `409 ACCOUNT_SHARED` means the account shares its customer record with other accounts (the store linked them), so only the store can erase it; nothing is changed. Deletion is rate limited to 3 a minute and 10 an hour per account, and 20 an hour per IP. --- # Payments URL: https://elaraone.app/docs/concepts/payments Take card payments through the store's own Stripe account and PayPal, handle test and live mode, and turn declines into clear messages. Elara One takes payments through the store's own Stripe and PayPal accounts. The money goes straight to the merchant, and card details are entered into Stripe's or PayPal's own form, so they never reach Elara One or your servers. This page covers how the two providers fit into checkout and how to handle a payment that does not go through. ## Card data never touches Elara One [#card-data-never-touches-elara-one] - **Stripe.** The shopper types their card into Stripe's Payment Element, which runs in an iframe served by Stripe. Your page and the API only ever see a PaymentIntent id (`pi_…`). - **PayPal.** The shopper pays in PayPal's own buttons and window. The API only sees the PayPal order id. Your storefront server and the API never handle card numbers. ## Connect the providers [#connect-the-providers] Merchants connect their accounts in the store admin under **Settings → Payments**. Each provider has a set of Test keys and a set of Live keys. The page also shows the webhook address on the store's own hooks host and the provider events to send to it. Your storefront reads what it needs from `GET /v1/checkout/config`: which `gateways` are on, the Stripe publishable key and the PayPal client id. You never handle the merchant's secret keys. ## Test and live mode [#test-and-live-mode] `gateway_mode` in `GET /v1/checkout/config` is `test` or `live`, and says which set of keys checkout is using. It is set for the store by Elara One rather than switched in the admin, and the top of the Payments page shows which mode is in use. - In `test` mode, show a "test payments" banner, and use the provider's test cards and sandbox accounts. - Load PayPal's JavaScript SDK with the client id for the same mode. - Staging stores, where your plan includes them, always use test payments. ## Stripe [#stripe] The Stripe flow uses a PaymentIntent and the Payment Element: 1. `POST /v1/checkout/payment-intent` returns a `client_secret` and the `publishable_key` for the cart's current total. 2. Mount the Payment Element with the client secret and let the shopper confirm. 3. Call `POST /v1/checkout/complete` with `{ "payment": { "gateway": "stripe", "payment_intent": "pi_…" } }`. ```ts title="In the browser, with Stripe.js" const stripe = Stripe(publishableKey) const elements = stripe.elements({ clientSecret }) elements.create('payment').mount('#payment-element') // On "Pay": const { error, paymentIntent } = await stripe.confirmPayment({ elements, redirect: 'if_required', confirmParams: { return_url: 'https://hearthandoak.example/checkout/return' }, }) if (!error) { // Ask your server to call POST /v1/checkout/complete with paymentIntent.id. } ``` ### When the money is taken [#when-the-money-is-taken] `stripe_capture_method` in `GET /v1/checkout/config` tells you how the PaymentIntent takes the money: | Value | What happens | |---|---| | `manual` | Confirming the payment in the browser only authorises the card (the PaymentIntent ends in `requires_capture`). `checkout/complete` checks stock, shipping and promotions, and only then captures. If it refuses, the authorisation is released and nothing is charged. | | `automatic` | Confirming the payment takes the money. If the order can then no longer be placed, the payment is refunded and checkout returns `409 PAYMENT_REFUNDED`. | If you mount the Payment Element before creating the PaymentIntent (a deferred intent), pass `stripe_capture_method` as Elements' `captureMethod`. After any refusal from `checkout/complete`, call `POST /v1/checkout/payment-intent` again before letting the shopper pay again. ## PayPal [#paypal] 1. In the PayPal buttons' `createOrder`, call `POST /v1/checkout/paypal/order` and return its `paypal_order_id`. 2. In `onApprove`, call `POST /v1/checkout/paypal/capture` with `{ "paypal_order_id": "…" }` and an `Idempotency-Key`. The capture is recorded before the order is placed, so a retry never captures twice. If PayPal refuses the shopper's funding source, nothing is taken: let them choose another in the PayPal buttons and call the capture again for the same PayPal order. The starter storefronts offer Stripe card checkout. PayPal is available in the API for storefronts you build yourself. ## Declined payments [#declined-payments] When a payment does not go through, `POST /v1/checkout/complete` and `POST /v1/checkout/paypal/capture` answer `402` with `error.code` `PAYMENT_FAILED`. When the provider declined it, `details.decline_code` says why: ```json title="402 Payment Required" { "error": { "code": "PAYMENT_FAILED", "message": "PayPal declined the payment method. Choose another way to pay and try again.", "details": { "decline_code": "try_another_method" } } } ``` | `decline_code` | Meaning | What to show | |---|---|---| | `card_declined` | The card issuer or PayPal refused it without a reason the shopper can act on. Lost, stolen and fraud-filter declines are reported this way on purpose. | "Your card was declined. Try another card or way to pay." | | `card_expired` | The card has expired. | Ask for another card. | | `insufficient_funds` | Not enough funds or credit. | Ask for another card or way to pay. | | `incorrect_card_details` | A wrong card number, security code (CVC) or postcode. | Ask the shopper to check and re-enter their details. | | `authentication_required` | 3-D Secure (Stripe) or PayPal's own check was not completed. | Ask the shopper to complete their bank's check, then try again. | | `try_another_method` | PayPal refused this funding source. | Reopen the PayPal buttons so the shopper can pick another, then capture the same PayPal order again. | | `unknown` | The provider declined for a reason Elara One does not map, or gave none. | Show `error.message`. | - **Treat any value you do not recognise as `unknown`.** The list may grow. - `error.message` is Elara One's own wording, safe to show. The provider's raw text is never returned. - `details` is only present on a provider decline. Other refusals, such as a released authorisation or a payment made for another store, have only `code` and `message`: show the message. - After a `402`, the cart and stock are unchanged and the shopper can try again. Branch on `error.code` first: a `402` with `TENANT_PAYMENT_REQUIRED` means the store itself is unavailable, not that the payment failed. ```ts title="Handle a checkout response" const body = await res.json() if (res.status === 402 && body.error.code === 'PAYMENT_FAILED') { const code = body.error.details?.decline_code ?? 'unknown' if (code === 'try_another_method') reopenPayPalButtons() showPaymentError(body.error.message) } ``` The other checkout outcomes, such as `SHIPPING_OPTION_CHANGED` and `PAYMENT_REFUNDED`, are covered in [checkout](/docs/concepts/checkout#what-can-go-wrong). --- # Search URL: https://elaraone.app/docs/concepts/search Results pages, instant search suggestions, the merchant's search settings, the anonymous search events beacon, synonyms and search rate limits. Every store has a search index that ranks results, tolerates typos, and stays in step with the catalogue. Four endpoints cover it: a results page, suggestions as the shopper types, the merchant's search settings, and a beacon that records what shoppers click. | Endpoint | Use | |---|---| | `GET /v1/search` | The results page: products, facets and filters | | `GET /v1/search/suggest` | Instant suggestions as the shopper types | | `GET /v1/search/config` | The merchant's search settings for your search box | | `POST /v1/search/events` | Anonymous click and add-to-basket events from search | Search answers are never personalised. Call these endpoints from your storefront server without a bearer token or cart token. ## The results page [#the-results-page] `GET /v1/search` takes a query `q` and the same `perPage`, `page` and `include` parameters as the [product list](/docs/concepts/catalogue#list-products). An empty `q` lists every published product, which is useful for brand and tag pages. | Parameter | Matches | |---|---| | `q` | The search text. Whitespace is trimmed and collapsed. | | `locale` | A store language code from `GET /v1/search/config`. Unknown or missing means the store's default. Always send it; the API does not read `Accept-Language`. | | `filter[collection]` | A collection slug | | `filter[brand]` | A brand slug. An unknown slug returns no products. | | `filter[tag]` | A tag value, exactly, ignoring case | | `filter[in_stock]` | `1` for only products in stock | | `filter[on_sale]` | `1` for only products on sale | | `filter[price_min]`, `filter[price_max]` | Integer minor units of the store's currency, never decimals | | `filter[attr][handle]` | A filterable attribute's value | | `sort` | `name`, `-name`, `price`, `-price` or `-created_at`. Leave it out to sort by relevance. | Malformed parameters, such as a decimal price or `filter[in_stock]=maybe`, return `422 VALIDATION_FAILED`. ```bash tab="cURL" curl -G https://api.elaraone.app/api/v1/search \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Client-IP: 203.0.113.7" \ --data-urlencode "q=oak lamp" \ --data-urlencode "locale=en" \ --data-urlencode "filter[in_stock]=1" \ --data-urlencode "include=variants.prices,images" ``` ```ts tab="TypeScript" const params = new URLSearchParams({ q: 'oak lamp', locale: 'en', 'filter[in_stock]': '1', include: 'variants.prices,images' }) const res = await fetch(`https://api.elaraone.app/api/v1/search?${params}`, { headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, 'X-Client-IP': shopperIp, // the shopper's IP, read from the incoming request }, }) const searchId = res.headers.get('X-Search-Id') // keep it for click events const { data, meta } = await res.json() ``` Besides the usual pagination fields, `meta` carries: - `total`: the number of matching products; - `locale` and `currency`: what the answer was searched and priced in; - `facets`: counts per value for each facet, such as `collection_slugs`, `brand_slug`, `tags`, `in_stock`, `on_sale` and `attr_{handle}` for each filterable attribute; - `facet_labels`: the heading to show for each facet, in display order; - `facet_value_labels`: display names for values that are identifiers, such as brand slugs; - `search_engine`: `meilisearch` normally, or `database` when the index is briefly unavailable. The answer is still a `200` with products, just without facets: render what you get. Each successful answer also carries an `X-Search-Id` response header. Keep it to report clicks (below). ## Instant suggestions [#instant-suggestions] `GET /v1/search/suggest` returns grouped suggestions for a partly typed query. ``` GET /api/v1/search/suggest?q=oak%20de&locale=en&types=products,collections,pages ``` | Parameter | Rules | |---|---| | `q` | Required, 1 to 200 characters after trimming. Shorter than the store's `min_chars` returns empty groups without searching. | | `locale` | As for `GET /v1/search` | | `types` | A comma-separated subset of `products`, `collections`, `brands`, `tags`, `pages` and `posts`. Missing means every type the merchant has switched on. | ```json title="Response (trimmed)" { "data": { "query": "oak de", "products": { "total": 3, "hits": [ { "id": 101, "slug": "oak-desk-lamp", "name": "Oak desk lamp", "name_html": "Oak desk lamp", "image": { "url": "https://…/lamp-small.webp", "url_medium": "https://…/lamp-medium.webp", "alt": "Oak desk lamp" }, "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }, "price_max": null, "sale_price": null, "on_sale": false, "in_stock": true, "default_variant_id": 1543 } ] }, "collections": { "total": 0, "hits": [] } }, "meta": { "search_engine": "meilisearch", "locale": "en", "currency": "GBP" } } ``` - A group is present only for types you asked for and the merchant has switched on. Treat a missing group as empty, and render groups in the order of `autocomplete.types` from the config. - `name_html` and `title_html` contain escaped text plus `` tags around the matched parts. Render them by splitting on `` and `` into text, never by injecting HTML. - `default_variant_id` is set when the product has exactly one variant you can buy, so an "Add to basket" button can add it directly. - Cancel the previous request when a new keystroke arrives, and keep the current suggestions on screen while the next ones load. ## Search settings [#search-settings] `GET /v1/search/config?locale=en` returns the merchant's choices from **Search settings** in the admin: whether search and suggestions are on, `min_chars`, `debounce_ms`, which suggestion types to show and how many of each, popular searches, layout, colours, the placeholder and the no-results text. Use it instead of hard-coding these values. The response carries an `ETag` and `Cache-Control: max-age=60, private`. Cache it for 60 seconds on your server and revalidate with `If-None-Match`; a `304` means nothing changed. `meta.version` changes whenever the merchant saves, so clear your suggestion cache when it does. ## The search events beacon [#the-search-events-beacon] `POST /v1/search/events` records what shoppers do with search results, so the merchant can see which searches lead to clicks and sales. Send events in batches of 1 to 20: ```json title="POST /v1/search/events" { "events": [ { "type": "settled", "search_id": "01JB8Z6Q4M9W2C7XK3T5VYHNPR", "trigger": "idle" }, { "type": "click", "search_id": "01JB8Z6Q4M9W2C7XK3T5VYHNPR", "item_type": "product", "item_id": "101", "position": 1 }, { "type": "add_to_cart", "search_id": "01JB8Z6Q4M9W2C7XK3T5VYHNPR", "product_id": 101 } ] } ``` | Event | Send when | Fields | |---|---|---| | `settled` | The shopper stops typing in the suggestions box (`idle`), leaves it (`blur`) or presses Enter (`enter`) | `trigger` | | `click` | The shopper chooses a result | `item_type` (`product`, `collection`, `brand`, `tag`, `page` or `post`), `item_id` as a string, `position` from 1 | | `add_to_cart` | The shopper adds a product straight from search | `product_id` | The answer is always `202 Accepted` with `{ "data": { "accepted": n } }`. Events for an unknown or expired search id (ids last 15 minutes) are dropped quietly. Do not send `settled` for the results page: the engine records results-page searches itself. **No shopper identifiers.** The beacon carries only the search id and what was chosen. Never send the query, customer ids or anything personal; the engine looks the query up from the search id and keeps only daily totals per query and per item. A `422` means a client bug: fix it rather than retrying. A `429` drops the batch; do not retry it. ## Synonyms [#synonyms] Merchants teach search their own words under **Search settings → Synonyms** in the admin, with no code needed: - **Multi-way** synonyms make words equal: searching any of "sofa", "settee" and "couch" finds the others. - **One-way** synonyms widen one word only: "tableware" can also find "mug" and "tray" without "mug" finding everything that is tableware. A store can have up to 1,000 rules, each with up to 20 terms, and can import and export them as CSV. Changes apply to suggestions and results alike. ## Rate limits [#rate-limits] | Endpoint | Budget | |---|---| | `GET /v1/search/suggest` | 1,200 requests a minute per store and shopper IP | | `POST /v1/search/events` | 300 batches a minute per store and shopper IP | | `GET /v1/search`, `GET /v1/search/config` | The store's general API budget | Suggestions and events have their own budgets, so typing never uses up the general one. The budgets are per shopper only when your server sends `X-Client-IP` with the server secret; otherwise every shopper shares your server's IP. On a `429`, keep the current results and wait for `Retry-After`. See [rate limits](/docs/concepts/rate-limits). --- # Responses and errors URL: https://elaraone.app/docs/concepts/errors The success and error envelopes, every error code with its HTTP status and what to do, the shape of validation errors, and which errors to retry. Every response from the API has the same outer shape, and every error has a stable `code` you can branch on. Branch on `error.code`, not on the HTTP status or the message: several codes share a status, and messages can be reworded. ## Success [#success] A successful response wraps its result in `data`: ```json title="200 OK" { "data": { "id": 101, "name": "Oak desk lamp", "slug": "oak-desk-lamp" } } ``` Lists add `links` and `meta` for [pagination](/docs/concepts/pagination). Some responses add a `meta` object with extra facts, such as `meta.order_access_token` after a guest checkout or `meta.shipping_option_cleared` after an address change. - Times are ISO 8601, in UTC. - Money is always a [money object](/docs/concepts/money), never a bare number. - New fields can appear at any time, so ignore fields you do not know. See [versioning](/docs/concepts/versioning). ## Errors [#errors] Every error uses one envelope: ```json title="422 Unprocessable Content" { "error": { "code": "COUPON_NOT_APPLICABLE", "message": "Spend £20.00 more to use this code.", "details": { "code": ["Spend £20.00 more to use this code."], "reason": "threshold", "remaining": { "amount": 2000, "currency": "GBP", "formatted": "£20.00" } } } } ``` | Field | Meaning | |---|---| | `code` | A stable, upper-case code. Always present. | | `message` | A sentence that names the problem and, where it can, the fix. Safe to log. Present on almost every error; a few store-identification errors carry only the code. | | `details` | Extra facts for that code, when there are any. | Every response also carries an `X-Request-Id` header. Log it, and quote it if you contact support about a request. ## Validation errors [#validation-errors] `422 VALIDATION_FAILED` means the request was understood but some input was refused. `details` maps each refused field to a list of messages. Nested fields use dots: ```json title="422 Unprocessable Content" { "error": { "code": "VALIDATION_FAILED", "message": "The billing.postcode field is required. (and 1 more error)", "details": { "billing.postcode": ["The billing.postcode field is required."], "billing.country_id": ["The billing.country_id field is required."] } } } ``` Show each message next to its field. A few business checks use `VALIDATION_FAILED` too, under a key that is not a request field: for example, an item that has run out of stock at checkout is reported under `cart`. ## Error codes [#error-codes] ### Store and access [#store-and-access] | Status | `code` | Meaning | What to do | |---|---|---|---| | 400 | `TENANT_KEY_MISSING` | No `X-Tenant-Key` header | Send the store key on every call | | 404 | `TENANT_NOT_FOUND` | Unknown key, a suspended store, or a rotated key after its overlap | Use the store's current key | | 402 | `TENANT_PAYMENT_REQUIRED` | The store is temporarily unavailable | Show a "shop temporarily unavailable" page. Do not retry the request. | | 403 | `ORIGIN_NOT_ALLOWED` | A browser call from a website the store has not registered | Register the website, or call from your server. See [stores and keys](/docs/concepts/stores-and-keys). | | 403 | `BROWSER_CALLS_DISABLED` | A browser call while browser calls are switched off | Call from your server with the secret | | 403 | `TENANT_SECRET_REQUIRED` | A server call without `X-Tenant-Secret` | Send the secret on every server call | | 403 | `TENANT_SECRET_INVALID` | A wrong secret, or a secret-only route called without it | Use the current secret from Settings → Storefront API | | 401 | `UNAUTHENTICATED` | A missing, expired or revoked bearer token | Sign the customer in again, or check the merchant API key | | 403 | `FORBIDDEN` | The token is valid but not allowed to do this, for example a merchant API key without the ability | Use a key with the right ability | | 404 | `FEATURE_NOT_ENABLED` | The store's plan does not include this feature, such as the shop | Hide the feature for this store | | 404 | `MODULE_NOT_ENABLED` | A module, such as live chat, is not switched on for the store | Hide the feature for this store | ### Requests [#requests] | Status | `code` | Meaning | What to do | |---|---|---|---| | 422 | `VALIDATION_FAILED` | Some input was refused | Show `details` next to the fields | | 404 | `NOT_FOUND` | The resource does not exist, or is not visible to this caller | Check the id, slug or reference | | 400 and others | `HTTP_ERROR` | A request the API could not accept, such as an unknown filter, sort or include, or the wrong HTTP method; it keeps its real status | Fix the request | | 409 | `IDEMPOTENCY_IN_PROGRESS` | A request with this `Idempotency-Key` is still running | Retry shortly with the same key | | 422 | `IDEMPOTENCY_KEY_REUSED` | This key was already used for a different request | Make a new key for a new action. See [idempotency](/docs/concepts/idempotency). | | 429 | `RATE_LIMITED` | Too many requests | Wait for `Retry-After`. See [rate limits](/docs/concepts/rate-limits). | | 429 | `STAGING_LIMIT` | A staging store (where your plan includes them) has used its daily allowance | Wait for `Retry-After`, the end of the UTC day | | 500 | `SERVER_ERROR` | Something went wrong on our side | Retry with backoff. Quote `X-Request-Id` if it persists. | | 503 | `CAPTCHA_UNAVAILABLE` | A form's spam check could not get an answer | Retry the submission with the same `Idempotency-Key` | ### Carts [#carts] | Status | `code` | Meaning | What to do | |---|---|---|---| | 404 | `CART_NOT_FOUND` | The cart token is unknown, expired, or its cart became an order | Create a new cart | | 404 | `CART_LINE_NOT_FOUND` | The line id is not in this cart | Refresh the cart | | 422 | `COUPON_INVALID` | No such code | Show `message` | | 422 | `COUPON_EXPIRED` | The code has not started or has ended (`details.reason`) | Show `message` | | 422 | `COUPON_USED_UP` | The code has reached its usage limit | Show `message` | | 422 | `COUPON_LIMIT_REACHED` | This shopper has used the code as often as allowed | Show `message` | | 422 | `COUPON_NOT_APPLICABLE` | Nothing in the basket qualifies (`details.reason`, and `details.remaining` for a spend threshold) | Show `message` | | 422 | `GIFT_LINE_LOCKED` | A free gift's quantity cannot be changed | Let the shopper remove it instead | | 422 | `ADDRESS_NOT_FOUND` | A saved `address_id` that is not the customer's, or sent without signing in (`details.reason` is `not_found` or `sign_in_required`) | Refresh the address book, or ask the shopper to sign in or type the address | | 422 | `SHIPPING_ADDRESS_REQUIRED` | Shipping options were asked for before a shipping address was set | Send the shopper to the address step | ### Checkout and payments [#checkout-and-payments] | Status | `code` | Meaning | What to do | |---|---|---|---| | 402 | `PAYMENT_FAILED` | The payment was declined or not taken; `details.decline_code` says why on a provider decline | See [payments](/docs/concepts/payments#declined-payments) | | 409 | `SHIPPING_OPTION_CHANGED` | The chosen shipping option is gone or its price changed | Show `details.options` and let the shopper choose again | | 409 | `PROMOTIONS_CHANGED` | The basket's discounts changed since payment was set up | Show the new total from `details.cart` and set up payment again | | 409 | `PAYMENT_REFUNDED` | The payment was taken but the order could not be placed, so it was refunded | Start a new checkout | | 409 | `PAYMENT_REFUND_PENDING` | As above, with the refund still being retried | Do not ask the shopper to pay again for this cart | | 409 | `PAYMENT_ALREADY_USED` | The payment already paid for another checkout | Set up a new payment | | 409 | `PAYMENT_SETUP_IN_PROGRESS` | Another request is setting up this cart's PaymentIntent | Retry shortly | | 422 | `PAYMENT_GATEWAY_UNAVAILABLE` | The gateway is unknown or not set up for this store | Only offer gateways from `checkout/config` | | 403 | `ORDER_ACCESS_TOKEN_INVALID` | A guest order token is missing, tampered with or expired (`details.reason` is `invalid` or `expired`) | Ask for the order reference and email again | ### Accounts, content and CRM [#accounts-content-and-crm] | Status | `code` | Meaning | What to do | |---|---|---|---| | 409 | `ACCOUNT_SHARED` | The account shares its customer record, so only the store can erase it | Ask the shopper to contact the store | | 403 | `PREVIEW_TOKEN_INVALID` | A page preview link is invalid or expired (`details.reason`) | Ask the editor for a new preview link | | 422 | `CONSENT_CONFIRMATION_INVALID` | A marketing consent confirmation link or code could not be confirmed | Ask the person to request a new confirmation | | 404 | `CONVERSATION_NOT_FOUND` | The chat token does not match a conversation | Start a new conversation | | 409 | `CONTACT_EXISTS` | A CRM contact with this email already exists; `details.id` names it | Update that contact instead | | 409 | `ORDER_TRANSITION_NOT_ALLOWED` | An order status change the order's current state does not allow | Check the order's state first | | 409 | `REFUND_NOT_ALLOWED` | A refund the order's payments do not allow | Check the amount and the order's payments | ## Which errors to retry [#which-errors-to-retry] | Retry | Errors | |---|---| | Yes, after a short wait, with the same `Idempotency-Key` | `500 SERVER_ERROR`, network errors and timeouts, `409 IDEMPOTENCY_IN_PROGRESS`, `409 PAYMENT_SETUP_IN_PROGRESS`, `503 CAPTCHA_UNAVAILABLE` | | Yes, after `Retry-After` | `429 RATE_LIMITED`, `429 STAGING_LIMIT` | | Only after the shopper changes something | `402 PAYMENT_FAILED`, the `409` checkout conflicts, `422` errors | | No | `400`, `401`, `403` and `404` errors: the same request gets the same answer | Use exponential backoff with jitter for automatic retries, and stop after a few attempts. ```ts title="Branch on error.code" const res = await fetch(url, init) if (!res.ok) { const { error } = await res.json() switch (error.code) { case 'VALIDATION_FAILED': return showFieldErrors(error.details) case 'CART_NOT_FOUND': return startNewCart() case 'RATE_LIMITED': return retryAfter(Number(res.headers.get('Retry-After') ?? 1)) default: console.error(error.code, error.message, res.headers.get('X-Request-Id')) return showGenericError() } } ``` --- # Pagination URL: https://elaraone.app/docs/concepts/pagination Page through lists with page and perPage, read links and meta, use cursors on merchant lists, and sync CRM records incrementally with filter[updated_since]. Lists that can grow come back one page at a time. Storefront lists use numbered pages. Merchant lists, such as CRM contacts and orders, use cursors, which stay correct while records are being added and changed. ## Numbered pages [#numbered-pages] Storefront lists take two query parameters: | Parameter | Default | Range | |---|---|---| | `perPage` | 24 | 1 to 100. Larger values are capped at 100. | | `page` | 1 | 1 and up | They apply to `GET /v1/products`, `GET /v1/collections`, `GET /v1/collections/{slug}/products`, `GET /v1/search` and a signed-in customer's `GET /v1/orders`. Note the camel case: `perPage`, not `per_page`. ```bash curl -G https://api.elaraone.app/api/v1/products \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ --data-urlencode "perPage=48" \ --data-urlencode "page=2" ``` The response carries `links` and `meta` next to `data`: ```json title="Response (trimmed)" { "data": [ … ], "links": { "first": "https://api.elaraone.app/api/v1/products?perPage=48&page=1", "last": "https://api.elaraone.app/api/v1/products?perPage=48&page=3", "prev": "https://api.elaraone.app/api/v1/products?perPage=48&page=1", "next": "https://api.elaraone.app/api/v1/products?perPage=48&page=3" }, "meta": { "current_page": 2, "from": 49, "last_page": 3, "per_page": 48, "to": 96, "total": 131, "path": "https://api.elaraone.app/api/v1/products" } } ``` - `links.next` is `null` on the last page, and `links.prev` on the first. - `meta.total` and `meta.last_page` are what you need for page numbers. - The links keep your other query parameters, such as filters and sorts. - A page past the end returns an empty `data` list, not an error. Lists that are always short are not paginated: `GET /v1/promotions`, `GET /v1/countries`, `GET /v1/cart/shipping-options` and `GET /v1/account/addresses` return everything at once. ## Cursors on merchant lists [#cursors-on-merchant-lists] The merchant CRM lists (contacts, companies, tasks and their related records) and the merchant orders list use cursor pagination: | Parameter | Meaning | |---|---| | `perPage` | 1 to 100, default 24. A value outside the range returns `422`. | | `cursor` | `meta.next_cursor` or `meta.prev_cursor` from the previous page | | `sort` | A sort the list allows, with `-` for descending | ```json title="Response (trimmed)" { "data": [ … ], "links": { "first": null, "last": null, "prev": null, "next": "https://api.elaraone.app/api/v1/merchant/crm/contacts?cursor=eyJ…" }, "meta": { "path": "https://api.elaraone.app/api/v1/merchant/crm/contacts", "per_page": 24, "next_cursor": "eyJ…", "prev_cursor": null } } ``` Keep requesting with `cursor` set to `meta.next_cursor` until it is `null`. Treat cursors as opaque: pass them back exactly as you received them. Cursor lists have no `total`. An unknown `filter[…]` or `sort` on these lists returns `400`; a malformed value, such as a date that cannot be read, returns `422 VALIDATION_FAILED`. ## Incremental sync with `filter[updated_since]` [#incremental-sync-with-filterupdated_since] To keep another system in step with the store's CRM, do not re-read everything on every run. The CRM contacts, companies and tasks lists take `filter[updated_since]`, an ISO 8601 time, and return only records changed at or after it. ### Run a full sync once [#run-a-full-sync-once] Page through the whole list with `sort=updated_at` and store each record. Note the time you started. ### Then fetch only what changed [#then-fetch-only-what-changed] On each later run, ask for records changed since the start of the previous run, and page through them the same way. ### Move the mark forward [#move-the-mark-forward] When the run finishes, save its start time as the next run's `updated_since`. ```bash tab="cURL" curl -G https://api.elaraone.app/api/v1/merchant/crm/contacts \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "Authorization: Bearer $ELARAONE_MERCHANT_API_KEY" \ --data-urlencode "filter[updated_since]=2026-10-23T02:00:00Z" \ --data-urlencode "sort=updated_at" \ --data-urlencode "perPage=100" ``` ```ts tab="TypeScript" async function* changedContacts(since: string) { let cursor: string | null = null do { const params = new URLSearchParams({ 'filter[updated_since]': since, sort: 'updated_at', perPage: '100' }) if (cursor) params.set('cursor', cursor) const res = await fetch(`https://api.elaraone.app/api/v1/merchant/crm/contacts?${params}`, { headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, Authorization: `Bearer ${process.env.ELARAONE_MERCHANT_API_KEY}`, }, }) const body = await res.json() yield* body.data cursor = body.meta.next_cursor } while (cursor) } ``` - Use the start of the previous run, not its end, so a record changed while the run was in progress is picked up next time. You may see a few records twice; make your writes idempotent by record id. - A tag change counts as a change to the contact. - Pair the sync with CRM change webhooks if you want to hear about changes as they happen, and keep the sync as a safety net. - Stay inside the store's API budget: see [rate limits](/docs/concepts/rate-limits). --- # Idempotency keys URL: https://elaraone.app/docs/concepts/idempotency Retry requests that change things without doing them twice, which routes accept an Idempotency-Key, and how to generate keys and retry safely. Networks fail. A request can reach the API and do its work, and the response can still be lost on the way back. An `Idempotency-Key` header lets you send the same request again and be sure it only happens once: the same order, the same basket line, the same form submission. ## How it works [#how-it-works] Generate a unique key for each action the shopper or your system takes, and send it as the `Idempotency-Key` header. Reuse that key only to retry that same action. ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/checkout/complete \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: 3f0c9a52-8a8e-4c6f-9d1b-5e2a7b4c9d10" \ -H "Content-Type: application/json" \ -d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_3QxYz…" } }' ``` ```ts tab="TypeScript" const key = crypto.randomUUID() const res = await fetch('https://api.elaraone.app/api/v1/cart/lines', { method: 'POST', headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, 'X-Cart-Token': cartToken, 'Idempotency-Key': key, 'Content-Type': 'application/json', }, body: JSON.stringify({ variant_id: 1543, quantity: 1 }), }) const replayed = res.headers.get('Idempotent-Replayed') === 'true' ``` ```php tab="PHP" use Illuminate\Support\Str; $key = (string) Str::uuid(); $response = Http::withHeaders([ 'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'), 'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'), 'X-Cart-Token' => $cartToken, 'Idempotency-Key' => $key, ])->post('https://api.elaraone.app/api/v1/cart/lines', ['variant_id' => 1543, 'quantity' => 1]); ``` What happens to a request with a key: | Situation | Answer | |---|---| | First time the key is seen | The request runs normally. | | The first request succeeded (any status below 400) | The stored response is replayed exactly, with the header `Idempotent-Replayed: true`. Nothing runs again. | | The first request is still running | `409 IDEMPOTENCY_IN_PROGRESS`. Wait a moment and retry with the same key. | | The first request failed (any status of 400 or above) | Nothing was stored, so the request runs again. | | The key already holds a success, and this request is different | `422 IDEMPOTENCY_KEY_REUSED`. Nothing runs. | ### Only successes are stored [#only-successes-are-stored] A success is kept for 24 hours and replayed to any retry in that time. Failures are not kept, on purpose: a `402 PAYMENT_FAILED` can succeed once the shopper fixes their card, and a `422` for stock can succeed once stock returns. Every refusal on these routes happens before anything is written, or is rolled back, so running the request again can never do it twice. ### What counts as the same request [#what-counts-as-the-same-request] A key is tied to the request it was first used with: the body, the path (including ids such as a cart line's), and the caller's credentials (`Authorization`, `X-Cart-Token`, `X-Chat-Token`). Reusing a key on another cart, another line or another customer's token returns `422 IDEMPOTENCY_KEY_REUSED` instead of someone else's response. Keys are scoped to the store and the route. Two stores, or two different routes, can never collide, even if they use the same key. ## Routes that accept a key [#routes-that-accept-a-key] | Route | Why | |---|---| | `POST /v1/cart/lines`, `PATCH /v1/cart/lines/{id}`, `DELETE /v1/cart/lines/{id}` | A retry never adds or removes a line twice | | `POST /v1/checkout/complete` | Exactly one order per checkout | | `POST /v1/checkout/paypal/capture` | Exactly one capture and one order | | `POST /v1/forms/{handle}` | One submission, one lead | | `PUT /v1/consent` | One consent change | | `POST /v1/marketing/confirm` | One confirmation | | `POST /v1/chat/messages` | One message | | `POST /v1/merchant/crm/contacts`, `/companies`, `/tasks` and `/{type}/{id}/notes` | No duplicate CRM records | | `POST /v1/merchant/orders/{reference}/dispatch`, `/offline-refund` | One dispatch, one refund | On other routes the header is ignored. The key is optional everywhere, but we strongly recommend it on checkout and anything that creates records. ## Generate keys [#generate-keys] - **Use a random UUID** (version 4) or a ULID: `crypto.randomUUID()` in JavaScript, `Str::uuid()` in Laravel, `uuidgen` on the command line. - **One key per action, not per request.** Make the key when the shopper clicks "Pay" or "Add to basket", store it with that action, and send the same key on every retry of it. - **A new action gets a new key.** If the shopper changes the basket and pays again, that is a new action. - **Do not derive keys from the body** (such as a hash of the cart). Two genuine, identical actions, like adding the same mug twice, must have different keys. ## Retry safely [#retry-safely] ```ts title="Retry with the same key" async function postWithRetry(url: string, body: unknown, headers: Record) { const key = crypto.randomUUID() // made once, reused for every attempt for (let attempt = 0; attempt < 5; attempt++) { let waitMs = Math.min(8000, 250 * 2 ** attempt) + Math.random() * 250 try { const res = await fetch(url, { method: 'POST', headers: { ...headers, 'Idempotency-Key': key, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const retryable = res.status === 409 || res.status === 429 || res.status >= 500 if (!retryable) return res if (res.status === 429) waitMs = Number(res.headers.get('Retry-After') ?? 1) * 1000 if (res.status === 409) { const { error } = await res.clone().json() if (error.code !== 'IDEMPOTENCY_IN_PROGRESS') return res // a real conflict, not a retry } } catch { // Network error or timeout: the request may or may not have run. Retrying is safe. } await new Promise((r) => setTimeout(r, waitMs)) } throw new Error('Request failed after retries') } ``` - Retry network errors, timeouts, `5xx`, `429` (after `Retry-After`) and `409 IDEMPOTENCY_IN_PROGRESS`, with the same key and the same body. - Do not retry other `4xx` answers unchanged: fix the request or ask the shopper, and use a new key for the new attempt if the body changes. - Treat `Idempotent-Replayed: true` as success. It means the first attempt worked and its response was lost. --- # Rate limits URL: https://elaraone.app/docs/concepts/rate-limits The request budgets per store and per caller, the rate-limit headers, per-shopper limits with X-Client-IP, and how to back off on 429. Rate limits keep one busy caller from slowing a store down for everyone else. Each budget counts requests per store and per caller over a short window. When a budget is spent, the API answers `429 RATE_LIMITED` until the window moves on. ## The budgets [#the-budgets] | Applies to | Budget | |---|---| | The storefront and merchant API in general | 600 requests a minute, per store and caller IP | | Register, sign in, password reset, profile and password changes, guest order lookup | 10 a minute, per store and caller IP | | Form submissions (`POST /v1/forms/{handle}`) | 30 a minute, per store and caller IP | | Search suggestions (`GET /v1/search/suggest`) | 1,200 a minute, per store and caller IP | | Search events (`POST /v1/search/events`) | 300 batches a minute, per store and caller IP | | Chat | 60 a minute, per conversation (or signed-in customer) | | Marketing consent confirmation (`POST /v1/marketing/confirm`) | 10 a minute and 30 an hour, per store and caller IP | | Account deletion (`DELETE /v1/account`) | 3 a minute and 10 an hour per account, and 20 an hour per IP | Search suggestions, search events and chat have their own budgets instead of the general one, so typing in a search box never uses up the budget your product pages need. The other tighter budgets apply to routes that take passwords or codes, where guessing must stay slow. ## Headers [#headers] Responses on rate-limited routes tell you where you stand: | Header | Meaning | |---|---| | `X-RateLimit-Limit` | The size of the budget for this route | | `X-RateLimit-Remaining` | How many requests are left in the current window | | `Retry-After` | On a `429`: how many seconds to wait before trying again | ```http title="429 Too Many Requests" HTTP/1.1 429 Too Many Requests Retry-After: 17 X-RateLimit-Limit: 600 X-RateLimit-Remaining: 0 Content-Type: application/json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` Browser code can read these headers too: they are exposed through CORS. ## Per-shopper limits with `X-Client-IP` [#per-shopper-limits-with-x-client-ip] A "caller" is the IP address the request comes from. For a storefront that calls the API from its own server, that is your server's IP, so every shopper would share one budget. Busy shops would run out quickly. To give each shopper their own budget, send the shopper's IP address in `X-Client-IP` on every server call. The API trusts it only together with a valid `X-Tenant-Secret`; on a browser call, or without the secret, it is ignored. ```ts title="Forward the shopper's IP from your server" // Next.js route handler: the shopper's IP from your hosting's proxy header. const shopperIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() const res = await fetch('https://api.elaraone.app/api/v1/products', { headers: { 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, ...(shopperIp ? { 'X-Client-IP': shopperIp } : {}), }, }) ``` - Send one IPv4 or IPv6 address, with no port and no list. - Only take the IP from a header your hosting platform sets and overwrites. A header a visitor can set themselves lets them pick their own budget. - Never forward `X-Forwarded-For` to the API itself. Send `X-Client-IP` instead. - The same IP is used for the per-IP parts of the tighter budgets, such as sign-in. ## Back off on 429 [#back-off-on-429] 1. Stop sending requests in that budget. 2. Wait for the number of seconds in `Retry-After`. 3. Retry. If the request changes something, retry with the same `Idempotency-Key` (see [idempotency](/docs/concepts/idempotency)). For background jobs and integrations, also: - watch `X-RateLimit-Remaining` and slow down before it reaches zero; - spread work out instead of running it in bursts, and run jobs one at a time per store; - use exponential backoff with jitter for repeated failures, and stop after a few attempts; - sync CRM records with `filter[updated_since]` instead of re-reading everything (see [pagination](/docs/concepts/pagination)). For shoppers, keep the page usable: keep the current search results on screen, and show a short "try again in a moment" message rather than an error page. ## Staging stores [#staging-stores] Staging stores, where your plan includes them, also have a daily ceiling: by default 20,000 API requests and 1,000 form submissions. Over it, the API answers `429 STAGING_LIMIT`, with `Retry-After` set to the end of the day in UTC. --- # Versioning and changes URL: https://elaraone.app/docs/concepts/versioning How the API is versioned, which changes we treat as breaking and which as additive, how to write clients that keep working, and where changes are announced. The API is versioned in its path, and the version only changes for breaking changes. Within a version, we add to the API without taking anything away. This page explains what that means for your code. ## The version is in the path [#the-version-is-in-the-path] Every endpoint lives under `/api/v1`: ``` https://api.elaraone.app/api/v1/products ``` There is no version header to send. The current version is `v1`. ## How we treat changes [#how-we-treat-changes] **Additive changes** can ship at any time within `v1`: - new endpoints; - new optional query parameters, request fields and headers; - new fields in responses, including new fields inside existing objects; - new keys in `meta` and `details`; - new error codes, for new situations; - new values in lists that are documented as open, such as `decline_code` on a declined payment; - new event types for webhooks you can subscribe to. **Breaking changes** are the ones we avoid within `v1`. We treat these as breaking: - removing or renaming an endpoint, a field, a parameter or a header; - changing a field's type or the meaning of a value; - making an optional parameter or field required; - changing which `error.code` an existing situation returns. When a security change has to tighten what existing credentials can do, it is phased in rather than switched on overnight. The storefront API lock is an example: each store ran in report-only mode first, with the calls that would be refused shown in the admin, before the lock was enforced. See [stores and keys](/docs/concepts/stores-and-keys#report-only-then-enforcing). ## Write clients that keep working [#write-clients-that-keep-working] Because additive changes can arrive at any time, build clients that tolerate them: - **Ignore fields you do not know.** Do not fail on unexpected keys, and do not use strict schemas that reject them. - **Treat unknown values as a safe default.** An unknown `decline_code` is `unknown`; an unknown order `status` should still show `status_label`; an unknown promotion `kind` can be shown by its `label`. - **Handle unknown error codes.** Fall back on the HTTP status and show `error.message`. - **Do not depend on the order of keys** in an object, or on the order of groups in search suggestions (use the order the search config gives you). - **Read optional fields defensively.** Fields documented as nullable can be `null`. ```ts title="Tolerant parsing" type DeclineCode = | 'card_declined' | 'card_expired' | 'insufficient_funds' | 'incorrect_card_details' | 'authentication_required' | 'try_another_method' | 'unknown' const known = new Set([ 'card_declined', 'card_expired', 'insufficient_funds', 'incorrect_card_details', 'authentication_required', 'try_another_method', 'unknown', ]) export function declineCode(value: unknown): DeclineCode { return typeof value === 'string' && known.has(value as DeclineCode) ? (value as DeclineCode) : 'unknown' } ``` ## Webhook payload versions [#webhook-payload-versions] CRM change webhooks carry an `api_version` in every body, so a receiver can tell which payload shape it is reading: ```json title="Webhook body (trimmed)" { "id": "01JA5W9Q3V8K2YB7N6T4M0H1XC", "type": "contact.updated", "created_at": "2026-10-13T09:30:00+00:00", "store": "hearth-and-oak", "api_version": "2026-10-01", "data": { "object": "contact", "id": 12, "changed": ["email", "lifecycle_stage"], "actor": { "type": "api" } } } ``` The current payload version is `2026-10-01`. Read it to tell which payload shape you are handling. New fields and new event types can still be added under the same version, so ignore what you do not recognise. ## Deprecations you can detect [#deprecations-you-can-detect] Some changes are also signalled in responses. For example, after a store key is rotated, responses to calls made with the old key carry `X-Tenant-Key-Deprecated: true` until the overlap ends. Log headers like this in your integration so they show up before anything stops working. See [stores and keys](/docs/concepts/stores-and-keys#rotate-the-public-key). ## The changelog [#the-changelog] Changes to the API are announced in the [changelog](/changelog). Check it when you upgrade a starter storefront or an integration. --- # Build a product listing page URL: https://elaraone.app/docs/guides/product-listing List products with collection, price and attribute filters, sorting, pagination and promotion badges, rendered on your server. A product listing page is one call to `GET /v1/products` (or `GET /v1/collections/{slug}/products`) with filters, a sort and a page number taken from the page's own URL. This guide builds one on the server, with a price slider fed by `GET /v1/products/price-bounds` and promotion badges from `include=offers`. ## What the listing endpoints accept [#what-the-listing-endpoints-accept] `GET /v1/products` and `GET /v1/collections/{slug}/products` share one set of query parameters. Only published products are ever returned. | Parameter | Example | What it does | |---|---|---| | `filter[collection]` | `filter[collection]=lighting` | Products in the collection with this slug. | | `filter[search]` | `filter[search]=lamp` | A simple text match. For instant search with suggestions, use `GET /v1/search`. | | `filter[price_min]` | `filter[price_min]=2000` | At least one variant's base price is at or above this amount, in minor units. | | `filter[price_max]` | `filter[price_max]=9000` | At least one variant's base price is at or below this amount, in minor units. | | `filter[attr][]` | `filter[attr][finish]=oak` | The product attribute with this handle equals this value. | | `sort` | `sort=-price` | `name`, `created_at` or `price`. Prefix with `-` for descending. | | `include` | `include=variants.prices,images,offers` | Related data to embed. See below. | | `perPage` | `perPage=24` | Page size. Default 24, maximum 100. | | `page` | `page=2` | Which page. | An unknown filter, sort or include is refused with a `400`, so a typo shows up straight away instead of being ignored. ### Attribute filters [#attribute-filters] Attribute filters are a nested array keyed by the attribute's handle, as set in the store admin: ```text filter[attr][finish]=oak&filter[attr][size]=large ``` Each handle is an exact match on the stored value, ignoring case, and several handles are combined with AND. For a translated attribute the value is matched against the current locale's text. There is no "any of" form: to offer "oak or walnut", make two requests or model the choice as a collection. ### Includes [#includes] | Include | Adds | |---|---| | `variants` | Each variant's `id`, `sku`, `stock` and `shippable`. | | `variants.prices` | Variants plus `price` and `pricing` (sale price, "was" price, saving and badges). | | `variants.options` | Each variant's option values and the product's `options` list, for an option picker. | | `images` | `id`, `name`, `url` and `thumbnail_url` for each image. | | `collections` | The collections the product belongs to, each with its `slug`. | | `offers` | The promotions the product advertises: badges, banners and bulk price tables. | For a listing card you usually want `include=variants.prices,images,offers`. `GET /v1/products/{slug}` always returns every include, offers too. ## Fetch the page on the server [#fetch-the-page-on-the-server] Call the engine from your server with both the store key and the server secret. The secret must never reach the browser. ```bash tab="cURL" curl -G https://api.elaraone.app/api/v1/products \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ --data-urlencode "filter[collection]=lighting" \ --data-urlencode "filter[price_max]=9000" \ --data-urlencode "filter[attr][finish]=oak" \ --data-urlencode "sort=price" \ --data-urlencode "include=variants.prices,images,offers" \ --data-urlencode "perPage=24" \ --data-urlencode "page=1" ``` ```ts tab="TypeScript" const ENGINE = 'https://api.elaraone.app/api/v1' export async function engineGet(path: string, query: URLSearchParams): Promise { const res = await fetch(`${ENGINE}${path}?${query}`, { headers: { Accept: 'application/json', 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, }, }) if (!res.ok) { const body = await res.json().catch(() => null) throw new Error(body?.error?.code ?? `HTTP_${res.status}`) } return res.json() as Promise } ``` ```php tab="PHP" $query = http_build_query([ 'filter' => [ 'collection' => 'lighting', 'price_max' => 9000, 'attr' => ['finish' => 'oak'], ], 'sort' => 'price', 'include' => 'variants.prices,images,offers', 'perPage' => 24, 'page' => 1, ]); $response = Http::withHeaders([ 'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'), 'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'), ])->acceptJson()->get("https://api.elaraone.app/api/v1/products?{$query}"); ``` The response is a page of products, with Laravel's `links` and `meta` for pagination. Money is always integer minor units. ```json title="Response (trimmed)" { "data": [ { "id": 101, "status": "published", "name": "Oak desk lamp", "description": "

Turned oak base, linen shade.

", "slug": "oak-desk-lamp", "variants": [ { "id": 412, "sku": "LAMP-OAK", "stock": 14, "shippable": true, "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }, "pricing": { "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }, "sale_price": { "amount": 7565, "currency": "GBP", "formatted": "£75.65" }, "compare_price": null, "on_sale": true, "saving": { "amount": { "amount": 1335, "currency": "GBP", "formatted": "£13.35" }, "percent_bp": 1500 }, "badges": [{ "label": "15% off", "kind": "product_adjustment" }] } } ], "images": [ { "id": 9, "name": "oak-desk-lamp", "url": "https://…/oak-desk-lamp.jpg", "thumbnail_url": "https://…/oak-desk-lamp-small.jpg" } ], "offers": [ { "id": 7, "kind": "product_adjustment", "label": "Autumn lighting sale", "badge": "15% off", "banner": null, "bulk_table": null, "bulk_table_variant_id": null, "ends_at": "2026-11-01T00:00:00Z", "scope": "collection" } ] } ], "links": { "first": "…", "last": "…", "prev": null, "next": "…" }, "meta": { "current_page": 1, "last_page": 3, "per_page": 24, "total": 61 } } ``` `saving.percent_bp` is in basis points: `1500` is 15%. ## Add a price slider [#add-a-price-slider] `GET /v1/products/price-bounds` returns the lowest and highest base price across the products the same listing would show. Forward the listing's query string unchanged: it honours `filter[collection]`, `filter[search]` and `filter[attr]`, and deliberately ignores `filter[price_min]` and `filter[price_max]`, so the slider never shrinks to the range the shopper just picked. ```json title="GET /v1/products/price-bounds?filter[collection]=lighting" { "data": { "currency": "GBP", "min": { "amount": 1800, "currency": "GBP", "formatted": "£18.00" }, "max": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" } } } ``` `min` and `max` are `null` when no product in the set has a price. Their `amount` values are exactly what `filter[price_min]` and `filter[price_max]` take. Price filters and bounds use each variant's base price. A product on promotion is matched on its normal price, so show the sale price on the card but filter on the base price. ## Keep filters in the URL [#keep-filters-in-the-url] Put the shopper's choices in your page's own URL (`/collections/lighting?finish=oak&max=9000&sort=price&page=2`) and translate them into engine parameters on the server. The page can then be shared, bookmarked, crawled and rendered without JavaScript. ```ts title="app/collections/[slug]/page.tsx" import { engineGet } from '@/lib/engine' type Money = { amount: number; currency: string; formatted: string } type PriceBounds = { currency: string | null; min: Money | null; max: Money | null } type ProductPage = { data: Array<{ id: number; name: string; slug: string }> meta: { current_page: number; last_page: number; total: number } } const SORTS = new Set(['name', '-name', 'created_at', '-created_at', 'price', '-price']) const ATTRIBUTES = ['finish', 'size'] // the attribute handles you offer as filters function engineQuery(slug: string, params: Record) { const one = (key: string) => (typeof params[key] === 'string' ? (params[key] as string) : undefined) const q = new URLSearchParams({ include: 'variants.prices,images,offers', perPage: '24' }) q.set('filter[collection]', slug) const min = one('min') const max = one('max') if (min && /^\d+$/.test(min)) q.set('filter[price_min]', min) if (max && /^\d+$/.test(max)) q.set('filter[price_max]', max) for (const handle of ATTRIBUTES) { const value = one(handle) if (value) q.set(`filter[attr][${handle}]`, value) } const sort = one('sort') if (sort && SORTS.has(sort)) q.set('sort', sort) const page = one('page') if (page && /^\d+$/.test(page)) q.set('page', page) return q } export default async function CollectionPage(props: { params: Promise<{ slug: string }> searchParams: Promise> }) { const { slug } = await props.params const query = engineQuery(slug, await props.searchParams) const [products, bounds] = await Promise.all([ engineGet('/products', query), engineGet<{ data: PriceBounds }>('/products/price-bounds', query), ]) // Render the grid from products.data, the slider from bounds.data, // and pagination from products.meta.current_page and products.meta.last_page. } ``` Only pass on values you recognise. An allow-list of sorts and attribute handles keeps a hand-edited URL from turning into a `400`. ## Show promotion badges [#show-promotion-badges] With `include=offers` each product carries `offers`, and each variant's `pricing` carries `badges`. A common pattern: - **Card badge:** the first variant's `pricing.badges[0].label`, or the first offer's `badge`. - **Price:** show `pricing.sale_price` with `pricing.price` struck through when `pricing.on_sale` is `true`. Show `pricing.compare_price` as a "was" price only when it is not `null`. - **Offers strip:** offers with `scope` set to `all` apply store-wide. `GET /v1/promotions` lists the store's live public offers for a banner. - **Countdown:** `ends_at` is an ISO 8601 time, or `null` for an open-ended offer. ## Collections for navigation [#collections-for-navigation] `GET /v1/collections` returns a flat, paginated list in tree order, each with `id`, `parent_id`, `name` and `slug`. Add `include=children` to get each collection's children nested. `GET /v1/collections/{slug}` returns one collection and answers `404 NOT_FOUND` for an unknown slug. ## What can go wrong [#what-can-go-wrong] | Status | Code | Cause | |---|---|---| | `400` | `HTTP_ERROR` | A filter, sort or include that is not on the allow-list. | | `404` | `NOT_FOUND` | An unknown collection slug on `GET /v1/collections/{slug}/products`. | | `429` | `RATE_LIMITED` | Too many requests. Wait for `Retry-After` seconds. | Catalogue responses are cached by the engine per store and query, and the cache is cleared when the merchant edits a product, price or collection, so you do not need your own short-lived cache to stay fast. --- # Checkout with Stripe URL: https://elaraone.app/docs/guides/stripe-checkout Take a card payment end to end, from cart to placed order, with the Stripe Payment Element and exactly-once order placement. This guide walks through a full card checkout: build a cart, set the addresses and delivery, collect the card with Stripe's Payment Element in the browser, then place the order from your server. The money goes to the store's own Stripe account; card details never touch Elara One or your server. Every call below except the Stripe.js part runs on your server with `X-Tenant-Key` and `X-Tenant-Secret`. The cart token travels as `X-Cart-Token`; keep it in an httpOnly cookie on your own domain. The TypeScript examples use this small helper: ```ts title="lib/engine.ts" export const ENGINE = 'https://api.elaraone.app/api/v1' export function engineHeaders(): Record { return { Accept: 'application/json', 'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!, 'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!, } } ``` ## Create a cart [#create-a-cart] ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/cart \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" ``` ```ts tab="TypeScript" const res = await fetch(`${ENGINE}/cart`, { method: 'POST', headers: engineHeaders() }) const { data } = await res.json() // Store data.cart_token in an httpOnly cookie, never in localStorage. ``` The answer is `201` with `cart_token` and the empty cart. For a signed-in shopper, send their `Authorization: Bearer` token too, so the order is linked to their account. ```json title="Response" { "data": { "cart_token": "…", "cart": { "id": 51, "currency": "GBP", "lines": [], "total": { "amount": 0, "currency": "GBP", "formatted": "£0.00" } } } } ``` ## Add lines [#add-lines] Send either a `variant_id` or a `sku`, and a `quantity`. Add an `Idempotency-Key` so a retried request never adds the line twice. The answer is the whole cart. ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/cart/lines \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "variant_id": 412, "quantity": 1 }' ``` ```ts tab="TypeScript" await fetch(`${ENGINE}/cart/lines`, { method: 'POST', headers: { ...engineHeaders(), 'X-Cart-Token': cartToken, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ sku: 'LAMP-OAK', quantity: 1 }), }) ``` ## Set the addresses [#set-the-addresses] `PUT /v1/cart/addresses` takes a `shipping` and a `billing` address, or `billing_same_as_shipping: true`. `country_id` comes from `GET /v1/countries`. The required fields are `first_name`, `line_one`, `city`, `postcode` and `country_id`. Always send `contact_email`. Order emails go to it, and a guest finds their order again with it. ```json title="PUT /v1/cart/addresses" { "shipping": { "first_name": "Ada", "last_name": "Lovelace", "line_one": "12 Hearth Lane", "city": "Bath", "postcode": "BA1 1AA", "country_id": 235, "contact_email": "ada@example.com", "contact_phone": "07700 900123" }, "billing_same_as_shipping": true } ``` Signed-in customers can send `{ "address_id": 42 }` instead of the fields, using their saved addresses. See [Customer accounts](/docs/guides/customer-accounts#use-saved-addresses-at-checkout). ## Choose delivery [#choose-delivery] `GET /v1/cart/shipping-options` lists exactly the options the cart can use. Each has an `identifier`, `name`, `price`, `discounted_price` (after any free-shipping promotion), and `kind` (`delivery` or `collect`). ```json title="Response (trimmed)" { "data": [ { "identifier": "standard", "name": "Standard delivery", "description": "2 to 4 working days", "price": { "amount": 495, "currency": "GBP", "formatted": "£4.95" }, "discounted_price": null, "collect": false, "kind": "delivery", "collection_location": null } ], "meta": { "selected": null, "unavailable": [], "address_required_for_delivery": false } } ``` Then store the choice: ```json title="PUT /v1/cart/shipping-option" { "identifier": "standard" } ``` The engine remembers the price it quoted. If the rate changes before payment, checkout refuses with `409 SHIPPING_OPTION_CHANGED` (see below) rather than charging a price the shopper never saw. ## Read the checkout config [#read-the-checkout-config] `GET /v1/checkout/config` describes the store's payment set-up. It holds only public values. ```json title="Response" { "data": { "gateways": ["stripe"], "currency": "GBP", "country": "GB", "gateway_mode": "live", "stripe_publishable_key": "pk_live_…", "stripe_capture_method": "manual", "paypal_client_id": null } } ``` - `gateway_mode` is `test` or `live`. Show a "test payments" banner when it is `test`. - `stripe_capture_method` is `manual` or `automatic`. With `manual`, confirming the card in the browser only authorises it, and the engine captures the money when it places the order. If you create the Payment Element before the PaymentIntent (Stripe's deferred intent flow), pass this value as Elements' `captureMethod`. ## Create the PaymentIntent [#create-the-paymentintent] `POST /v1/checkout/payment-intent` creates (or reuses) the PaymentIntent for this cart and returns what Stripe.js needs. Neither value is a secret, so your server can pass both to the browser. ```json title="Response" { "data": { "client_secret": "pi_…_secret_…", "publishable_key": "pk_live_…" } } ``` A second request for the same cart while one is being set up gets `409 PAYMENT_SETUP_IN_PROGRESS`. Wait a moment and try again. ## Confirm the card in the browser [#confirm-the-card-in-the-browser] Mount the Payment Element with `@stripe/stripe-js`, then confirm it when the shopper presses **Place order**. ```ts title="checkout.client.ts" import { loadStripe } from '@stripe/stripe-js' const intent = await fetch('/api/checkout/payment-intent', { method: 'POST' }).then((r) => r.json()) const stripe = await loadStripe(intent.data.publishable_key) const elements = stripe!.elements({ clientSecret: intent.data.client_secret }) elements.create('payment').mount('#payment-element') let attemptKey: string | null = null let confirmedIntentId: string | null = null export async function placeOrder() { if (!confirmedIntentId) { const { error, paymentIntent } = await stripe!.confirmPayment({ elements, redirect: 'if_required', confirmParams: { return_url: `${location.origin}/checkout/return` }, }) if (error) return showError(error.message ?? 'Your card could not be charged.') confirmedIntentId = paymentIntent.id } // One key per order attempt: a retry after a network error reuses it. attemptKey ??= crypto.randomUUID() const res = await fetch('/api/checkout/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attemptKey }, body: JSON.stringify({ payment_intent: confirmedIntentId }), }) // Handle the answer as described in "When checkout refuses". } ``` Payment methods that redirect (some banks' 3-D Secure pages, for example) come back to `return_url` with Stripe's `payment_intent` query parameter. Read it there and carry on with the next step. ## Place the order from your server [#place-the-order-from-your-server] Your server calls `POST /v1/checkout/complete` with the cart token, the PaymentIntent id and the attempt's `Idempotency-Key`. The engine checks stock, promotions, the delivery price and the total, then captures the payment and places the order. ```bash tab="cURL" curl -X POST https://api.elaraone.app/api/v1/checkout/complete \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Cart-Token: $CART_TOKEN" \ -H "Idempotency-Key: 9b2c7a52-6f1e-4c1b-a0d4-2f7e8d1c3b90" \ -H "Content-Type: application/json" \ -d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_3Q…" } }' ``` ```ts tab="TypeScript" // app/api/checkout/complete/route.ts import { cookies } from 'next/headers' export async function POST(req: Request) { const { payment_intent } = await req.json() const jar = await cookies() const cartToken = jar.get('cart')?.value const key = req.headers.get('Idempotency-Key') if (!cartToken || !key || typeof payment_intent !== 'string') { return Response.json({ error: { code: 'BAD_REQUEST' } }, { status: 400 }) } const res = await fetch(`${ENGINE}/checkout/complete`, { method: 'POST', headers: { ...engineHeaders(), 'X-Cart-Token': cartToken, 'Idempotency-Key': key, 'Content-Type': 'application/json' }, body: JSON.stringify({ payment: { gateway: 'stripe', payment_intent } }), signal: AbortSignal.timeout(60_000), // capture can take longer than a normal call }) const body = await res.json() if (res.status === 201) { jar.delete('cart') // the engine retires the cart token on success if (body.meta?.order_access_token) { jar.set('order_access', body.meta.order_access_token, { httpOnly: true, secure: true, sameSite: 'lax' }) } return Response.json({ data: body.data }, { status: 201 }) } return Response.json(body, { status: res.status }) } ``` Success is `201` with the order. A guest order also carries `meta.order_access_token`; keep it server-side. ```json title="Response (trimmed)" { "data": { "id": 3307, "reference": "00003307", "status": "payment-received", "status_label": "Payment Received", "placed_at": "2026-10-24T10:15:02+00:00", "lines": ["…"], "sub_total": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }, "shipping_total": { "amount": 495, "currency": "GBP", "formatted": "£4.95" }, "total": { "amount": 9395, "currency": "GBP", "formatted": "£93.95" } }, "meta": { "order_access_token": "…", "order_access_token_expires_at": "2026-10-24T11:15:02+00:00" } } ``` Retrying with the same `Idempotency-Key` replays this exact answer with `Idempotent-Replayed: true`, so a double click or a lost response never places two orders. See [Idempotency keys](/docs/concepts/idempotency). ## When checkout refuses [#when-checkout-refuses] Branch on `error.code`, never on the message. | Status | Code | What to do | |---|---|---| | `402` | `PAYMENT_FAILED` | The payment did not go through. Show a message from `details.decline_code` (below), or `error.message` when there is none. | | `409` | `SHIPPING_OPTION_CHANGED` | The chosen delivery option changed price or is no longer offered. `details.options` lists what is available now. Show it, let the shopper choose, and `PUT /v1/cart/shipping-option` again. | | `409` | `PROMOTIONS_CHANGED` | A discount changed or ran out. `details.cart` is the recalculated cart. Show it and ask the shopper to confirm. | | `422` | `VALIDATION_FAILED` | The cart is not ready (for example, stock ran out or an address is missing). `details` says which field. | | `409` | `IDEMPOTENCY_IN_PROGRESS` | The first request with this key is still running. Retry with the same key shortly. | | `404` | `CART_NOT_FOUND` | The cart token no longer resolves. Start a new cart. | ### Decline codes [#decline-codes] A `402 PAYMENT_FAILED` that the card issuer refused carries `details.decline_code`: ```json { "error": { "code": "PAYMENT_FAILED", "message": "Your card was declined. Try another card or way to pay.", "details": { "decline_code": "insufficient_funds" } } } ``` | `decline_code` | Meaning | |---|---| | `card_declined` | Refused with no reason the shopper can act on. | | `card_expired` | The card has expired. | | `insufficient_funds` | Not enough funds or credit. | | `incorrect_card_details` | A wrong card number, security code or postcode. | | `authentication_required` | 3-D Secure was not completed. | | `try_another_method` | The provider refused this way of paying (PayPal). | | `unknown` | Any other reason. | Treat a value you do not recognise as `unknown`; the list may grow. The engine never returns the provider's own text. ### Building a new card form after a refusal [#building-a-new-card-form-after-a-refusal] With `stripe_capture_method` set to `manual`, any definite refusal from `checkout/complete` (a `402`, `409` or `422`) means the engine has already released the hold on the card. Nothing was charged, and that PaymentIntent cannot be used again. Before the shopper tries again, call `POST /v1/checkout/payment-intent` for a fresh client secret, mount a new Payment Element, and use a new `Idempotency-Key`. When `checkout/complete` gives no definite answer (a network error, a timeout or a `5xx`), the hold is still there. Send the **same** PaymentIntent id again with the **same** key, without confirming the card a second time. Keep the confirmed PaymentIntent id until `checkout/complete` gives a definite answer. Confirming it again fails, because it is no longer waiting for payment. ## Show the order to a guest [#show-the-order-to-a-guest] The confirmation page reads the order with the token from `meta.order_access_token`: ```bash curl https://api.elaraone.app/api/v1/orders/guest/00003307 \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \ -H "X-Order-Access-Token: $ORDER_ACCESS_TOKEN" ``` The token is short-lived (an hour by default; `order_access_token_expires_at` says when). A bad or expired token gets `403 ORDER_ACCESS_TOKEN_INVALID` with `details.reason` set to `invalid` or `expired`. A guest who comes back later can find the order with `POST /v1/orders/lookup` and `{ "reference": "00003307", "email": "ada@example.com" }`, which returns the order and a new token. A wrong reference and a wrong email give the same `404 NOT_FOUND`. A signed-in customer gets no token: their bearer token already reaches `GET /v1/orders/{reference}`. ## PayPal [#paypal] The engine also takes PayPal through the store's own PayPal account. When `gateways` includes `paypal`, load PayPal's JS SDK with `paypal_client_id`, then: 1. `createOrder`: your server calls `POST /v1/checkout/paypal/order` with the cart token and returns `data.paypal_order_id` to the buttons. 2. `onApprove`: your server calls `POST /v1/checkout/paypal/capture` with `{ "paypal_order_id": "…" }`, the cart token and an `Idempotency-Key`. It answers `201` with the order, exactly like `checkout/complete`. On a `402` with `try_another_method`, reopen the PayPal buttons and capture the same PayPal order again. The starter storefronts ship Stripe card checkout only; PayPal buttons are something you add yourself. --- # Add customer accounts URL: https://elaraone.app/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] ### 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. ### 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. ### 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. ## 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. | --- # Lead forms URL: https://elaraone.app/docs/guides/lead-forms Render a store form from its schema, submit it safely with spam protection and consent, and know what happens to each lead. Forms are built in the store admin (enquiry, quote request, newsletter sign-up, or blank) and published to your storefront through the API. Your site reads the form's schema, renders it in your own design, and posts the answers back. Each submission lands in the CRM as a contact, and can open a lead, send email notifications and start flows. ## Read the form [#read-the-form] `GET /v1/forms/{handle}` returns the public schema. It never includes the form's notification settings or internal wiring. ```bash tab="cURL" curl https://api.elaraone.app/api/v1/forms/quote-request \ -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \ -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" ``` ```ts tab="TypeScript" const res = await fetch(`${ENGINE}/forms/quote-request`, { headers: engineHeaders() }) if (res.status === 404) notFound() // unknown or switched-off form const { data: form } = await res.json() ``` ```json title="Response (trimmed)" { "data": { "handle": "quote-request", "name": "Quote request", "fields": [ { "handle": "name", "type": "text", "label": "Your name", "required": true, "options": [], "rules": ["max:120"], "constraints": { "max_length": 120 }, "marketing_consent": false }, { "handle": "email", "type": "email", "label": "Email", "required": true, "options": [], "rules": [], "constraints": {}, "marketing_consent": false }, { "handle": "budget", "type": "select", "label": "Budget", "required": false, "options": ["Under £500", "£500 to £2,000", "Over £2,000"], "rules": [], "constraints": {}, "marketing_consent": false }, { "handle": "news", "type": "checkbox", "label": "Send me offers and news from Hearth & Oak", "required": false, "options": [], "rules": [], "constraints": {}, "marketing_consent": true } ], "captcha": { "provider": "turnstile", "site_key": "0x4AAAAAAA…" } } } ``` A form that does not exist or is switched off answers `404 NOT_FOUND`; the API never says which. ## Render the fields [#render-the-fields] | `type` | Render as | Sent as | |---|---|---| | `text` | `` | string | | `email` | `` | string, checked as an email address | | `phone` | `` | string | | `textarea` | `