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.

NounPathWhat it is
Product/v2/productsWhat 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/ordersThe 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/itemsAn 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.

MethodPathScope
GET/v2/healthnone
GET/v2/menone
GET/v2/companiescompanies:read
GET/v2/companies/{slug}companies:read
GET, PATCH/v2/companies/{company_id}/brandbrand:read / brand:write
GET/v2/companies/{company_id}/brand/assetsbrand: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.

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.

Protected headless surface

MethodPathScope
GET/v2/productscatalog:read
GET/v2/products/{product_id}catalog:read
GET/v2/pickup-pointscatalog:read
POST, PUT/v2/greeting-images/upload-intents*orders:create
POST/v2/quotesquotes:create
POST/v2/ordersorders:create
GET/v2/ordersorders:read
GET/v2/orders/{po_order_id}orders:read
GET/v2/orders/{po_order_id}/gift-card.pdforders:read
GET/v2/orders/{po_order_id}/lines/{line_id}/gift-card.pdforders:read
POST/v2/orders/{po_order_id}/checkout-sessionscheckout:create
POST/v2/orders/{po_order_id}/settleorders:settle plus negotiated entitlement
POST/v2/orders/{po_order_id}/refundorders: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 for the safe catalog, quote, unpaid order, Lifepeaks-hosted QuickPay, canonical polling, and fulfillment sequence. Follow Signed webhooks for event verification and recovery. The interactive API 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.

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.

