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.
The safe flow
- Your backend reads
GET /v2/productsand presents only active products. One request is the whole page: each product carriesdelivery_methods,personalizationandtermsbeside 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. - 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. - After the buyer confirms, your backend creates an unpaid order with
POST /v2/orders, carrying the buyer's greeting and delivery choice. - Your backend creates a checkout session at
POST /v2/orders/{order_id}/checkout-sessionsand redirects the browser to its Lifepeaks-hosted QuickPay URL. - QuickPay returns the browser to your
return_url. The browser return never proves payment, even when its URL looks successful. - Your backend reads the canonical order with
GET /v2/orders/{order_id}and verifies each signed webhook against its raw request body. Do not trust query parameters or browser state as payment evidence. - Lifepeaks starts fulfillment only after confirmed payment. Show a gift card as delivered only when canonical
fulfillment.statusisfulfilled. - A fulfilled order carries a
gift_card_pdflink on every GIFT-CARD line — a value card, an experience or a bundle. An event or special-offer line carriesnullthere; fetch those documents per issued item fromGET /v2/items/{code}/document.pdf. Fetch the documents from your backend if you want your own copy of what Lifepeaks delivered.
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.
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 asdelivery_methods. It also carries the company's open campaigns inpromotions, so you can render the offers without a second call.gcv_<id>(type: gift_card_variant) is one experience the company sells. It is priced by the option the buyer picks, so each entry carries anoptionsarray ofgcvo_<id>entries with their ownamount,order_fee, quantity bounds, and stock.evt_<id>(type: event_ticket) is one event. It carries aneventblock with the dates, the venue, the collection points and what the organiser asks of a buyer, and oneevto_<id>option per ticket type.so_<id>(type: special_offer) is one discounted offer, sold as a voucher. Eachsov_<id>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 documents every field, the filters, and the 404-never-403 rule for one product by id.
2. Request authoritative pricing
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:
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:
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 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.
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:
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.
"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_<id> 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.
"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.
"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:
{
"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.discountis a positive magnitude that is subtracted. It is a whole number in the smallest unit of the currency, like every other figure here.totalissubtotal−discount+fees+shipping.subtotalstays 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[].discountis that line's share, and is0on every line of an order with no code.- The top-level
discountobject names what caused it:{code, percent}, ornull. 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.shippingitself rather than offamounts.discount, exactly as the Lifepeaks order page reduces the postage line. So a shipping-only code leavesamounts.discountat0whileamounts.shippingandtotalboth fall. - Never fees.
amounts.feesis untouched by any code. - Never an event ticket or a special offer. An order made only of those answers
422withdiscount_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.
"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 asdesign_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_idwith"source": "upload". See A buyer's 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.
"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:
"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.
"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, 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.
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.
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:
"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.
"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-Keyvalues 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_progresswith aRetry-Afterheader. 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.
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:
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:
{
"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=<reference>, 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:
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. 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; headless v2 integrations should use the protected flow above.
Start from working code
The Lifepeaks storefront starter 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 for the exact commands.