# AI Agents Everything on this site is available to an AI agent in a form it can consume directly: a hosted MCP server, an installable skill, and the whole documentation set as Markdown and OpenAPI. This page lists the exact commands and URLs. ## Fastest paths ### Connect the MCP server Agents that speak the Model Context Protocol should connect to the server rather than call REST by hand. It exposes headless commerce reads and order creation, branded gift-card PDF templates, and order-page configuration, publishes the field catalog at runtime, and ships prompts that encode the correct workflow. It shows an agent only the tools its key holds the scope for. ```bash claude mcp add --transport http lifepeaks https://api.lifepeaks.dk/mcp \ --header "Authorization: Bearer lp_live_" ``` For Cursor, VS Code, and every other client, see [MCP Server — Install](https://docs.lifepeaks.dk/mcp#install). ### Install the order-page skill The `lifepeaks-order-page` skill teaches an agent to read and customize a Lifepeaks order page. Install it from this site with the [skills CLI](https://github.com/vercel-labs/skills){rel=""nofollow""}: ```bash npx skills add https://docs.lifepeaks.dk/skills/lifepeaks-order-page/SKILL.md ``` To load it manually — into a system prompt, a repository, or a client that does not use the CLI — fetch the raw files: - Skill definition — `https://docs.lifepeaks.dk/skills/lifepeaks-order-page/SKILL.md` - Field catalog reference — `https://docs.lifepeaks.dk/skills/lifepeaks-order-page/references/fields.md` ## Machine-readable surfaces **Documentation index** — `https://docs.lifepeaks.dk/llms.txt` Every page with a one-line description, following the [llms.txt convention](https://llmstxt.org/){rel=""nofollow""}. Use this when the agent can fetch pages on demand and should decide for itself what to read. **Whole corpus** — `https://docs.lifepeaks.dk/llms-full.txt` Every page inlined into a single Markdown document. Use this when the agent cannot fetch again and the entire surface has to fit in one pass. **One page as Markdown** — `https://docs.lifepeaks.dk/raw/.md` The Markdown source of a single page, for example `https://docs.lifepeaks.dk/raw/endpoints.md`. Every page on this site has one; the introduction is at `/raw/index.md`. **OpenAPI 3.1, v2** — `https://docs.lifepeaks.dk/openapi/v2.yaml` The authoritative description of every v2 endpoint. Prefer it over prose whenever exact field names, types, or response shapes matter. **OpenAPI 3.0, v1 (legacy)** — `https://docs.lifepeaks.dk/openapi/v1.json` The same for the legacy OAuth2 API, for agents working against an existing v1 integration. Every documentation page also carries a **Copy page** control next to its title, which copies that page's Markdown or hands it to Claude or ChatGPT directly. ## What the skill covers - Authentication and the scope each operation needs - The draft-review-publish workflow, from `orderpage_list_fields` through `orderpage_publish_draft` - The full writable-field catalog with types, maximum lengths, and per-locale support - Type validation rules for `string`, `bool`, `color`, `csv`, `gtm`, and `int` - Writing translations by drafting one locale at a time - What is deliberately not writable, and the errors returned when it is attempted - Worked end-to-end examples ## Notes for agent implementers - The writable field catalog is **dynamic**. Call `GET /v2/order-page/schema` or `orderpage_list_fields` before writing, rather than hardcoding field names. - Prefer a draft over patching the live page. Nothing reaches buyers until it is published, and a mistaken publish can be rolled back by restoring an earlier revision. - Every draft edit and publish needs the newest `ETag`. A stale one is rejected, which is what stops two editors overwriting each other. - Non-catalog fields are rejected with `422 Unprocessable Entity`, not silently ignored. - Write tools take an `idempotency_key`, and most also require `confirm: true`. Send `confirm` only after the user has approved that specific side effect. - The `key` returned by `POST /v2/api-keys` is shown exactly once. An agent that stores credentials must store it on receipt. - Minimum scopes for the order page: `orderpage:read` to read, `orderpage:write` to draft and publish. # Authentication The Lifepeaks v2 API uses **Bearer API keys** for authentication. A key carries the prefix `lp_live_` or `lp_test_`; see [Live keys and test keys](https://docs.lifepeaks.dk/#live-keys-and-test-keys). ::callout **API keys are server-only credentials.** Keep them in a managed secret store and send them only from your backend to Lifepeaks. Never expose a key in frontend JavaScript, a mobile binary, source control, analytics, browser storage, or logs. :: ## Making authenticated requests Pass the key in the `Authorization` header on every request: ```http Authorization: Bearer lp_live_ ``` A key is its prefix, a 32-character hexadecimal key id, an underscore, and a 64-character hexadecimal secret. Example with curl: ```bash curl -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ https://api.lifepeaks.dk/v2/health ``` ## Checking what a key can do `GET /v2/me` returns the company the key acts on, the scopes it actually carries, when it expires, and which settlement modes the company holds. It needs no scope, so any key can call it. Use it at start-up rather than discovering a missing scope as a `403` in production. ```bash curl -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ https://api.lifepeaks.dk/v2/me ``` ```json { "api_version": "v2", "company": { "id": "acme-hotels", "name": "Acme Hotels" }, "credential": { "id": "4f2c9a71b83d4e6fa0c15d27e93b6a08", "label": "Storefront production", "environment": "live", "scopes": ["catalog:read", "quotes:create", "orders:create", "orders:read", "checkout:create"], "expires_at": "2027-02-01T00:00:00+00:00" }, "commerce": { "modes": ["lifepeaks_checkout"], "default_mode": "lifepeaks_checkout", "negotiated_settlement": false } } ``` `credential.id` is the public key id — the middle segment of the key, safe to log. The secret half never appears in any response. ## Live keys and test keys A key is either `lp_live_…` or `lp_test_…`, fixed when it is created. `GET /v2/me` reports which one you hold, as `environment`. **Both read and write the same data.** There is one Lifepeaks database per environment, so a gift card issued with a test key is a real row you can look up, redeem and see in the back office. A test key is not a separate world to develop in. **The difference is where it may take money.** What makes a payment real is the environment, not the key: the demo environment's payment credentials belong to QuickPay's test account, so every payment made there is a test payment — use QuickPay's test cards. Production's credentials are live. **A test key cannot take money in production.** Starting a checkout session or a refund there with an `lp_test_` key is refused, before anything is charged: ```json { "error": { "code": "test_key_not_accepted_in_production", "message": "A test key cannot take or return a payment on the production environment, whose payment credentials are live. Use a live key here, or this test key on the demo environment." } } ``` That answer is `409`. On demo a test key takes payments like any other key. Mint one with `POST /v2/api-keys` by sending `"environment": "test"`. Omit the field and you get a live key, which is what every existing caller already gets. The admin screen still issues live keys only. **A test key can only mint test keys.** Asking for `live` from one, or omitting `environment` so the default would apply, answers `422` with `error.code` of `validation_failed` and `environment` named in `error.fields`. It is refused rather than quietly downgraded, so a caller is never left thinking it asked for something it did not get. That makes a test key safe to hand to a contractor with `credentials:write`: they cannot turn it into a credential that charges real cards. ## Rate limits Every `/v2` request spends one request from a budget held on the key that made it. The default budget is **600 requests per minute per key**, and it refills continuously, so a client that stays under the rate never sees a rejection. Each key has its own budget: exhausting one does not affect another, even in the same company. Every response carries the state of that budget: | Header | Meaning | | ------------------------ | -------------------------------------- | | `X-Rate-Limit-Limit` | Requests a full window allows | | `X-Rate-Limit-Remaining` | Requests left right now | | `X-Rate-Limit-Reset` | Seconds until the budget is full again | A request that arrives with an empty budget is answered `429` and no work is done: ```json { "error": { "code": "rate_limited", "message": "Too many requests for this API key. Retry after the interval in the Retry-After header." } } ``` `Retry-After` gives the seconds to wait. Wait at least that long, then retry: a `429` is always safe to retry, and retrying a write with the same `Idempotency-Key` cannot duplicate it. `GET /v2/pickup-points` has a second, tighter limit of its own — 60 lookups per company per minute — because it reaches the courier on our account. See [Collection points](https://docs.lifepeaks.dk/endpoints#collection-points). ## Scopes Every API key carries one or more scopes that determine what it can do. | Scope | What it permits | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `companies:read` | List the companies the key may act for, and read one of them | | `brand:read` | Read a company's brand profile and brand assets | | `brand:write` | Update the brand profile and replace or delete brand assets | | `credentials:read` | List API keys without plaintext values | | `credentials:write` | Create and revoke API keys | | `orderpage:read` | Read order-page config, schema, revisions, drafts, and previews | | `orderpage:write` | Update config and create, edit, publish, or restore revisions | | `pdf_templates:read` | Read and preview branded gift-card PDF templates | | `pdf_templates:write` | Upload, publish, and revert branded gift-card PDF templates | | `items:read` | Search and inspect items, list events, list gift-card campaigns | | `items:write` | Redeem, refund, cancel, activate and resend items; redeem a whole order | | `catalog:read` | List products available to the acting company | | `quotes:create` | Request authoritative Lifepeaks pricing | | `orders:create` | Create unpaid headless orders; compatibility issuance additionally needs `orders:settle` and a contract | | `orders:read` | Read canonical order, payment, and fulfillment state, and download the fulfilled gift-card PDF | | `checkout:create` | Create Lifepeaks-hosted QuickPay checkout sessions | | `orders:settle` | Request negotiated, entitlement-gated settlement | | `orders:refund` | Return money on an order. Deliberately not implied by `orders:settle`: a key that may settle an invoice should not thereby move money back out | | `webhooks:read` | List webhook endpoints and deliveries | | `webhooks:write` | Register and disable endpoints, rotate secrets, and replay deliveries | | `reports:read` | Analytics, claimed-item transactions, subscribers, event participants | Scopes are independent — a key with only `orderpage:read` cannot create or revoke keys, a key with `items:read` cannot redeem anything, and `pdf_templates:write` does not imply `pdf_templates:read`. Every endpoint states its required scope; see [Endpoints](https://docs.lifepeaks.dk/endpoints) and [Events & Reporting](https://docs.lifepeaks.dk/events-and-reporting). Four scope boundaries are worth noting: - **`GET /v2/me` and `GET /v2/health` need no scope at all.** Any valid key can describe itself and check that the API is reachable, which makes `/v2/me` the right place to discover what a key can actually do. - **Event participants need `reports:read`**, not `items:read` — the rows carry participant personal data. - **Headless order creation, reading, and checkout use separate scopes.** A return-page backend can hold `orders:read` without permission to create new orders. - **`orders:settle` is necessary but not sufficient.** Settlement also requires an active negotiated entitlement for the company, credential, and mode. `GET /v2/me` reports which modes are available. When issuing a new key you cannot assign scopes that exceed your own. ## Issuing API keys ### Via the admin UI Log in to the Lifepeaks admin panel, open your company's settings, and choose the **API** tab. The **Lifepeaks API v2** section there lists your existing keys and issues new ones. It is separate from the **Lifepeaks API** block above it, which holds the v1 OAuth client id and secret. To issue a key, give it a label, tick the scopes it needs, choose **Live** or **Test**, and optionally set an expiry between 1 and 365 days. The plaintext key is shown **once**, immediately after creation, and never again. Who can issue a key: - A **super admin** (company owner) can manage keys for their own company. - A **Lifepeaks system administrator** can manage keys for any company, from that company's own settings screen. - No other role has access. **Live and test keys** read and write the same data — there is one Lifepeaks database per environment, so a test key is not a sandbox. The difference is money: on production a test key is refused for real payments. Use a test key for integration work and a live key for real traffic. ### Via the REST API `POST /v2/api-keys` requires `credentials:write` scope. ```http POST /v2/api-keys Authorization: Bearer lp_live_ Content-Type: application/json { "label": "My integration key", "scopes": ["items:read", "items:write"], "expires_in_days": 90 } ``` | Field | Required | Notes | | ----------------- | -------- | --------------------------------------------------------------------------- | | `label` | yes | Human-readable name for the key | | `scopes` | yes | Any subset of your own scopes | | `expires_in_days` | no | Integer `1`–`365`, counted from creation. Omit to issue a non-expiring key. | **Response `201`:** ```json { "id": 42, "label": "My integration key", "scopes": ["items:read", "items:write"], "key": "lp_live_4f2c9a71b83d4e6fa0c15d27e93b6a08_5d8e1c04b7a9f3620e4d81c7a5b93f0d2e6c48a1b309f75d84e2c6b19a03d7f5", "expires_at": "2026-11-25T00:00:00+00:00" } ``` `expires_at` is an ISO 8601 timestamp, or `null` when the key never expires. Note that the listing endpoint reports the same instant as `YYYY-MM-DD HH:MM:SS` instead. The plaintext key is returned **once** in the `key` field. Lifepeaks stores only a hash of it, so it cannot be retrieved again — write it to your secret store immediately, and if you lose it, revoke the key and issue another. Key management always acts on the key's own company and takes no `company` parameter, so an agency cannot mint or revoke credentials inside a company assigned to it. ## Listing and revoking keys **List all keys** for the authenticated company (`credentials:read`): ```http GET /v2/api-keys ``` The listing includes revoked and expired keys, with `expires_at`, `revoked_at`, and `last_used_at`, which makes it an audit of every credential ever issued. Plaintext values are never included. This route takes no query parameters and no pagination. **Revoke a key** by its numeric ID (`credentials:write`): ```http DELETE /v2/api-keys/{id} ``` Returns `204 No Content` on success, or `404` if no such key belongs to your company. Revocation takes effect immediately with no grace period, so deploy the replacement first. It cannot be undone. ## Error responses | HTTP status | Error code | Meaning | | -------------------------- | ------------------------------------- | ----------------------------------------------------------------- | | `401 Unauthorized` | `unauthorized` | No key supplied, or the key is invalid / expired / revoked | | `403 Forbidden` | `insufficient_scope` | Key is valid but lacks the required scope | | `404 Not Found` | `not_found` | The record does not exist for the company the request acts on | | `409 Conflict` | `test_key_not_accepted_in_production` | A test key tried to take or return a payment in production | | `422 Unprocessable Entity` | `validation_failed` | The request broke a business or input rule | | `429 Too Many Requests` | `rate_limited` | The key's request budget is empty. Wait for `Retry-After` seconds | A `401` body looks like this: ```json { "error": { "code": "unauthorized", "message": "Valid credentials required." } } ``` A `403` names the scope that was missing: ```json { "error": { "code": "insufficient_scope", "message": "Missing scope: orderpage:write" } } ``` # Changelog v2 is in beta. Changes that alter an existing response are listed here as **beta-breaking** and are kept to the minimum; everything else is additive. ## Any issued item has a printable document `GET /v2/items/{code}/document.pdf` streams the document of one item, whatever family it belongs to: a gift card, an event ticket, a special-offer voucher or a benefit deal. It answers `200` with `application/pdf`, or `404` with the JSON error envelope, so branch on the response `Content-Type` rather than on the status. It takes `items:read` and answers to exactly the same tenancy rule as reading the item, so a benefit deal is printable by the company it was issued to as well as by the company that issued it. A foreign code, an unknown code and a document that cannot be produced all answer the same `404`. This is the per-item counterpart of the per-line download on a headless order. Use the line route when you hold a `po_…` order; use this one when you hold a code. See [Endpoints — One item, one document](https://docs.lifepeaks.dk/endpoints#one-item-one-document). ## Special offers are sold through the API `GET /v2/products` publishes a company's special offers as a fourth product family: `type` of `special_offer`, id `so_`, one `sov_` option per voucher. They are quoted and ordered through the same `POST /v2/quotes` and `POST /v2/orders` as everything else, and issue vouchers that read and redeem at `/v2/items` like any other item. Narrow the catalog to them with `?type=special_offer`. Each option publishes three prices, all whole numbers in the smallest unit of the currency: `amount` is what the buyer pays, `regular_amount` is what it cost before the offer, and **`redemption_value` is what the issued voucher is worth when it is spent**. Those last two are the ones worth reading closely. An offer configured to redeem at the regular price sells at `amount` and redeems at `regular_amount`, so a receipt that shows only what was paid will disagree with the operator's redeem screen. `discount_percent` is the saving the operator advertised, and `available_until` is the day the offer stops being orderable. Its `order_fee` is charged once per line, which is what `fee_basis` of `per_line` says. A special offer cannot be posted: `delivery.method` of `postal` is refused and `amounts.shipping` is always `0`. Two refusals are new and specific to this family. A line that loses the last voucher to another buyer between the quote and the order answers **`409` with `insufficient_stock`** — nothing in the body is wrong, so re-read the catalog rather than correcting a field. A quantity outside the option's own bounds answers `422` with `below_minimum` or `above_maximum` on `items..quantity`. Benefit deals share the same storage but are not sold: they are issued to a company and are read and redeemed by code only. They never appear in the catalog. `commerce_list_products`, `commerce_create_quote` and `commerce_create_order` on the MCP server cover the family too, so an agent can discover an `so_` offer and order one of its `sov_` vouchers. See [Endpoints — A special offer and its vouchers](https://docs.lifepeaks.dk/endpoints#a-special-offer-and-its-vouchers). ## Event tickets are sold through the API `GET /v2/products` publishes a company's events as a third product family: `type` of `event_ticket`, id `evt_`, one `evto_` option per ticket type. They are quoted and ordered through the same two routes as a gift card, and issue tickets that read and redeem at `/v2/items`. Narrow the catalog to them with `?type=event_ticket`. **`GET /v2/events` is unchanged and is still the reporting view.** It reports how many tickets an event sold, held and started with, and publishes no prices, no ticket types and no id an order can name. The catalog is where tickets are bought. Each event carries an `event` block: `starts_at`, `ends_at` and `date_precision`, the venue, whether the tickets are grouped, whether the organiser accepts a comment and what to label it, what the event requires of a buyer, its capacity mode, and its `epp_` collection points. Each option carries its price, its `ticket_fee`, its availability and a `requires` block saying whether that ticket needs a code, an address or a collection point. An option marked `add_on` cannot be bought on its own. **The ticket fee is charged per seat, not per line.** `fee_basis` of `per_ticket` on the product says so, and a two-seat line is charged the fee twice. Every other family stays `per_line`. A basket totalled without reading `fee_basis` will be short by a ticket fee on every seat after the first. An event line may carry a `ticket` object with `comment`, `code`, `collection_point_id` or `address` — each one conditional on something the product publishes. An event cannot be posted: `delivery.method` of `postal` is refused and `amounts.shipping` is always `0`. Two rules differ from the Lifepeaks order form, deliberately. The catalog lists exactly what it sells, so an undated event is neither listed nor orderable, where the order form hides it and still lets a direct link buy it. And an event that counts orders rather than seats caps the tickets of the whole order, where the order form checks each line on its own. `commerce_list_products`, `commerce_create_quote` and `commerce_create_order` on the MCP server cover the family too, including the `ticket` object, so an agent can discover an `evt_` event and order seats from its `evto_` ticket types. See [Endpoints — An event and its tickets](https://docs.lifepeaks.dk/endpoints#an-event-and-its-tickets). ## A discount code can be applied at checkout `POST /v2/quotes` and `POST /v2/orders` accept a `discount_code` — the code the buyer typed, 5 to 25 characters, matched case-insensitively against the campaigns the partner created in the Lifepeaks back office. Lifepeaks decides whether it applies and calculates the money; there is nowhere to send a discount amount. ::callout **Beta-breaking for anyone who recomputes the total.** `amounts.discount` is now always present, and **`total` is `subtotal` − `discount` + `fees` + `shipping`**. Until now `subtotal` + `fees` + `shipping` reached the total on every order, and code that adds the parts up to check a figure or to build its own receipt will now be wrong by the discount on any order that carried a code — and right on every order that did not, which is what makes it easy to miss. `subtotal` deliberately stays **gross**: a partner needs the list price it sold at as well as the money it took. `total` is and stays the authoritative figure. `tax` is still not a term in that sum; it is the VAT already inside `fees` and `shipping`. :: The result comes back in three places, all of them always present so nothing has to branch on a missing key: `amounts.discount` as a positive figure that is subtracted, `line_items[].discount` as each line's share, and a top-level `discount` of `{code, percent}` or `null`. The code is kept on the order as it was sent, so renaming or deleting a finished campaign cannot rewrite what the order says. What a code can reduce: value gift-card lines, experience lines, and postage. A shipping campaign comes off `amounts.shipping` itself and leaves `amounts.discount` at `0`, exactly as the Lifepeaks order page reduces the postage line. **Fees are never discounted**, and neither is an event ticket or a special offer — an order made only of those answers `422` with `discount_code_not_applicable`. An unknown, switched-off, unopened, expired or exhausted code answers `422` with `discount_code_not_found`, one answer for all five, so the field cannot be used to discover a competitor's campaigns. The card is still worth its full face value: the buyer pays less and redeems the same. The selling partner absorbs the gap, and the Lifepeaks commission is calculated on the discounted subtotal. A code is consumed when the order is written, not when it is quoted, so a limited-use code can run out between the two. `commerce_create_quote` and `commerce_create_order` on the MCP server take the same field. See [Headless checkout — A discount code](https://docs.lifepeaks.dk/headless-checkout#a-discount-code). ## An order can be placed by a company `sender` on `POST /v2/orders` accepts a nested `company` object: `name` and `vat_number` always, and `street`, `postcode`, `city` and `country` as an all-or-nothing set. It is the buyer's own company — who paid — and it is on the sender alone. The company an envelope is addressed to is still `delivery.address.company_name`, a different field with a different meaning. `POST /v2/quotes` accepts it and ignores it, so a body a quote took is a body the order takes. Validation happens at order time. **A product may now refuse a private buyer.** An experience or a special offer whose `company_required` is `true`, and an event whose `event.requires.company` is `true`, answer `422` with a new `error.code` of `sender_company_required` when the order carries no company, or carries one without its address. That flag was published before this and nothing enforced it, so read it and show the company fields before the buyer reaches payment. **One correction to earlier documentation:** `company_required` means the buyer must **be** a company. It was previously described as meaning the buyer must name the company the experience is redeemed at, which was wrong. The order reads `sender.company` back on the authenticated calls, with `country` as the alpha-2 code it was sent as. It is buyer identity, so it never appears in a webhook payload. Where the selling partner has configured business-buyer wording, an order marked as bought by a company now prints that text on the gift card — expect it to appear on the first such order. `commerce_create_order` on the MCP server takes the same object. See [Headless checkout — Buying as a company](https://docs.lifepeaks.dk/headless-checkout#buying-as-a-company). ## An own-post card can choose how fast it travels A company that posts its own cards can offer more than one speed, and until now v2 could price the faster one but never sell it. The `postal` entry in a product's `delivery_methods` now carries a `priorities` array of `spri_` entries with a label, a description, a per-card price in the smallest unit of the currency, and which one is the default. `POST /v2/orders` and `POST /v2/quotes` take the buyer's pick as `delivery.shipping_priority`. **The key is absent whenever there is no real choice** — fewer than two priorities, two that price the same, or a default that is not the cheapest. Read its presence as "offer the buyer a choice" and leave the control out otherwise. Omitting the field charges the default, which is always the cheapest and is the price `destinations[]` already publishes, so an order that says nothing prices exactly as it did before the field existed. Two named refusals come with it: `shipping_priority_unsupported` when the company ships with a courier, which offers one speed, and `shipping_priority_unavailable` when the id is not one that company publishes. The chosen priority reads back on the order as `delivery.shipping_priority`, and is `null` for a courier parcel. `commerce_create_order` on the MCP server takes the same field. See [Endpoints — How the card is delivered](https://docs.lifepeaks.dk/endpoints#how-the-card-is-delivered). ## An order line says how many items it issued, and where its document is Every line of an order now carries `issued_count`, and every line of a **fulfilled** order carries a family-neutral `pdf` block beside the `gift_card_pdf` it already had. Both are additive; nothing that reads `gift_card_pdf` has to change. `pdf` and `gift_card_pdf` are the same link under two names. `pdf` is on every fulfilled line whatever it sold. `gift_card_pdf` stays on gift-card and experience lines forever, because integrations already read it, and is deliberately absent from event and special-offer lines where the name would be a lie. Read `pdf` in new code. `issued_count` is how many items the line actually produced, which is not always its `quantity`: an event that groups its tickets issues one ticket carrying every seat, so a four-seat line reports `1`. Use it to know how many codes to expect. The codes themselves still come from `GET /v2/items?order_id=`, and each code's own document from `GET /v2/items/{code}/document.pdf`. ## An order can carry the buyer's marketing consent `POST /v2/orders` accepts a `marketing_consent` object: whether the buyer opted in, where they were asked, the exact wording they were shown, and your own label for that wording. Additive — omit it and the order behaves exactly as it does today, which is also what an unticked box means. `consent_text` is the evidence and is required whenever `granted` is `true`. Send the sentence that was actually on the page, verbatim. `granted: false` records that the buyer was asked and declined, which is worth keeping. The consent reaches `GET /v2/subscribers` only after the order's payment is captured, so a buyer who consents and then abandons checkout never appears — the same rule the Lifepeaks order pages have always applied. **One thing to check on `GET /v2/subscribers`:** the list now has two sources, so a row's `id` is no longer always a number. Rows collected on Lifepeaks order pages keep the numeric ids they have always had, and a consent recorded on a v2 order carries a prefix. Treat the value as opaque and send it back to `starting_after` unchanged. The order is stable, so a full walk still visits every subscriber exactly once. `commerce_create_order` on the MCP server takes the same object, so an agent can record a consent the HTTP API accepts. See [Headless checkout](https://docs.lifepeaks.dk/headless-checkout#marketing-consent). ## Every list answers one envelope **Beta-breaking.** Every list now answers `{ "object": "list", "data": [...], "has_more": , "next_cursor": }`. Until now the v2-native reads used `data` while `GET /v2/api-keys`, `/v2/items`, `/v2/promotions`, `/v2/subscribers`, `/v2/webhook-endpoints` and `/v2/webhook-deliveries` put their rows under `items`, and the event lists under `events` and `participants`. Those keys are gone, and so are `itemsCount`, `eventsCount`, `participantCount` and the `filter` echo: count `data` yourself, and read `next_cursor` for the next page on every list. `GET /v2/products`, `/v2/companies` and `/v2/pickup-points` gained `next_cursor` (always `null` on a pickup lookup, which never pages). **Also beta-breaking:** `GET /v2/items/{code}` returns the item object itself, no longer a one-row list, matching `GET /v2/products/{product_id}`. The two reporting routes are reports rather than lists and keep their `items` / `itemsCount` rows with `offset` paging. ## An order can be refunded through the API `POST /v2/orders/{order_id}/refund` returns money to the buyer through the gateway that took it. Send an `amount` for part of it, or nothing to return everything still refundable. Card refunds were admin-only before this, so a partner had to ask a person for every one. Read `refunded` and `refundable` off the response rather than assuming the `amount` went through in full: a gift card used as payment absorbs part of a refund before the card is touched, and the gateway clamps. The figures come back off the payment itself. A refund is a payment in reverse, so the same rule applies: a test key cannot refund in production. It takes the new `orders:refund` scope, deliberately separate from `orders:settle`: a key that may settle an invoice should not thereby move money back out. ## `tax` carries the real VAT, and an order names its seller `tax` was always `0` on quotes and orders. That was never a statement about Danish VAT law, only a figure v2 did not calculate. It now carries the VAT on the order fee and the shipping fee. **No total changes.** `tax` is already inside `total` and always was; what changed is that the number now describes the tax contained in it rather than reading zero. Do not add it on top. The face value of a gift card is not taxed at purchase, because a card is exchanged for goods or services later and the VAT on those belongs to that sale, so an order with no order fee and no shipping still reads `0`. The order fee and the shipping can carry different rates, so no single rate is published anywhere. A quote and the order it produces always carry the same figure. Every order read also gained `seller`, with the selling company's `name` and `vat_number` — its VAT registration number, the CVR number for a Danish company. With `tax` and `total` it is everything a compliant receipt needs, without a second lookup. Additive. ## An order announces itself before it is paid The new `order.created` event fires when an order is accepted, before any payment. It is queued immediately after the order commits, so it never describes an order that rolled back, and a retried create fires no second event. Queueing it deliberately cannot refuse the order — a failure to write the outbox row is logged and the order still succeeds — so treat the event as a prompt and reconcile with `GET /v2/orders` rather than as a guarantee that one exists for every order. Subscribe to it to open your own record the moment the order exists rather than waiting for the payment that may never come. ## Orders can be listed `GET /v2/orders` returns the acting company's orders, newest first, each the same full object `GET /v2/orders/{order_id}` returns. Narrow it by `status`, `payment_status`, `created_after`, `created_before`, `email` and `client_reference`; filters combine with AND and each may be repeated. `email` matches the buyer, which is the card's sender rather than its recipient. The orders come back under `data`, in the same `{object: "list", data: [...]}` envelope `GET /v2/products` and `GET /v2/companies` answer with. It pages with `limit` and `starting_after` and answers `has_more` and `next_cursor`, with a default page of 25. It is a window over many orders, not a replacement for reading one: when you need the authoritative state of a particular order, read that order. ## Every response reports the key's request budget Every `/v2` response now carries `X-Rate-Limit-Limit`, `X-Rate-Limit-Remaining` and `X-Rate-Limit-Reset`. The default budget is 600 requests per minute per key, it refills continuously, and each key has its own — so a client staying under the rate never sees a change. An empty budget answers `429` with `error.code` of `rate_limited` and a `Retry-After`, and does no work. A `429` is always safe to retry, and retrying a write with the same `Idempotency-Key` cannot duplicate it. ## Test keys, and where they may take money A key is `lp_live_…` or `lp_test_…`, and `GET /v2/me` reports which as `environment`. Both read and write the same data — there is one Lifepeaks database per environment — and the difference is where the key may take money. What makes a payment real is the environment. On demo the payment credentials belong to QuickPay's test account, so every payment there is a test payment and testers pay with QuickPay's test cards. In production the credentials are live, and a test key is refused there with `409` and `error.code` of `test_key_not_accepted_in_production` — for a checkout session and for a refund alike, before anything is charged. ## An endpoint can be tested before an order depends on it `POST /v2/webhook-endpoints/{endpoint_id}/ping` queues a signed `webhook.ping` for one endpoint: the same secret, the same headers, the same retry schedule, and it appears in `GET /v2/webhook-deliveries` like any other delivery. If you can verify a ping, you can verify anything. It answers `202` — the delivery is queued, and its outcome arrives later. `Idempotency-Key` is required and doubles as the event's identity, so a retried request cannot flood your handler. `webhook.ping` cannot be subscribed to; a handler that does not recognise it should answer `2xx` and ignore it. ## Orders nobody paid for are written off The new `order.expired` event fires once for an order whose hosted checkout link lapsed more than an hour ago, or that never started checkout and is more than a day old, and the order's payment status becomes `cancelled`. It is terminal. An order whose card was declined expires on the same two windows, because the buyer can retry checkout on the same order and a decline is not terminal on its own. Before this, an abandoned checkout stayed `pending_payment` for ever and could not be told from one still being paid. Subscribe to `order.expired` if you keep your own basket state. ## Four more lists page by cursor ::callout **Beta-breaking: `GET /v2/items`, `GET /v2/events` and `GET /v2/events/{slug}/participants` no longer accept `offset` or `order`.** Use `limit` with `starting_after`. The old parameters are ignored rather than rejected, so a caller sending them silently re-reads the first page — change the caller. `GET /v2/events/{slug}/participants` also loses its `active`/`claimed` `order` control: a forward cursor needs one stable order. :: All four of those, plus `GET /v2/webhook-deliveries`, now answer `has_more` and `next_cursor`. **Treat `next_cursor` as opaque**: read it off a response and send it back as `starting_after` unchanged, and do not build, parse or compute one. On the lists that publish no `next_cursor`, send the `id` of the last row instead. `GET /v2/events/{slug}/participants` answers `200` with an empty array for an event with no participants. It used to answer `404`, which could not be told from an event that does not exist; a `404` now means only that. ## A card can be posted, and postage is its own amount `POST /v2/orders` accepts `delivery.method` of `postal`, with a `delivery.address` and an optional `delivery.pickup_point_id`. The address is checked against the destinations the product's postal method publishes for the shape of delivery asked for, so an order the courier cannot ship is refused before any money moves rather than failing days later with the payment already taken. The country is a two-letter ISO 3166-1 code. ::callout **Beta-breaking: `amounts` gained a `shipping` field.** It is present on every quote, order and order read, and is `0` on every e-mail order, so `total` is unchanged for existing integrations. A consumer that recomputes a total from the parts must now add `shipping`; one that reads `total` directly always could. The sum is `subtotal` + `fees` + `shipping` — `tax` is not a term in it, because it is the VAT already inside `fees` and `shipping`. Send the buyer's `delivery` object to `POST /v2/quotes` as well, or the quoted total will not be the charged one. :: A courier shipment cannot be scheduled. `send_at` together with `postal` answers `422` rather than being silently discarded. ## An order reads back the buyer's choices, and has a number people can read `POST /v2/orders` and `GET /v2/orders/{order_id}` now echo `sender`, `recipient`, `greeting` and `delivery`, read off the card itself. It is what a thank-you page needs and what a partner reconciling a postal order needs, without smuggling either through the payment redirect. Both also carry `reference`, the short order number Lifepeaks operators search by — `A7K2M9XQ4B` rather than `po_` and 32 hexadecimal characters. Print it on a thank-you page and quote it to support. `id` is still the only thing an API call addresses the order by. The greeting **message** is the text the buyer wrote, or `null`. The caller is the company whose own storefront collected the note, and the same response already echoes both parties' e-mail addresses, so the text is not a new category of buyer detail on that surface. It is absent from every webhook payload, which carries no buyer detail at all. `delivery.send_at` is `null` on an order that was not scheduled, rather than the moment it happened to be sent, and `delivery.method` is how a subscriber tells a posted card from an e-mailed one after `order.fulfilled` fires. **Webhook payloads carry less, on purpose.** They embed the same order object without `sender`, `recipient`, `greeting` or the postal `address`, because a payload goes to a URL you configured rather than to an authenticated caller. No buyer e-mail address has ever appeared in one and none starts to now. `reference`, `delivery.method` and `delivery.send_at` are additive there and name nobody. ## A card can carry a greeting and a delivery choice `POST /v2/orders` accepts two new optional objects. `greeting` prints a message on its own page of the gift card and shows it in the delivery e-mail, with an optional picture beside it. `delivery` says how the card reaches its recipient and, on the recipient's own e-mail, when. `send_at` takes an RFC 3339 timestamp **with an offset**. One without is refused rather than read as a local time, because a card meant for Christmas morning that arrives nine hours late is a real failure. A scheduled order stays `paid` with `fulfillment.status` of `processing` until its moment arrives, and `order.fulfilled` fires then rather than at payment — plan reconciliation around that. Nothing changes for an order that sends neither. Both are additive, and an order that omits them is delivered exactly as before. ## The product says how a card is delivered, personalized and licensed ::callout **Beta-breaking: `delivery_methods` on a product is a list of objects, not a list of strings.** It used to be the constant `["email"]`, which said nothing a caller could act on. It is now one entry per method — `sender_email`, `recipient_email`, `postal` — each with a `label`, an `available` flag, a `price`, `price_varies`, and `supports_send_at`, and the postal entry with its carrier, its per-destination prices and its per-extra-card price. A reader that treated the old value as text gets objects; there is nothing to migrate, because the old value carried no information. :: Every product also gained `personalization` and `terms`. `personalization` says whether a greeting is offered, how long it may be, which file types and size a buyer's own picture may be, which pictures the company offers for the greeting page as `designs` with `gid_` ids, and the decorative amount-step strip. `terms` carries the company's policy link, or the policy text to show before payment, sanitised. **All of it rides on the product because it is per-company configuration, not per-product.** One request builds the whole page, the list a storefront renders is the list an order is checked against, and there is no second route to keep in step. `GET /v2/pickup-points?country=DK&postcode=2200` is the one delivery read with a route of its own, because collection points open, close and move. It is a live call to the courier, and it is the one catalog route with its own rate limit: 60 lookups per company per minute, then `429` with `error.code` of `rate_limited`. It takes `catalog:read`, like the catalog itself. `amount_images` moved from `GET /v2/companies/{company_id}/brand` onto the product, as `personalization.amount_images`. It is the same list, and it was in two places. ## A buyer can put their own picture on the greeting Three new operations under `/v2/greeting-images/upload-intents` reserve an upload slot, take the bytes, and check them: the same shape the gift-card PDF upload uses, so one client covers both. JPEG, PNG and WebP, up to 10 MB. Finalize checks the declared checksum and byte size, and the file's own leading bytes. The declared type is only a claim — a file announced as `image/png` that does not begin with PNG's signature is refused, because what this accepts is drawn into a PDF and e-mailed. **A finished picture works exactly once.** One order may name it as `greeting.image.upload_id`, and a second naming the same id is refused, so one buyer's photograph can never reach another buyer's card. A picture no order uses is deleted after seven days. These three take `orders:create`, not `catalog:read`: staging a buyer's picture is part of placing that buyer's order. ## Experiences under a running campaign are no longer published An experience carrying an active gift-card campaign was listed and sold by this API at its full price, while the Lifepeaks order page priced the same experience at the campaign price on the same day. The API applies no campaign, so it now withholds the product instead: such an experience is absent from `GET /v2/products`, answers `404 not_found` by id, and its options are neither resolvable, quotable nor orderable. It returns by itself when the campaign ends, and a closed, switched-off or gift-card-value campaign never withheld it. Sell that experience through the Lifepeaks order page while its campaign runs. ::callout **Beta-breaking: `GET /v2/products` can now return fewer experiences than before.** A catalog that listed an experience yesterday may not list it today, because a campaign started on it. Treat the list as live configuration and handle a `gcvo_` id that stops resolving — the quote and order routes answer the usual `422` `Product does not exist.` for it. :: ## `quantity.maximum` is enforced, not just advertised The catalog published a maximum the order path did not apply, so an option with no configured maximum advertised `100` while a single line could ask for a thousand cards. One number now serves both: `quantity.maximum` is what `GET /v2/products` publishes **and** what `POST /v2/quotes` and `POST /v2/orders` enforce. An option configuring none is published and sold at `100`. Publishing stock lowers it to what remains; hiding stock keeps the configured maximum and refuses above the real stock at order time. Every option is capped at `1000`, the Lifepeaks order page's own per-line ceiling. `POST /v2/orders` also gained an order-wide ceiling of `1000` cards across all lines, alongside the existing limit of 20 lines. Crossing it answers `422` on the `items..quantity` that crossed the total. ## Order totals are checked against what a payment can hold **New `error.code`: `amount_precision_unsupported`, `422`, on `POST /v2/orders`.** A Lifepeaks payment amount carries at most six significant digits, so `9000.70` and `100050.00` are storable and `10024.95` is not — a rule about digits, not about size. Such a total was previously written and then failed at checkout forever, or surfaced as a `500`. It is now refused before anything is written: no order, no payment, no card, no stock movement, and the idempotency key is released. The error names `items`. Split the basket over two orders, or pick amounts that leave no øre on the total. `POST /v2/quotes` is unaffected, so a basket that priced cleanly can still be refused when it is ordered. ## Experiences no longer need a value gift card behind them A company that never configured gift-card amount limits sells no variable-value card, and `GET /v2/products`, `POST /v2/quotes` and `POST /v2/orders` all answered `500` for it. They now work: the list holds the company's experiences alone, and a quote and an order need no value product. `GET /v2/products/gift_card_value` answers `404 not_found` for such a company, and a value line naming it is refused by name. A company that is inactive, or that has no commerce catalog at all, now answers a typed `422` instead of a `500`. ## A line's gift-card PDF carries every card the line bought `GET /v2/orders/{order_id}/lines/{line_id}/gift-card.pdf` returned only the first card of a line bought in quantity, and the rest had no route at all. One line is now one document holding every card of it, the way the Lifepeaks order page has always produced a bundle. There is still no per-card route and no per-card id. ::callout **Beta-breaking: a line whose cards no longer exist answers `404`.** It used to answer `200` with the generic company placeholder — a valid-looking gift card with no code on it. `404` is the honest answer for a card that has been deleted, and it arrives in the usual JSON error envelope, so keep branching on the response `Content-Type`. :: ## The MCP commerce tools cover experiences `commerce_list_products` returned the value gift card alone, so an agent could not discover an experience or its option id. It now answers through the same service `GET /v2/products` uses. `commerce_create_quote` and `commerce_create_order` share one line schema mirroring the HTTP contract: one to twenty lines, an optional `product_option_id`, `amount` documented as value-only, and the no-mixing, one-option-once and 1000-card rules stated where an agent reads them. `commerce_create_order` also declares the order body it takes rather than a free-form object. ## The acting company may be named in a write body One rule now covers every route: `company` is accepted in the query string wherever the acting company is honoured, and a `POST` or `PATCH` may carry the same slug as a `company` field in its JSON body instead, with the query string winning when both carry it. `POST /v2/quotes` is the exception, since its body accepts `items` alone. Key management and `GET /v2/health` honour no acting company at all. ## Experiences became orderable `POST /v2/quotes` and `POST /v2/orders` now take experience lines as well as value lines. An experience line names a `gcv_` product and one of its `gcvo_` options and carries no `amount`; the option's configured price is authoritative. One request carries a single value line or one to twenty experience lines, and mixing the two families answers `422`. However many lines an order has, it is one payable amount and one checkout. See [Endpoints — Quote and order lines](https://docs.lifepeaks.dk/endpoints#quote-and-order-lines). Campaigns are not applied to experience lines. `promotion_id` and the embedded `promotions` array remain gift-card-value features. **Quote and order responses gained `line_items`.** Every quote line, of either family, now states the `fees` it carries — the order fee, charged once per line. An order's `line_items` additionally carry the line's own `poli_` id and `"object": "line_item"`, and report `product_option_id` as `null` on a value line. Adding fields does not change what an existing caller already read. **New: `GET /v2/orders/{order_id}/lines/{line_id}/gift-card.pdf`** — scope `orders:read`. Each line of a fulfilled order produces its own gift card, and each line of a fulfilled order carries the download for it in `gift_card_pdf`. It answers `200 application/pdf` or the usual `404` JSON envelope, so branch on the response `Content-Type`. An experience carrying its own PDF template brands its own cards; the rest of the order uses the company's published v2 template. ::callout **Beta-breaking: the order-level gift-card PDF is single-line only.** `GET /v2/orders/{order_id}/gift-card.pdf` resolved the first card of the order, which is the wrong card as soon as an order has several lines. An order with more than one line now answers `409 multiple_gift_cards` and carries no order-level `gift_card_pdf` key at all. A single-line order is unaffected and keeps both links. Follow `line_items[].gift_card_pdf.url` instead of assembling the order-level path. :: ## The product catalog grew past the value gift card `GET /v2/products` now carries one product per experience the company sells, next to the value gift card it always held. An experience is a `gcv_` product of `type` `gift_card_variant`, priced by the `gcvo_` options it carries rather than by a buyer-chosen amount. `GET /v2/products/{product_id}` reads either id form, and a foreign, private, de-scheduled or unknown id answers `404 not_found` rather than `403`. The list gained the `type` and `category` filters and the shared `limit` / `starting_after` / `has_more` paging. See [Endpoints — The product catalog](https://docs.lifepeaks.dk/endpoints#the-product-catalog). ::callout **Beta-breaking: `GET /v2/products` no longer returns exactly one entry.** A company with experiences configured now answers with several. The value gift card is still the first entry, so a caller reading `data[0]` is unaffected, but a caller that assumed a single-element list, or that treated every entry as variable-priced, has to branch on `type` instead. :: **The value product embeds its campaigns.** `gift_card_value` gained a `promotions` array holding the open, public campaigns a buyer may pick, so a storefront can render the product and its offers in one request. Each entry's `id` is the same bare integer `GET /v2/promotions` publishes and `POST /v2/items` accepts as `promotion_id`. Two differences from the standalone list are worth reading before you use both: the embedded entry names its kind as `kind` and `value` rather than `modification` / `reduced_price` / `added_value`, and its `amount_from` / `amount_to` are whole numbers of the smallest currency unit where the standalone list uses decimals. Campaign codes appear on neither surface. ## Route and field names settled v2 is pre-launch, so the duplicate names were removed outright rather than deprecated. There is no alias: the old spellings answer `404 not_found`. ::callout **Beta-breaking: three routes were renamed and the old paths are gone.** | Old path | New path | | ------------------------------------------------------------------- | --------------------------------------------------- | | `GET /v2/catalog/products`, `GET /v2/catalog/products/{product_id}` | `GET /v2/products`, `GET /v2/products/{product_id}` | | `GET /v2/gift-card-campaigns` and `GET /v2/value-modifications` | `GET /v2/promotions` | | `POST /v2/gift-cards` | `POST /v2/items` | Scope names did not follow the paths: `catalog:read` still guards `GET /v2/products`, and issuance still needs `orders:create` plus `orders:settle`. :: ::callout **Beta-breaking: the campaign field on `POST /v2/items` is now `promotion_id`.** It was `gc_value_modification`. The value is unchanged — the `id` of a campaign from `GET /v2/promotions`. The old field name is no longer accepted, and because the body is now closed (below) sending it fails with `422` instead of being ignored. :: ::callout **Beta-breaking: the `POST /v2/items` body is closed.** A field that is not in the documented list is rejected with `422` rather than ignored, so a misspelled field name cannot quietly issue a card at the wrong price. The accepted fields are `amount`, `piece`, `receiver_name`, `receiver_email`, `sender_name`, `sender_email`, `promotion_id`, `validity`, `sdh_product_id`, `capture`, and `company`. Remove any extra key you send today before you upgrade. :: ## List pagination `GET /v2/promotions`, `/v2/subscribers`, `/v2/webhook-endpoints`, `/v2/api-keys` and `/v2/companies` now accept `limit` and `starting_after`, and report `has_more`. See [Endpoints](https://docs.lifepeaks.dk/endpoints#conventions) for the exact semantics. ::callout **Beta-breaking: `GET /v2/subscribers` now returns at most 50 rows.** It used to return every confirmed subscriber in one response, which grows without bound. A call that sends no `limit` now gets the first 50 and `has_more: true`. To restore the previous result, page through the list: send `limit=100` and follow `starting_after` with the `id` of the last row until `has_more` is `false`. Rows are ordered by `id`, so a full walk visits every subscriber exactly once. :: The other four lists still return every row when called without parameters, so only callers that opt into `limit` see pages. ::callout **Beta-breaking: `itemsCount` now counts the returned page, not the whole list.** On `GET /v2/subscribers` and `GET /v2/promotions` it used to be the total number of matching rows. It is now the number of rows in the response you are holding, so a company with 400 subscribers reads `itemsCount: 50` on an unparameterized call. This is a changed value rather than an added field, so a caller that treats `itemsCount` as a total starts reporting the page size instead. Count a full walk yourself, or follow `has_more` to the end of the list. :: **`has_more` on paged list responses.** Every list above gained a `has_more` boolean next to its existing `items` (or `data`) array. Adding a field does not break a client that reads the array it already read. **Campaign order is stable.** `GET /v2/promotions` orders by `name`, then by `id` for campaigns sharing a name, so a `starting_after` walk cannot skip or repeat one. `GET /v2/subscribers` orders by `id`. # Endpoints The v2 transactional surface has two distinct paths. New partner storefronts use the protected headless flow, where Lifepeaks controls the quote and hosted payment. Compatibility endpoints remain for direct issuance and existing integrations. Do not combine the two order models: headless orders use `po_…` IDs; compatibility issuance returns legacy order IDs such as `devOEVLB1EW33`. ## The three nouns Almost everything in v2 is one of three things, and knowing which one you are holding tells you which endpoints apply. | Noun | Path | What it is | | ----------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Product** | `/v2/products` | What a company can sell: its variable-value gift card, its experiences, its event tickets and its special offers. Read-only, and the starting point of any purchase. | | **Order** | `/v2/orders` | The purchase: what was bought, what it costs, whether it is paid. An order is created unpaid and becomes paid through Lifepeaks-hosted checkout, or through negotiated settlement. | | **Item** | `/v2/items` | An issued voucher — the gift card, ticket or offer voucher a buyer actually holds, with a code, a balance, and a validity date. Items are what an order produces, and what redemption acts on. | Direct issuance skips the purchase flow and creates items immediately: `POST /v2/items` mints cards against a negotiated invoice contract, without a customer payment. It is the same noun the redemption endpoints use, so a code created that way is read at `GET /v2/items/{code}` and redeemed at `POST /v2/items/{code}/claim`. Gift-card campaigns sit beside the three: `GET /v2/promotions` lists the discounted and added-value offers a company can apply to a card it issues. The same campaigns are embedded on the value product, so a storefront does not need a second call to show them. **Scope names do not follow path names.** `catalog:read` guards the product routes even though neither says "catalog". A scope is baked into every key already issued, so it is frozen once keys exist. ## Account and company surface Start here when you are wiring up a new integration. These endpoints tell you which company a key belongs to, what it is allowed to do, and which other companies it may act for. | Method | Path | Scope | | ----------------------- | ------------------------------------------------- | ---------------------------------------- | | `GET` | `/v2/health` | none | | `GET` | `/v2/me` | none | | `GET` | `/v2/companies` | `companies:read` | | `GET` | `/v2/companies/{slug}` | `companies:read` | | `GET`, `PATCH` | `/v2/companies/{company_id}/brand` | `brand:read` / `brand:write` | | `GET` | `/v2/companies/{company_id}/brand/assets` | `brand:read` | | `PUT`, `DELETE` | `/v2/companies/{company_id}/brand/assets/{asset}` | `brand:write` | | `POST`, `GET`, `DELETE` | `/v2/api-keys*` | `credentials:write` / `credentials:read` | `GET /v2/me` needs no scope, so any valid key can call it. It returns the acting company, the credential's real scope list and expiry, and the settlement modes the company holds — which is a far better way to find out what a key can do than triggering a `403` in production. See [Authentication](https://docs.lifepeaks.dk/authentication#checking-what-a-key-can-do). The brand endpoints control the identity Lifepeaks applies to a company's generated gift-card PDF: display name, tagline, the four brand colors, typography, and the images stored under the roles `logo`, `logo_on_light`, and `pdf_background`. Saving a brand change regenerates that company's **default** PDF as a new version; it never switches a published partner template back to the default. See [Branded gift-card PDFs](https://docs.lifepeaks.dk/pdf-templates). ## Protected headless surface | Method | Path | Scope | | ----------------------- | -------------------------------------------------------- | -------------------------------------------- | | `GET` | `/v2/products` | `catalog:read` | | `GET` | `/v2/products/{product_id}` | `catalog:read` | | `GET` | `/v2/pickup-points` | `catalog:read` | | `POST`, `PUT` | `/v2/greeting-images/upload-intents*` | `orders:create` | | `POST` | `/v2/quotes` | `quotes:create` | | `POST` | `/v2/orders` | `orders:create` | | `GET` | `/v2/orders` | `orders:read` | | `GET` | `/v2/orders/{po_order_id}` | `orders:read` | | `GET` | `/v2/orders/{po_order_id}/gift-card.pdf` | `orders:read` | | `GET` | `/v2/orders/{po_order_id}/lines/{line_id}/gift-card.pdf` | `orders:read` | | `POST` | `/v2/orders/{po_order_id}/checkout-sessions` | `checkout:create` | | `POST` | `/v2/orders/{po_order_id}/settle` | `orders:settle` plus negotiated entitlement | | `POST` | `/v2/orders/{po_order_id}/refund` | `orders:refund` | | `GET`, `POST`, `DELETE` | `/v2/webhook-endpoints*`, `/v2/webhook-deliveries*` | `webhooks:read` / `webhooks:write` | | `GET`, `POST`, `PUT` | `/v2/pdf-templates*` | `pdf_templates:read` / `pdf_templates:write` | Follow [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) for the safe catalog, quote, unpaid order, Lifepeaks-hosted QuickPay, canonical polling, and fulfillment sequence. Follow [Signed webhooks](https://docs.lifepeaks.dk/signed-webhooks) for event verification and recovery. The interactive [API Reference](https://docs.lifepeaks.dk/reference) contains exact request and response schemas. `GET /v2/orders/{po_order_id}/gift-card.pdf` streams the fulfilled document as `application/pdf`, for attaching to your own confirmation email or offering as a download. It exists only after fulfillment completes; before that it answers `404` with the usual JSON error envelope, so branch on the response `Content-Type` rather than the status alone. Read the order and wait for `fulfillment.status` of `fulfilled` instead of polling the PDF. An order with several product lines produces several cards, so it is downloaded a line at a time from `GET /v2/orders/{po_order_id}/lines/{line_id}/gift-card.pdf`. See [One document per line](https://docs.lifepeaks.dk/#one-document-per-line). ## Compatibility and item-operation surface The operations below remain supported for migration, direct gift-card issuance, redemption, and back-office workflows. `POST /v2/items` with `capture` and `/v2/orders/{legacy_order_id}/capture` are not the protected public checkout flow. | Method | Path | Scope | | ------ | ------------------------------- | -------------------------------------------- | | `POST` | `/v2/items` | `orders:create` + `orders:settle` + contract | | `GET` | `/v2/promotions` | `items:read` | | `POST` | `/v2/orders/{order_id}/capture` | `orders:settle` + contract | | `POST` | `/v2/orders/{order_id}/claim` | `items:write` | | `GET` | `/v2/items` | `items:read` | | `GET` | `/v2/items/{code}` | `items:read` | | `GET` | `/v2/items/{code}/document.pdf` | `items:read` | | `POST` | `/v2/items/{code}/claim` | `items:write` | | `POST` | `/v2/items/{code}/refund` | `items:write` | | `POST` | `/v2/items/{code}/cancel` | `items:write` | | `POST` | `/v2/items/{code}/activate` | `items:write` | | `POST` | `/v2/items/{code}/resend` | `items:write` | ## Conventions **Your own company is the default.** Every request acts on the company that owns the API key unless you say otherwise. A code, order or event belonging to a different company is reported as `404 not_found` — the same answer as a code that does not exist at all. **`company` acts on an assigned company.** If Lifepeaks has assigned other companies to yours — the arrangement agencies use — pass that company's slug in the optional `company` query parameter. **One rule says where the slug goes.** The `company` query parameter is accepted on every route that honours the acting company. A `POST` or a `PATCH` may carry the same slug as a `company` field in its JSON body instead, and the query string wins when both carry it. The one exception is `POST /v2/quotes`, which takes no `company` field in its body — name the company in its query string there. ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/items?company=acme-hotels&search=77xrB3e44T" ``` A slug that is neither your own company nor one assigned to you answers `404 not_found`, and so does an unknown or inactive company. One key can therefore serve every company an agency works for; a key per company works just as well. Call `GET /v2/companies` to see exactly which slugs a key may use. Three route families express tenancy differently. The API-key endpoints and `GET /v2/health` always act on the key's own company and take no `company` parameter. `GET /v2/companies` and `GET /v2/companies/{slug}` enumerate the permitted set, so they too start from the key's own company. The brand endpoints name their company in the path as `{company_id}`, which accepts the same slugs. **Success responses are unwrapped.** A successful response body *is* the payload. There is no `{"status": "OK", "response": …}` wrapper — that belongs to the legacy v1 API. Errors always use the `{"error": {…}}` envelope. **Pagination is endpoint-specific.** Send only query parameters listed for that operation. Unknown pagination parameters are not a portable way to page a route and may be rejected or ignored. Every list answers the same envelope: `{ "object": "list", "data": [...], "has_more": , "next_cursor": }`. The rows are always under `data`. Lists page with `limit` and a `starting_after` cursor. Only the two reporting routes still use `offset`, because they are windows over a report rather than walks along a list. - `limit` is a whole number from `1` to `100`. A value outside that range is clamped into it, and a value that is not a number is ignored, so a list never fails because of its `limit`. - `starting_after` continues from where the previous page stopped. Read it off the response and send it back unchanged. An id or cursor this API did not issue answers `422` with `starting_after` named in `error.fields`, rather than an empty page, so a bad cursor cannot look like the end of the list. - `has_more` is `true` while rows remain. Stop when it is `false`; do not infer the end from a short page. - **One list envelope, everywhere.** Every list — products, companies, pickup points, orders, items, events, participants, promotions, subscribers, api-keys, webhook endpoints and webhook deliveries — answers `{ "object": "list", "data": [...], "has_more": , "next_cursor": }`. The rows are always under `data`, and a list never publishes a count: count `data` yourself. The only list without paging fields is the brand-asset list, which is never paged. The two reporting routes are reports, not lists, and keep their own `items` / `itemsCount` rows with `offset` paging. - `next_cursor` is the cursor for the next page, or `null` on the last one. **Treat it as opaque** — do not build one, parse one, or do arithmetic on one; its contents differ between routes and may change. Send it back as `starting_after` unchanged. ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/subscribers?limit=50&starting_after=8842" ``` | List route | Accepted pagination and list controls | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v2/reports/analytics`, `/v2/reports/claimed-items` | `offset` (default `0`), `limit` (default `10`, maximum `100`), and `order` (`asc` or `desc`, default `desc`) plus the route's documented filters | | `GET /v2/items` | `limit` and `starting_after`, answering `has_more` and `next_cursor`, plus the route's documented filters. It no longer accepts `offset` or `order` | | `GET /v2/events` | `limit` and `starting_after`, answering `has_more` and `next_cursor`. It no longer accepts `offset` | | `GET /v2/events/{slug}/participants` | `limit` and `starting_after`, answering `has_more` and `next_cursor`. Its `offset` and its `active`/`claimed` `order` control are both gone: a forward cursor needs one stable order | | `GET /v2/subscribers` | `limit` (default `50`) and `starting_after`; optional `company`, `date_from`, and `date_to`. The list spans both the subscribers collected on Lifepeaks order pages and the marketing consents recorded on v2 orders, so a row's `id` is opaque: send it back unchanged rather than parsing it | | `GET /v2/promotions` | `limit` and `starting_after`; optional `company`. Returns every campaign when `limit` is omitted. Ordered by `name`, then `id` | | `GET /v2/webhook-endpoints` | `limit` and `starting_after`; optional `company`. Returns every endpoint when `limit` is omitted | | `GET /v2/companies` | `limit` and `starting_after`. Returns every permitted company when `limit` is omitted | | `GET /v2/api-keys` | `limit` and `starting_after`. Returns every key when `limit` is omitted | | `GET /v2/products` | Optional `company`, `type` and `category` filters, plus `limit` and `starting_after`. Returns the whole catalog when `limit` is omitted | | `GET /v2/orders` | Optional `company`, `status`, `payment_status`, `created_after`, `created_before`, `email` and `client_reference`, plus `limit` (default `25`) and `starting_after`, answering `has_more` and `next_cursor` | | `GET /v2/webhook-deliveries` | Optional `company`, `limit` (`1`–`100`, default `50`) and `starting_after`, answering `has_more` and `next_cursor`; no `offset` and no `order` | | `GET /v2/order-page/revisions` | Optional `company` and `lang` only; no pagination | | `GET /v2/companies/{company_id}/brand/assets` | No query parameters and no pagination | `GET /v2/subscribers` and `GET /v2/webhook-deliveries` are the lists with a default page size, because both grow without bound. The short, hand-built lists return everything when you send no `limit`, so adding a cursor changed nothing for callers that were already using them. **An event with no participants answers `200` with an empty array.** It used to answer `404`, which could not be told from an event that does not exist. A `404` now means only that. See [Events & Reporting](https://docs.lifepeaks.dk/events-and-reporting) for the different event and reporting list controls, and the [API Reference](https://docs.lifepeaks.dk/reference) for the exact parameter set of every operation. **Dates are `YYYY-MM-DD`.** A value that does not parse in that exact format is silently ignored rather than rejected, and the endpoint behaves as if you had not sent it. **Two different money units live under `/v2`.** The headless commerce routes take whole numbers of the smallest unit of the currency, while the compatibility issuance and item routes keep v1's decimals. The field is called `amount` on both sides, so this is worth checking before your first live call: | Route | `amount` for a DKK 500.00 card | | ------------------------------------ | ------------------------------ | | `POST /v2/quotes`, `POST /v2/orders` | `50000` — whole øre | | `POST /v2/items` | `500` — decimal kroner | `subtotal`, `fees`, `shipping`, `tax`, `total`, `minimum_amount`, `maximum_amount`, and `order_fee` are all whole numbers of the smallest unit; whole numbers of the smallest unit avoid rounding errors. **`tax` is the VAT on the order, and it is already inside `total`.** It covers the order fee and the shipping fee. The face value of a gift card is not taxed at purchase: a card is exchanged for goods or services later, and the VAT on those belongs to that sale rather than this one. So an order with no order fee and no shipping carries `0`, and that is correct rather than missing. `subtotal`, `fees`, `shipping` and `tax` describe how `total` was reached; `total` is what the buyer pays. The rates are the company's own, and the order fee and the shipping can carry different ones, which is why no single rate is published. A quote and the order it produces always carry the same figure. The `amount` fields on `/v2/items/*` and `/v2/reports/*` are decimals, like v1's. See [Migrating from v1](https://docs.lifepeaks.dk/migrate-from-v1) for the side-by-side. **Error codes:** | HTTP status | `error.code` | Meaning | | ----------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | `unauthorized` | Missing, malformed, expired or revoked key | | `403` | `insufficient_scope` | Valid key, missing the scope the operation needs | | `404` | `not_found` | No such resource for the company you are acting as — or no such company. **One envelope covers every miss**: an unknown code, order, event, line, PDF template, upload intent or company all answer `not_found`, so a 404 never tells you which of them was missing, and never confirms that a foreign one exists | | `422` | `validation_failed` | The request broke a business or input rule | | `422` | `amount_precision_unsupported` | `POST /v2/orders` only. The order total needs more precision than a payment amount can hold — see [Totals a payment can hold](https://docs.lifepeaks.dk/#totals-a-payment-can-hold) | | `422` | `greeting_image_not_found` | `POST /v2/orders` only. The `design_id` or `upload_id` on `greeting.image` names no picture this company can use | | `422` | `delivery_method_unavailable` | `POST /v2/orders` and `POST /v2/quotes`. The method named is not one the company offers | | `422` | `delivery_destination_unavailable` | The courier does not deliver that way to that country | | `422` | `send_at_unsupported` | The method chosen cannot be scheduled | | `422` | `shipping_priority_unsupported` | `delivery.shipping_priority` was sent to a company whose cards travel with a courier. A courier offers one priority, so there is nothing to choose | | `422` | `shipping_priority_unavailable` | The priority named is not one the postal method publishes for this company | | `422` | `sender_company_required` | The product may only be bought by a company and the order carried no `sender.company`, or carried one without its address | | `422` | `discount_code_not_found` | The `discount_code` is unknown, switched off, not yet open, expired, or fully used. One answer for all five, deliberately | | `422` | `discount_code_not_applicable` | The code is live, but nothing in this order answers to it | | `409` | `insufficient_stock` | Special offers only. The last voucher went to another buyer between the quote and the order. Nothing in the body is wrong, so re-read the catalog rather than correcting a field | | `422` | `below_minimum`, `above_maximum` | Special offers only. The line's `quantity` is outside the option's own bounds, and is named on `items..quantity` | | `503` | `image_store_unavailable` | Finalizing a greeting picture only. The upload was accepted; storing it failed. Retry after `Retry-After` with the same key | | `503` | `pickup_lookup_unavailable` | `GET /v2/pickup-points` only. The courier could not be reached. Not an empty result | | `503` | `checkout_unavailable` | The payment gateway could not open a session. Retry with the same key | | `409` | `test_key_not_accepted_in_production` | A test key tried to take or return a payment in production. Use a live key, or this key on demo | | `422` | `invalid_sha256`, `invalid_byte_size`, `invalid_mime` | Reserving a greeting-image slot. The declaration is malformed or outside the published limits | | `409` | `greeting_image_already_used` | An order has already taken that picture | | `409` | `order_not_refundable` | `POST /v2/orders/{id}/refund` only. The order was never paid, or its payment cannot be read | | `409` | `order_fully_refunded` | `POST /v2/orders/{id}/refund` only. Nothing is left to return | | `422` | `refund_amount_too_large` | `POST /v2/orders/{id}/refund` only. The `amount` is above what remains | | `502` | `refund_declined` | `POST /v2/orders/{id}/refund` only. The gateway refused, and nothing was refunded | | `409` | `refund_in_progress` | `POST /v2/orders/{id}/refund` only. Another refund of that order is still being processed | | `409` | `amount_precision_unsupported` | `POST /v2/orders/{id}/refund`. The order's stored total cannot be represented exactly, so it cannot be refunded through the API | | `403` | `credential_not_attributed` | Every write route. The key predates per-key attribution, so a write cannot be recorded against it. Mint a new key | | `403` | `checkout_not_enabled` | `POST /v2/orders/{id}/checkout-sessions` only. Lifepeaks Checkout is not on this company's contract | | `409` | `checkout_in_progress` | `POST /v2/orders/{id}/checkout-sessions` only. Another session for the same order is still being opened. Retry after `Retry-After` with the same key | | `409` | `order_not_payable` | `POST /v2/orders/{id}/checkout-sessions` only. The order is not a pending unpaid gateway order any more — it was paid, settled, cancelled, or its payment does not match it | | `403` | `settlement_not_enabled` | The settlement routes only. The mode asked for is not on this company's contract | | `409` | `order_not_settleable` | `POST /v2/orders/{id}/settlement` only. The order is not an intact pending headless order | | `409` | `settlement_reference_conflict` | `POST /v2/orders/{id}/settlement` only. That order, or that `external_reference`, has already been settled | | `409` | `delivery_not_replayable` | `POST /v2/webhook-deliveries/{id}/replay` only. That delivery cannot be replayed — it is already queued, or its endpoint is gone | | `503` | `template_unavailable` | `GET /v2/pdf-templates/{id}/file` only. The template exists and its bytes could not be read. Retry | --- ## The product catalog `GET /v2/products` — scope `catalog:read` One list holds everything the acting company sells. Four product families share it, and the `type` field says which one you are holding: | `type` | `id` | Option id | What it is | | ------------------- | ----------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gift_card` | `gift_card_value` | — | The company's variable-value gift card. The buyer picks any amount between `pricing.minimum_amount` and `pricing.maximum_amount`. One per company. | | `gift_card_variant` | `gcv_` | `gcvo_` | One experience the company sells — a spa day, a dinner, a course. It is priced by the option the buyer picks, so `pricing.type` is `fixed` and the money sits on `options[]`. | | `event_ticket` | `evt_` | `evto_` | One event the company sells tickets to. It carries an `event` block with the dates, the venue and the rules, and one option per ticket type. | | `special_offer` | `so_` | `sov_` | One discounted offer the company sells as a voucher. Each option publishes what it costs now, what it cost before, and what the issued voucher is worth. | The value product is the first entry whenever the company has one, which keeps an older integration that reads `data[0]` working. That is compatibility, not contract: branch on `type`, and fetch the value product at `GET /v2/products/gift_card_value` when it is the one you want. **`fee_basis` says how often the fee is charged.** Every product carries it, so a basket can be totalled without knowing which family it holds. It is `per_line` for gift cards, experiences and special offers — one fee per line, whatever the quantity — and `per_ticket` for events, where the fee is charged once for every seat. A two-seat event line is charged its fee twice; a two-voucher offer line is charged its fee once. `POST /v2/quotes` applies the same rule, so the quote and the charge always agree. **Three things are sold by an operator, never through this API.** Ticket coupons, Saved Special Vouchers and New Special Vouchers are issued from the Lifepeaks back office. They are absent from the catalog, they cannot be quoted or ordered, and the codes they produce are read and redeemed through `/v2/items` like any other item. **Benefit deals are read-and-redeem only.** A benefit deal is issued to a company, not bought by a buyer, so it has no catalog, quote or order surface. The company it was issued to reads it at `GET /v2/items/{code}`, prints it at `GET /v2/items/{code}/document.pdf` and redeems it at `POST /v2/items/{code}/claim`, exactly as it does a gift card. Searching for `type=BENEFITDEAL` at `GET /v2/items` lists them. **An experience does not need a value gift card behind it.** A company that never configured gift-card amount limits sells no variable-value card, so the `gift_card_value` entry is simply absent from the list and `GET /v2/products/gift_card_value` answers `404 not_found`. Its experiences are listed, quoted and ordered exactly as any other company's. Read what the list actually holds rather than assuming the first entry is the value card. ```bash curl -sS -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/products?type=gift_card_variant" ``` ```json { "object": "list", "has_more": false, "data": [ { "id": "gcv_4821", "object": "product", "type": "gift_card_variant", "name": "Spa day for two", "description": "