MethodPathScope
POST/v2/itemsorders:create + orders:settle + contract
GET/v2/promotionsitems:read
POST/v2/orders/{order_id}/captureorders:settle + contract
POST/v2/orders/{order_id}/claimitems:write
GET/v2/itemsitems:read
GET/v2/items/{code}items:read
GET/v2/items/{code}/document.pdfitems:read
POST/v2/items/{code}/claimitems:write
POST/v2/items/{code}/refunditems:write
POST/v2/items/{code}/cancelitems:write
POST/v2/items/{code}/activateitems:write
POST/v2/items/{code}/resenditems: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": <bool>, "next_cursor": <string or null> }. 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": <bool>, "next_cursor": <string or null> }. 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 routeAccepted pagination and list controls
GET /v2/reports/analytics, /v2/reports/claimed-itemsoffset (default 0), limit (default 10, maximum 100), and order (asc or desc, default desc) plus the route's documented filters
GET /v2/itemslimit and starting_after, answering has_more and next_cursor, plus the route's documented filters. It no longer accepts offset or order
GET /v2/eventslimit and starting_after, answering has_more and next_cursor. It no longer accepts offset
GET /v2/events/{slug}/participantslimit 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/subscriberslimit (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/promotionslimit and starting_after; optional company. Returns every campaign when limit is omitted. Ordered by name, then id
GET /v2/webhook-endpointslimit and starting_after; optional company. Returns every endpoint when limit is omitted
GET /v2/companieslimit and starting_after. Returns every permitted company when limit is omitted
GET /v2/api-keyslimit and starting_after. Returns every key when limit is omitted
GET /v2/productsOptional company, type and category filters, plus limit and starting_after. Returns the whole catalog when limit is omitted
GET /v2/ordersOptional 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-deliveriesOptional company, limit (1100, default 50) and starting_after, answering has_more and next_cursor; no offset and no order
GET /v2/order-page/revisionsOptional company and lang only; no pagination
GET /v2/companies/{company_id}/brand/assetsNo 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 for the different event and reporting list controls, and the API 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:

Routeamount for a DKK 500.00 card
POST /v2/quotes, POST /v2/orders50000 — whole øre
POST /v2/items500 — 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 for the side-by-side.

Error codes:

HTTP statuserror.codeMeaning
401unauthorizedMissing, malformed, expired or revoked key
403insufficient_scopeValid key, missing the scope the operation needs
404not_foundNo 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
422validation_failedThe request broke a business or input rule
422amount_precision_unsupportedPOST /v2/orders only. The order total needs more precision than a payment amount can hold — see Totals a payment can hold
422greeting_image_not_foundPOST /v2/orders only. The design_id or upload_id on greeting.image names no picture this company can use
422delivery_method_unavailablePOST /v2/orders and POST /v2/quotes. The method named is not one the company offers
422delivery_destination_unavailableThe courier does not deliver that way to that country
422send_at_unsupportedThe method chosen cannot be scheduled
422shipping_priority_unsupporteddelivery.shipping_priority was sent to a company whose cards travel with a courier. A courier offers one priority, so there is nothing to choose
422shipping_priority_unavailableThe priority named is not one the postal method publishes for this company
422sender_company_requiredThe product may only be bought by a company and the order carried no sender.company, or carried one without its address
422discount_code_not_foundThe discount_code is unknown, switched off, not yet open, expired, or fully used. One answer for all five, deliberately
422discount_code_not_applicableThe code is live, but nothing in this order answers to it
409insufficient_stockSpecial 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
422below_minimum, above_maximumSpecial offers only. The line's quantity is outside the option's own bounds, and is named on items.<n>.quantity
503image_store_unavailableFinalizing a greeting picture only. The upload was accepted; storing it failed. Retry after Retry-After with the same key
503pickup_lookup_unavailableGET /v2/pickup-points only. The courier could not be reached. Not an empty result
503checkout_unavailableThe payment gateway could not open a session. Retry with the same key
409test_key_not_accepted_in_productionA test key tried to take or return a payment in production. Use a live key, or this key on demo
422invalid_sha256, invalid_byte_size, invalid_mimeReserving a greeting-image slot. The declaration is malformed or outside the published limits
409greeting_image_already_usedAn order has already taken that picture
409order_not_refundablePOST /v2/orders/{id}/refund only. The order was never paid, or its payment cannot be read
409order_fully_refundedPOST /v2/orders/{id}/refund only. Nothing is left to return
422refund_amount_too_largePOST /v2/orders/{id}/refund only. The amount is above what remains
502refund_declinedPOST /v2/orders/{id}/refund only. The gateway refused, and nothing was refunded
409refund_in_progressPOST /v2/orders/{id}/refund only. Another refund of that order is still being processed
409amount_precision_unsupportedPOST /v2/orders/{id}/refund. The order's stored total cannot be represented exactly, so it cannot be refunded through the API
403credential_not_attributedEvery write route. The key predates per-key attribution, so a write cannot be recorded against it. Mint a new key
403checkout_not_enabledPOST /v2/orders/{id}/checkout-sessions only. Lifepeaks Checkout is not on this company's contract
409checkout_in_progressPOST /v2/orders/{id}/checkout-sessions only. Another session for the same order is still being opened. Retry after Retry-After with the same key
409order_not_payablePOST /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
403settlement_not_enabledThe settlement routes only. The mode asked for is not on this company's contract
409order_not_settleablePOST /v2/orders/{id}/settlement only. The order is not an intact pending headless order
409settlement_reference_conflictPOST /v2/orders/{id}/settlement only. That order, or that external_reference, has already been settled
409delivery_not_replayablePOST /v2/webhook-deliveries/{id}/replay only. That delivery cannot be replayed — it is already queued, or its endpoint is gone
503template_unavailableGET /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:

typeidOption idWhat it is
gift_cardgift_card_valueThe company's variable-value gift card. The buyer picks any amount between pricing.minimum_amount and pricing.maximum_amount. One per company.
gift_card_variantgcv_<id>gcvo_<id>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_ticketevt_<id>evto_<id>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_offerso_<id>sov_<id>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": "<p>Two hours in the spa, robes included.</p>",
      "formalities": "<p>Booking required. Valid Monday to Thursday.</p>",
      "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": "<p>Monday to Thursday.</p>",
          "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.

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_<id> 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": "<p>Six courses.</p>",
      "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_<id> 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": "<p>Friday and Saturday.</p>",
  "formalities": "<p>Subject to availability.</p>",
  "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": "<p>Two nights for two.</p>",
      "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

ParameterNotes
typegift_card, gift_card_variant, event_ticket or special_offer. Narrows the list to one family
categoryA cat_<id> from a product's categories. Categories belong to experiences, so naming one drops the value product AND every special offer
limit, starting_afterThe shared cursor paging described under 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_<id>, evt_<id> or so_<id>. 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.


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.

idWhat it does
sender_emailE-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_emailE-mails it straight to the recipient. This is the only method that can be scheduled.
postalPosts 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 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.

LineShape
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.<n>.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.<n>.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.<n>.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.<n>.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.

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"
  }
}
FieldWhen it is requiredNotes
commentneverThe 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
codewhen the option's requires.code is trueThe code the organiser demanded, at most 100 characters. It is stored on the issued ticket
collection_point_idwhen the option's requires.collection_point is trueAn epp_<id> from the event's own event.collection_points
addresswhen the option's requires.address is true and no collection point is givenAn 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.<n>.product_option_id, and an option that does not belong to the product named on the same line answers it on items.<n>.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 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.

