Branded gift-card PDFs
Every company has a usable gift-card PDF from day one. Lifepeaks generates it from the onboarded company profile — name, logo where available, brand colors, locale, and currency — so nothing has to be uploaded before the first order.
Partners can replace that generated default with their own artwork. Uploading does not make anything live: publishing is a separate, explicit call, and it is the only step buyers can see the effect of.
Templates are stored per locale, and the default locale is da-DK.
Read the current template
GET /v2/pdf-templates — scope pdf_templates:read
curl "https://api.lifepeaks.dk/v2/pdf-templates?lang=da-DK" \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY"
The response reports every stored version in templates, plus the one new orders render
from in current. Each version carries source (default or partner), status,
version, bytes, page_count, sha256, and published_at. Storage keys, buckets, and
credentials are never returned.
GET /v2/pdf-templates/{template_id} returns a single version in the same shape.
The list omits versions with status uploading. Those exist only after a finalize that
crashed part-way, can never be published, and are collected automatically — so what you
see is the set of versions you can actually act on. Reading such a version by id still
works and reports "status": "uploading".
Which version current reports
A published partner version always outranks a generated default. Lifepeaks resolves
current — and the template a new order is issued with — in this order:
- published partner version for the exact locale;
- published partner version for the same base language, any region;
- published partner version for
en-GB; - the company default for that locale.
Tiers 2 and 3 let one published override serve every locale you sell in, so current for
a locale you never uploaded artwork for can still report source: "partner". That is
deliberate: most partners want one design everywhere, and only upload a second when they
genuinely need one.
Publish partner artwork
The lifecycle is five calls: intent → upload → finalize → preview → publish. Declaring the file before uploading is what makes the upload verifiable — the bytes that arrive are checked against the checksum and size you committed to, so a truncated or substituted file is caught rather than published.
1. Create an upload intent
POST /v2/pdf-templates/upload-intents — scope pdf_templates:write
FILE=gift-card.pdf
curl -sS -X POST https://api.lifepeaks.dk/v2/pdf-templates/upload-intents \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
-H "Idempotency-Key: pdf_template_autumn_intent_01" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg sha "$(shasum -a 256 "$FILE" | cut -d' ' -f1)" \
--argjson size "$(wc -c < "$FILE")" \
'{language: "da-DK", sha256: $sha, byte_size: $size, mime: "application/pdf"}')"
Response 201:
{
"upload_intent": {
"id": "pi_5a3c81e0d74b426f90ac17b5e2d8630f",
"expires_at": "2026-08-27T10:46:04+00:00",
"upload": {
"url": "https://…",
"method": "PUT",
"headers": {
"Content-Type": "application/pdf",
"Content-Length": "284915",
"x-amz-checksum-sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
}
}
}
}
The file may be at most 8,000,000 bytes. A larger byte_size is rejected here, and
the size is checked again at finalize.
The intent expires 15 minutes after it is created. Upload and finalize inside that window; afterwards create a new intent rather than retrying the old one. An expired intent that was never finalized is garbage-collected — it and the object it reserved are deleted the next time your company creates an intent — so an abandoned upload cannot be picked up again later.
2. Upload the bytes
# Send every header the intent listed, exactly as given.
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
-H "Content-Length: $(wc -c < "$FILE")" \
-H "x-amz-checksum-sha256: $CHECKSUM_FROM_INTENT" \
--data-binary "@$FILE"
Storing the bytes is not the same as accepting them. Everything is verified in the next step.
Against the local development target, Content-Length is not merely signed but
required: without it there is nothing to size-check the upload against before reading it,
so a request that omits it answers 411 with error.code of length_required. Most HTTP
clients set it for you when the body is a file. Deployed uploads already carry the value
the intent listed, so this only bites while developing locally.
3. Finalize into a version
POST /v2/pdf-templates/upload-intents/{intent_id}/finalize — scope pdf_templates:write
The path carries the pi_… intent from step 1. The template version does not exist yet;
this call is what creates it, and its pt_… id comes back in the response.
curl -sS -X POST \
"https://api.lifepeaks.dk/v2/pdf-templates/upload-intents/pi_5a3c81e0d74b426f90ac17b5e2d8630f/finalize" \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
-H "Idempotency-Key: pdf_template_autumn_finalize_01"
Response 201:
{
"template": {
"id": "pt_63c07e19a4b85d2f701ea9c6b3d4820f",
"language": "da-DK",
"source": "partner",
"status": "validated",
"version": 3,
"bytes": 284915,
"page_count": 1,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"published_at": null
}
}
Finalizing checks the declared checksum, byte size, and MIME type, then parses the document for real. A file that fails any check is rejected and no version is created, so invalid artwork can never reach a buyer:
error.code | Status | What to fix |
|---|---|---|
upload_checksum_mismatch | 422 | The bytes do not match the declared sha256 |
upload_size_mismatch | 422 | The bytes do not match the declared byte_size |
upload_mime_mismatch | 422 | The object is not application/pdf |
uploaded_object_missing | 422 | Nothing was uploaded for this intent |
upload_intent_expired | 422 | The 15-minute window closed; create a new intent |
invalid_pdf_signature | 422 | The file does not begin as a PDF |
unsupported_pdf_version | 422 | The PDF version is outside 1.3 to 1.7 |
pdf_page_limit_exceeded | 422 | More than 10 pages |
encrypted_pdf_not_supported | 422 | The document is encrypted |
active_content_not_supported | 422 | It embeds JavaScript or another active action |
invalid_pdf_structure | 422 | The document could not be parsed |
upload_intent_already_finalized | 409 | This intent already produced a version |
Two failures are not your fault and are worth retrying. When the malware scanner or
the PDF parser is temporarily down, finalize answers 503 with Retry-After: 30 and an
error.code of malware_scanner_unavailable or pdf_validation_unavailable. Wait and
retry with the same idempotency key; do not treat the document as invalid. Every 422
above is a real rejection and will fail identically on retry.
4. Preview before publishing
GET /v2/pdf-templates/{template_id}/preview — scope pdf_templates:read
curl -sS -o preview.pdf \
"https://api.lifepeaks.dk/v2/pdf-templates/pt_63c07e19a4b85d2f701ea9c6b3d4820f/preview" \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY"
The response is the document itself as application/pdf, served as an attachment
under Content-Security-Policy: default-src 'none'; sandbox. Download the bytes and open
them in a PDF viewer — pointing an iframe or embed at this endpoint will not render
the file. No storage URL or credential is exposed, so a preview cannot be shared outside
your authenticated integration. This is the last point at which a mistake costs nothing.
5. Publish
POST /v2/pdf-templates/{template_id}/publish — scope pdf_templates:write
curl -sS -X POST \
"https://api.lifepeaks.dk/v2/pdf-templates/pt_63c07e19a4b85d2f701ea9c6b3d4820f/publish" \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
-H "Idempotency-Key: pdf_template_autumn_publish_01"
The version becomes published and the one it replaces becomes superseded.
A validated or superseded partner version can be published, so artwork retired by
a revert or by a later publish can be brought back without re-uploading it. Publishing a
version that is already live is a no-op and returns it unchanged. Only two things are
refused, both 409: a Lifepeaks-generated default (partner_template_required), and a
version that never passed validation (validated_template_required).
Revert to the generated default
POST /v2/pdf-templates/revert-to-default — scope pdf_templates:write
curl -sS -X POST https://api.lifepeaks.dk/v2/pdf-templates/revert-to-default \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
-H "Idempotency-Key: pdf_template_revert_dadk_01" \
-H "Content-Type: application/json" \
-d '{"language": "da-DK"}'
This is the rollback for a publish that turned out wrong.
Nothing is deleted — superseded partner versions stay in the history and can be published again later without re-uploading. The call creates no new version and is safe to repeat.
Retries
upload-intents, finalize, publish, and revert-to-default all require an
Idempotency-Key header. Generate one per logical action, store it, and reuse it
unchanged when retrying, exactly as on the headless checkout
path. A replay returns the original result instead of acting twice, so a timeout can never
leave you unsure whether artwork went live. Use a separate key for each step — reusing the
intent key on finalize answers 409.
The upload itself takes no key: the intent it targets already identifies the action, and re-sending the same bytes to the same target is harmless.
Which template an order uses
A new order is issued with whatever current reports for its locale, following the same
four-tier chain described under Which version current reports.
Orders snapshot their template at creation. A v2 order records the version in force at
that moment, so publishing new artwork later never rewrites a document a buyer has already
received. Once an order is fulfilled you can fetch each line's document from its
gift_card_pdf link — see Headless checkout.
An experience may bring its own artwork. When a company has configured a PDF template on one of its experiences, the cards bought from that experience are branded with it, and every other line of the same order uses the company's published v2 template. The cards of one order need not look alike, which is why each line has its own download.
Changing the generated default
The generated default is derived from the company's brand profile. Updating the profile or its assets through the brand endpoints regenerates the default as a new version — it never changes which template is live, so a company running a published partner override keeps it until it reverts explicitly.