Two hours in the spa, robes included.

", "formalities": "

Booking required. Valid Monday to Thursday.

", "currency": "DKK", "categories": ["cat_18", "cat_22"], "images": [{ "url": "https://assets.lifepeaks.dk/spa.webp", "role": "product" }], "validity": { "type": "months", "months": 24, "valid_from": null, "valid_until": null }, "company_required": true, "pricing": { "type": "fixed" }, "options": [ { "id": "gcvo_9107", "object": "product_option", "name": "Weekday", "description": "

Monday to Thursday.

", "amount": 100050, "fees": { "order_fee": 2500 }, "quantity": { "minimum": 1, "maximum": 20 }, "availability": { "limited": true, "remaining": 7, "in_stock": true }, "images": [{ "url": "https://assets.lifepeaks.dk/weekday.webp", "role": "option" }], "active": true } ], "active": true } ] } ``` `description` and `formalities` are the company's own HTML, in the response language. `validity` is either `{"type": "months"}` with a `months` count, or `{"type": "fixed_date"}` with `valid_from` and `valid_until` as timestamps. **`company_required` means the buyer must BE a company**, not that they must name one. It is on experiences, on special offers and — as `event.requires.company` — on events. An order for such a product must carry `sender.company` with its name, its VAT number and its full address, or it is refused with `sender_company_required`. The value gift card always publishes `false`: the flag lives on the products a company configures, never on the company's own value card. See [Buying as a company](https://docs.lifepeaks.dk/headless-checkout#buying-as-a-company). **Every option carries its own price and its own order fee.** `amount` and `order_fee` are whole numbers of the smallest currency unit, like the rest of the headless path. The fee is charged once per line, so each option repeats the same value rather than sharing one. **`availability` is reported, never hidden.** A sold-out option stays in the list with `in_stock: false`, exactly as the Lifepeaks order form shows it, so a storefront can grey it out instead of quietly changing the price range. `remaining` is a number only when the experience is configured to publish its stock level; otherwise it is `null` while `limited` still says the stock is finite. **`quantity.maximum` is enforced, not advertised.** It is the same number `POST /v2/quotes` and `POST /v2/orders` apply to that line, so a storefront can bound its quantity control by it and trust the answer. An option that configures no maximum of its own is published and sold at `100`. An experience that limits its stock **and** publishes the remaining count lowers the maximum to what is left; one that hides its count keeps its configured maximum and is refused above its real stock when the order is written. Every option is capped at `1000`, the ceiling the Lifepeaks order page applies to one line. **The catalog never advertises what an order would refuse.** An experience is listed only while it is active, public, of the orderable kind, inside its publication window, and holds at least one active option **that is not sold out**. An option is listed only while it is active — including a sold-out one, as long as a sibling option can still be bought. An experience whose every option is sold out therefore leaves the catalog entirely: `GET /v2/products/gcv_` answers `404` and it cannot be quoted. **An experience running a campaign is sold on the Lifepeaks order page, not through this API.** While a gift-card campaign is active on an experience, the Lifepeaks order page prices that experience at the campaign price. The API applies no campaign, so it does not publish the product at all rather than sell the same thing at full price on the same day. Such an experience is absent from `GET /v2/products`, answers `404 not_found` by id, and its options are neither resolvable, quotable nor orderable. It returns to the API by itself when the campaign ends. A campaign that has closed, that has been switched off, or that applies to gift-card value rather than to the experience leaves the product on sale throughout. ### An event and its tickets An `event_ticket` product is one event. Everything about the occasion sits in its `event` block; everything about a seat sits on the option. ```json { "id": "evt_412", "object": "product", "type": "event_ticket", "name": "New Year's dinner", "currency": "DKK", "categories": ["cat_18"], "images": [{ "url": "https://assets.lifepeaks.dk/dinner.webp", "role": "product" }], "event": { "starts_at": "2026-12-31T18:00:00+01:00", "ends_at": "2027-01-01T02:00:00+01:00", "date_precision": "date_time_range", "tagline": "Six courses and a view", "venue": { "name": "Hotel Nord", "address": "Havnegade 4", "postcode": "1058" }, "grouped_tickets": false, "comment": { "available": true, "label": "Allergies?", "max_length": 500 }, "requires": { "phone": false, "company": false }, "capacity_mode": "per_ticket", "tickets_per_event": null, "add_ons_sold_alone": false, "collection_points": [ { "id": "epp_31", "object": "collection_point", "name": "Main entrance", "address": "Havnegade 4", "city": "1058 København K" } ] }, "validity": { "type": "fixed_date", "months": null, "valid_from": "2026-12-31T18:00:00+01:00", "valid_until": "2027-01-01T02:00:00+01:00" }, "fee_basis": "per_ticket", "pricing": { "type": "fixed" }, "options": [ { "id": "evto_9310", "object": "product_option", "name": "Dinner seat", "description": "