FilterWhat it narrows to
statusLifecycle state: pending_payment, paid, fulfilled, cancelled, expired
payment_statusPayment state: pending, authorized, captured, failed, cancelled, refunded, partially_refunded
created_after, created_beforeAn RFC 3339 timestamp, or a plain YYYY-MM-DD date
emailThe buyer's address
client_referenceThe 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.
  • greetingmessage 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.
  • deliverymethod, 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.

AnswerWhen
409 order_not_refundableThe order was never paid, or its payment cannot be read
409 order_fully_refundedNothing is left to return
422 refund_amount_too_largeThe amount is above what remains. The message names the figure
502 refund_declinedThe gateway refused. Nothing was refunded and nothing is held, so a retry with a new key is accepted at once
409 refund_in_progressAnother refund of that order really is still being processed. Retry shortly. A refund the gateway declined is never one of these
409 amount_precision_unsupportedThe 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=<reference>, 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.


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, where Lifepeaks creates an unpaid po_… order and controls QuickPay payment before fulfillment.

FieldRequiredNotes
amountyesValue 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
piecenoHow many cards to create. Default 1, must be greater than 0
receiver_namenoDefaults to your company's default sender name
receiver_emailnoDefaults to no-email@lifepeaks.dk — see the delivery note below
sender_namenoDefaults to your company's default sender name
sender_emailnoDefaults to no-email@lifepeaks.dk
promotion_idnoId of an active campaign from GET /v2/promotions
validitynoExpiry as YYYY-MM-DD. Anything else falls back to the company default
sdh_product_idnoStays product id; only relevant for Stays partners
capturenoDefault 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.

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.

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.

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.

ParameterNotes
order_idEvery item of one order. Also sorts by the order's own item sequence
group_codeEvery item sharing a group code
searchCode, sender/receiver name or email, and a few further columns. Minimum 5 characters
statusStatus id: 3 Used, 4 Cancelled, 5 Expired, 6 Paid, 10 Awaiting Payment
typeGIFTCARD, GIFTCARD VARIATION, DISCOUNTED GC, GIFT CARD WITH ADDED VALUE, TICKET, SPECIALOFFER, BENEFITDEAL
only_claimabletrue returns only items that can be redeemed right now
date_from, date_toCreation-date range, YYYY-MM-DD
offset, limit, orderPaging, 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

FieldRequiredNotes
typeyesall, amount or piece. Any other value fails with 422
amountwith type: amountValue to redeem, greater than 0 and at most the remaining value
numberwith type: piecePieces to redeem, greater than 0 and at most the remaining pieces
claim_notenoStored 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

RequirementUse
New partner-owned customer storefrontCatalog, quote, unpaid POST /v2/orders, Lifepeaks-hosted checkout, canonical read, signed webhooks
Existing direct-issuance integrationPOST /v2/items and, where already contracted, compatibility capture
Redemption or back-office item action/v2/items* or compatibility order claim
Partner-controlled or external settlementCommercial 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.