Six courses.

", "amount": 145000, "fees": { "ticket_fee": 2500 }, "quantity": { "minimum": 1, "maximum": 6 }, "availability": { "limited": true, "remaining": 42, "in_stock": true }, "requires": { "code": false, "address": false, "collection_point": true }, "add_on": false, "images": [], "active": true } ], "active": true } ``` `amount` and `ticket_fee` are whole numbers of the smallest currency unit, like the rest of the headless path. **`ticket_fee` is charged per seat**, which is what `fee_basis` of `per_ticket` announces; a line of two seats pays it twice. It is `0` on an add-on option and on an event whose shop absorbs the fee — in both cases the buyer really pays nothing for it. `ticket_fee` is the figure the quote charges and the figure the order stores, so the three can never disagree. - `date_precision` says how much of the date the organiser actually set: `date` for a single day, `date_time` for a day and a start time, `date_time_range` for a day with a start and an end time, `date_range` for a span of days. `ends_at` is `null` on the first two, so render a `date` event as a day rather than as midnight. An event's `validity` is always `fixed_date` and repeats those two moments, so a ticket is valid for the occasion and nothing else. - `grouped_tickets` of `true` means the whole line is issued as **one** item carrying every seat, rather than one item per seat. It is what `issued_count` on the order line reports back. - `capacity_mode` is `per_ticket` when the event counts seats, or `per_order` when it counts orders. A `per_order` event caps the tickets of a whole order at `tickets_per_event`, across every line of that event, not line by line. - `requires.phone` and `requires.company` are the event's own demands on the buyer. `requires.company` is the `company_required` rule above. - `collection_points` are where a ticket is handed over at the event. They are `epp_` and are **not** the courier collection points at `GET /v2/pickup-points`, which are for posting a gift card. An option whose `requires.collection_point` is `true` must name one. - `add_on` of `true` marks an option that cannot be bought on its own — a drinks package beside a seat. It is orderable only alongside a non-add-on line of the same event, unless the event sets `add_ons_sold_alone`. - `comment.available` says the organiser accepts a note from the buyer, and `comment.label` is the question to show. Send the answer as `items[].ticket.comment`. - `availability` reads exactly as it does on an experience: `limited` is always `true` for a seat, `in_stock` says whether any are left, and `remaining` is a number only when the event is configured to publish its seat count and `null` otherwise. A sold-out ticket type stays in the list with `in_stock: false` rather than disappearing, so a storefront can grey it out. **The catalog lists exactly what it sells.** An event is published only while it is active, public, fully dated and inside its open window, and the quote and the order resolve it through the same read — so an event missing from the list is an event that answers `Product does not exist.` The Lifepeaks order form hides an undated event and still lets a direct link order it; this API does neither. ### A special offer and its vouchers A `special_offer` product is one offer. Each option is one voucher a buyer can hold. ```json { "id": "so_884", "object": "product", "type": "special_offer", "name": "Two nights, half price", "description": "

Friday and Saturday.

", "formalities": "

Subject to availability.

", "currency": "DKK", "images": [{ "url": "https://assets.lifepeaks.dk/twonights.webp", "role": "product" }], "validity": { "type": "months", "months": 12, "valid_from": null, "valid_until": null }, "company_required": false, "available_until": "2026-11-30T23:59:59+01:00", "fee_basis": "per_line", "pricing": { "type": "fixed" }, "options": [ { "id": "sov_5501", "object": "product_option", "name": "Double room", "description": "

Two nights for two.

", "amount": 120000, "regular_amount": 240000, "discount_percent": 50, "redemption_value": 240000, "fees": { "order_fee": 2500 }, "quantity": { "minimum": 1, "maximum": 4 }, "availability": { "limited": true, "remaining": 12, "in_stock": true }, "active": true } ], "active": true } ``` Every one of those amounts is a whole number of the smallest currency unit. - `amount` is what the buyer pays now. - `regular_amount` is what the option cost before the offer, for a storefront to strike through, or `null` when the operator recorded none. - `discount_percent` is the saving the operator advertised, or `null`. - **`redemption_value` is what the issued voucher is worth when it is spent**, and it is the one field worth reading closely. On most offers it equals `amount`. On an offer configured to redeem at the regular price it equals `regular_amount` instead: the buyer pays 1200,00 kr. and holds a voucher worth 2400,00 kr. Show this figure wherever you tell a buyer what they are getting, or your receipt will disagree with the operator's redeem screen. - `available_until` is the moment the offer stops being orderable, as an RFC 3339 timestamp, or `null` when it never does. The Lifepeaks order form prints it as "Offer is online until"; a storefront needs the same date to say the same thing. - `validity` is how long the issued voucher lasts, and is read the same way as on any other product: `{"type": "months"}` with a count, or `{"type": "fixed_date"}` with `valid_from` and `valid_until`. An offer that fixes only a start date reports `valid_until` of `null` rather than inventing an end. An offer's `order_fee` is charged once per line, whatever the quantity, which is what `fee_basis` of `per_line` says. `availability` reads as it does everywhere else: `limited` is always `true` for a voucher, `in_stock` says whether any are left, and `remaining` is a number only when the variation is configured to publish its count. A sold-out option stays in the list with `in_stock: false` rather than being hidden. `quantity.maximum` is the number the order enforces, so a line the catalog allows is a line the order accepts. ### Filtering and paging the catalog | Parameter | Notes | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `gift_card`, `gift_card_variant`, `event_ticket` or `special_offer`. Narrows the list to one family | | `category` | A `cat_` from a product's `categories`. Categories belong to experiences, so naming one drops the value product AND every special offer | | `limit`, `starting_after` | The shared cursor paging described under [Conventions](https://docs.lifepeaks.dk/#conventions). Omitting `limit` returns the whole catalog | A filter value nothing carries answers with an empty list rather than the unfiltered one, so a typo can never look like a match. ### One product by id `GET /v2/products/{product_id}` — scope `catalog:read` Takes any id the list publishes: `gift_card_value`, or a `gcv_`, `evt_` or `so_`. The response is the product object itself, not wrapped in a list. **Every miss is the same `404 not_found`, never a `403`.** Another company's product, a private one, one outside its publication window, an undated event, a benefit deal, and a plain typo all answer identically, so the response can never confirm that a foreign product exists. ### Campaigns embedded on the value product The value product carries a `promotions` array — the gift-card campaigns a buyer may pick right now — so a storefront renders the product and its offers in one request: ```json { "id": "gift_card_value", "type": "gift_card", "promotions": [ { "id": 499, "name": "Autumn discount", "kind": "discount", "value": 20, "amount_from": 20000, "amount_to": 100000 }, { "id": 512, "name": "Black Friday bonus", "kind": "added_value", "value": 15, "amount_from": null, "amount_to": null } ] } ``` - `id` is the same bare integer `GET /v2/promotions` publishes and `POST /v2/items` takes as `promotion_id`, so a campaign read off the product can be ordered with unchanged. - `kind` is `discount` (the buyer pays less) or `added_value` (the card is worth more). `value` is that campaign's percentage. - `amount_from` and `amount_to` bound the card value the campaign accepts, and are `null` when it is unbounded. **Here they are whole numbers of the smallest unit; on `GET /v2/promotions` the same two fields are decimals.** - Only public, currently open campaigns appear. A campaign left switched on after its end date is not published, and a secret campaign never is. No read surface hands out a campaign code. Experience products carry no `promotions` array. A campaign applies to gift-card value, and is never applied to an experience line — the option's configured price is what a buyer pays. An experience that has a campaign running on it is not published here at all, so the two rules never meet: while the campaign runs, that experience is sold through the Lifepeaks order page. See [Quote and order lines](https://docs.lifepeaks.dk/#quote-and-order-lines). --- ## How the card is delivered Published on every product as `delivery_methods`, by `GET /v2/products`. There is no separate route for it: how a card travels is a property of the company, not of the product, so every product of a company carries the same list — and the list your page renders is the list an order is checked against. Three ways a buyer can receive a gift card, and what each costs this company's buyers. All three are always present; `available` says which ones the company offers. | `id` | What it does | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `sender_email` | E-mails the card to the buyer, who passes it on. It travels on the payment receipt, so it costs nothing and cannot be scheduled. | | `recipient_email` | E-mails it straight to the recipient. This is the only method that can be scheduled. | | `postal` | Posts a printed card, through a courier or the company's own post. | ```json "delivery_methods": [ { "id": "sender_email", "label": "Your E-mail", "available": true, "price": 0, "price_varies": false, "supports_send_at": false }, { "id": "recipient_email", "label": "Receivers E-mail", "available": true, "price": 0, "price_varies": false, "supports_send_at": true }, { "id": "postal", "label": "with GLS", "available": true, "price": 6000, "price_varies": true, "supports_send_at": false, "carrier": "gls", "pickup_point_required": true, "extra_item_price": 1000, "phone_required": false, "destinations": [ { "country": "DK", "name": "Denmark", "pickup_price": 6000, "private_address_price": null, "company_address_price": 7000 } ], "priorities": [ { "id": "spri_1", "label": "Almindelig post", "description": "3-5 hverdage", "hint": null, "price": 4500, "default": true }, { "id": "spri_2", "label": "Quickbrev", "description": "1-2 hverdage", "hint": null, "price": 7500, "default": false } ] } ] ``` **An unavailable method is present, not hidden.** A company that does not post gift cards still gets a `postal` entry, with `available` of `false` and its prices intact, so your page can say "this partner does not post gift cards" rather than quietly dropping a choice. An order naming an unavailable method is refused by name. **`price` is a from-price, and `price_varies` says so.** Money is in the smallest currency unit, like everywhere else in v2: `6000` is DKK 60.00. Posting is not one number — a Danish collection point costs 60,00 kr., a Danish business address 70,00 kr., and abroad ranges from 75,00 to 175,00 kr., plus `extra_item_price` for each card after the first. Show `price` with a "from", and take the exact figure from `POST /v2/quotes` once the buyer has chosen a country and an address. **`supports_send_at` gates scheduling.** An order may carry `delivery.send_at` on any method whose `supports_send_at` is `true`, and on no other. That is `recipient_email` always, and `postal` when the company posts its own cards. **Naming no method at all is fine too:** an order carrying `send_at` is scheduled to the recipient, so a body a quote accepted is accepted by the order. A courier shipment cannot be held back, and a card sent to the buyer travels on the payment receipt. **`label` is what the Lifepeaks-hosted order page calls the method,** in the response language. Show it or write your own copy — the `id` is what an order names. A postal entry carries where it ships and what each shape of address costs there. A `null` price means that shape is not offered in that country: with the current courier, private home delivery is offered nowhere, and business delivery only in Denmark. **`priorities` is how fast the card travels, and it appears only when the company posts its own cards.** A company that hands its cards to a courier gets no `priorities` key at all, because a courier offers one speed; sending `delivery.shipping_priority` to such a company is refused with `shipping_priority_unsupported` rather than ignored. `price` is the postage for one card in the smallest currency unit, excluding the surcharge for each card after the first, which is the same convention `destinations[].*_price` uses. The entry whose `default` is `true` is what an order with no `shipping_priority` is charged, and it is always the cheapest. An id this list does not contain is refused with `shipping_priority_unavailable`. The key is absent, rather than a one-entry list, whenever there is no real choice to publish: fewer than two priorities, two priorities that price the same, or a default that is not the cheapest. Read the key's presence as "offer the buyer a choice", and leave the control out when it is missing. ### Collection points `GET /v2/pickup-points?country=DK&postcode=2200` — scope `catalog:read` The shops and parcel lockers the courier can deliver to near one postcode. Ask once the buyer has given a postcode, and let them choose. ```json { "object": "list", "data": [ { "object": "pickup_point", "id": "gls-DK-1234", "name": "Kiosk Nørrebro", "address": "Griffenfeldsgade 1, 2200, København N, DK", "latitude": 55.6889, "longitude": 12.5501 } ], "has_more": false } ``` **This is the one delivery fact not already on the product.** Everything else about delivery is per-company configuration and is published in advance; collection points open, close and move, so they are asked of the courier there and then. That also makes it the one catalog route with its own rate limit: **60 lookups per company per minute**, then `429` with `error.code` of `rate_limited` and a `Retry-After` header. Look points up when the buyer finishes typing, not on every keystroke. `country` is a two-letter ISO 3166-1 code and must be one the postal method lists as a destination. A country name instead of a code answers `422` rather than being guessed at: the courier reads anything it does not recognise as Denmark, and would otherwise return Danish points for a German postcode with nothing to say so. A postcode with no points nearby answers an empty list, not an error. **An empty list always means "no points here".** An environment that cannot reach the courier answers `503` with `pickup_lookup_unavailable` and a `Retry-After` instead, so you never tell a buyer their postcode has no collection points when nobody was able to ask. The address arrives as one line, exactly as the courier composes it. Show it as given. ## What a buyer can add to the card Published on every product as `personalization`, by `GET /v2/products`. Like the delivery methods it is a property of the company, so every product carries the same block. ```json "personalization": { "greeting": { "available": true, "max_length": 2000, "image": { "accepted_mime": ["image/jpeg", "image/png", "image/webp"], "max_bytes": 10485760 } }, "designs": [ { "id": "gid_4711", "label": "Image1", "url": "https://cdn.lifepeaks.dk/dev/images/birthday.jpg", "thumbnail_url": "https://cdn.lifepeaks.dk/dev/images/thumb/330x200/birthday.jpg" } ], "amount_images": ["https://cdn.lifepeaks.dk/dev/order_form_amount_images/thumb/330x200/strip-a.jpg"] } ``` **Bound your character counter by `max_length`.** It is the number the order path rejects at, not a display hint, so a message your page accepts is a message the order accepts. **`designs` are the pictures the company offers for the greeting page** — its own uploads, the same ones the Lifepeaks-hosted order page shows. An id from this list is what an order names as `greeting.image.design_id`. An empty list means the company offers none, which is normal: read it as "do not show the chooser", and let the buyer write a message on its own. **These print on the greeting page, not on the card.** The artwork of the gift card itself is fixed per company and set in the Lifepeaks back office; nothing in this API changes it. **`amount_images` are decoration.** They are the strip the Lifepeaks order page shows beside the amount step, as plain URLs with no ids, because a buyer never picks one. Show them, or leave them out, as your own design calls for. A buyer's own photograph takes a different route: reserve a slot at `POST /v2/greeting-images/upload-intents`, send the bytes, and finalize. See [A buyer's own picture](https://docs.lifepeaks.dk/headless-checkout#a-buyers-own-picture) for the whole sequence. Those three operations take `orders:create` rather than `catalog:read`, because staging a buyer's picture is part of placing that buyer's order. `accepted_mime` and `max_bytes` are the limits that route enforces, so your copy and its rejection cannot disagree. ## What a buyer accepts before paying Published on every product as `terms`, by `GET /v2/products`. Your storefront collects the consent on its own page, so it has to be able to show the same terms the Lifepeaks-hosted order page shows. ```json "terms": { "url": "https://acme-hotels.example/terms", "html": null } ``` Exactly one of the two is ever set, so there is nothing to choose between: link to `url` when it is present, render `html` when it is not. A company that has published no terms at all reads `null` for both. `html` is the classic text the Lifepeaks order page assembles — the platform's general policy, the company's own payment terms, and the platform's closing policy. It is sanitised before it leaves the API: the formatting an operator wrote survives, and nothing in it can execute on your page. Render it inside your own layout. ## Quote and order lines `POST /v2/quotes` — scope `quotes:create` `POST /v2/orders` — scope `orders:create` Both routes take the same `items` array, and every product family is ordered through it. A line names what is being bought; Lifepeaks prices it. | Line | Shape | | ----------------- | --------------------------------------------------------------------------------------------- | | **Value** | `{"product_id": "gift_card_value", "amount": 50000, "quantity": 1}` | | **Experience** | `{"product_id": "gcv_4821", "product_option_id": "gcvo_9107", "quantity": 2}` | | **Event ticket** | `{"product_id": "evt_412", "product_option_id": "evto_9310", "quantity": 2, "ticket": { … }}` | | **Special offer** | `{"product_id": "so_884", "product_option_id": "sov_5501", "quantity": 1}` | **An experience, event or special-offer line never carries `amount`.** Only a value line does. Everything else is priced by the option it names, so sending money on one fails with `422` on `items..amount` rather than being honored. That is also why the v1-to-v2 unit trap cannot reach those lines: their caller never sends money at all. **A request holds one family.** Mixing two families answers `422` on `items`. An order has exactly one product type, so a buyer with a mixed basket places one order per family. One value line is allowed per request; experience, event and special-offer lines may number from 1 to 20. Several lines of one family may name different events or different offers. **An option may appear only once.** A second line naming the same option id answers `422` on `items..product_option_id`. Buy several of one option with `quantity`, not with repeated lines. **Quantity bounds come off the option.** `quantity` must satisfy the option's own `quantity.minimum` and `quantity.maximum` from the catalog, and a stock-limited option is never sold beyond what remains. The published `quantity.maximum` is the number that is enforced, so a line the catalog says is allowed is a line the order accepts. **A quote reserves nothing; an order locks.** The quote prices what the catalog says right now and holds no stock at all, so two buyers can be quoted the same last seat. `POST /v2/orders` locks the option rows inside its own transaction, re-runs every minimum, maximum, stock and capacity check against the locked figures, and only then writes. An option that sold out in between is refused there rather than oversold. A special-offer line refused that way answers `409` with `insufficient_stock`, because nothing in the body is wrong and the caller has nothing to correct; a quantity outside the option's bounds answers `422` with `below_minimum` or `above_maximum` on `items..quantity`. Event and gift-card lines answer `422` with the message on the same field. **A `per_order` event caps the whole order.** An event whose `capacity_mode` is `per_order` limits the tickets of that event across every line of the order to its `tickets_per_event`, and takes one order slot however many seats were bought. The Lifepeaks order form checks each line on its own, which lets two lines of one event pass together and exceed the cap; this API sums them. **One order issues at most 1000 items.** Two ceilings apply together: at most 20 lines, and at most `1000` cards, seats or vouchers once every line's `quantity` is added up. Too many lines answers `422` on `items`; too many items answers `422` on the `items..quantity` that crossed the total. **Postal delivery is refused for events and offers.** A ticket and an offer voucher are delivered electronically, so `delivery.method` of `postal` answers `422` on `delivery.method` and `amounts.shipping` is always `0` for them. Every other delivery method works as it does for a gift card. **Only gift-card and experience lines answer to a discount code.** A `discount_code` also reduces postage, and never reduces fees. It never applies to an event ticket or a special offer, so an order made only of those answers `422` with `discount_code_not_applicable`. See [A discount code](https://docs.lifepeaks.dk/headless-checkout#a-discount-code). **An experience under a running campaign cannot be ordered.** It is not in the catalog while the campaign runs, so its option answers the same `422` `Product does not exist.` that a foreign or invented id gets. Sell it through the Lifepeaks order page until the campaign ends. ### What an event line carries An event line may carry an optional `ticket` object with what the organiser asked the buyer for. Every key in it is optional until the event or the option demands it, and a key the object does not list is refused with `422`. ```json { "product_id": "evt_412", "product_option_id": "evto_9310", "quantity": 2, "ticket": { "comment": "Two vegetarians", "code": "STAFF-2026", "collection_point_id": "epp_31" } } ``` | Field | When it is required | Notes | | --------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `comment` | never | The buyer's note to the organiser, at most 500 characters. Allowed only while the event's `event.comment.available` is `true`; sending one otherwise is refused | | `code` | when the option's `requires.code` is `true` | The code the organiser demanded, at most 100 characters. It is stored on the issued ticket | | `collection_point_id` | when the option's `requires.collection_point` is `true` | An `epp_` from the event's own `event.collection_points` | | `address` | when the option's `requires.address` is `true` and no collection point is given | An object of `name`, `street` and `city`, all three together. Where the ticket is delivered by hand | `address` and `collection_point_id` are alternatives: giving both is refused. **One comment per event, not per line** — two lines of the same event carrying different comments are refused rather than one of them silently dropped, because the engine keeps a single comment per event. ### The greeting and the delivery An order body may also carry `greeting` and `delivery`. Neither belongs on a quote line: both are choices about the whole order, and both are optional. `greeting` prints a message on its own page of the card and shows it in the e-mail. It takes a `message` of at most 2000 characters, a picture, or both. The picture is either one of the company's own — `{"source": "design", "design_id": "gid_4711"}` — or a buyer's own photograph already put through the upload flow — `{"source": "upload", "upload_id": "gimg_…"}`. A picture id that does not resolve answers `422` with an `error.code` of `greeting_image_not_found` on the field that carried it, the same way a foreign `product_id` is refused. Every miss reads alike — another company's id, a deleted one, a typo, and an `upload_id` a previous order already spent, because an uploaded picture works exactly once. `delivery` takes `method` and `send_at`, and on a postal order an `address` and an optional `pickup_point_id`. `method` must be one the product's `delivery_methods` reports as available, so what your page offers and what an order accepts can never disagree. `send_at` is an RFC 3339 timestamp **with an offset**; one without is refused rather than read as a local time. It must be in the future, at most 24 months ahead, and on a method whose `supports_send_at` is `true`. Omit `delivery` and the card goes out as soon as payment is confirmed, to the recipient's own address when it differs from the buyer's. A scheduled order stays `paid` with `fulfillment.status` of `processing` until its moment arrives. A postal `address` takes `name`, `street`, `postcode`, `city`, `country` and `email`, with `company_name` when `kind` is `company` and `phone` when the method's `phone_required` is `true`. `country` is a two-letter ISO 3166-1 code, checked against the destinations the postal method publishes for the shape of delivery asked for — an unshippable order is refused before any money moves, rather than failing at the courier days later with the payment already taken. An `address` on a method other than `postal` answers `422`. **Postage is its own amount.** `amounts.shipping` carries the postage plus the surcharge for each card after the first, and is `0` on every e-mail order. Send the same `delivery` object to `POST /v2/quotes` to see it before the buyer commits; a quote without it shows a total that is not what will be charged. **Every miss is `Product does not exist.`** A foreign, private, inactive, de-scheduled or simply invented option answers the same `422` message on `items..product_option_id`, and an option that does not belong to the product named on the same line answers it on `items..product_id`. Nothing in the response confirms that another company's id exists. ### What comes back ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/quotes \ -H "Authorization: Bearer lp_live_..." \ -H "Content-Type: application/json" \ -d '{"items":[ {"product_id":"gcv_4821","product_option_id":"gcvo_9107","quantity":2}, {"product_id":"gcv_4821","product_option_id":"gcvo_9108","quantity":1} ]}' ``` ```json { "id": "quo_5b29e740a1c34df8062be95c71d84f30", "object": "quote", "line_items": [ { "product_id": "gcv_4821", "product_option_id": "gcvo_9107", "amount": 100050, "quantity": 2, "fees": 2500, "subtotal": 200100, "discount": 0 }, { "product_id": "gcv_4821", "product_option_id": "gcvo_9108", "amount": 149595, "quantity": 1, "fees": 2500, "subtotal": 149595, "discount": 0 } ], "discount": null, "amounts": { "currency": "DKK", "subtotal": 349695, "discount": 0, "fees": 5000, "shipping": 0, "tax": 1000, "total": 354695 }, "expires_at": "2026-08-27T10:45:00+00:00" } ``` `line_items` echoes the request in the order it listed the lines, priced. `amount` is the price of one card, seat or voucher, `subtotal` is `amount` multiplied by `quantity`, and `amounts` sums the whole basket. The quoted price is the price the catalog published: read an option's `amount` and its fee from `GET /v2/products` and the quote charges exactly those. **`amounts.discount` and the top-level `discount` are always present.** They are `0` and `null` on every quote and order that carried no code, so a storefront reads one shape rather than branching on a missing key. See [A discount code](https://docs.lifepeaks.dk/headless-checkout#a-discount-code) for what they mean and how they change the total. **How often the fee is charged depends on the family, and `fee_basis` says which.** For gift cards, experiences and special offers it is once per line: not once per card and not once per order, so a two-line order pays it twice whatever the quantities. For an event it is once per seat, so a line of two seats pays it twice on its own. Either way each line states its own `fees` and `amounts.fees` is their sum. A value quote line has no `product_option_id` key at all. An **order** line always carries the key and reports `null` for a value line, so every family reads alike on `GET /v2/orders/{po_order_id}`. Order lines additionally carry their own `id` (`poli_` and 32 hexadecimal characters), `"object": "line_item"` and `issued_count`. **Campaigns are not applied to experience lines.** `promotion_id` belongs to direct issuance of gift-card value, and the embedded `promotions` array appears only on the value product. An experience is charged the price its option is configured with. ### Listing orders `GET /v2/orders` — scope `orders:read` The acting company's orders, newest first, each the same full object one order reads back. Use it to reconcile a day's takings, to find the order behind a buyer's e-mail address when they write in, or to sweep up orders whose webhook you missed. **It is not a substitute for reading one order.** When you need the authoritative state of a particular order, read that order. This is a window over many, and a filtered page can lag a state change you are waiting on. | Filter | What it narrows to | | --------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `status` | Lifecycle state: `pending_payment`, `paid`, `fulfilled`, `cancelled`, `expired` | | `payment_status` | Payment state: `pending`, `authorized`, `captured`, `failed`, `cancelled`, `refunded`, `partially_refunded` | | `created_after`, `created_before` | An RFC 3339 timestamp, or a plain `YYYY-MM-DD` date | | `email` | The buyer's address | | `client_reference` | The reference you sent when the order was created | Filters combine with AND, and each may be repeated. `status=paid&status=fulfilled`, `status[]=paid&status[]=fulfilled` and `status=paid,fulfilled` all mean the same thing, because that is what different HTTP clients produce for "more than one of these". An unknown value in `status` or `payment_status` answers `422` naming the values that exist, rather than quietly matching nothing. A date neither format can read answers `422` too. **`email` matches the buyer,** which is the card's sender — the person who paid — not the recipient it was addressed to. Capitalisation is ignored, so an address matches however it was typed at checkout. Pages with `limit` and `starting_after`, answering `has_more` and `next_cursor`. Without a `limit` you get the first 25. The orders arrive under `data`, in the same envelope `GET /v2/products` and `GET /v2/companies` answer with: ```json { "object": "list", "data": [ { "id": "po_3f81a04d7c2e496b8a05f1c73de29b64", "object": "order", "reference": "A7K2M9XQ4B", "status": "paid" } ], "has_more": true, "next_cursor": "po_3f81a04d7c2e496b8a05f1c73de29b64" } ``` Each entry is the whole order object, cut short here. Read the next page by sending `next_cursor` back as `starting_after`, unchanged. ### What an order reads back The same order object comes back on four calls: `POST /v2/orders`, `GET /v2/orders/{po_order_id}`, `GET /v2/orders`, and the order embedded in a checkout session. One assembler builds all four, so a response you stored and one you re-fetch later never have to be reconciled. Alongside the states and the money it carries the buyer's own choices, read off the card itself: - **`reference`** — the short order number, for people. Print it on a thank-you page and quote it to support; it is what Lifepeaks operators search orders by. `id` stays the only thing an API call addresses the order by. - **`sender` and `recipient`** — the names and e-mail addresses the order was created with, so a thank-you page can say who the card went to without carrying it through the payment redirect. They appear on all four of those calls, and never in a webhook payload. `sender.company` is the buyer's own company when the order was placed by one, and `null` when a private person bought it; its `country` comes back as the alpha-2 code the order was placed with. - **`greeting`** — `message` is the text the buyer wrote, or `null` when there is none. It appears wherever the order object reaches an authenticated caller, including the list, and never in a webhook payload. `image` reports the `source` it was ordered with, and `design_id` for a company picture. - **`delivery`** — `method`, and `send_at`, which is `null` on an order that was not scheduled rather than the moment it happened to go out. A postal order also reads back its `address`, its `pickup_point_id` and its `shipping_priority`, on the same four calls as `sender`. `shipping_priority` is `null` for a courier parcel and for an own-post order placed before the field existed. - **`discount`** — `{code, percent}` when a discount code was applied, `null` otherwise. The code is kept as it was sent, on the order itself rather than looked up, so renaming or deleting the campaign later cannot rewrite what a finished order says. - **`seller`** — the selling company's `name` and `vat_number`, its VAT registration number, which is the CVR number for a Danish company. With `tax` and `total` it is everything a compliant receipt or invoice needs, without a second lookup. **A webhook payload carries less on purpose.** It embeds the same order object but without `sender`, `recipient`, `greeting` or the postal `address`, because it is delivered to a URL you configured rather than answered to an authenticated caller. `reference`, `delivery.method` and `delivery.send_at` are there, which is what a subscriber needs to reconcile an order and to tell a posted card from an e-mailed one after `order.fulfilled` fires. ### Refunding an order `POST /v2/orders/{po_order_id}/refund` — scope `orders:refund` Returns money to the buyer through the same gateway that took it. Send an `amount` to refund part of the order, or no body at all to refund everything still refundable. ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/orders/po_2f8c.../refund \ -H "Authorization: Bearer lp_live_..." \ -H "Idempotency-Key: refund_018f4f37b949" \ -H "Content-Type: application/json" \ -d '{"amount": 12500}' ``` ```json { "object": "refund", "order_id": "po_2f8c1d4e6a7b8c9d0e1f2a3b4c5d6e7f", "amount": 12500, "refunded": 12500, "refundable": 37500, "currency": "DKK", "payment": { "status": "partially_refunded" } } ``` **Read `refunded` and `refundable` rather than assuming your `amount` went through in full.** A gift card used as payment absorbs part of a refund before the card is touched, and the gateway clamps, so the figures come back off the payment itself rather than off what you asked for. **A test key cannot refund in production.** A refund is a payment in reverse, so it is answered `409` with `error.code` of `test_key_not_accepted_in_production`, before anything moves. Amounts are whole numbers of the smallest currency unit. A decimal is refused rather than rounded: sending `12.5` means you are thinking in kroner, and silently making it `12` or `13` is the worst way to find that out. | Answer | When | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `409` `order_not_refundable` | The order was never paid, or its payment cannot be read | | `409` `order_fully_refunded` | Nothing is left to return | | `422` `refund_amount_too_large` | The `amount` is above what remains. The message names the figure | | `502` `refund_declined` | The gateway refused. Nothing was refunded and nothing is held, so a retry with a new key is accepted at once | | `409` `refund_in_progress` | Another refund of that order really is still being processed. Retry shortly. A refund the gateway declined is never one of these | | `409` `amount_precision_unsupported` | The order's stored total cannot be represented exactly. It cannot be refunded through the API | `orders:refund` is deliberately separate from `orders:settle`: a key that may settle an invoice should not thereby be able to move money back out. `Idempotency-Key` is required and covers the amount, so the same key with a different amount answers `409` rather than refunding twice. **A refund that succeeded at the gateway is never lost.** If the money moved and Lifepeaks then failed to finish recording it, you still get `200` with the refund, and the same key returns that same answer afterwards. Retrying is safe, and a retry never refunds twice. ### One payment per order However many lines an order carries, it is one order, one payable amount and one checkout session. Nothing downstream — checkout, payment confirmation, webhooks, settlement — changes with the line count. An order is all-or-nothing across its lines. If any line cannot be written, nothing at all is created and the idempotency key is released, so retrying the identical request is safe once the cause is gone. ### Totals a payment can hold A Lifepeaks payment amount carries at most six significant digits. The rule is about digits, not about size: `9000.70` and `100050.00` both fit, and `10024.95` does not, because it needs seven. A large round total is fine while a mid-sized one with øre on the end may not be. `POST /v2/orders` checks the subtotal, the fees and the total against that limit **before** it writes anything. A total it cannot hold answers `422` with an `error.code` of `amount_precision_unsupported` on `items`, and nothing is created: no order, no payment, no card, no stock movement, and the idempotency key is released for a new attempt. Split the basket over two orders, or pick amounts that leave no øre on the total. `POST /v2/quotes` is not subject to the rule, because a quote is not stored. A basket that quotes cleanly can still be refused when it is ordered, so handle this code on the order call. ### One document per line `GET /v2/orders/{po_order_id}/lines/{line_id}/gift-card.pdf` — scope `orders:read` Each line of a fulfilled order has its own gift-card document, and once `fulfillment.status` is `fulfilled` every entry in `line_items` carries the download for its own: ```json { "id": "po_3f81a04d7c2e496b8a05f1c73de29b64", "status": "fulfilled", "line_items": [ { "id": "poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d", "object": "line_item", "product_id": "gcv_4821", "product_option_id": "gcvo_9107", "amount": 100050, "quantity": 2, "fees": 2500, "subtotal": 200100, "discount": 0, "issued_count": 2, "pdf": { "url": "/v2/orders/po_3f81a04d7c2e496b8a05f1c73de29b64/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf", "mime": "application/pdf" }, "gift_card_pdf": { "url": "/v2/orders/po_3f81a04d7c2e496b8a05f1c73de29b64/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf", "mime": "application/pdf" } } ] } ``` **`pdf` and `gift_card_pdf` are one link under two names.** `pdf` is the family-neutral name and is on every fulfilled line, whatever it sold. `gift_card_pdf` stays on gift-card and experience lines forever, because that is the name integrations already read, and is deliberately absent from event and special-offer lines where the name would be a lie. Read `pdf` in new code. Neither key appears before the order is fulfilled. **`issued_count` says how many items the line actually produced**, which is not always its `quantity`. A gift-card line issues one card per unit. An event line for an event with `grouped_tickets` issues **one** ticket carrying every seat, so a line of four seats reports `issued_count` of `1`. Use it to know how many codes to expect; the codes themselves come from `GET /v2/items?order_id=`, which has always owned them, and each item's own document from `GET /v2/items/{code}/document.pdf`. **One line is one document, however many items it issued.** A line with `quantity` above one is issued as a bundle, and its download lays every item of the bundle out in a single PDF — the same document the Lifepeaks order page has always produced for a bundle. There is no per-card route and no per-card id: the line is the unit you ordered and the unit you download. Follow the `url` rather than assembling it. It answers `200` with `application/pdf`, or `404` with the JSON error envelope, so branch on the response `Content-Type` exactly as on the order-level route. An unfulfilled order, a line of a different order, another company's order and an invented id all answer the same `404`. So does a line whose cards no longer exist, because they were deleted after the order was fulfilled — Lifepeaks answers `404` rather than hand back a gift card with no code on it. The optional `company` query parameter works here as everywhere else. **A multi-line order has no order-level PDF.** `GET /v2/orders/{po_order_id}/gift-card.pdf` would have to guess which card you meant, so it answers `409 multiple_gift_cards` instead, and a fulfilled multi-line order is offered no order-level `gift_card_pdf` key at all. A single-line order keeps both: the order-level link and its one line's link. Artwork follows the experience. When an experience carries its own PDF template, its cards are branded with it and the rest of the order uses the company's published v2 template — so one order's cards need not look alike. See [Branded gift-card PDFs](https://docs.lifepeaks.dk/pdf-templates). --- ## Issue gift cards directly `POST /v2/items` — scopes `orders:create` and `orders:settle`, plus an active invoice-settlement contract Creates one or more gift cards and returns them together with the legacy-compatible `order_id` of the new order. This direct-issuance operation preserves an existing API workflow. A new customer-facing storefront should use [Headless checkout](https://docs.lifepeaks.dk/headless-checkout), where Lifepeaks creates an unpaid `po_…` order and controls QuickPay payment before fulfillment. ::callout **`amount` here is a decimal in your currency, not the smallest unit.** `500` means DKK 500.00, exactly as in v1. The headless routes `POST /v2/quotes` and `POST /v2/orders` use the same field name for whole øre, where DKK 500.00 is `50000` — sending that value here asks for a DKK 50,000.00 card. That mistake is now caught: `amount` is bounded by your company's configured gift-card range whenever a campaign does not set one of its own, so a value outside it fails with `422`. A company that has configured no range is still unbounded. :: | Field | Required | Notes | | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | yes | Value of each card as a decimal in your currency, greater than `0`, and inside the campaign range when `promotion_id` is set or your company's configured range otherwise | | `piece` | no | How many cards to create. Default `1`, must be greater than `0` | | `receiver_name` | no | Defaults to your company's default sender name | | `receiver_email` | no | Defaults to `no-email@lifepeaks.dk` — see the delivery note below | | `sender_name` | no | Defaults to your company's default sender name | | `sender_email` | no | Defaults to `no-email@lifepeaks.dk` | | `promotion_id` | no | Id of an active campaign from `GET /v2/promotions` | | `validity` | no | Expiry as `YYYY-MM-DD`. Anything else falls back to the company default | | `sdh_product_id` | no | Stays product id; only relevant for Stays partners | | `capture` | no | Default `true` — activate immediately. `false` creates the order uncaptured | The body is closed: a field that is not in this table is rejected with `422` rather than ignored, so a misspelling cannot quietly issue a card at the wrong price. `company` is accepted alongside them as the acting-company selector. ::callout **Delivery depends on `receiver_email`.** When `receiver_email` equals `sender_email` — which is what happens when you omit both, since they share the default `no-email@lifepeaks.dk` — Lifepeaks sends **no email at all**, and delivering the code to the buyer is up to you. Pass a distinct `receiver_email` if you want Lifepeaks to email the gift card. :: ```bash curl -X POST https://api.lifepeaks.dk/v2/items \ -H "Authorization: Bearer lp_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 500, "piece": 1, "receiver_name": "Jane Doe", "receiver_email": "jane@example.com", "sender_name": "Acme Hotels", "capture": false }' ``` **Response `201`:** ```json { "items": [ { "code": "77xrB3e44T", "order_id": "devOEVLB1EW33", "item_type_identification": "Gift Card", "amount": 500, "currency": "DKK", "remaining_amount": 500, "status": 6, "status_name": "Paid", "validity": "2029-05-10 00:00:00", "reciever_name": "Jane Doe", "reciever_email": "jane@example.com", "claim_url": "https://app.lifepeaks.dk/l/77xrB3e44T", "pdf_short_url": "https://app.lpeaks.dk/p/W3377xrB3e44T" } ], "itemsCount": 1, "order_id": "devOEVLB1EW33" } ``` Receiver fields are spelled `reciever_name` / `reciever_email` in responses. The misspelling is part of the wire format and is kept for compatibility; request fields use the correct `receiver_` spelling. **Retry compatibility mutations safely with `X-Request-Guid`.** Every successful response to this operation carries an `X-Request-Guid` response header. If a create times out on your side, retry the identical request with that value as a request header: when the original actually completed, the recorded response is replayed — same body, same `201` — and no second card is minted. Replays are scoped to the company the request acted on. Headless durable operations use `Idempotency-Key` instead; see [Migrating from v1](https://docs.lifepeaks.dk/migrate-from-v1#idempotency). ### Applying a campaign `GET /v2/promotions` (scope `items:read`) lists the company's active gift-card campaigns — discounted or added-value offers that can be applied when creating a card: ```json { "object": "list", "data": [ { "id": 499, "name": "API Campaign Added 10%", "modification": "added-value", "amount_from": 10, "amount_to": 100, "reduced_price": null, "added_value": 10 } ], "has_more": false, "next_cursor": null } ``` Pass the `id` as `promotion_id` when creating the card. If `amount` falls outside the campaign's `amount_from` / `amount_to` bounds the create fails with `422`. Without a campaign the company's own configured gift-card range applies instead, so a plain variable-value card is bounded too. --- ## Capture an order `POST /v2/orders/{order_id}/capture` — scope `orders:settle`, plus an active invoice-settlement contract Activates a contract-settled compatibility order created with `capture: false`. Only *uncaptured* legacy-compatible orders of the company you are acting as can be activated; anything else answers `404`. There is no request body. ::callout **Capture is not payment confirmation for a new headless storefront.** Do not take public payment in a partner processor and call this endpoint. Use a Lifepeaks-hosted checkout session so Lifepeaks controls payment and commission. Non-Lifepeaks settlement requires the separate `orders:settle` scope and an active negotiated entitlement. :: ```bash curl -X POST https://api.lifepeaks.dk/v2/orders/devOEVLB1EW33/capture \ -H "Authorization: Bearer lp_live_..." ``` ```json { "order_id": "devOEVLB1EW33", "message": "Order devOEVLB1EW33 has been captured!" } ``` Capturing an order that was already captured answers `404` — the order is no longer in the uncaptured set. Treat that as "already settled" rather than as a lost order. --- ## Search items `GET /v2/items` — scope `items:read` **At least one of `order_id`, `group_code` or `search` is required**, and `search` must be 5 characters or longer. Without one of them the call fails with `422`. | Parameter | Notes | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `order_id` | Every item of one order. Also sorts by the order's own item sequence | | `group_code` | Every item sharing a group code | | `search` | Code, sender/receiver name or email, and a few further columns. Minimum 5 characters | | `status` | Status id: `3` Used, `4` Cancelled, `5` Expired, `6` Paid, `10` Awaiting Payment | | `type` | `GIFTCARD`, `GIFTCARD VARIATION`, `DISCOUNTED GC`, `GIFT CARD WITH ADDED VALUE`, `TICKET`, `SPECIALOFFER`, `BENEFITDEAL` | | `only_claimable` | `true` returns only items that can be redeemed right now | | `date_from`, `date_to` | Creation-date range, `YYYY-MM-DD` | | `offset`, `limit`, `order` | Paging, as described above | ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/items?search=77xrB3e44T&only_claimable=true" ``` ```json { "object": "list", "data": [ { "code": "77xrB3e44T", "order_id": "devOEVLB1EW33", "item_type_identification": "Gift Card", "status": 6, "status_name": "Paid", "amount": 500, "remaining_amount": 400, "remaining_pieces": null, "currency": "DKK", "available_claim": true, "available_amount_claim": true, "available_piece_claim": false, "available_refund": true, "claim_error": null, "refund_error": null, "validity": "2029-05-10 00:00:00" } ], "has_more": false, "next_cursor": null } ``` A search that matches nothing is a success, not an error: you get `{ "object": "list", "data": [], "has_more": false, "next_cursor": null }`. Before offering a redeem action, check `available_claim`. When it is `false`, `claim_error` explains why in the response language — expired, already used, cancelled, wrong company — and is safe to show to staff. `GET /v2/items/{code}` returns the item object itself, not wrapped in a list. Use it when you already have a code and do not need the search rules. ### One item, one document `GET /v2/items/{code}/document.pdf` — scope `items:read` The printable document of one issued item, whatever family it belongs to: a gift card, an event ticket, a special-offer voucher or a benefit deal. It answers `200` with `application/pdf`, or `404` with the usual JSON error envelope, so branch on the response `Content-Type` rather than the status alone. ```bash curl -sS -H "Authorization: Bearer lp_live_..." \ -o voucher.pdf \ "https://api.lifepeaks.dk/v2/items/77xrB3e44T/document.pdf" ``` This is the per-item counterpart of the per-line download on a headless order. Reach for the line route when you hold a `po_…` order and want everything one line bought in a single file; reach for this one when you hold a code — from `GET /v2/items`, from a redemption screen, or from a benefit deal that was never bought through an order at all. **It answers to exactly the same tenancy rule as reading the item.** A code that belongs to another company, one that does not exist, and one whose document cannot be produced all answer the same `404`, so nothing here confirms that a foreign code exists. A benefit deal is readable — and therefore printable — by the company it was issued to as well as by the company that issued it. The optional `company` query parameter works here as everywhere else. --- ## Redeem an item `POST /v2/items/{code}/claim` — scope `items:write` | Field | Required | Notes | | ------------ | ------------------- | ------------------------------------------------------------------------ | | `type` | yes | `all`, `amount` or `piece`. Any other value fails with `422` | | `amount` | with `type: amount` | Value to redeem, greater than `0` and at most the remaining value | | `number` | with `type: piece` | Pieces to redeem, greater than `0` and at most the remaining pieces | | `claim_note` | no | Stored with the transaction — a booking or reservation number, typically | ```bash curl -X POST https://api.lifepeaks.dk/v2/items/77xrB3e44T/claim \ -H "Authorization: Bearer lp_live_..." \ -H "Content-Type: application/json" \ -d '{"type": "amount", "amount": 100, "claim_note": "Booking 55123"}' ``` ```json { "info": "Gift Card partly claimed", "message": "Successfully claimed 100 DKK.", "remaining_value": 400, "remaining_number": null, "partly_claimed": true, "claimed_amount": 100 } ``` `partly_claimed` is `false` once nothing is left. `claimed_amount` is omitted when the call redeemed the whole item. A `type: amount` (or `type: piece`) request for the entire remaining value is treated as a claim of the whole item. That is what lets an indivisible product — a single voucher, or a variation with amount claims disabled — be redeemed through the same endpoint. Partial claims are never promoted this way, so partial redemption of a fixed-product voucher stays blocked. The claim endpoint also accepts two kinds of code that are not yet items of their own: - a **dormant campaign code**, which is activated and then redeemed in the same call; - for companies configured for it, a **Wonderbox/GoDream vendor code**, which is claimed at the vendor and imported. ### Reversing, cancelling, resending `POST /v2/items/{code}/refund` takes the same `type` / `amount` / `number` fields and returns `partly_refunded` instead of `partly_claimed`. The item's `available_refund` must be `true`; when it is not, `refund_error` comes back as the `422` message. `POST /v2/items/{code}/cancel` takes no body. It cancels the item, or discards it when discarding is the applicable action — the `message` says which happened. `POST /v2/items/{code}/activate` turns a dormant campaign code into a live item. Campaigns created without a fixed value require a numeric `amount`; campaigns that carry their own value ignore it. ```json { "message": "Item zF92Emxn_c has been activated! Value: 500. Validity: 06.05.2029.", "amount": 500, "validity": "06.05.2029" } ``` `POST /v2/items/{code}/resend` emails the item to `{"email": "buyer@example.com"}` and records the resend in the item's action log. --- ## Redeem a whole order `POST /v2/orders/{order_id}/claim` — scope `items:write` Fully redeems every item of an order in one call. Pass `{"group_code": "…"}` to limit it to one group. When the order cannot be redeemed as a group the response is `422` and the items have to be claimed one at a time. ```json { "info": "Whole Order claimed", "message": "Whole Order devOEVLB1EW33 has been fully claimed!" } ``` Note the scope: activating a contract-settled compatibility order needs `orders:settle`, while redeeming one needs `items:write`. They are different operations on the same legacy-compatible order. --- ## Choosing the correct order flow | Requirement | Use | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | | New partner-owned customer storefront | Catalog, quote, unpaid `POST /v2/orders`, Lifepeaks-hosted checkout, canonical read, signed webhooks | | Existing direct-issuance integration | `POST /v2/items` and, where already contracted, compatibility capture | | Redemption or back-office item action | `/v2/items*` or compatibility order claim | | Partner-controlled or external settlement | Commercial review, `orders:settle` scope, and active entitlement before implementation | Never infer that a gift card was paid because a create or redirect request returned successfully. For the protected path, only Lifepeaks' canonical order state and signed events establish payment and fulfillment. # Events & Reporting The read-only side of the v2 API: what you sold, what has been redeemed, who is coming to your events, and who signed up to your newsletter. For creating and redeeming items see [Endpoints](https://docs.lifepeaks.dk/endpoints). | Method | Path | Scope | | ------ | -------------------------------- | -------------- | | `GET` | `/v2/events` | `items:read` | | `GET` | `/v2/events/{slug}/participants` | `reports:read` | | `GET` | `/v2/reports/analytics` | `reports:read` | | `GET` | `/v2/reports/claimed-items` | `reports:read` | | `GET` | `/v2/subscribers` | `reports:read` | All five act on the company that owns the API key — or on an assigned company when you pass the optional `company` parameter — and return the payload unwrapped. Pagination differs by operation: - `GET /v2/events`, event participants and subscribers are lists: they answer the one list envelope (`object`, `data`, `has_more`, `next_cursor`) and page with `limit` and `starting_after`. None accepts `offset`, and the participants list no longer takes its `active`/`claimed` `order` control: a forward cursor needs one stable order. - Analytics and claimed-items are reports, not lists. They accept `offset`, `limit`, and `order` (`asc` or `desc`), and answer their rows under `items` with an `itemsCount`. They are windows over a report rather than walks along a list, so they kept the offset. See [Endpoints — Conventions](https://docs.lifepeaks.dk/endpoints#conventions) for defaults and the other list-route families. ## Events `GET /v2/events` — scope `items:read` **This is the reporting view of your events, not the sales one.** It answers how many tickets an event has sold, held and started with, for a dashboard or a reconciliation. It publishes no prices, no ticket types and no ids an order can name, and nothing here can be bought. **To sell a ticket, read the catalog instead.** An event is published as a product at `GET /v2/products?type=event_ticket`, with the id `evt_`, one `evto_` option per ticket type, the price and fee of each, the collection points, and what the organiser asks of a buyer. That product id is what `POST /v2/quotes` and `POST /v2/orders` take. See [Endpoints — An event and its tickets](https://docs.lifepeaks.dk/endpoints#an-event-and-its-tickets) and [Headless checkout](https://docs.lifepeaks.dk/headless-checkout). The two lists also answer different questions about which events exist. This one reports every event of the company, including undated and private ones, because a report that hid them would under-count. The catalog publishes only what is genuinely on sale — active, public, fully dated and inside its open window — because a product it lists is a product an order accepts. Lists your events with their ticket counts. `online=true` narrows to events currently visible in the order form; `private=true` returns private events instead of public ones. ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/events?online=true&limit=2" ``` ```json { "object": "list", "data": [ { "name": "Hotel Party nr.1", "slug": "hotel-party-nr1", "private": false, "online": true, "venue": "London", "venue_address": "", "venue_postcode": "", "date_activate": null, "date_deactivate": null, "sold": 435, "blocked": 0, "all": 1773 } ], "has_more": false, "next_cursor": null } ``` `sold` counts tickets sold, `blocked` counts tickets held in baskets awaiting payment, and `all` is the total the event was set up with. Count `data` for the size of the returned page; it is not the total number of events. Send `next_cursor` back as `starting_after` until `has_more` is `false`; never infer the end from a short page. ## Event participants `GET /v2/events/{slug}/participants` — scope `reports:read` Uses the `slug` from the events list. This endpoint needs `reports:read` rather than `items:read` because the rows carry participant personal data. | Parameter | Notes | | ------------------------- | --------------------------------------------------------- | | `search` | Free-text search over the participant rows | | `limit`, `starting_after` | Paging. The response carries `has_more` and `next_cursor` | ```json { "object": "list", "data": [ { "sender_name": "Jane Doe", "sender_email": "jane@example.com", "sender_phone": "", "sender_comment": "", "sender_code": null, "ticket_variation": "3 x Standard", "ticket_price": 255, "created_at": "2026-01-03 20:15:10", "payment_type": "API created - Payment with invoice", "payment_OTH": null, "claimed_at": null } ], "has_more": false, "next_cursor": null } ``` **An event with no participants answers `200` with an empty array.** It used to answer `404`, which could not be told from an event that does not exist. A `404` now means only that: no such event for this company. ## Analytics `GET /v2/reports/analytics` — scope `reports:read` The same items as `GET /v2/items`, reduced to the analytic columns: type, titles, status, amounts, VAT, currency, remaining value, validity, creation time and language. No availability flags, no buyer identity, no claim or PDF links. Unlike `GET /v2/items` this endpoint has no mandatory filter — a date range on its own is a valid call. ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/reports/analytics?date_from=2026-01-01&date_to=2026-01-31&limit=100" ``` ```json { "items": [ { "item_type": "Gift Card", "item_type_identification": "Gift Card", "item_title": "Gift Card", "event_title": null, "ticket_title": null, "is_token": false, "item_info": null, "status": 6, "status_name": "Paid", "amount": 500, "vat": 25, "vat_value": 100, "currency": "DKK", "remaining_amount": 400, "remaining_pieces": null, "company_name": "Acme Hotels", "validity": "2029-05-10 00:00:00", "created_at": "2026-01-12 15:31:57", "language": "en-GB" } ], "itemsCount": 1 } ``` `search`, `status`, `date_from` and `date_to` filter the same way they do on `GET /v2/items`, except that `search` has no minimum length here — a short term is searched rather than refused. ## Claimed items `GET /v2/reports/claimed-items` — scope `reports:read` The redemption ledger: one row per claim, refund or status transaction, across gift cards, special offers and tickets, newest first by default. **Send both `date_from` and `date_to`.** The `422` fires only when *both* are missing, so a request carrying one of them is accepted — and answers an empty page, because the missing bound is compared against nothing and matches no row. An empty list from this route is therefore a formatting mistake more often than it is a quiet month. ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/reports/claimed-items?date_from=2026-01-01&date_to=2026-01-31" ``` ```json { "items": [ { "transaction_id": 123465, "status": 3, "status_name": "Used", "date": "2026-01-12", "time": "01:36:48", "item_type": "Gift Card Variation", "item_type_identification": "Gift Card Variation", "code": "77xrB3e44T", "amount": 100, "claim_note": "Booking 55123", "claimed_by_username": null, "claimed_by_company_name": null, "currency": "DKK", "company_name": "Acme Hotels" } ], "itemsCount": 1 } ``` `claim_note` is whatever you passed when redeeming the item, which makes this the endpoint to reconcile Lifepeaks redemptions against your own bookings. `claimed_by_username` is `null` for anything redeemed with an API key — a key is not a user. Redemptions made by staff in the admin UI still carry their username. ## Subscribers `GET /v2/subscribers` — scope `reports:read` The confirmed newsletter subscribers collected through your order pages, together with the marketing consents recorded on v2 orders through [`marketing_consent`](https://docs.lifepeaks.dk/headless-checkout#marketing-consent). When your company is a root company, the subscribers of its child companies are included. A subscriber appears here only once the order that produced them is paid, whichever of the two sources it came from. ```json { "object": "list", "data": [ { "id": "3183", "company": "acme-hotels", "email": "jane@example.com", "name": "Jane Doe" } ], "has_more": false, "next_cursor": null } ``` `date_from` and `date_to` filter by signup date, but only when **both** are present and both parse as `YYYY-MM-DD`. If either is missing or malformed the interval is ignored entirely and every subscriber is returned — so check your date formatting before concluding that a month was unusually busy. This list grows with every buyer, so it pages by default: without a `limit` you get the first 50 rows and `has_more: true`. To walk the whole list, raise `limit` to at most `100` and pass the `id` of the last row you received as `starting_after` until `has_more` is `false`. The event and participant lists page the same way; only the two reporting routes still use `offset`. **Treat a row's `id` as opaque.** The list has two sources, so the ids are not all numbers — a consent recorded on a v2 order carries a prefix. Send the value back exactly as you received it and do not parse it or do arithmetic on it. The order is stable either way, so a walk visits every subscriber exactly once. # Headless checkout Build the storefront in your own brand and keep Lifepeaks as the commerce backend. Your application owns the customer experience; Lifepeaks owns the authoritative catalog, authoritative quote, payable amount, commission, Lifepeaks-hosted QuickPay checkout, canonical order state, and fulfillment. ::callout **The API key is server-only.** The browser calls your backend. Only your backend calls `https://api.lifepeaks.dk/v2`. Never place an `lp_live_…` key in browser JavaScript, a mobile application, a public repository, analytics, or logs. :: ## The safe flow 1. Your backend reads `GET /v2/products` and presents only active products. One request is the whole page: each product carries `delivery_methods`, `personalization` and `terms` beside its prices, so how the card can arrive, what the buyer may add to it and what they accept before paying all arrive with it. 2. Your backend sends the buyer's selection to `POST /v2/quotes`, with the delivery choice when the card is posted. Lifepeaks calculates the authoritative quote, including fees, postage and total. 3. After the buyer confirms, your backend creates an unpaid order with `POST /v2/orders`, carrying the buyer's greeting and delivery choice. 4. Your backend creates a checkout session at `POST /v2/orders/{order_id}/checkout-sessions` and redirects the browser to its Lifepeaks-hosted QuickPay URL. 5. QuickPay returns the browser to your `return_url`. The browser return never proves payment, even when its URL looks successful. 6. Your backend reads the canonical order with `GET /v2/orders/{order_id}` and verifies each [signed webhook](https://docs.lifepeaks.dk/signed-webhooks) against its raw request body. Do not trust query parameters or browser state as payment evidence. 7. Lifepeaks starts fulfillment only after confirmed payment. Show a gift card as delivered only when canonical `fulfillment.status` is `fulfilled`. 8. A fulfilled order carries a `gift_card_pdf` link on every GIFT-CARD line — a value card, an experience or a bundle. An event or special-offer line carries `null` there; fetch those documents per issued item from `GET /v2/items/{code}/document.pdf`. Fetch the documents from your backend if you want your own copy of what Lifepeaks delivered. ```text browser -> partner backend -> Lifepeaks catalog / quote / order browser <- partner backend <- Lifepeaks checkout URL browser --------------------> Lifepeaks-hosted QuickPay browser -> partner return -> partner backend -> canonical Lifepeaks order ^ | | signed webhook ``` ## 1. Read the catalog `GET /v2/products` returns everything the company sells. Read its live limits and fees instead of hardcoding them. ```bash curl -sS https://api.lifepeaks.dk/v2/products \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" ``` Four product families share the list, and the `type` field says which one each entry is: - **`gift_card_value`** (`type: gift_card`) is the variable-value gift card. It has a tenant-specific minimum and maximum, quantity limits, and an order fee. How it can be delivered is a property of the company rather than of the product, and is carried on it as `delivery_methods`. It also carries the company's open campaigns in `promotions`, so you can render the offers without a second call. - **`gcv_`** (`type: gift_card_variant`) is one experience the company sells. It is priced by the option the buyer picks, so each entry carries an `options` array of `gcvo_` entries with their own `amount`, `order_fee`, quantity bounds, and stock. - **`evt_`** (`type: event_ticket`) is one event. It carries an `event` block with the dates, the venue, the collection points and what the organiser asks of a buyer, and one `evto_` option per ticket type. - **`so_`** (`type: special_offer`) is one discounted offer, sold as a voucher. Each `sov_` option publishes what it costs now, what it cost before, and what the voucher is worth when it is spent. **Read `fee_basis` on every product.** It is `per_line` for gift cards, experiences and special offers, and `per_ticket` for events. A basket totalled without it charges a two-seat event line one fee where Lifepeaks charges two. Ticket coupons and the Saved and New Special Vouchers are issued by an operator in the Lifepeaks back office and never appear here. Benefit deals are read and redeemed through `/v2/items`, never bought. The value product is the first entry whenever the company has one, so an integration that reads `data[0]` keeps working. Branch on `type` rather than on position, page the list with `limit` and `starting_after`, and narrow it with `type` or `category`. Treat the whole response as configuration that may change. A company that never configured gift-card amount limits sells no variable-value card. The `gift_card_value` entry is then absent and `GET /v2/products/gift_card_value` answers `404 not_found`, while its experiences are listed, quoted and ordered exactly as usual. Render what the list holds. An experience with a gift-card campaign running on it is not published here. The Lifepeaks order page prices such an experience at the campaign price and this API applies no campaign, so the product is withheld rather than sold at full price on the same day. It is missing from the list, answers `404` by id, and cannot be quoted or ordered until the campaign ends. Point buyers at the Lifepeaks order page for that experience meanwhile. Every family is sold through the same quote and order routes. One request buys one of them: a value line, or one to twenty lines of a single other family. Mixing families answers `422`, so a cart holding two of them is checked out as two orders. [Endpoints — The product catalog](https://docs.lifepeaks.dk/endpoints#the-product-catalog) documents every field, the filters, and the 404-never-403 rule for one product by id. ## 2. Request authoritative pricing ::callout **Amounts on this path are whole numbers of the smallest unit of the currency.** `50000` means DKK 500.00 — øre, not kroner. Whole numbers of the smallest unit avoid rounding errors, so `amount`, `subtotal`, `fees`, `shipping`, `tax`, `total`, `minimum_amount`, `maximum_amount`, and `order_fee` all work this way. The compatibility route `POST /v2/items` is the exception: its `amount` is a decimal in the currency itself, where `500` means DKK 500.00. Same field name, different unit — see [Migrating from v1](https://docs.lifepeaks.dk/migrate-from-v1). :: ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/quotes \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"items":[{"product_id":"gift_card_value","amount":50000,"quantity":1}]}' ``` An experience is quoted by naming the option the buyer picked instead of an amount, and one quote may carry up to twenty such lines: ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/quotes \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"items":[ {"product_id":"gcv_4821","product_option_id":"gcvo_9107","quantity":2}, {"product_id":"gcv_4821","product_option_id":"gcvo_9108","quantity":1} ]}' ``` An event or a special offer is quoted the same way, by naming the option: ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/quotes \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"items":[{"product_id":"evt_412","product_option_id":"evto_9310","quantity":2}]}' ``` Only a value line carries `amount`: everywhere else the option's price is authoritative, and sending one is rejected with `422`. Campaigns are not applied to those lines either, so what the catalog published is what the buyer pays. Render `amounts.currency`, `subtotal`, `discount`, `fees`, `shipping`, `tax`, and `total` from this response, and `line_items` when you show a basket. **`total` is `subtotal` − `discount` + `fees` + `shipping`, and `tax` is not a term in that sum.** It is the VAT on the order fee and the shipping, not on the face value of the card, which is taxed when the card is spent rather than when it is bought. `shipping` is `0` unless the quote carried a postal `delivery` object, so send the buyer's delivery choice with the quote once they have made it. `discount` is `0` unless the quote carried a `discount_code`. A quote has an `expires_at`; refresh it after expiry. Lifepeaks also calculates the order amount server-side, so compare the new order totals with the quote before redirecting the buyer. **Where the fee lands depends on `fee_basis`.** A gift card, an experience or a special offer pays its fee once per line, so two lines pay it twice whatever the quantities. An event pays its ticket fee once per seat, so one line of two seats pays it twice on its own. Bound your quantity control by the option's `quantity.maximum`. It is the number the quote and the order enforce, not a display hint, so a line the catalog allows is a line Lifepeaks accepts. An order also issues at most `1000` items across all of its lines. **A quote reserves nothing; the order locks and checks again.** The quote prices what the catalog says at that instant and holds no stock, so two buyers can be quoted the same last seat. `POST /v2/orders` locks the option rows inside its own transaction and re-runs every minimum, maximum, stock and capacity check against the locked figures before it writes, so an option that sold out in between is refused rather than oversold. Handle that rejection on the way to checkout: a special-offer line answers `409` with `insufficient_stock`, and a quantity outside the option's bounds answers `422` with `below_minimum` or `above_maximum`. [Endpoints — Quote and order lines](https://docs.lifepeaks.dk/endpoints#quote-and-order-lines) lists every line rule and the exact response fields. ## 3. Create one unpaid order Create and durably store the order's `Idempotency-Key` before the first request. Reuse that exact key only when retrying the identical operation and body. ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/orders \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: order_checkout_018f4f37b949" \ -H "Content-Type: application/json" \ -d '{ "items":[{"product_id":"gift_card_value","amount":50000,"quantity":1}], "sender":{"name":"Alex Sender","email":"alex@example.com"}, "recipient":{"name":"Sam Recipient","email":"sam@example.com"}, "client_reference":"cart_018f4f37b949", "locale":"da-DK", "return_url":"https://partner.example/checkout/return" }' ``` The `201` response is an unpaid `po_…` order. Creating it does not confirm payment and does not authorize fulfillment. An experience order sends the same body with experience lines, and the response echoes them as `line_items`: ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/orders \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: order_checkout_018f4f37b949" \ -H "Content-Type: application/json" \ -d '{ "items":[ {"product_id":"gcv_4821","product_option_id":"gcvo_9107","quantity":2}, {"product_id":"gcv_4821","product_option_id":"gcvo_9108","quantity":1} ], "sender":{"name":"Alex Sender","email":"alex@example.com"}, "recipient":{"name":"Sam Recipient","email":"sam@example.com"}, "client_reference":"cart_018f4f37b949", "locale":"da-DK", "return_url":"https://partner.example/checkout/return" }' ``` An event or special-offer order sends the same body with event or offer lines. An event line may add a `ticket` object; nothing else about the call changes. Besides `items`, `sender`, `recipient`, `return_url`, `client_reference` and `locale`, the body accepts `greeting`, `delivery`, `marketing_consent` and `discount_code`, each covered below. Every one of them is optional and every one is a **closed** object: a field it does not list answers `422` naming the field, rather than being ignored. **One order is one payment, however many lines it carries.** Everything after this step is unchanged by the line count: one checkout session, one payable total, one set of webhooks. An order is also all-or-nothing — if any line cannot be created, no order, no payment and no card exists, and the key is released so the identical request can be retried. **A total needs to be a number a payment can hold.** Six significant digits is the limit, so `9000.70` and `100050.00` are fine and `10024.95` is not. A basket that crosses it answers `422` with an `error.code` of `amount_precision_unsupported`, before anything is written — no order, no card, no stock movement, and the key released. Split the basket over two orders, or pick amounts that leave no øre on the total. Quotes are not subject to the rule, so a basket that priced cleanly can still be refused here. ### What an event line asks for An event line may carry a `ticket` object with what the organiser asked the buyer for. It is optional, and each key inside it is optional until the event or the option demands it. ```json "items": [{ "product_id": "evt_412", "product_option_id": "evto_9310", "quantity": 2, "ticket": { "comment": "Two vegetarians", "code": "STAFF-2026", "collection_point_id": "epp_31" } }] ``` Read what to ask for off the product rather than guessing: `event.comment.available` and `event.comment.label` say whether to show a note field and what to label it, and each option's `requires` block says whether that ticket needs a `code`, a delivery `address` or a `collection_point_id`. A collection point is an `epp_` from the event's own `event.collection_points`, which are places at the event and not the courier collection points used for posting a gift card. `address` takes `name`, `street` and `city` together, and is an alternative to a collection point rather than an addition to one. **One comment per event** — two lines of the same event carrying different comments are refused, because the engine keeps a single comment per event. `ticket` is closed like every other object here: a key it does not list answers `422` naming the key, rather than being ignored. ### Buying as a company When the buyer is a business rather than a person, send their company inside `sender`. ```json "sender": { "name": "Alex Sender", "email": "alex@example.com", "company": { "name": "Acme ApS", "vat_number": "DK12345678", "street": "Gothersgade 12", "postcode": "1123", "city": "København K", "country": "DK" } } ``` | Field | Required | Notes | | --------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | yes | The company's name, at most 255 characters | | `vat_number` | yes | Its VAT registration number, at most 50 characters. Required whenever the object is present, matching what the Lifepeaks order form asks every company buyer | | `street`, `postcode`, `city`, `country` | all four or none | The address is optional **as a set**. Send all four or send none; three of them answers `422` naming the one you left out. `country` is a two-letter ISO 3166-1 code | **Some products may only be bought by a company.** An experience or a special offer with `company_required` of `true`, and an event with `event.requires.company` of `true`, refuse an order with no `sender.company` — and refuse one that carries a company without its address — with `422` and an `error.code` of `sender_company_required`. Read the flag off the product and show the company fields before the buyer reaches payment. **A company buyer may see extra text on the document.** Where the selling partner has configured business-buyer wording, an order marked as bought by a company prints it on the gift card. Nothing in the request controls it; sending `sender.company` is what turns it on. `POST /v2/quotes` accepts `sender` and ignores it. A company changes no price, so a quote never has to carry one — but a body your quote accepted stays a body the order accepts. The company is validated at order time, not at quote time. The order reads `sender.company` back on the authenticated calls, with `country` as the alpha-2 code you sent. It is buyer identity, so it never appears in a webhook payload. ### A discount code Send the code exactly as the buyer typed it. Matching is case-insensitive, and Lifepeaks decides everything about the money. ```json "discount_code": "BLACKFRIDAY" ``` A code is 5 to 25 characters. Codes are created by the partner in the Lifepeaks back office; there is no API that mints one, and nothing in the request says what a code is worth. **What comes back.** The quote and the order report the result in three places, and all three are always present — `0` and `null` when no code was sent — so nothing has to branch on a missing key: ```json { "discount": { "code": "BLACKFRIDAY", "percent": 20 }, "line_items": [ { "product_id": "gcv_4821", "product_option_id": "gcvo_9107", "amount": 100050, "quantity": 2, "fees": 2500, "subtotal": 200100, "discount": 40020 } ], "amounts": { "currency": "DKK", "subtotal": 200100, "discount": 40020, "fees": 2500, "shipping": 0, "tax": 500, "total": 162580 } } ``` - `amounts.discount` is a **positive magnitude that is subtracted**. It is a whole number in the smallest unit of the currency, like every other figure here. - **`total` is `subtotal` − `discount` + `fees` + `shipping`.** `subtotal` stays gross, so a partner still has the list price it sold at as well as the money it took. If your code recomputes the total from the parts, this is the line to change. - `line_items[].discount` is that line's share, and is `0` on every line of an order with no code. - The top-level `discount` object names what caused it: `{code, percent}`, or `null`. The code is kept as it was sent, so renaming or deleting the campaign later cannot rewrite a finished order. **What a code can and cannot reduce.** - Value gift-card lines and experience lines, when the campaign is configured for them. - Postage, when the campaign is a shipping one. That comes off `amounts.shipping` itself rather than off `amounts.discount`, exactly as the Lifepeaks order page reduces the postage line. So a shipping-only code leaves `amounts.discount` at `0` while `amounts.shipping` and `total` both fall. - **Never fees.** `amounts.fees` is untouched by any code. - **Never an event ticket or a special offer.** An order made only of those answers `422` with `discount_code_not_applicable`. **The card is still worth its full face value.** A discounted gift card is redeemed for the amount printed on it; the buyer simply paid less for it. The selling partner absorbs that gap, which is also why the Lifepeaks commission is calculated on the discounted subtotal. **Two refusals, and they are deliberately different.** `discount_code_not_found` covers unknown, switched off, not yet open, expired and fully used — one answer for all five, so the field cannot be used to discover a competitor's campaign names. `discount_code_not_applicable` means the code is live but nothing in this order answers to it. **A code is consumed when the order is written, not when it is quoted.** A limited-use code that runs out between the quote and the order is refused at order time. Quote with the code the moment the buyer applies it, and quote again before you send them to payment. ### A greeting on the card Send `greeting` with the order to print a message on its own page of the gift card and show it in the delivery e-mail. Omit it and the card carries no greeting, exactly as if the buyer had skipped the step. ```json "greeting": { "message": "Tillykke med dagen!", "image": { "source": "design", "design_id": "gid_4711" } } ``` A greeting can be a message alone, a picture alone, or both. Bound your character counter by the product's `personalization.greeting.max_length` rather than a number of your own: it is the length the order path rejects at, so a message your page accepts is one the order accepts. The picture comes from one of two places: - **The company's own pictures.** They are on the product, as `personalization.designs`. Name the one the buyer chose as `design_id`. An empty list means this company offers none, so do not show the chooser. - **The buyer's own photograph.** Upload it first, then name the id it produced as `upload_id` with `"source": "upload"`. See [A buyer's own picture](https://docs.lifepeaks.dk/#a-buyers-own-picture). `"source": "none"` says the buyer chose no picture, which is the same as leaving `image` out. The greeting applies to every card the order produces. An order for five cards prints the same message on all five. ### How and when it is delivered Send `delivery` to say how the card reaches its recipient, and when. ```json "delivery": { "method": "recipient_email", "send_at": "2026-12-24T09:00:00+01:00" } ``` Read the product's `delivery_methods` first and offer only the entries it reports as `available`, with the prices it publishes. A method the company does not offer is refused by name, so what your page offers and what an order accepts always agree. Show `price` with a "from" wherever `price_varies` is `true`. Posting is not one number, and the exact figure comes back from the quote once the buyer has chosen a country and an address. `method` is `recipient_email` to e-mail the card straight to the recipient, `sender_email` to e-mail it to the buyer, who passes it on, or `postal` to post a printed card. Leave `delivery` out entirely and Lifepeaks makes the same choice it always has: the recipient's own address when it differs from the buyer's, the buyer's otherwise. `send_at` schedules the card. It is an RFC 3339 timestamp **with an offset** — `2026-12-24T09:00:00+01:00`, never `2026-12-24T09:00:00`. A timestamp without one is refused rather than guessed at, because a card meant for Christmas morning that arrives nine hours late is a real failure. It must be in the future and at most 24 months ahead. Only a method whose `supports_send_at` is `true` accepts it. A card sent to the buyer travels on the payment receipt and cannot be held back, so scheduling it is refused rather than silently dropped. **Sending `send_at` without a `method` is fine.** An order carrying it is scheduled to the recipient, so a body your quote accepted is accepted by the order. A postal order carries the address too, and pays for the postage: ```json "delivery": { "method": "postal", "pickup_point_id": "gls-DK-1234", "address": { "kind": "private", "name": "Bo Hansen", "street": "Gothersgade 12", "postcode": "1123", "city": "København K", "country": "DK", "email": "bo@example.com" } } ``` `country` is a two-letter ISO 3166-1 code, and it must be one the postal method lists as a destination for the shape of delivery you asked for. A country the courier does not serve is refused here, before any money moves, rather than failing days later at the courier with the payment already taken. Sending a `pickup_point_id` makes it a collection delivery; leaving it out sends the card to the address itself. The two are priced differently, and the product's postal method publishes both figures per country, in `destinations`. Find the points themselves at `GET /v2/pickup-points?country=DK&postcode=2200`, once the buyer has given a postcode. `kind` of `company` needs a `company_name` and is priced differently again. A phone number is required only when the method's `phone_required` is `true`. **Quote the postal order before you show a total.** Send the same `delivery` object to `POST /v2/quotes` and read `amounts.shipping` — it is `0` on every e-mail order, and on a postal one it carries the postage plus the surcharge for each card after the first. Quoting without it shows the buyer a total that is not what they will be charged. A courier shipment cannot be scheduled. `send_at` together with `postal` is refused rather than silently dropped, so a partner is never left believing a card ships on a date it will not. **A company that posts its own cards may offer a choice of speed.** Look for a `priorities` array on the product's `postal` method. When it is there, show the entries and send the buyer's pick as `delivery.shipping_priority`; when it is missing there is no choice to make, and the field is refused. ```json "delivery": { "method": "postal", "shipping_priority": "spri_2", "address": { "kind": "private", "name": "Bo Hansen", "street": "Gothersgade 12", "postcode": "1123", "city": "København K", "country": "DK", "email": "bo@example.com" } } ``` Omitting it charges the default, which is always the cheapest entry and is the price `destinations[].*_price` already publishes. Send the same value to `POST /v2/quotes` to see the faster price before the buyer commits. A priority sent to a company that ships with a courier answers `422` with `shipping_priority_unsupported`; one the company does not publish answers `422` with `shipping_priority_unavailable`. **Neither an event ticket nor a special offer can be posted.** Both are delivered electronically, so `method` of `postal` on such an order answers `422` on `delivery.method` and their `amounts.shipping` is always `0`. **A scheduled order is paid long before it is fulfilled.** It stays `paid` with `fulfillment.status` of `processing` until the moment arrives, and `order.fulfilled` fires then, not at payment. If you reconcile same-day, expect paid orders with no fulfilment for as long as the buyer chose. ### A buyer's own picture A buyer who wants their own photograph on the greeting page uploads it in three steps, before the order. It is the same shape as the [gift-card PDF upload](https://docs.lifepeaks.dk/pdf-templates), so a client written for one works for the other. **1. Reserve a slot.** Declare the file you are about to send — its SHA-256, its exact byte size, and its type. ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/greeting-images/upload-intents \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: greeting_upload_018f4f37b949" \ -H "Content-Type: application/json" \ -d '{"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "byte_size":248130,"mime":"image/jpeg"}' ``` A greeting picture may be `image/jpeg`, `image/png` or `image/webp`, up to 10 MB. Read those two limits off the product rather than hardcoding them: `personalization.greeting.image.accepted_mime` and `.max_bytes` are the same values this route enforces, so your copy and its rejection cannot disagree. The slot closes after 15 minutes. **2. Send the bytes.** Use `upload.url`, `upload.method` and `upload.headers` from the response exactly as given. In a deployed environment the URL is a signed storage URL and the headers carry its signature, so altering one makes the upload fail. **3. Finalize.** This is where the file is actually checked. ```bash curl -sS -X POST \ https://api.lifepeaks.dk/v2/greeting-images/upload-intents/gimg_5a3c81e0d74b426f90ac17b5e2d8630f/finalize \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: greeting_finalize_018f4f37b949" ``` The checksum, the size and the file's own leading bytes must all agree with what you declared. The declared type is only a claim: a file announced as `image/png` that does not begin with PNG's signature is refused, because what this accepts is drawn into a PDF and e-mailed to a stranger. **A `503` here is not a rejection.** `image_store_unavailable` means the bytes were accepted and checked, and only storing them failed. Wait for the seconds in `Retry-After` and finalize again with the same `Idempotency-Key`; the upload is untouched and the retry completes it. The `201` carries the picture's id. Name it on the order: ```json "greeting": { "image": { "source": "upload", "upload_id": "gimg_5a3c81e0d74b426f90ac17b5e2d8630f" } } ``` **A picture works exactly once.** One order may name it and no other can, which is what stops one buyer's photograph reaching another buyer's card. A second order naming it answers `422` with `greeting_image_not_found`, exactly as an id that never existed does, so the answer never tells you about an order you may not own. Reserve a new slot for every buyer. A picture no order uses is deleted after seven days, so finalize when the buyer has chosen it rather than days ahead of checkout. ### Marketing consent If you ask the buyer for permission to send them marketing, record what they answered on the order. Send the object with the exact sentence they were shown. ```json "marketing_consent": { "granted": true, "source": "partner_checkout", "consent_text": "Ja tak, send mig nyheder og tilbud på e-mail. Du kan afmelde når som helst.", "consent_text_version": "2026-09-01" } ``` | Field | Required | What it means | | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------- | | `granted` | yes | `true` when the buyer opted in, `false` when they were asked and declined | | `source` | yes | `storefront_checkout`, `partner_checkout` or `partner_import` — where the consent was taken | | `consent_text` | when `granted` is `true` | The exact wording shown to the buyer, at most 500 characters | | `consent_text_version` | no | Your own label for that wording, at most 40 characters | **Omit the object entirely and nothing is recorded.** That is what an unticked box means, and it is what every order placed before this field existed does. Sending `granted: false` is different: it records that the buyer was asked and said no, which is worth keeping — it is the difference between "declined" and "never asked". **`consent_text` is the evidence.** Send the sentence that was actually on the page, copied verbatim, not a paraphrase and not a constant in your code that could drift from what you render. If the wording is ever disputed, this field is the answer, so a stored sentence that never appeared on screen is worse than no field at all. **A consent becomes a subscriber only when the payment is captured.** Until then it is recorded but invisible to `GET /v2/subscribers`, so a buyer who ticks the box and abandons checkout never reaches your list. This matches how the Lifepeaks order pages have always worked. The object is closed: a field it does not list is refused with `422` rather than ignored, so a misspelled `consent_text` cannot leave you holding a consent with no evidence in it. There is no double opt-in. A ticked box next to its own wording, stored with that wording and the moment it was ticked, is the whole record. ### Idempotency rules - Use stable, independent `Idempotency-Key` values for order creation and checkout-session creation. Never reuse the order key for the checkout call. - Persist each key with the operation name, exact request body, and resulting resource ID. - A network timeout is an unknown result. Retry the same operation with the same key and body. The replay returns the original response, whatever the line count; no second card is issued and no stock is deducted twice. - Reusing a key with different input returns `409 idempotency_conflict`; generate a new key only for a genuinely new operation. - A duplicate that arrives while the first request is still running returns `409 request_in_progress` with a `Retry-After` header. Wait that long and retry the same key and body rather than starting a second order. - Do not generate a new key inside a retry loop. That can create duplicate durable work. ## 4. Create Lifepeaks-hosted QuickPay checkout Use a second persisted idempotency key. This call has no body because Lifepeaks takes the payable amount, currency, approved return URL, and payment configuration from the order and tenant configuration. ```bash curl -sS -X POST \ https://api.lifepeaks.dk/v2/orders/po_0123456789abcdef0123456789abcdef/checkout-sessions \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: checkout_session_018f4f37b949" ``` Redirect the browser to the returned `url`. It is a short-lived QuickPay URL hosted under Lifepeaks' payment configuration. Partners cannot replace the merchant, payable amount, commission, callback, or capture decision. An identical replay may return `200`; a newly created session returns `201`. Both are success. If the session expires, create a new checkout session for the same unpaid order with a new operation key. Do not create a duplicate order. ## Confirm payment on the return page The return page should know only your own stable checkout reference. Resolve that reference to the stored `po_…` ID on your backend, then read the order: ```bash curl -sS \ https://api.lifepeaks.dk/v2/orders/po_0123456789abcdef0123456789abcdef \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" ``` Use canonical fields, not the redirect: | State | Partner experience | | ------------------------------------------ | --------------------------------------------------------------------------------------------- | | `status: pending_payment` | Keep polling with backoff and offer the active checkout again. | | `payment.status: failed` or `cancelled` | Explain that no gift card was issued and allow a safe retry. | | `payment.status: authorized` or `captured` | Payment is confirmed; continue waiting for fulfillment if needed. | | `fulfillment.status: fulfilled` | Show the order as complete. Delivery is by e-mail unless the order was posted. | | `fulfillment.status: failed` | Keep the order paid and escalate fulfillment; never ask the buyer to pay again automatically. | **The checkout-session response already carries the whole order.** The `order` inside it is the same object `GET /v2/orders/{order_id}` returns — `reference`, `sender`, `recipient`, `greeting` and `delivery` included — so a thank-you page can be built from it alone. It is a snapshot of the moment the session was created, though, so re-read the order before you act on payment. **The order carries everything a thank-you page needs to say.** `reference` is the short order number to print and to quote to support. `recipient.email` is where the card went, `delivery.method` says how, and `delivery.send_at` says when if the buyer scheduled it. Read them from the order rather than carrying them through the payment redirect, where a buyer can edit them. Polling makes the return page reliable when a webhook is delayed. Webhooks make the partner backend responsive when the buyer closes the browser. Production integrations should use both. ## Hand over the gift-card document Each line of an order has its own document. Once `fulfillment.status` is `fulfilled`, every entry in `line_items` carries a `pdf` block, and a single-line order carries a `gift_card_pdf` at order level too: ```json { "id": "po_0123456789abcdef0123456789abcdef", "status": "fulfilled", "payment": { "status": "captured" }, "fulfillment": { "status": "fulfilled" }, "line_items": [ { "id": "poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d", "object": "line_item", "product_id": "gcv_4821", "product_option_id": "gcvo_9107", "amount": 100050, "quantity": 2, "fees": 2500, "subtotal": 200100, "discount": 0, "issued_count": 2, "pdf": { "url": "/v2/orders/po_0123456789abcdef0123456789abcdef/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf", "mime": "application/pdf" }, "gift_card_pdf": { "url": "/v2/orders/po_0123456789abcdef0123456789abcdef/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf", "mime": "application/pdf" } } ] } ``` The key is **absent** until fulfillment completes, so test for its presence rather than for an empty value — and do not poll the download to detect fulfillment; read the order. **Follow `pdf`, not `gift_card_pdf`.** They are the same link under two names. `pdf` is on every fulfilled line whatever it sold; `gift_card_pdf` is on gift-card and experience lines only, kept because integrations already read it, and is absent from event and special-offer lines where the name would be wrong. **`issued_count` is how many items the line actually produced.** It equals `quantity` for a gift card. It does not for an event that groups its tickets: such a line issues one ticket carrying every seat, so a four-seat line reports `1`. Use it to know how many codes to expect. The codes themselves come from `GET /v2/items?order_id=`, and each code's own document from `GET /v2/items/{code}/document.pdf` — which is the route to use for a ticket or an offer voucher you hold by code rather than by order line. Fetch each one from your server with your API key, following the `url` the order gave you: ```bash curl -sS -o gift-card.pdf \ "https://api.lifepeaks.dk/v2/orders/po_0123456789abcdef0123456789abcdef/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf" \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" ``` A line that bought several cards downloads all of them in one document, so there is one download per line and never one per card. A line whose cards have since been deleted answers `404` rather than an empty-looking gift card. **Do not build the order-level URL for a multi-line order.** `GET /v2/orders/{order_id}/gift-card.pdf` cannot know which card you meant, so it answers `409 multiple_gift_cards`, and such an order is offered no order-level link at all. Iterate `line_items` and follow each line's `url`; that works for a one-line order too. This is an authenticated Lifepeaks endpoint, not a public or storage link, so never hand the URL to a browser. Attach the bytes to your own confirmation email, or serve them from your account area behind your own session. Lifepeaks emails the cards to the recipient regardless; this is for your own copy of the same documents. The artwork comes from the branded template that was in force when the order was created — see [Branded gift-card PDFs](https://docs.lifepeaks.dk/pdf-templates). An experience that carries its own template brands its own cards, so one order's cards need not look alike, and the rest of the order uses the company's published template. Publishing a new template later never changes a document a buyer has already received. ## Contract-gated settlement `POST /v2/orders/{order_id}/settle` is not an alternative public payment shortcut. It requires the `orders:settle` scope **and** an active negotiated commercial entitlement for the tenant, credential, and settlement mode. It is disabled by default, audit-backed, and cannot be used after hosted checkout starts. Agency/marketplace models, POS/PMS integrations, and non-Lifepeaks settlement require a commercial and technical review. Contact Lifepeaks before designing around them. Existing legacy v1 integrations are documented separately under [Migrating from v1](https://docs.lifepeaks.dk/migrate-from-v1); headless v2 integrations should use the protected flow above. ## Start from working code The [Lifepeaks storefront starter](https://github.com/Lifepeaks/lifepeaks-storefront-starter){rel=""nofollow""} implements this whole sequence: the partner backend, durable idempotency, webhook verification, the canonical return page, and per-industry manifests. The repository is private, so ask Lifepeaks for access first. See [Starter templates](https://docs.lifepeaks.dk/starter-templates) for the exact commands. # Lifepeaks Public API v2 The Lifepeaks `/v2` REST API lets a partner build its own frontend while Lifepeaks protects gift-card pricing, QuickPay payment, commission, canonical order state, and fulfillment. It also supports gift-card operations, redemption and reporting, plus customization of the built-in Lifepeaks order page. Order-page configuration is additionally available through the **Lifepeaks MCP server** for approved AI agents. Start with [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) when building a partner storefront. Start with the [Built-in order page](https://docs.lifepeaks.dk/order-page) when Lifepeaks should provide the storefront too. ::callout **Keep API keys on your server.** A browser or mobile client calls your backend, never Lifepeaks with an `lp_live_…` key. The browser return from payment is not proof of payment; reconcile the canonical order and verify signed webhooks. :: ## Protected headless commerce | Step | REST endpoint | Responsibility | | --------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Discover | `GET /v2/products` | Lifepeaks publishes active products, their limits, options, and fees, and on each one how the card can be delivered, what a buyer may add to it, and the terms they accept before paying. | | Price | `POST /v2/quotes` | Lifepeaks returns the authoritative total. | | Create | `POST /v2/orders` | Partner creates an unpaid order with a stable idempotency key. | | Pay | `POST /v2/orders/{order_id}/checkout-sessions` | Lifepeaks returns its hosted QuickPay URL. | | Reconcile | `GET /v2/orders/{order_id}` | Partner reads canonical payment and fulfillment state. | | Subscribe | `/v2/webhook-endpoints` | Partner receives signed payment and fulfillment events. | | Deliver | `GET /v2/orders/{order_id}/lines/{line_id}/gift-card.pdf` | Partner downloads each fulfilled gift-card document. | The catalog holds four product families: the variable-value `gift_card_value` gift card, one `gcv_` product per experience, one `evt_` product per event, and one `so_` product per special offer — each of the last three with its own priced options. Checkout sells all four. A gift card travels by e-mail or by post; a ticket and an offer voucher are electronic only. One order buys one family: a value line, or up to twenty lines of a single other family, priced by the options they name. Read `fee_basis` on each product, because an event charges its fee per seat where the rest charge per line. Restaurant, hotel, spa and wellness, retail, and experience templates are different presentations of that same supported contract. Ticket coupons and the Saved and New Special Vouchers stay operator-issued and are not in the catalog. Benefit deals are read and redeemed by code, never bought. Any issued item prints at `GET /v2/items/{code}/document.pdf`. ## Other v2 surfaces | Capability | REST or MCP surface | | ---------------------------------------- | ---------------------------------------------------- | | Check what a key can do | `GET /v2/me` — no scope required | | Discover the companies a key may act for | `GET /v2/companies`, `GET /v2/companies/{slug}` | | Set brand identity for generated PDFs | `/v2/companies/{company_id}/brand*` | | Search and inspect existing items | `GET /v2/items`, `GET /v2/items/{code}` | | Redeem, refund, cancel, activate, resend | `POST /v2/items/{code}/…` | | List gift-card campaigns | `GET /v2/promotions` | | Read events and reporting | `GET /v2/events`, `/v2/reports/*`, `/v2/subscribers` | | Customize built-in order page | `/v2/order-page*` and `orderpage_*` MCP tools | | Brand gift-card PDFs | `/v2/pdf-templates*` and `pdf_templates_*` MCP tools | | Issue, list, and revoke API keys | `/v2/api-keys` | | Browse exact schemas | [API Reference](https://docs.lifepeaks.dk/reference) | `POST /v2/items` and `/v2/orders/{legacy_order_id}/capture` remain compatibility operations for direct issuance and migration. They are not the protected public headless payment flow. New partner storefronts use catalog, quote, unpaid `po_…` order, and Lifepeaks-hosted checkout. Contract-gated settlement at `POST /v2/orders/{order_id}/settle` is disabled by default. Agency/marketplace, POS/PMS, and non-Lifepeaks settlement models require negotiation and an active entitlement. ## Tenancy By default every request acts on the company that owns the key. If Lifepeaks has assigned other companies to yours, add the optional `company` **query parameter** to act on one of them — a `POST` or `PATCH` may carry the same slug in its body instead, and the query string wins when both do. See [Endpoints — Conventions](https://docs.lifepeaks.dk/endpoints#conventions). Call `GET /v2/companies` to see which slugs your key may use. Unknown, inactive, foreign, and unassigned companies all return `404 not_found`. ## Servers | Environment | Base URL | | -------------------------------- | ------------------------------- | | Production | `https://api.lifepeaks.dk` | | Demo | `https://api-demo.lifepeaks.dk` | | Development and QuickPay testing | `https://api-dev.lifepeaks.dk` | ## Where to go next - [Authentication](https://docs.lifepeaks.dk/authentication) — issue least-privilege, server-only API keys - [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) — protected catalog-to-fulfillment flow - [Signed webhooks](https://docs.lifepeaks.dk/signed-webhooks) — raw-body verification, deduplication, and recovery - [Starter templates](https://docs.lifepeaks.dk/starter-templates) — runnable restaurant, hotel, wellness, retail, and experience starters - [Endpoints](https://docs.lifepeaks.dk/endpoints) — v2 surface map plus compatibility issuance and item operations - [Events & Reporting](https://docs.lifepeaks.dk/events-and-reporting) — events, participants, analytics, claimed items, subscribers - [Built-in order page](https://docs.lifepeaks.dk/order-page) — reading, versioning, previewing, and publishing hosted-page configuration - [Branded gift-card PDFs](https://docs.lifepeaks.dk/pdf-templates) — company defaults, partner uploads, validation, and publication - [Migrating from v1](https://docs.lifepeaks.dk/migrate-from-v1) — moving an existing OAuth2 integration to v2 - [MCP Server](https://docs.lifepeaks.dk/mcp) — connecting AI agents via the MCP protocol - [AI Agents](https://docs.lifepeaks.dk/ai-agents) — machine-readable docs and llms.txt - [API Reference](https://docs.lifepeaks.dk/reference) — interactive OpenAPI specification ## Errors All non-2xx responses return a JSON error envelope: ```json { "error": { "code": "string", "message": "string", "fields": { "field_name": ["error message"] } } } ``` The `fields` property is only present on `422 Unprocessable Entity` responses. # MCP Server The Lifepeaks MCP server exposes part of the v2 API as **tools**, a **resource**, and **prompts** that any MCP-compatible AI agent can use. Each tool does exactly what the matching REST endpoint does, with the same scope and the same validation. Three areas are covered: headless commerce reads and order creation, branded gift-card [PDF templates](https://docs.lifepeaks.dk/pdf-templates), and configuration of the Lifepeaks-hosted [order page](https://docs.lifepeaks.dk/order-page). Payment confirmation, capture, refunds, and negotiated settlement are deliberately **not** exposed as tools. Those decisions stay with your own server against the REST API. ## Connecting The MCP endpoint speaks JSON-RPC over streamable HTTP: ```text https://api.lifepeaks.dk/mcp ``` All requests require a Bearer API key in the `Authorization` header: ```text Authorization: Bearer lp_live_ ``` **The tool list depends on the key.** The server registers only the tools the presented key holds the scope for, so an agent with a read-only key never sees a write tool at all. A key with no matching scope connects successfully and sees an empty tool list. Write tools additionally require the key to be attributable to a credential record, which every key issued through `POST /v2/api-keys` or the admin UI is. ## Install **Claude Code** — one command, with the key supplied as a header: ```bash claude mcp add --transport http lifepeaks https://api.lifepeaks.dk/mcp \ --header "Authorization: Bearer lp_live_" ``` **Cursor** and **VS Code** install from a deep link. Both carry the endpoint plus a placeholder `Authorization` header — after installing, replace `lp_live_` with your own key in the client's MCP settings. - [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=lifepeaks&config=eyJ1cmwiOiJodHRwczovL2FwaS5saWZlcGVha3MuZGsvbWNwIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIGxwX2xpdmVfPHlvdXIta2V5PiJ9fQ==) - [Add to VS Code](vscode\:mcp/install?%7B%22name%22%3A%22lifepeaks%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapi.lifepeaks.dk%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20lp_live_%3Cyour-key%3E%22%7D%7D) **Any other MCP client:** ```bash npx add-mcp https://api.lifepeaks.dk/mcp ``` `add-mcp` registers the endpoint only, so add the `Authorization` header in that client's MCP configuration afterwards. Without it every call returns `401`. ## Available tools ### Headless commerce | Tool | Scope required | Description | | ----------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `commerce_list_products` | `catalog:read` | List every product the company sells — its variable-value gift card, its experiences, its events and its special offers — with the limits, prices and fees of each, plus how a card can be delivered, what a buyer may add to it, and the terms | | `commerce_list_pickup_points` | `catalog:read` | Find the courier's collection points near one postcode, for a postal order | | `commerce_create_quote` | `quotes:create` | Calculate an authoritative quote for one value gift-card line, or for up to twenty lines of one other family | | `commerce_get_order` | `orders:read` | Read canonical order, payment, and fulfillment state | | `commerce_create_order` | `orders:create` | Create an unpaid order — this never confirms payment | | `commerce_create_checkout` | `checkout:create` | Create a Lifepeaks-hosted QuickPay checkout for an unpaid order | Amounts in these tools are whole numbers of the smallest unit of the currency, exactly as in the REST API: `50000` is DKK 500.00. Whole numbers of the smallest unit avoid rounding errors. **Every product family is read, priced and ordered here.** `commerce_list_products` answers with the same catalog `GET /v2/products` returns, so an agent can discover a `gcv_` experience, an `evt_` event or an `so_` special offer and the option a buyer wants. `commerce_create_quote` and `commerce_create_order` take the same lines the HTTP routes take, under the same rules: every line but a value one names a `product_option_id` and never an `amount`, a request carries one value line or one to twenty lines of a single other family, families are never mixed, an option appears once, and one order issues at most 1000 items. Both also take the same `delivery` object, and `commerce_create_quote` needs it whenever the buyer is posting the card: a postal quote sent without it reports `shipping` of `0`, and the order then charges up to DKK 175,00 more. Price the selection with `commerce_create_quote` before creating the order, so the user approves a total Lifepeaks calculated. See [Quote and order lines](https://docs.lifepeaks.dk/endpoints#quote-and-order-lines) for every rule and the exact `422` a broken line gets. **Read `fee_basis` before totalling anything.** It is `per_line` for gift cards, experiences and special offers, and `per_ticket` for events. An agent that assumes one fee per line quotes a two-seat event line short by a whole ticket fee. **An event line carries what the organiser asked for.** `commerce_create_quote` and `commerce_create_order` accept an optional `ticket` object on an event line: `comment`, `code`, `collection_point_id` and `address`. Each one is conditional on something the product publishes — the event's `comment.available` and the option's `requires.code`, `requires.address` and `requires.collection_point` — so read the product and send only what it asks for. A collection point is an `epp_` from the event's own `event.collection_points` and is not what `commerce_list_pickup_points` answers with; those are courier collection points for posting a gift card. `address` and `collection_point_id` are alternatives, and one event takes one comment however many lines name it. **A special offer publishes what the voucher is worth.** `redemption_value` on an offer option is what the issued voucher can be spent on, and it is not always what the buyer pays: an offer configured to redeem at the regular price sells at `amount` and redeems at `regular_amount`. Tell the user `redemption_value` when describing what they are getting. **A business buyer goes in `sender.company`.** `commerce_create_order` takes the buyer's own company there — `name` and `vat_number` always, and `street`, `postcode`, `city`, `country` as an all-or-nothing set. It is on the sender alone; the recipient schema has no `company`, because the company an envelope is addressed to is `delivery.address.company_name` and a different thing. A product whose `company_required` is `true`, or an event whose `event.requires.company` is `true`, refuses an order without it under `sender_company_required`. Never invent a VAT number. **A discount code is a plain string, and Lifepeaks does the arithmetic.** `commerce_create_quote` and `commerce_create_order` take `discount_code`; there is nowhere for an agent to put a discount amount, deliberately. The answer comes back as `amounts.discount`, a positive figure that is subtracted, and a top-level `discount` object naming the code and its percentage. `total` is `subtotal` − `discount` + `fees` + `shipping`. A code never reduces fees, never applies to an event ticket or a special offer, and a shipping code comes off `amounts.shipping` while leaving `amounts.discount` at `0`. **A quote reserves nothing.** `commerce_create_order` locks the option rows and re-checks stock, minimums, maximums and capacity before it writes, so an agent that quoted a last seat can still be refused at the order. Re-quote before asking the user to pay. **Neither an event ticket nor a special offer can be posted.** `delivery.method` of `postal` on such an order is refused, and their `shipping` is always `0`. **Delivery and the greeting are read before the order, not guessed at, and they come back with the product.** Each entry `commerce_list_products` answers carries `delivery_methods` — which of `sender_email`, `recipient_email` and `postal` the company actually offers, what each costs, and which of them can be scheduled — and `personalization`, which says how long a greeting may be and which pictures the company offers for the greeting page. An empty `designs` list means there is nothing to choose. There is no second tool for either: one product call is everything an agent needs to describe a whole order, and one list cannot fall out of step with another. `commerce_create_order` then carries the user's answers as `greeting` and `delivery` on the order body. See [How the card is delivered](https://docs.lifepeaks.dk/endpoints#how-the-card-is-delivered). `commerce_list_pickup_points` is the one delivery fact not already on the product, because collection points open, close and move. It is a live call to the courier and is rate-limited, so ask for points once the user has given a postcode rather than exploring. Its `country` is a two-letter ISO 3166-1 code; a country name is refused rather than guessed at. **How fast a posted card travels is on the product too.** A company that posts its own cards publishes a `priorities` list of `spri_` speeds on its `postal` method, and `commerce_create_order` names one as `delivery.shipping_priority`. The key is absent whenever there is no genuine choice, and sending a priority then is refused rather than ignored, so read the product before offering the option. Omitting it charges the cheapest, which is the price `destinations[]` already publishes. **Marketing consent is recorded, never assumed.** `commerce_create_order` takes the same `marketing_consent` object the HTTP body takes, so an agent can carry a user's answer onto the order. Send it only when the user was actually shown a consent sentence and acted on it, and put that sentence in `consent_text` verbatim — it is the evidence, so it must never be composed or paraphrased. Omit the object when nobody was asked. See [Marketing consent](https://docs.lifepeaks.dk/headless-checkout#marketing-consent). **A buyer's own photograph cannot be uploaded over MCP.** The protocol has no way to carry the bytes, so `greeting.image` over MCP names one of the company's own pictures with `design_id`, or an `upload_id` that an HTTP client already finalized. An agent with neither should send a message with `"source": "none"` rather than invent an id. ### Branded gift-card PDF templates | Tool | Scope required | Description | | ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `pdf_templates_get_current` | `pdf_templates:read` | Read the template in force for a locale (defaults to `da-DK`); optional `lang` | | `pdf_templates_list` | `pdf_templates:read` | List stored template versions; optional `lang` narrows to one locale | | `pdf_templates_get` | `pdf_templates:read` | Read one template version | | `pdf_templates_preview` | `pdf_templates:read` | Get a controlled preview URL, with no storage details | | `pdf_templates_create_upload_intent` | `pdf_templates:write` | Create a short-lived upload target bound to a checksum and a size. The type is always `application/pdf` and is not an argument | | `pdf_templates_finalize` | `pdf_templates:write` | Validate an uploaded file into one immutable version | | `pdf_templates_publish` | `pdf_templates:write` | Select a validated version for future orders | | `pdf_templates_revert_to_default` | `pdf_templates:write` | Go back to the company-branded default for a locale | ### Hosted order page | Tool | Scope required | Description | | -------------------------- | ----------------- | ------------------------------------------------------------------------------------ | | `orderpage_get_current` | `orderpage:read` | Read the live configuration and published revision metadata; optional `lang` | | `orderpage_list_fields` | `orderpage:read` | List every writable field with its type and constraints | | `orderpage_list_revisions` | `orderpage:read` | List draft, published, and archived revisions; optional `lang` narrows to one locale | | `orderpage_get_revision` | `orderpage:read` | Read one revision, including its fields and ETag | | `orderpage_preview_draft` | `orderpage:read` | Preview a draft without touching the live page | | `orderpage_create_draft` | `orderpage:write` | Start a draft — the live page is unchanged | | `orderpage_update_draft` | `orderpage:write` | Edit a draft using its exact ETag | | `orderpage_publish_draft` | `orderpage:write` | Publish a reviewed draft to the live page | ## Resource | Resource URI | Scope required | Description | | -------------------- | ---------------- | --------------------------------------------------- | | `orderpage://schema` | `orderpage:read` | The writable field catalog as a structured resource | ## Built-in prompts | Prompt | Appears when the key can | Purpose | | ------------------------- | ------------------------ | -------------------------------------------------------------- | | `build_headless_checkout` | Reach any commerce tool | The safe catalog-to-checkout workflow for a partner storefront | | `customize_order_page` | Read the order page | The draft-review-publish workflow for the hosted page | ## Writes are gated and idempotent Every write tool takes an `idempotency_key` — a stable string of 16 to 255 characters that you generate once per logical action and reuse on retry, exactly like the `Idempotency-Key` header in REST. Most write tools additionally take `confirm`, which must be `true`. Send it only after the user has explicitly approved that specific side effect; an unconfirmed call returns an error instead of acting. The gated tools are `commerce_create_order`, `commerce_create_checkout`, `pdf_templates_publish`, `pdf_templates_revert_to_default`, `orderpage_update_draft`, and `orderpage_publish_draft`. ## Order-page workflow Changing the hosted page goes through a draft, so nothing reaches buyers until it is published: 1. **`orderpage_list_fields`** — discover which fields exist, their types, maximum lengths, and whether they support per-locale values. 2. **`orderpage_get_current`** — read what is live today. 3. **`orderpage_create_draft`** — start a draft with an `idempotency_key`, optionally naming a `lang`, a `name`, and a `from_revision_id` to branch from a published revision rather than from the live page. Keep the returned `id` and `etag`. 4. **`orderpage_update_draft`** — send `revision_id`, a `fields` object, the exact `etag` from the last read, an `idempotency_key`, and `confirm: true`. Each edit returns a new ETag; use it for the next one. 5. **`orderpage_preview_draft`** — review the exact payload the page would render. 6. **`orderpage_publish_draft`** — publish with the newest `etag`, a fresh `idempotency_key`, and `confirm: true`. ### orderpage\_update\_draft input shape ```json { "revision_id": "opr_7c1e93a4b60d48f2a5e7c081b3d92f64", "fields": { "orderform_company_box_bg": "#2d3a8c", "orderform_signup_text": "Tilmeld dig eksklusive fordele" }, "etag": "\"opr_7c1e93a4b60d48f2a5e7c081b3d92f64:v1\"", "idempotency_key": "orderpage_draft_edit_018f4f37b949", "confirm": true } ``` Send only the fields you intend to change. Unknown fields, wrong types, and over-length values are rejected and nothing is written. Fields marked `i18n: true` hold one value per locale. Choose the locale when you create the draft with `lang`; a draft covers a single locale, so translating a page means one draft per locale. ## Error handling A rejected write returns a runtime error carrying a JSON payload. The common causes are: - A field name that is not in the catalog from `orderpage_list_fields`. - A value of the wrong type — a string where `bool` is expected, for example. - A value longer than the field's `max`. - A stale `etag`, meaning someone else edited the draft. Re-read the revision, reconcile, and retry with its new ETag. - A missing or `false` `confirm` on a gated write. ## Relationship to the REST API The tools and the REST endpoints act on the same data, so the two can be mixed freely. Three differences are worth knowing: - **The MCP server does not manage API keys.** Use `POST /v2/api-keys` for that. - **The MCP server always acts on the key's own company.** The [`company` selector](https://docs.lifepeaks.dk/endpoints#conventions) is REST-only, in the query string or in a write body alike, so an agency working on an assigned company's order page uses `GET`/`PATCH /v2/order-page?company=` instead. - **Payment and money operations are REST-only.** There is no MCP tool for confirming payment, capturing, refunding, or settling. ## Skill file The full skill definition for AI agents is published on this site: - [`/skills/lifepeaks-order-page/SKILL.md`](https://docs.lifepeaks.dk/skills/lifepeaks-order-page/SKILL.md) — worked examples, type rules, and the read-then-update workflow in agent-friendly format. - [`/skills/lifepeaks-order-page/references/fields.md`](https://docs.lifepeaks.dk/skills/lifepeaks-order-page/references/fields.md) — the field catalog the skill references. # Migrating from v1 v2 provides API-key replacements for reading, redemption, reporting, order-page configuration, and negotiated compatibility issuance. It also adds a protected headless checkout architecture that v1 did not have: authoritative catalog and quote, unpaid `po_…` orders, Lifepeaks-hosted QuickPay, canonical payment state, and signed webhooks. Do not mechanically translate v1 create-and-capture into a new public storefront. Existing compatibility operations have contract gates, and v2 capture does not prove buyer payment. New buyer journeys should follow [Headless checkout](https://docs.lifepeaks.dk/headless-checkout). v1 is **not** being switched off. Migrate when it suits you. ::callout **`amount` means different things on v1 and on headless v2. Read this before you send one.** v1's `amount` is a decimal in kroner. Headless v2's `amount` is a whole number of øre — the smallest unit of the currency. The field kept its name, so nothing rejects a v1-shaped value: it is simply accepted as a card worth **one hundred times less**. | Route | `amount` for a DKK 500.00 card | | -------------------------------------------- | ------------------------------ | | v1 `POST /action/create-gc` | `500` | | v2 `POST /v2/quotes`, `POST /v2/orders` | `50000` | | v2 `POST /v2/items` (compatibility issuance) | `500` — unchanged from v1 | So the compatibility route you migrate onto keeps v1's unit, and only the new headless path changes. If you copy an amount from existing v1 code into a quote or an order, multiply it by 100 first. Whole numbers of the smallest unit are what the headless path uses throughout — `amount`, `subtotal`, `discount`, `fees`, `shipping`, `tax`, `total`, `minimum_amount`, `maximum_amount`, and `order_fee` — because whole numbers of the smallest unit avoid rounding errors. The trap cannot reach an experience line: it names an option instead of a price, and sending `amount` on one is rejected with `422`. :: ## What changes, in one screen | | v1 | v2 | | ----------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | Credential | OAuth2 access token from `POST /token` | API key, `Authorization: Bearer lp_live_…` | | Token lifetime | 24 h, refreshed with a refresh token | No expiry unless you set one at minting | | Permissions | OAuth client scope (`admin`) plus per-user rights | Least-privilege resource scopes on the key; settlement also needs an entitlement | | Company | `company` parameter on some calls; agencies act for many | Unchanged — optional `company` parameter, defaulting to the key's own company | | Success body | `{"status": "OK", "response": …}` | The payload itself, unwrapped | | Errors | `status` field plus a message | `{"error": {"code", "message", "fields"}}` with real HTTP status codes | | Read calls | Mostly `POST` with a JSON body | `GET` with query parameters | | Language | Path prefix (`/en/`, `/se/`, `/de/`) | Unchanged — the same prefixes apply | | Money | Decimal kroner everywhere | Unchanged on compatibility and item routes; **whole øre** on the headless path | | What you can sell | Gift cards only, one at a time | Gift cards, experiences, event tickets and special offers, through one catalog and one order route | ## What v2 sells that v1 did not v1 issues gift cards. v2's headless path sells four product families from one catalog, and every one of them is quoted, ordered, paid and fulfilled by the same three calls: | Family | `type` | Product id | Option id | | --------------- | ------------------- | ----------------- | ----------- | | Value gift card | `gift_card` | `gift_card_value` | — | | Experience | `gift_card_variant` | `gcv_` | `gcvo_` | | Event ticket | `event_ticket` | `evt_` | `evto_` | | Special offer | `special_offer` | `so_` | `sov_` | None of this changes anything a v1 integration already does. `POST /v2/items` still issues gift-card value and nothing else, and the item, redemption and reporting routes read every family by code exactly as they always have — the `type` filter on `GET /v2/items` already accepts `TICKET`, `SPECIALOFFER` and `BENEFITDEAL`. Three things a Lifepeaks operator issues by hand are deliberately outside the public API: ticket coupons, Saved Special Vouchers and New Special Vouchers. Benefit deals are read and redeemed by code, never bought. All four still appear at `GET /v2/items` and redeem at `POST /v2/items/{code}/claim`, and any of them prints at `GET /v2/items/{code}/document.pdf`. ## Authentication v1 uses an OAuth2 password grant: your `client_id` / `client_secret` plus a Lifepeaks username and password are exchanged at `POST /token` for a 24-hour access token, refreshed with a refresh token. v2 replaces all of that with a long-lived API key. ```bash curl -X POST https://api.lifepeaks.dk/v2/api-keys \ -H "Authorization: Bearer lp_live_" \ -H "Content-Type: application/json" \ -d '{ "label": "Booking system", "scopes": ["items:read", "items:write", "orders:create"], "expires_in_days": 365 }' ``` `expires_in_days` is optional — an integer from `1` to `365`, counted from creation. Omit it and the key never expires; the response's `expires_at` is then `null`. There is no refresh flow: rotate by minting a new key and revoking the old one with `DELETE /v2/api-keys/{id}`. Your first key comes from the admin panel (**Settings > API Keys**); a key can only mint keys whose scopes it already holds. See [Authentication](https://docs.lifepeaks.dk/authentication) for the full lifecycle. ### Scope mapping v1 has effectively one OAuth client scope, `admin`, which unlocks every action; access is then narrowed per action by *user rights* on the logged-in account (`agency_user`, `cancel_user`, `right_disable_listing_items`). v2 splits that broad access into explicit resource scopes and drops the user-rights layer — a key is not a user. | v1 action | v1 requirement | v2 endpoint | v2 scope | | ---------------------------------- | --------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- | | `POST /action/list` | scope `admin` | `GET /v2/items` | `items:read` | | `GET /item/{code}` | scope `admin` | `GET /v2/items/{code}` | `items:read` | | `POST /item/{code}/claim` | scope `admin`, blocked for `agency_user` | `POST /v2/items/{code}/claim` | `items:write` | | `POST /item/{code}/refund` | scope `admin`, blocked for `agency_user` | `POST /v2/items/{code}/refund` | `items:write` | | `POST /item/{code}/cancel` | scope `admin`, needs `agency_user` or `cancel_user` | `POST /v2/items/{code}/cancel` | `items:write` | | `POST /item/{code}/activate` | scope `admin` | `POST /v2/items/{code}/activate` | `items:write` | | `POST /item/{code}/resend` | scope `admin` | `POST /v2/items/{code}/resend` | `items:write` | | `POST /action/create-gc` | scope `admin`, needs `agency_user` | `POST /v2/items` compatibility route | `orders:create` + `orders:settle` + active contract | | `POST /order/{order_id}/capture` | authenticated user | `POST /v2/orders/{order_id}/capture` compatibility route | `orders:settle` + active contract | | `POST /order/{order_id}/claim` | scope `admin` | `POST /v2/orders/{order_id}/claim` | `items:write` | | `POST /event/list` | scope `admin`, blocked for `agency_user` | `GET /v2/events` | `items:read` | | `POST /event/{slug}/participants` | scope `admin`, blocked for `agency_user` | `GET /v2/events/{slug}/participants` | `reports:read` | | `GET /list/gc-value-modifications` | scope `admin` | `GET /v2/promotions` | `items:read` | | `POST /action/analytics` | scope `admin` | `GET /v2/reports/analytics` | `reports:read` | | `POST /action/claimed-items` | scope `admin` | `GET /v2/reports/claimed-items` | `reports:read` | | `GET /action/subscribers` | ungated | `GET /v2/subscribers` | `reports:read` | | `GET /action/ping` | scope `admin` | `GET /v2/health` | authenticated API key | Two consequences of dropping the user-rights layer are worth planning for: - **Cancel is no longer creator-restricted.** v1 additionally narrows cancel to items the calling user created, unless the user holds `cancel_user`. A key has no creator, so the company boundary is the whole check: a key with `items:write` can cancel any of that company's cancellable items. - **`GET /v2/subscribers` is now gated.** In v1 the endpoint is ungated and any authenticated caller can read it. v2 requires `reports:read`, because the rows are names and email addresses. - **Credential management is separate.** Listing keys needs `credentials:read`; issuing or revoking them needs `credentials:write`. v1's `gc-value-modifications` list is `GET /v2/promotions` in v2. The rows and the rules are the same; only the name changed, to the word a partner would actually use for them. ## Tenancy: `company` works as it always did In v1 an agency account passes `company` (a slug) to act for one of its assigned companies. v2 keeps that exactly as it was: pass `company` and the request acts on that company. - **Omit it** and the request acts on the API key's own company. That is the default and covers every single-company integration. - **Pass it** as the `company` query parameter and the request acts on that company, provided the slug is your own company or one Lifepeaks has assigned to yours. A `POST` or a `PATCH` may carry the same slug as a `company` field in its JSON body instead, and the query string wins when both carry it. The one exception is `POST /v2/quotes`, whose body accepts `items` alone — name the company in its query string there. One key can therefore serve an agency across all of its assigned companies, just as one v1 account did. A key per company remains equally valid. Only the API-key endpoints and `GET /v2/health` take no `company` parameter; they always act on the key's own company. Anything outside that boundary is `404 not_found`, never `403` — an unknown slug, an inactive company, and a company that belongs to someone else all answer the same way. v2 deliberately does not distinguish "does not exist" from "belongs to someone else". Idempotent replays follow the same boundary. Compatibility operations record `X-Request-Guid` against the acting company. New durable headless operations require `Idempotency-Key`, also within the acting-company boundary. ## Redemption of expired items v2 follows the **company-level** `claim_expired` setting exactly: - `claim_expired >= 0` — expired items stay redeemable for that many months after expiry, on every channel including v2. Past the grace period the claim fails with the company's grace-period message. - `claim_expired = -1` — the company allows expired items to be redeemed, and v2 allows it. The **per-user** `claim_expired` setting never applies to an API key, because a key is not a user and has no per-user settings to read. In v1 the user setting only takes effect when the company setting is `-1`, and it defaults to `-1` itself, so for most companies the two behave identically and nothing changes on migration. ::callout **If your company relies on per-user claim restrictions, set the company-level value before you migrate.** A user-level restriction that today blocks staff from redeeming long-expired items will not be applied to any API key. Set the company-level `claim_expired` to the same number of months to keep the restriction in force. :: ## Creating gift cards `POST /v2/items` is a negotiated compatibility operation for invoice-settled integrations. It preserves the familiar v1 field names, validation, campaign range rules, and response shape, but requires `orders:create`, `orders:settle`, and an active invoice-settlement contract. Send `company` in the query string or in the JSON body; the query string wins when both carry it. **`amount` here keeps v1's unit:** a decimal in the company's currency, so `500` is a DKK 500.00 card. Existing v1 amounts move across unchanged. The headless routes are the ones that differ — see the callout at the top of this page. For a new buyer-facing frontend, do not use this route. Use `POST /v2/quotes`, create an unpaid `POST /v2/orders`, and redirect through Lifepeaks-hosted QuickPay. Lifepeaks then controls payment, commission, and fulfillment. Those two calls take `amount` in whole øre, so multiply a v1 amount by 100. ### The POS/PMS path stays on v1 v1 settles differently for a caller registered as a POS/PMS OAuth client: pay-on-location commission and payment type instead of invoice settlement. That behaviour is selected from the OAuth **client id**, which an API key does not have. The v2 compatibility create route is invoice-settled only and contract-gated. If your integration is registered as a POS/PMS client, keep its create path on v1 until Lifepeaks agrees a v2 contract. Reading, redeeming, refunding, and reporting can move independently. Agency/marketplace, POS/PMS, pay-on-location, external-processor, and non-Lifepeaks settlement designs are negotiated and entitlement-gated; an API scope alone does not enable them. ### Delivery: the quirk that surprises everyone ::callout **When `receiver_email` equals `sender_email`, Lifepeaks sends nothing at all.** Both fields default to `no-email@lifepeaks.dk`, so **omitting both makes them equal** and the platform deliberately sends no email — the assumption is that you, the partner, hand the code to the buyer yourself. This is not new in v2: v1's create behaves identically. If you want Lifepeaks to email the gift card, pass a `receiver_email` that differs from `sender_email`. If you are handling delivery yourself, take the code and `pdf_short_url` from the create response. :: ### Attribution on created rows v1 stamps the creating user on every created row. A key is not a user, so v2 records `created_by` only when the key was minted by a backend user, and `null` otherwise. The key itself is recorded on the payment instead, so a row can still be traced back to the integration that created it. In reports, `claimed_by_username` is likewise `null` for anything an API key redeemed. ## Compatibility capture is not payment capture v1's capture finds the uncaptured order **by the user who created it** — one user cannot capture another's order, even inside the same company. The v2 compatibility route narrows by company, but it only activates an invoice-settled gift-card order that was created with `capture: false`. It does **not** capture card funds and must not be used as evidence that a buyer paid. It requires `orders:settle` plus an active invoice-settlement contract. Do not migrate the two halves of a public v1 payment workflow independently. For protected headless checkout, a browser redirect never proves payment; use the `po_…` order's canonical payment state and signed webhooks. An already activated compatibility order answers `404` because it is no longer in the uncaptured set. ## Wonderbox / GoDream passthrough The passthrough is supported on v2. v1 gates it on the logged-in user's claimable companies; v2 gates it on the company you are acting as, so a company carrying `wonderbox_implementation` can look up and redeem a GoDream code on v2 exactly as on v1. For a company without the flag, a numeric code is treated as an ordinary code. One thing to keep in mind when reconciling: responses for vendor codes are **synthesized from the vendor's API**, exactly as in v1. Those rows are bounded by what your company's vendor account returns, not by Lifepeaks' own item filtering — so a vendor code can appear in a lookup even though no Lifepeaks item exists for it yet, and vendor rows will not necessarily line up with a `GET /v2/items` search over local data. ## Response shape Unwrap one level and you are most of the way there. v1: ```json { "status": "OK", "response": { "items": [ … ], "itemsCount": 1 } } ``` v2: ```json { "object": "list", "data": [ … ], "has_more": false, "next_cursor": null } ``` Row shapes inside are unchanged, including the `reciever_name` / `reciever_email` misspelling, which is kept deliberately so parsers do not have to change. Three response details differ beyond the unwrapping: - **Every v2 list uses the same envelope.** The rows are under `data`, never `items`, `events` or `participants`; there is no `itemsCount`, `eventsCount` or `participantCount` — count `data` — and `next_cursor` is the cursor for the next page. - **Subscribers** were a bare array in v1's `response`. v2 returns the list envelope, like every other list. - **`filter`** is a v1 development-server echo (`api-dev.lifepeaks.dk`). v2 never returns it. Errors are no longer carried inside a `200`. Check the HTTP status, then `error.code`: `unauthorized` (401), `insufficient_scope` (403), `not_found` (404), `validation_failed` (422). Field-level detail arrives in `error.fields` on 422 only. ## Idempotency v1 and the v2 compatibility operations use the `X-Request-Guid` replay header. Every successful response to a compatibility mutation (`POST /v2/items`, compatibility capture, order claim, and the five item write operations) carries that response header. Send it back when retrying the identical request to replay the recorded response. New durable headless operations use a required `Idempotency-Key` instead: order creation, order refund, checkout-session creation, negotiated settlement, greeting-image finalize, every PDF-template write (upload intent, finalize, activate, delete), webhook registration/rotation/replay and ping, and order-page draft create/publish/restore. Generate and persist a stable key before the first call. Use independent keys for independent operations, and never generate a new key inside a retry loop. Two differences from v1 worth knowing: - v2 replays only for the company the original request acted on, and only after authentication — a GUID is not a bearer credential on v2. - v2 compatibility operations have no silent time-window replay (v1 replays an identical request from the same client within a few seconds even without the header). On a compatibility operation, no header means the request executes. Headless durable operations reject a missing `Idempotency-Key`. ## Suggested order of migration 1. Mint a key with `items:read` only and re-point your read calls (`GET /v2/items`, `GET /v2/items/{code}`). 2. Add `reports:read` and move analytics, claimed items and subscribers. 3. Add `items:write` and move redemption, refund, cancel, activate and resend. 4. Keep v1 creation in place until Lifepeaks confirms whether your existing invoice/POS/PMS contract is eligible for a v2 compatibility route. 5. Design any new customer storefront as a separate protected headless integration with `catalog:read`, `quotes:create`, `orders:create`, `orders:read`, `checkout:create`, and the webhook scopes it needs. The read, redemption, and reporting steps are independent because v1 and their v2 replacements operate on the same underlying items. Treat protected headless checkout and any settlement migration as separate reviewed projects. # Built-in order page ::callout **This page is about the Lifepeaks-hosted order page** — the ready-made storefront Lifepeaks serves for your company. These endpoints, and the `orderpage_*` MCP tools, change how that page looks and reads. If you have built your own frontend against the REST API, none of this applies to you. :: The order-page endpoints let you read and update your company's order-page configuration. Only fields in the **safe field catalog** are writable through the API — sensitive settings (raw HTML/CSS/JS, payment amounts, commissions) are intentionally excluded. ## Hosted order page as your storefront The hosted order page is the fastest way to start selling, because there is no frontend to build: Lifepeaks serves the page and keeps checkout, payment and delivery working, and you shape it through these endpoints — branding, colors, copy, links and images. Treat it as the quick-start path. Build your own storefront when you need a fully custom experience. That path uses the catalog, quote, order, checkout-session, and webhook resources described in [Headless checkout](https://docs.lifepeaks.dk/headless-checkout). Order-page configuration endpoints apply only to the Lifepeaks-hosted storefront. ## Endpoints | Method | Path | Scope | Description | | -------------- | ------------------------------------------------ | ------------------------------------ | --------------------------------------------- | | `GET` | `/v2/order-page` | `orderpage:read` | Get current config | | `PATCH` | `/v2/order-page` | `orderpage:write` | Partially update config | | `GET` | `/v2/order-page/schema` | `orderpage:read` | List safe configurable fields | | `GET` | `/v2/order-page/revisions` | `orderpage:read` | List published, archived, and draft revisions | | `POST` | `/v2/order-page/drafts` | `orderpage:write` | Create an isolated draft | | `GET`, `PATCH` | `/v2/order-page/drafts/{revision_id}` | `orderpage:read` / `orderpage:write` | Read or edit a draft | | `GET` | `/v2/order-page/drafts/{revision_id}/preview` | `orderpage:read` | Read the exact no-cache preview payload | | `POST` | `/v2/order-page/drafts/{revision_id}/publish` | `orderpage:write` | Publish a reviewed draft | | `POST` | `/v2/order-page/revisions/{revision_id}/restore` | `orderpage:write` | Copy an older revision into a new draft | ## Recommended draft workflow For customer-facing changes, use a draft instead of patching the live page directly: 1. Read `GET /v2/order-page/schema` and accept only its safe fields. 2. Create a draft with a stable `Idempotency-Key` at `POST /v2/order-page/drafts`. 3. Store the returned `opr_…` revision ID and `ETag`. 4. Update `fields` with `PATCH /v2/order-page/drafts/{revision_id}` and the current `If-Match` value. 5. Read the private, no-store preview payload. 6. Publish the reviewed draft with the newest `If-Match` and an independent `Idempotency-Key`. ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/order-page/drafts \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: order_page_draft_018f4f37" \ -H "Content-Type: application/json" \ -d '{"lang":"da-DK","name":"Autumn campaign"}' ``` `If-Match` prevents one editor from silently overwriting another. A stale tag returns `412`; read the current draft, reconcile the changes, and retry with its new tag. Publishing archives the previous published revision rather than mutating it. Restoring is also non-destructive. `POST /v2/order-page/revisions/{revision_id}/restore` copies the selected revision into a new draft for review and publication; it never rewrites history. ## GET /v2/order-page Returns the current order-page configuration, including all safe field values and image URLs. **Query parameters:** | Parameter | Type | Description | | --------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `lang` | string | BCP 47 locale for i18n fields (e.g. `da-DK`, `en-GB`). Defaults to `en-GB`. | | `company` | string | Slug of an assigned company to read instead of your own. See [Endpoints — Conventions](https://docs.lifepeaks.dk/endpoints#conventions). | **Example request:** ```bash curl -H "Authorization: Bearer lp_live_..." \ "https://api.lifepeaks.dk/v2/order-page?lang=da-DK" ``` **Response `200`:** ```json { "fields": { "orderform_company_box_bg": "#1a2b3c", "orderform_signup_text": "Tilmeld dig eksklusive fordele", "orderform_unsplash_enabled": 1, "gtm": "GTM-ABC1234" }, "images": { "logo": ["https://cdn.lifepeaks.dk/company/42/logo.png"] } } ``` ## PATCH /v2/order-page Partially updates the order-page configuration. Include only the fields you want to change. **Query parameters:** | Parameter | Type | Description | | --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `lang` | string | BCP 47 locale for i18n fields (overridden by `lang` in the request body if both are supplied). | | `company` | string | Slug of an assigned company to update instead of your own. It may also be sent as a `company` field in the request body, where the query string wins if both carry it. See [Endpoints — Conventions](https://docs.lifepeaks.dk/endpoints#conventions). | **Example request — update brand color and Danish signup text:** ```bash # Update brand color (not i18n, no lang needed) curl -X PATCH \ -H "Authorization: Bearer lp_live_..." \ -H "Content-Type: application/json" \ -d '{"orderform_company_box_bg": "#2d3a8c"}' \ https://api.lifepeaks.dk/v2/order-page # Update Danish copy (i18n field, pass lang) curl -X PATCH \ -H "Authorization: Bearer lp_live_..." \ -H "Content-Type: application/json" \ -d '{"orderform_signup_text": "Tilmeld dig eksklusive fordele", "lang": "da-DK"}' \ https://api.lifepeaks.dk/v2/order-page ``` Returns the full updated config (`200`) on success. Returns `422` if a field is unknown, the wrong type, or exceeds its max length. ## GET /v2/order-page/schema Returns the allow-list of configurable fields with their types, constraints, and grouping. **Example response (abbreviated):** ```json { "fields": [ { "field": "orderform_signup_text", "type": "string", "max": 2000, "i18n": true, "group": "copy", "table": "company_orderform" }, { "field": "orderform_company_box_bg", "type": "color", "max": 7, "i18n": false, "group": "theme", "table": "company_orderform" } ] } ``` ## Safe field catalog Fields are organized into five groups. Only these fields are accepted in `PATCH /v2/order-page`. ### theme Color tokens for the order-page UI. | Field | Type | Max | Notes | | -------------------------- | ------- | --- | ------------------------------------------------------- | | `orderform_company_box_bg` | `color` | 7 | Background color of the company box. Must be `#rrggbb`. | ### copy (i18n-capable) Text strings displayed on the order page. Pass `lang` to write or read in a specific locale. | Field | Type | Max | i18n | | ----------------------------- | ------ | ---- | ---- | | `orderform_text_option` | string | — | yes | | `orderform_text_value` | string | — | yes | | `orderform_text_variant` | string | — | yes | | `orderform_text_event` | string | — | yes | | `orderform_text_specialoffer` | string | — | yes | | `orderform_text_ticketcoupon` | string | — | yes | | `orderform_signup_text` | string | 2000 | yes | | `tagline` | string | — | yes | | `payment_terms_all` | string | — | yes | | `payment_terms_ticket` | string | — | yes | | `payment_terms_benefitdeal` | string | — | yes | | `footer_name` | string | 4000 | yes | ### links URLs and link-related strings. | Field | Type | Max | | ------------------------------------- | ------ | ---- | | `order_link` | string | 1000 | | `order_link_text` | string | 100 | | `order_link_subheading` | string | 255 | | `order_logo_link_url` | string | 1000 | | `orderform_policy_link` | string | 255 | | `orderform_policy_link_benefit_deals` | string | 255 | | `orderform_thankyou_redirect` | string | 1000 | ### flags Boolean toggles — integer `0` or `1`. | Field | Meaning when `1` | | ----------------------------------- | ------------------------------------------------- | | `orderform_greetings_with_image` | Include image in greeting card flow | | `orderform_unsplash_enabled` | Allow Unsplash images in greetings | | `specials_with_greetings` | Enable greetings for special offers | | `benefitdeals_with_greetings` | Enable greetings for benefit deals | | `manually_created_personalize` | Enable personalization for manually created items | | `variants_visible_only_direct` | Hide variants from aggregated listing | | `tickets_visible_only_direct` | Hide tickets from aggregated listing | | `specials_visible_only_direct` | Hide specials from aggregated listing | | `ticketcoupons_visible_only_direct` | Hide ticket coupons from aggregated listing | | `orderform_gc_phone_required` | Require phone number on gift card checkout | | `link_priority_order` | Show custom link above product list | | `custom_mail_sender_enabled` | Use company custom mail sender | | `variant_images_full` | Show variant images full-width | | `orderform_logo_full_width` | Render logo full-width | | `logo_in_menu` | Show logo inside navigation menu | | `postmail_use_phone` | Use phone for postal mail contact | ### other | Field | Type | Max | Notes | | --------------------- | ----- | --- | ------------------------------------------------ | | `orderform_languages` | `csv` | 20 | Comma-separated locale codes, e.g. `da-DK,en-GB` | | `preset_buttons` | `csv` | — | Comma-separated preset amount buttons | | `gtm` | `gtm` | 20 | Google Tag Manager ID, format `GTM-XXXXXXX` | ## Field type validation | Type | Accepted values | | -------- | ------------------------------------------------------------------ | | `string` | Any string within the `max` length limit | | `bool` | Integer `0` or `1` (or string `"0"` / `"1"`) | | `color` | 7-character hex `#rrggbb` (case-insensitive) | | `gtm` | `GTM-` followed by uppercase alphanumeric characters, max 20 chars | | `csv` | Comma-separated string within the `max` length limit | | `int` | Integer value | ## i18n (per-locale values) Fields with `i18n: true` can be written in any supported locale by passing a `lang` key alongside the field map: ```json { "orderform_signup_text": "Tilmeld dig eksklusive fordele", "lang": "da-DK" } ``` Omitting `lang` writes to the default locale (`en-GB`). Supported locales include `da-DK` and `en-GB`. To read in a specific locale, pass `?lang=da-DK` on `GET /v2/order-page`. ## What is NOT writable The following categories of fields are intentionally excluded from the safe-field catalog and will be rejected with `422`: - Raw HTML, CSS, or JavaScript (`orderform_css`, `orderform_code`, etc.) - Payment fees and commission rates - Any field not returned by `GET /v2/order-page/schema` # Branded gift-card PDFs Every company has a usable gift-card PDF from day one. Lifepeaks generates it from the onboarded company profile — name, logo where available, brand colors, locale, and currency — so nothing has to be uploaded before the first order. Partners can replace that generated default with their own artwork. Uploading does not make anything live: publishing is a separate, explicit call, and it is the only step buyers can see the effect of. ::callout **The uploaded PDF is a design surface only.** Do not draw gift-card values, codes, or validity placeholders on it. Lifepeaks overlays the authoritative values when it renders a card, and a file containing those placeholders is rejected. :: Templates are stored **per locale**, and the default locale is `da-DK`. ## Read the current template `GET /v2/pdf-templates` — scope `pdf_templates:read` ```bash curl "https://api.lifepeaks.dk/v2/pdf-templates?lang=da-DK" \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" ``` The response reports every stored version in `templates`, plus the one new orders render from in `current`. Each version carries `source` (`default` or `partner`), `status`, `version`, `bytes`, `page_count`, `sha256`, and `published_at`. Storage keys, buckets, and credentials are never returned. `GET /v2/pdf-templates/{template_id}` returns a single version in the same shape. The list omits versions with status `uploading`. Those exist only after a finalize that crashed part-way, can never be published, and are collected automatically — so what you see is the set of versions you can actually act on. Reading such a version by id still works and reports `"status": "uploading"`. ### Which version `current` reports A published partner version always outranks a generated default. Lifepeaks resolves `current` — and the template a new order is issued with — in this order: 1. published partner version for the exact locale; 2. published partner version for the same base language, any region; 3. published partner version for `en-GB`; 4. the company default for that locale. Tiers 2 and 3 let one published override serve every locale you sell in, so `current` for a locale you never uploaded artwork for can still report `source: "partner"`. That is deliberate: most partners want one design everywhere, and only upload a second when they genuinely need one. ## Publish partner artwork The lifecycle is five calls: **intent → upload → finalize → preview → publish**. Declaring the file before uploading is what makes the upload verifiable — the bytes that arrive are checked against the checksum and size you committed to, so a truncated or substituted file is caught rather than published. ### 1. Create an upload intent `POST /v2/pdf-templates/upload-intents` — scope `pdf_templates:write` ```bash FILE=gift-card.pdf curl -sS -X POST https://api.lifepeaks.dk/v2/pdf-templates/upload-intents \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: pdf_template_autumn_intent_01" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg sha "$(shasum -a 256 "$FILE" | cut -d' ' -f1)" \ --argjson size "$(wc -c < "$FILE")" \ '{language: "da-DK", sha256: $sha, byte_size: $size, mime: "application/pdf"}')" ``` **Response `201`:** ```json { "upload_intent": { "id": "pi_5a3c81e0d74b426f90ac17b5e2d8630f", "expires_at": "2026-08-27T10:46:04+00:00", "upload": { "url": "https://…", "method": "PUT", "headers": { "Content-Type": "application/pdf", "Content-Length": "284915", "x-amz-checksum-sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } } } } ``` The file may be at most **8,000,000 bytes**. A larger `byte_size` is rejected here, and the size is checked again at finalize. The intent **expires 15 minutes** after it is created. Upload and finalize inside that window; afterwards create a new intent rather than retrying the old one. An expired intent that was never finalized is garbage-collected — it and the object it reserved are deleted the next time your company creates an intent — so an abandoned upload cannot be picked up again later. ### 2. Upload the bytes ::callout **Send the file to the URL the intent gave you, with the headers it gave you.** Copy `upload.url`, `upload.method`, and the whole `upload.headers` map onto the request rather than picking fields out of them, and never hardcode a path. In a deployed environment the target is a short-lived signed storage URL, and the headers — `Content-Type`, `Content-Length`, and `x-amz-checksum-sha256` — are covered by its signature. Omitting or altering any one makes the upload fail the signature check. The exact set differs between environments, which is another reason to pass it through untouched. :: ```bash # Send every header the intent listed, exactly as given. curl -sS -X PUT "$UPLOAD_URL" \ -H "Content-Type: application/pdf" \ -H "Content-Length: $(wc -c < "$FILE")" \ -H "x-amz-checksum-sha256: $CHECKSUM_FROM_INTENT" \ --data-binary "@$FILE" ``` Storing the bytes is not the same as accepting them. Everything is verified in the next step. Against the **local development** target, `Content-Length` is not merely signed but required: without it there is nothing to size-check the upload against before reading it, so a request that omits it answers `411` with `error.code` of `length_required`. Most HTTP clients set it for you when the body is a file. Deployed uploads already carry the value the intent listed, so this only bites while developing locally. ### 3. Finalize into a version `POST /v2/pdf-templates/upload-intents/{intent_id}/finalize` — scope `pdf_templates:write` The path carries the `pi_…` intent from step 1. The template version does not exist yet; this call is what creates it, and its `pt_…` id comes back in the response. ```bash curl -sS -X POST \ "https://api.lifepeaks.dk/v2/pdf-templates/upload-intents/pi_5a3c81e0d74b426f90ac17b5e2d8630f/finalize" \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: pdf_template_autumn_finalize_01" ``` **Response `201`:** ```json { "template": { "id": "pt_63c07e19a4b85d2f701ea9c6b3d4820f", "language": "da-DK", "source": "partner", "status": "validated", "version": 3, "bytes": 284915, "page_count": 1, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "published_at": null } } ``` Finalizing checks the declared checksum, byte size, and MIME type, then parses the document for real. A file that fails any check is rejected and no version is created, so invalid artwork can never reach a buyer: | `error.code` | Status | What to fix | | --------------------------------- | ------ | ------------------------------------------------ | | `upload_checksum_mismatch` | `422` | The bytes do not match the declared `sha256` | | `upload_size_mismatch` | `422` | The bytes do not match the declared `byte_size` | | `upload_mime_mismatch` | `422` | The object is not `application/pdf` | | `uploaded_object_missing` | `422` | Nothing was uploaded for this intent | | `upload_intent_expired` | `422` | The 15-minute window closed; create a new intent | | `invalid_pdf_signature` | `422` | The file does not begin as a PDF | | `unsupported_pdf_version` | `422` | The PDF version is outside 1.3 to 1.7 | | `pdf_page_limit_exceeded` | `422` | More than 10 pages | | `encrypted_pdf_not_supported` | `422` | The document is encrypted | | `active_content_not_supported` | `422` | It embeds JavaScript or another active action | | `invalid_pdf_structure` | `422` | The document could not be parsed | | `upload_intent_already_finalized` | `409` | This intent already produced a version | Two failures are **not** your fault and are worth retrying. When the malware scanner or the PDF parser is temporarily down, finalize answers `503` with `Retry-After: 30` and an `error.code` of `malware_scanner_unavailable` or `pdf_validation_unavailable`. Wait and retry with the same idempotency key; do not treat the document as invalid. Every `422` above is a real rejection and will fail identically on retry. ### 4. Preview before publishing `GET /v2/pdf-templates/{template_id}/preview` — scope `pdf_templates:read` ```bash curl -sS -o preview.pdf \ "https://api.lifepeaks.dk/v2/pdf-templates/pt_63c07e19a4b85d2f701ea9c6b3d4820f/preview" \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" ``` The response is the document itself as `application/pdf`, served as an **attachment** under `Content-Security-Policy: default-src 'none'; sandbox`. Download the bytes and open them in a PDF viewer — pointing an `iframe` or `embed` at this endpoint will not render the file. No storage URL or credential is exposed, so a preview cannot be shared outside your authenticated integration. This is the last point at which a mistake costs nothing. ### 5. Publish `POST /v2/pdf-templates/{template_id}/publish` — scope `pdf_templates:write` ```bash curl -sS -X POST \ "https://api.lifepeaks.dk/v2/pdf-templates/pt_63c07e19a4b85d2f701ea9c6b3d4820f/publish" \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: pdf_template_autumn_publish_01" ``` The version becomes `published` and the one it replaces becomes `superseded`. A `validated` **or `superseded`** partner version can be published, so artwork retired by a revert or by a later publish can be brought back without re-uploading it. Publishing a version that is already live is a no-op and returns it unchanged. Only two things are refused, both `409`: a Lifepeaks-generated default (`partner_template_required`), and a version that never passed validation (`validated_template_required`). ## Revert to the generated default `POST /v2/pdf-templates/revert-to-default` — scope `pdf_templates:write` ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/pdf-templates/revert-to-default \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: pdf_template_revert_dadk_01" \ -H "Content-Type: application/json" \ -d '{"language": "da-DK"}' ``` This is the rollback for a publish that turned out wrong. ::callout **Reverting is company-wide, not per locale.** It supersedes *every* published partner version you hold, across all locales, and then publishes the generated default for the locale you name — so `language` selects which default goes live, not which locale gets retired. It has to work that way. Because the selection chain lets a published override for one locale serve another, retiring a single locale would leave that override still reachable through the base-language and `en-GB` tiers. :: Nothing is deleted — superseded partner versions stay in the history and can be published again later without re-uploading. The call creates no new version and is safe to repeat. ## Retries `upload-intents`, `finalize`, `publish`, and `revert-to-default` all **require** an `Idempotency-Key` header. Generate one per logical action, store it, and reuse it unchanged when retrying, exactly as on the [headless checkout](https://docs.lifepeaks.dk/headless-checkout#idempotency-rules) path. A replay returns the original result instead of acting twice, so a timeout can never leave you unsure whether artwork went live. Use a separate key for each step — reusing the intent key on finalize answers `409`. The upload itself takes no key: the intent it targets already identifies the action, and re-sending the same bytes to the same target is harmless. ## Which template an order uses A new order is issued with whatever `current` reports for its locale, following the same four-tier chain described under [Which version `current` reports](https://docs.lifepeaks.dk/#which-version-current-reports). **Orders snapshot their template at creation.** A v2 order records the version in force at that moment, so publishing new artwork later never rewrites a document a buyer has already received. Once an order is fulfilled you can fetch each line's document from its `gift_card_pdf` link — see [Headless checkout](https://docs.lifepeaks.dk/headless-checkout#hand-over-the-gift-card-document). **An experience may bring its own artwork.** When a company has configured a PDF template on one of its experiences, the cards bought from that experience are branded with it, and every other line of the same order uses the company's published v2 template. The cards of one order need not look alike, which is why each line has its own download. ## Changing the generated default The generated default is derived from the company's brand profile. Updating the profile or its assets through the [brand endpoints](https://docs.lifepeaks.dk/endpoints#account-and-company-surface) regenerates the default as a **new version** — it never changes which template is live, so a company running a published partner override keeps it until it reverts explicitly. # Signed webhooks Webhooks notify your backend when an order's payment or fulfillment changes. They complement, but do not replace, the [canonical order read](https://docs.lifepeaks.dk/headless-checkout#confirm-payment-on-the-return-page). ## Register an endpoint The endpoint must be an approved, publicly reachable HTTPS URL. Create a stable idempotency key before the request and store the returned secret immediately. ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/webhook-endpoints \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: webhook_endpoint_primary_v1" \ -H "Content-Type: application/json" \ -d '{ "url":"https://partner.example/webhooks/lifepeaks", "events":["order.payment_authorized","order.fulfilled","order.payment_failed"] }' ``` The first successful `201` response includes a `whsec_…` signing secret. It is shown once; list responses never return it. Store it in a managed secret store, outside source control and logs. An idempotent replay returns `200` without the secret. If the first response was lost before you stored it, rotate the endpoint secret and store the new one-time value. Do not create duplicate endpoints to recover a missing secret. ## Verify before parsing Lifepeaks sends these headers: | Header | Purpose | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `Lifepeaks-Webhook-Id` | Immutable `evt_…` event ID and deduplication key | | `Lifepeaks-Webhook-Timestamp` | Unix timestamp in seconds | | `Lifepeaks-Webhook-Secret-Version` | The **current** secret version. During a rotation overlap it does not name the previous one, so do not use it to choose a secret | | `Lifepeaks-Webhook-Signature` | One or more `v1=` HMAC-SHA256 hex digests. During a rotation overlap it carries **comma-separated** values, newest first | ::callout **Compare against every value in the signature header.** It normally carries one `v1=` digest, but during a [rotation overlap](https://docs.lifepeaks.dk/#rotate-without-downtime) it carries one per live secret. Compute your digest once and accept a match on any of them; never try to pick a value by version. The verifier below already does this. :: The signed bytes are exactly: ```text timestamp + "." + event_id + "." + raw_request_body ``` Read the raw request body before JSON parsing or middleware changes whitespace. Reject stale timestamps, compare signatures in constant time, and only then parse the JSON. This complete verifier uses Node/Bun's standard crypto implementation: ```ts import { createHmac, timingSafeEqual } from 'node:crypto' export function verifyLifepeaksWebhook(input: { secret: string eventId: string timestamp: number rawBody: string signature: string now?: number }): boolean { const now = input.now ?? Math.floor(Date.now() / 1000) if (!input.secret || !input.eventId || input.eventId.includes('.')) return false if (!Number.isSafeInteger(input.timestamp) || Math.abs(now - input.timestamp) > 300) return false const signed = `${input.timestamp}.${input.eventId}.${input.rawBody}` const expected = Buffer.from( `v1=${createHmac('sha256', input.secret).update(signed).digest('hex')}`, ) return input.signature.split(',').some((value) => { const candidate = Buffer.from(value.trim()) return candidate.length === expected.length && timingSafeEqual(candidate, expected) }) } ``` ## Acknowledge durably Process each request in this order: 1. Limit the request body size. 2. Read the exact raw body and required headers. 3. Reject timestamps outside a five-minute tolerance. 4. Verify the signature in constant time. 5. Parse JSON and validate `id`, `type`, `api_version`, and `data.order`. 6. Insert `Lifepeaks-Webhook-Id` into durable storage with a unique constraint. 7. Commit the event to your queue or inbox table. 8. Return `204` only after that durable commit. Repeated event IDs are normal. A duplicate that is already durable should also receive `204`, without running business side effects again. Return a non-2xx response when the event was not made durable so Lifepeaks can retry it. ::callout **The event is a prompt to reconcile.** Its embedded order is useful context, but read `GET /v2/orders/{order_id}` before an irreversible partner-side action. Never fulfill from a browser return or an unsigned body. :: ## Event types | Event | Meaning | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `order.created` | An order was accepted. It fires before any payment, immediately after the order commits, so it never describes an order that rolled back. A retried create fires no second event. Queueing it can never refuse the order: an outbox failure is logged and the order still succeeds, so on the rare occasion one is lost, reconcile with `GET /v2/orders`. | | `order.payment_authorized` | Lifepeaks confirmed payment authorization. Fulfillment may still be processing. | | `order.fulfilled` | Lifepeaks completed gift-card fulfillment. | | `order.payment_failed` | Payment failed; no gift card should be presented as delivered. | | `order.refunded` | The order payment was fully refunded. | | `order.partially_refunded` | The order payment was partially refunded. | | `order.expired` | Nobody paid for the order in time. Lifepeaks wrote it off and set its payment status to `cancelled`. Terminal: an expired order cannot be paid, and no further event follows it. | | `webhook.ping` | A test event you asked for. It is not subscribable, so it never appears in an endpoint's `events` list. | **`order.expired` closes a basket the buyer walked away from.** An order expires when its hosted checkout link lapsed more than an hour ago, or when it never started checkout at all and is more than a day old. An order whose card was declined expires on the same two windows: a declined payment is not terminal on its own, because the buyer can retry checkout on the same order, so it is given the same grace as an untouched one before being written off. Both windows are deliberately generous, so a buyer still sitting on the payment page is never written off, and an order whose payment lands before the sweep runs is never expired. Subscribe to it if you keep your own basket state: before it existed, an abandoned checkout stayed `pending_payment` for ever and could not be told from one still being paid. **A webhook payload carries no buyer detail.** It embeds the order object without `sender`, `recipient`, `greeting` or the postal address, because it is delivered to a URL you configured rather than answered to an authenticated caller. That boundary is the one that actually protects the buyer: the authenticated read carries the greeting the buyer wrote, and the outbound payload never does. Both sides are pinned by tests. `reference`, `delivery.method` and `delivery.send_at` are present, which is what a subscriber needs to reconcile an order and to tell a posted card from an e-mailed one. Webhook payloads have this envelope: ```json { "id": "evt_0123456789abcdef0123456789abcdef", "type": "order.fulfilled", "api_version": "v2", "occurred_at": "2026-08-22T12:00:00+00:00", "data": { "order": { "id": "po_0123456789abcdef0123456789abcdef" } } } ``` Use the embedded `id` only after verifying that it matches `Lifepeaks-Webhook-Id`. ## Test an endpoint before it matters `POST /v2/webhook-endpoints/{endpoint_id}/ping` — scope `webhooks:write` Queues a signed `webhook.ping` event for one endpoint, so you can prove your connectivity and your signature check work before a real order depends on them. ```bash curl -sS -X POST https://api.lifepeaks.dk/v2/webhook-endpoints/wh_8f2c/ping \ -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` The ping is a real delivery. It is signed with the endpoint's current secret, carries the same headers as any other event, retries on the same schedule, and appears in `GET /v2/webhook-deliveries` beside your order events. If you can verify a ping, you can verify anything. The body carries nothing, and sending request fields answers `422` rather than ignoring them. `data` is the endpoint's own id: ```json { "id": "evt_0123456789abcdef0123456789abcdef", "type": "webhook.ping", "api_version": "v2", "occurred_at": "2026-09-03T10:14:00+00:00", "data": { "endpoint": { "id": "wh_8f2c" } } } ``` A handler that does not recognise `webhook.ping` should answer `2xx` and ignore it, the same as any other type it does not act on. Naming the endpoint is the request, so a ping arrives whatever the endpoint is subscribed to — you cannot subscribe to it and it is not a valid entry in an endpoint's `events` list. The answer is `202`, not `200`: the delivery is queued, and its outcome arrives later. Read it back from `GET /v2/webhook-deliveries` rather than inferring success here. `Idempotency-Key` is required and doubles as the event's identity, so two pings with the same key are one event and one delivery; use a fresh key to send another. A disabled endpoint answers `404`, the same as one that never existed. ## Rotate without downtime Lifepeaks runs the overlap, so rotation needs no coordinated switch-over: 1. **Rotate.** `POST /v2/webhook-endpoints/{endpoint_id}/rotate-secret` returns the new `whsec_…` secret — once — along with `previous_secret_version` and `previous_secret_expires_at`. 2. **Read the deadline.** `previous_secret_expires_at` is when the old secret stops being accepted, 24 hours after the rotation. 3. **Install the new secret before that instant.** Until then every delivery is signed with *both* secrets, so a receiver still holding only the old one keeps verifying. A receiver that verifies against every value in the signature header — as the one above does — needs no change at all beyond the new secret value. 4. **Do nothing else.** The old secret expires by itself once the window closes, and deliveries go back to a single signature. ```json { "id": "wh_9d41b7e0c8524fa63b1e70d5482ac9f6", "secret_version": 2, "previous_secret_version": 1, "previous_secret_expires_at": "2026-08-28T04:13:05+00:00", "secret": "whsec_Kd8pR1zW6vN3yT5bG9hJ2mQ4sX7cF0aL" } ``` Both overlap fields read `null` when no window is open — on an endpoint that has never been rotated, and once a window has closed. **Rotating again inside an open window retires the older secret immediately.** Only two secrets are ever live, so a second rotation before you have deployed the first one will cut off a receiver still using the original. Deploy, then rotate again. ## Operate and recover - `GET /v2/webhook-endpoints` lists endpoint status, failure counts, and any open rotation overlap, without secrets. - `POST /v2/webhook-endpoints/{endpoint_id}/rotate-secret` returns the new secret on the first execution only. Use a stable, operation-specific `Idempotency-Key`. - `GET /v2/webhook-deliveries` shows recent attempts and terminal state. - `POST /v2/webhook-deliveries/{delivery_id}/replay` requeues an eligible delivery. Its idempotency key must be independent from registration and rotation keys. - `DELETE /v2/webhook-endpoints/{endpoint_id}` disables the endpoint and cancels pending deliveries. The [Lifepeaks storefront starter](https://github.com/Lifepeaks/lifepeaks-storefront-starter){rel=""nofollow""} ships a working receiver with durable deduplication, timestamp checks, and tamper tests. The repository is private, so ask Lifepeaks for access first. Run `bun run doctor` before exposing your endpoint publicly. # Starter templates The [Lifepeaks storefront starter](https://github.com/Lifepeaks/lifepeaks-storefront-starter){rel=""nofollow""} is one production-shaped partner application with five Figma-composed, non-classic templates. The shared application owns API calls, server-only secrets, durable idempotency, webhook verification, and canonical order reconciliation. A template changes presentation without forking that security boundary. The classic compatibility template remains separate and untouched. ## External standalone The starter repository is currently private. Ask Lifepeaks for access, then clone it and run the generator from its repository root. Your API key, approved return origin, and webhook secret are issued separately during partner onboarding — the generator never creates them. ```bash git clone https://github.com/Lifepeaks/lifepeaks-storefront-starter.git cd lifepeaks-storefront-starter bun run create:storefront -- --template restaurant --out ../my-lifepeaks-storefront cd ../my-lifepeaks-storefront bun install cp .env.example .env chmod 0600 .env # Fill .env with the credentials supplied by Lifepeaks. bun run doctor bun run check bun run dev ``` Open the local URL printed by `bun run dev`. Replace `restaurant` with `hotel`, `spa-wellness`, `retail`, or `experience` to start with another vertical. The generated package also supports the non-watching local server `bun run start`. ## Customization layers The generator copies application code into the new repository. Customize it in layers: 1. Edit `storefront.manifest.json` for brand, copy, locale, buyer guidance, and display choices. `page.locations` defines the visible location options and `page.designs` defines labels and images for visible designs. `amountPresets` are display suggestions only: positive whole numbers in the smallest unit of the currency, where `50000` is DKK 500.00. 2. Replace project-local files in `public/assets` and update their `/assets` references in the manifest. 3. Change the `--lp-*` custom properties in `src/ui/tokens/tokens.css` for global theme values. 4. Edit the owned component copies under `src/ui` for structural or component-level changes. The starter already contains these copies, so users do not need access to the private component registry. Generated projects contain owned copies and no component updater. Edit those files directly; they are yours. A separate Lifepeaks component registry exists for teams that want to pull refreshed components, and access to it is granted on request — note that installing over a component replaces your edits to it. ## Server-authoritative commerce Manifest values never become commerce authority. The Lifepeaks catalog supplies currency, amount limits, pricing type, quantity limits, and delivery methods. The current starter supports only `DKK`, requires variable pricing, sends every card by e-mail, and always submits quantity one even when the catalog maximum is higher. Those are limits of the starter, not of the API: the catalog publishes postal delivery and the order route accepts it. The server obtains the authoritative quote, refreshes it when inputs change or it expires, and refuses an expired quote. Checkout redirects to the Lifepeaks-hosted QuickPay page. Canonical order reads and signed, deduplicated webhooks remain authoritative after the browser returns. Keep catalog, quote, checkout, order, and webhook calls in the reviewed server adapter. The manifest cannot change payable totals, fees, commission, merchant, callback, capture, idempotency, fulfillment, API-key, or webhook-secret policy. ## Intentionally omitted UI The five non-classic templates intentionally omit cart, quantity controls, add-ons, greeting fields, uploads, and alternate delivery methods. Every one of those has a public API contract already — see [How the card is delivered](https://docs.lifepeaks.dk/endpoints#how-the-card-is-delivered) and [What a buyer can add to the card](https://docs.lifepeaks.dk/endpoints#what-a-buyer-can-add-to-the-card) — so extending a starter is your own server validation, idempotency and tests against a contract that exists, not manifest-only controls and not a wait. ## Production environment The required runtime fields are `LIFEPEAKS_API_BASE_URL` (normally `https://api.lifepeaks.dk`), `LIFEPEAKS_API_KEY`, `LIFEPEAKS_COMPANY_ID`, and the HTTPS `PARTNER_PUBLIC_URL`. Doctor readiness also requires `LIFEPEAKS_APPROVED_RETURN_ORIGIN` to equal the public URL, a current `LIFEPEAKS_WEBHOOK_SECRET`, and exactly these scopes: ```text catalog:read,quotes:create,orders:create,orders:read,checkout:create ``` `LIFEPEAKS_WEBHOOK_SECRET` is the current secret. `LIFEPEAKS_WEBHOOK_PREVIOUS_SECRET` is optional during rotation; keep both values only for the overlap window. Other optional fields are `LIFEPEAKS_COMPANY_SLUG` for an assigned agency tenant, `PARTNER_DB_PATH` (default `./var/storefront.sqlite`), `PARTNER_HOST` (default `127.0.0.1`), and `PARTNER_PORT` (default `4310`). `LIFEPEAKS_API_HOST_HEADER` is local-only and must not be used with `https://api.lifepeaks.dk`. Production deployments need HTTPS, server-only secret storage, and durable storage for the database path. Run `bun run doctor` after configuration changes. It checks required secrets and origins without printing their values. Run the starter's full check command before deployment: ```bash bun run check ``` ## Current templates Every current template checks out the same product: `gift_card_value`, a variable-value gift card, and sends it by e-mail. The template does not create a new Lifepeaks product type. The API sells the company's experiences through the same quote and order routes, so adding experience lines to a starter is a change in your own code rather than a different integration — see [Endpoints — Quote and order lines](https://docs.lifepeaks.dk/endpoints#quote-and-order-lines). | Template | Customer framing | Guide | | -------------- | --------------------- | ------------------------------------------------------------------ | | Restaurant | Dining gift value | [Restaurant](https://docs.lifepeaks.dk/templates/restaurant) | | Hotel | Stay gift value | [Hotel](https://docs.lifepeaks.dk/templates/hotel) | | Spa & wellness | Wellness gift value | [Spa & wellness](https://docs.lifepeaks.dk/templates/spa-wellness) | | Retail | Store gift value | [Retail](https://docs.lifepeaks.dk/templates/retail) | | Experience | Experience gift value | [Experience](https://docs.lifepeaks.dk/templates/experience) | ## Capability boundaries Event tickets and special offers are sold through the API, as the `event_ticket` and `special_offer` product families. **No starter template presents them.** Every template here sells gift value, so treat a ticket or an offer as work you build on top: read the families in [Endpoints — The product catalog](https://docs.lifepeaks.dk/endpoints#the-product-catalog) and price them with `POST /v2/quotes` like any other line. `GET /v2/events` remains a reporting route and is not a checkout contract. Agency, marketplace, POS, and PMS models require Lifepeaks review because tenancy, liabilities, and payment responsibilities differ. Their availability is negotiated and entitlement-gated. Non-Lifepeaks settlement is negotiated and entitlement-gated. `POST /v2/orders/{order_id}/settle` requires a dedicated scope plus an active commercial entitlement for the tenant, credential, and settlement mode; it is disabled by default. Do not design a public integration around it until Lifepeaks confirms the contract. Physical delivery, inventory, booking, capacity, fixed packages, and ticket issuance are not implied by a template's copy or imagery. Integrate those concerns in your own system only when you have a separate source of truth and a documented Lifepeaks contract. ## Production checklist - Use HTTPS for the storefront, return URL, and webhook endpoint. - Keep the Lifepeaks key and webhook secret in managed server-side secrets. - Persist operation keys, partner references, `po_…` IDs, and webhook event IDs in a transactional database. - Apply rate limits and request-size limits at the public partner edge. - Allow only your exact frontend origin to call partner checkout endpoints. - Log Lifepeaks resource IDs and idempotency keys, never credentials or recipient data. - Poll canonical order state on return and consume signed webhooks in the background. - Test successful, cancelled, failed, expired-session, lost-callback, and duplicate-delivery paths. Continue with [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) for the request sequence and [Signed webhooks](https://docs.lifepeaks.dk/signed-webhooks) for the receiver contract. # Experience template Use this template to sell for an attraction, activity, or experience provider. It checks out `gift_card_value`, the variable-value gift card, and the provider's own experiences are sold through the same quote and order routes. Fulfillment is email delivery after confirmed payment. ```bash bun run create:storefront -- \ --template experience \ --out ../my-experience-storefront ``` ## Customer journey 1. Tell the experience story and explain how the gift is redeemed. 2. Offer the provider's experiences and their live options, or suggested amounts within live catalog limits plus custom value. 3. Display the authoritative Lifepeaks quote and total. 4. Collect sender and recipient details through your backend. 5. Redirect to Lifepeaks-hosted QuickPay. 6. Reconcile the canonical order and wait for fulfilled email delivery. Occasion-led labels can inspire buyers without implying that a specific session has been purchased. ## Experiences in the catalog `GET /v2/products` publishes the company's experiences alongside the value gift card. Each one is a `gcv_` product carrying its name, description, formalities, images, categories, validity, and an `options` array of priced `gcvo_` entries with quantity bounds and stock. Read them to build a browsing experience that matches what the provider actually offers, instead of inventing a package list in your frontend. Two things the list decides for you. A provider that never configured gift-card amount limits sells no value card, so `gift_card_value` is simply absent and the experiences stand alone. An experience with a gift-card campaign running on it is withheld from the API entirely and is sold through the Lifepeaks order page until the campaign ends. ## Selling an experience An experience line names the product and the option the buyer picked, and never a price: ```json {"product_id": "gcv_4821", "product_option_id": "gcvo_9107", "quantity": 2} ``` Send it to `POST /v2/quotes` and then to `POST /v2/orders`, exactly as with a value line. One order may carry up to twenty experience lines, and it is paid once. One order buys one family, so a basket that mixes an experience with gift value is checked out as two orders. Each line has its own gift-card document, and a fulfilled order carries a download per line. A line that bought several cards downloads all of them in that one document. See [Endpoints — Quote and order lines](https://docs.lifepeaks.dk/endpoints#quote-and-order-lines). ## Integration boundary This template does not claim to sell admission, a dated ticket, a timeslot, participant capacity, a reservation, or event registration. It does not issue tickets. **Sell a dated ticket as an event, not as an experience.** The API has an `event_ticket` family of its own — `evt_` products with `evto_` ticket options, real dates, real seat capacity and a per-seat fee — and that is what issues a ticket. Do not simulate one with `gift_card_value` or with a catalog experience. See [Endpoints — An event and its tickets](https://docs.lifepeaks.dk/endpoints#an-event-and-its-tickets). Keep schedules, capacity, waivers, and booking references in the experience provider's system. Do not convert a suggested amount, or a listed experience option, into a capacity promise. ## Launch checks - Explain the redemption and booking steps after the recipient receives the gift card. - Avoid "ticket confirmed" or "booking complete" language. - Show experience prices from live `options`, never from a hardcoded list. - Test an option that sells out between the quote and the order, which is refused with `422`. - Test quote expiry and an expired checkout-session renewal. - Test a multi-line order and download each line's gift-card PDF. - Test payment failure without delivery. - Test webhook replay and canonical-order reconciliation. Review [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) and the shared [Starter templates](https://docs.lifepeaks.dk/starter-templates) production checklist before launch. # Hotel template Use this template to sell flexible hotel gift value in the hotel's brand. The current Lifepeaks product is `gift_card_value`, with email delivery to the recipient after confirmed payment. ```bash bun run create:storefront -- \ --template hotel \ --out ../my-hotel-storefront ``` ## Customer journey 1. Present the property, destination, and occasions a hotel gift card suits. 2. Offer suggested amounts inside current catalog limits and retain a custom-value option. 3. Render the authoritative Lifepeaks quote and fee-inclusive total. 4. Collect sender and recipient details through your partner backend. 5. Redirect the buyer to Lifepeaks-hosted QuickPay. 6. Reconcile the canonical order on return and display completion only after fulfillment. Labels such as “Weekend contribution”, “Dinner and stay gift”, or “A special escape” are marketing copy, not fixed inventory. ## Integration boundary This template does not claim to sell a room, room-night, stay package, reservation, occupancy, dated availability, or booking confirmation. It does not connect to a PMS. `gift_card_value` remains flexible monetary value and email delivery remains the only current template delivery method. Keep booking availability and reservation references in the hotel's booking system. Agency, POS, and PMS behavior requires a negotiated Lifepeaks integration; a template selection does not grant those entitlements. ## Launch checks - State redemption locations, restrictions, validity, and booking conditions clearly. - Never imply that buying gift value reserves a date or room type. - Test quote expiry and changed catalog limits. - Test cancelled and failed payment without fulfillment. - Test canonical return polling and signed webhook recovery. Review [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) and the shared [Starter templates](https://docs.lifepeaks.dk/starter-templates) production checklist before launch. # Restaurant template Use this template to sell flexible dining gift value from a restaurant-branded storefront. The current Lifepeaks product is `gift_card_value`, and fulfillment is email delivery to the recipient. ```bash bun run create:storefront -- \ --template restaurant \ --out ../my-restaurant-storefront ``` ## Customer journey 1. Introduce the restaurant, atmosphere, and occasions a gift card suits. 2. Offer suggested amounts within the live catalog minimum and maximum, while keeping a custom value option. 3. Show the authoritative Lifepeaks quote before the buyer confirms. 4. Collect sender and recipient names and email addresses on your backend-backed form. 5. Redirect to Lifepeaks-hosted QuickPay. 6. On return, show canonical order state and wait for fulfilled email delivery. Useful amount labels include “Lunch for two”, “Dinner contribution”, and “Celebration gift”. They are customer-facing suggestions only; every purchase remains variable monetary value. ## Integration boundary This template does not claim to sell a fixed menu, table reservation, seating capacity, dated booking, takeaway order, or event ticket. It does not reserve a table. If you link to a reservation system, keep its booking reference and availability separate from the Lifepeaks order. Do not encode a menu price or reservation entitlement into `product_id`. Read `gift_card_value` from the catalog, quote the chosen `amount`, and let Lifepeaks control the payable total and payment. ## Launch checks - Explain where the gift card may be redeemed and link the restaurant's own terms. - Make recipient email confirmation clear before payment. - Test a cancelled QuickPay session without presenting a gift card. - Test the successful path until `fulfillment.status` is `fulfilled`. - Keep booking or POS integration claims out of the page unless Lifepeaks has approved a separate contract. Review [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) and the shared [Starter templates](https://docs.lifepeaks.dk/starter-templates) production checklist before launch. # Retail template Use this template to sell flexible store gift value from a retail-branded frontend. The current Lifepeaks product is `gift_card_value`, delivered by email after payment and fulfillment are confirmed. ```bash bun run create:storefront -- \ --template retail \ --out ../my-retail-storefront ``` ## Customer journey 1. Present the brand, range, and eligible redemption channels. 2. Offer suggested values within current catalog limits and allow a custom amount. 3. Render the authoritative Lifepeaks quote instead of calculating fees in the browser. 4. Collect sender and recipient details through the partner backend. 5. Redirect to Lifepeaks-hosted QuickPay. 6. Read canonical order state on return and confirm email delivery only after fulfillment. Suggested values can be framed by occasion or budget. They remain monetary gift value rather than a SKU. ## Integration boundary This template does not claim to sell physical goods, a fixed product bundle, store inventory, shipping, pickup, returns logistics, or a SKU reservation. Email delivery is digital; it is not physical gift-card delivery. If your ecommerce system lets a customer redeem the value online, that redemption integration is separate from checkout. Keep product inventory and shipment status in the ecommerce platform, not in the Lifepeaks gift-card order. ## Launch checks - State online and in-store redemption eligibility precisely. - Keep physical-delivery controls out of the form unless separately implemented. - Test currency formatting from whole numbers of the smallest unit of the currency (`50000` is DKK 500.00). - Test canonical payment failure and cancellation states. - Deduplicate webhook-driven customer notifications. Review [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) and the shared [Starter templates](https://docs.lifepeaks.dk/starter-templates) production checklist before launch. # Spa & wellness template Use this template to sell flexible wellness gift value for a spa, clinic, or salon. The current Lifepeaks product is `gift_card_value`; successful fulfillment uses email delivery. ```bash bun run create:storefront -- \ --template spa-wellness \ --out ../my-wellness-storefront ``` ## Customer journey 1. Present the venue, approach, and gift occasions without promising a specific appointment. 2. Offer suggested values within the live catalog range and a custom-value option. 3. Show the authoritative quote calculated by Lifepeaks. 4. Collect sender and recipient identity and email through your backend. 5. Redirect to Lifepeaks-hosted QuickPay. 6. Resolve the return to canonical order state and wait for fulfilled email delivery. Copy such as “Time to unwind” or “Wellness contribution” can make the flexible value relevant without converting it into a package. ## Integration boundary This template does not claim to sell a fixed treatment, appointment, practitioner slot, dated package, membership, or capacity-controlled booking. It does not reserve treatment availability. Keep scheduling in the wellness business's appointment system. Do not represent a suggested amount as a guaranteed treatment price. Prices and availability can change independently; redemption terms should explain how the monetary gift value is applied. ## Launch checks - Use careful language around treatments, suitability, and redemption conditions. - Make the recipient email visible for confirmation before checkout. - Verify responsive layouts and accessible form labels. - Test failed payment and webhook retries without duplicate customer messages. - Confirm the partner UI waits for canonical fulfillment. Review [Headless checkout](https://docs.lifepeaks.dk/headless-checkout) and the shared [Starter templates](https://docs.lifepeaks.dk/starter-templates) production checklist before launch. # API info :legacy-banner # API info **LifePeaks.dk API**, specification version **1.3**. Lifepeaks.dk API uses standard OAuth2 REST API. ## Servers Three environments serve the same v1 surface. Pick the base URL for the environment you are integrating against and prefix every path in the [v1 API reference](https://docs.lifepeaks.dk/reference/v1) with it. | Environment | Base URL | Description | | ----------- | ------------------------------- | ------------------------------- | | LIVE | `https://api.lifepeaks.dk` | LIVE API | | Developer | `https://api-dev.lifepeaks.dk` | Developer / testing API version | | Demo | `https://api-demo.lifepeaks.dk` | Demo version | The LIVE server accepts `https` only. The developer and demo servers accept `http` or `https`, and default to `https`. Prefix the base URL with a language segment to change the response language — see [Data](https://docs.lifepeaks.dk/v1/data). The developer and demo environments additionally return a `request` debug object that LIVE omits — see [Testing](https://docs.lifepeaks.dk/v1/testing). ## Contact information | | | | ----- | ------------------------------------------------------------------- | | Name | API Support | | Email | | | URL | {rel=""nofollow""} | ## Terms of service {rel=""nofollow""} # Authorization of Request :legacy-banner # Authorization of Request Authorization is done via headers with included "Authorization" header with access token: ```http Authorization: Bearer {{Access Token}} ``` Access Token can be retrieved from API by Grant Type Authorization Request. ![Authorization request configured in Postman](https://docs.lifepeaks.dk/img/v1/auth.png) (example from Postman) Callback URL is the url where will be user redirected after successful login. | Setting | Live | Developer version | | ---------------- | ------------------------------------ | ---------------------------------------- | | Auth URL | `https://api.lifepeaks.dk/authorize` | `https://api-dev.lifepeaks.dk/authorize` | | Access Token URL | `https://api.lifepeaks.dk/token` | `https://api-dev.lifepeaks.dk/token` | State parameter can be anything and is required, more info {rel=""nofollow""} ## Grant types Three grant types are enabled. There is no `client_credentials` grant. | `grant_type` | Additional fields | Used for | | -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | `password` | `username`, `password` | Server-to-server integrations. See [Authorization via curl](https://docs.lifepeaks.dk/v1/authorization-curl). | | `authorization_code` | `code`, `redirect_uri` | Browser flows that send the user through `/authorize`. | | `refresh_token` | `refresh_token` | Exchanging a refresh token for a new access token. A new refresh token is issued every time. | `client_id` and `client_secret` are required for all three. They can be sent in the form body, or as HTTP Basic auth. Access tokens are valid for **86400 seconds (24 hours)**. Refresh tokens expire after **1209600 seconds (14 days)**. ## Endpoints | Endpoint | Purpose | | --------------------- | ------------------------------------------------------------------------------------------------------ | | `POST /token` | Exchange credentials for an access token. Also reachable as `POST /oauth2/token`. | | `POST /oauth2/revoke` | Revoke an access or refresh token. Takes `token` and an optional `token_type_hint`. | | `GET /authorize` | Browser entry point for the authorization code flow. Redirects to `/login` when no session exists. | | `GET /login` | Login form for the authorization code flow. Posting valid credentials continues the authorize request. | | `GET /logout` | Ends the browser session. | The full request and response shapes are in the [v1 API reference](https://docs.lifepeaks.dk/reference/v1). ## Idempotency Every response carries an `X-Request-Guid` header. Sending that value back as the `X-Request-Guid` request header on a retry replays the recorded response instead of executing the action twice. See [Data](https://docs.lifepeaks.dk/v1/data). ## Company credentials API Client ID and API secret can be found in company settings in Super admin users. You will need to fill in also API redirect (callback) URL in company settings. ![Company API settings in the admin](https://docs.lifepeaks.dk/img/v1/company-api.png) ## API users You will need an user with the user right API user for accessing API. Super admins can create API users as other users in Users Admin. Every company can have more users: one for POS, another for website, another for affiliates, etc. ![Creating an API user in the admin](https://docs.lifepeaks.dk/img/v1/user.png) # Authorization via curl :legacy-banner # Authorization via curl You can get authorize code via curl using password grant type. ## Example ```bash curl "https://api-dev.lifepeaks.dk/token" \ -d 'client_id={clientId}&client_secret={clientSecret}&grant_type=password&username={apiUserUsername}&password={apiUserPassword}' ``` Response example: ```json { "access_token": "413cf40b7c3e2f05d329ed3577441c2311366497", "expires_in": 86400, "token_type": "Bearer", "scope": "admin", "refresh_token": "e01008fda47a4fd2ac17eccb9dcef62187372266" } ``` ## Refreshing the token When the access token expires after 24 hours, exchange the refresh token rather than sending the credentials again. A new refresh token is issued each time, so store the one from the latest response. ```bash curl "https://api-dev.lifepeaks.dk/token" \ -d 'client_id={clientId}&client_secret={clientSecret}&grant_type=refresh_token&refresh_token={refreshToken}' ``` ## FAQ **Q: I have incorrect credentials. Error is saying "Invalid username and password combination".** A: Ensure values in request are encoded. For testing you can use service like {rel=""nofollow""}. **Q: Auth request returns error on my GET request.** A: Auth request is accepting **POST** requests. # Data :legacy-banner # Data API returns JSON-encoded responses. API works with UTC dates. DateTime in format `yyyy-MM-dd HH:mm:ss`. Date in format `yyyy-MM-dd`. Charset is UTF-8. ## Language Language is controlled by url. Danish language is default when you need English, Swedish or German, you need to add `en/` for English etc. | Language | Base URL | | -------- | ------------------------------ | | Danish | `https://api.lifepeaks.dk/` | | English | `https://api.lifepeaks.dk/en/` | | Swedish | `https://api.lifepeaks.dk/se/` | | German | `https://api.lifepeaks.dk/de/` | ## Guid You will receive `X-Request-Guid` in header in every request. It is recomended to use this value in future requests to avoid duplicate requests. Use it in critical requests like claim, refund, cancel, create gift card etc. Example for claiming: Each response from our server includes an `X-Request-Guid` header. You can retrieve this when sending the initial API call (e.g., detail action) prior to making the claim request. Once saved, this GUID should be included in the subsequent claim request. If the same GUID is detected again, the server will recognize the duplicate and return the already-processed response without executing the action again. # FAQ :legacy-banner # FAQ ## I have incorrect credentials. Error is saying "Invalid username and password combination". Ensure values in request are encoded. For testing you can use service like {rel=""nofollow""}. ## Auth request returns error on my GET request. Auth request is accepting **POST** requests. ## Do generated refresh tokens expire? Yes, generated refresh tokens in our implementation expire after 14 days. This duration is configured in line with our security policies to balance convenience and security. ## How long is an access token valid? Access tokens are valid for 24 hours. This duration was chosen to ensure that users maintain an active session while minimizing the risk of long-term exposure in case of token compromise. ## If 2 clients generate a new access token in parallel, are both access tokens valid? If so, how big a buffer of parallel tokens do you have? Yes, if two clients generate new access tokens in parallel, both tokens remain valid simultaneously. Our system does not currently limit the number of active access tokens per client, so multiple tokens can be valid at the same time as long as they fall within their respective validity periods. # API v1 Introduction :legacy-banner # API v1 Introduction The LifePeaks.dk API is organized around REST. Our API accepts JSON-encoded requests, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs. The LifePeaks.dk API is based on the principles of Representational State Transfer (REST) allowing clients to create, view, modify and delete resources using standard HTTP request methods with OAuth2 authentication. ## Endpoint groups Every v1 endpoint is listed below. The complete request and response shapes are described in the [v1 API reference](https://docs.lifepeaks.dk/reference/v1), generated from the [v1 OpenAPI specification](https://docs.lifepeaks.dk/openapi/v1.json). | Group | Endpoints | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Authorization | `POST /token`, `POST /oauth2/revoke` | | Actions | `GET /action/ping`, `POST /action/list`, `POST /action/analytics`, `POST /action/claimed-items`, `GET /action/subscribers` | | Item | `GET /item/{code}`, `POST /item/{code}/resend`, `POST /item/{code}/claim`, `POST /item/{code}/refund`, `POST /item/{code}/cancel`, `POST /item/{code}/activate`, `POST /action/create-gc` | | Order | `POST /order/{order_id}/claim`, `POST /order/{order_id}/capture` | | Events | `POST /event/list`, `POST /event/{slug}/participants` | | Lists | `GET /list/gc-value-modifications` | Alongside these, `/authorize`, `/login` and `/logout` serve the browser-based authorization code flow — see [Authorization of Request](https://docs.lifepeaks.dk/v1/authorization). ## Scopes and user rights Access is controlled by two independent things: the **scope** attached to the OAuth2 client, and the **user right** of the API user the token belongs to. | Scope | What it permits | | -------- | -------------------------- | | `admin` | Every endpoint. | | `zapier` | `GET /action/subscribers`. | Most endpoints require the `admin` scope. `GET /action/subscribers` performs no scope check at all — any authenticated token reaches it, and the company is taken from the token holder. Claim, refund and event endpoints additionally reject users whose user right is `agency_user`. ## Recommended reading - HTTP: {rel=""nofollow""} - Headers: {rel=""nofollow""} - Basic access authentication: {rel=""nofollow""} - OAuth authentication: {rel=""nofollow""} - Status codes: {rel=""nofollow""} - REST: {rel=""nofollow""} - JSON: {rel=""nofollow""} ## Where to go next - [Authorization of Request](https://docs.lifepeaks.dk/v1/authorization) — obtaining an OAuth2 access token - [Authorization via curl](https://docs.lifepeaks.dk/v1/authorization-curl) — password grant example - [API info](https://docs.lifepeaks.dk/v1/api-info) — base URLs, support contact, and terms of service - [Data](https://docs.lifepeaks.dk/v1/data) — encoding, dates, languages, and request GUIDs - [Testing](https://docs.lifepeaks.dk/v1/testing) — developer and demo environments - [FAQ](https://docs.lifepeaks.dk/v1/faq) — token lifetimes and common errors - [v1 API reference](https://docs.lifepeaks.dk/reference/v1) — the full OpenAPI specification # Testing :legacy-banner # Testing Testing is done on developer site {rel=""nofollow""} or on demo site {rel=""nofollow""}. There are request data for debugging in response in developer version: **Live API server doesn't return request object. This is for improving preformance on Live API server.** ```json { "status": "OK", "response": "Item VPPF44gN7q has been resend to buyer@example.com!", "request": { "ip": "203.0.113.10", "user": "APITester", "language": "Danish", "datetime": "2020-10-27 14:20:46", "query": { "code": "VPPF44gN7q" }, "post": { "email": "test@lifepeaks.dk" } } } ```