Signed webhooks
Webhooks notify your backend when an order's payment or fulfillment changes. They complement, but do not replace, the canonical order read.
Register an endpoint
The endpoint must be an approved, publicly reachable HTTPS URL. Create a stable idempotency key before the request and store the returned secret immediately.
curl -sS -X POST https://api.lifepeaks.dk/v2/webhook-endpoints \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
-H "Idempotency-Key: webhook_endpoint_primary_v1" \
-H "Content-Type: application/json" \
-d '{
"url":"https://partner.example/webhooks/lifepeaks",
"events":["order.payment_authorized","order.fulfilled","order.payment_failed"]
}'
The first successful 201 response includes a whsec_… signing secret. It is shown once; list responses never return it. Store it in a managed secret store, outside source control and logs.
An idempotent replay returns 200 without the secret. If the first response was lost before you stored it, rotate the endpoint secret and store the new one-time value. Do not create duplicate endpoints to recover a missing secret.
Verify before parsing
Lifepeaks sends these headers:
| Header | Purpose |
|---|---|
Lifepeaks-Webhook-Id | Immutable evt_… event ID and deduplication key |
Lifepeaks-Webhook-Timestamp | Unix timestamp in seconds |
Lifepeaks-Webhook-Secret-Version | The current secret version. During a rotation overlap it does not name the previous one, so do not use it to choose a secret |
Lifepeaks-Webhook-Signature | One or more v1= HMAC-SHA256 hex digests. During a rotation overlap it carries comma-separated values, newest first |
The signed bytes are exactly:
timestamp + "." + event_id + "." + raw_request_body
Read the raw request body before JSON parsing or middleware changes whitespace. Reject stale timestamps, compare signatures in constant time, and only then parse the JSON.
This complete verifier uses Node/Bun's standard crypto implementation:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyLifepeaksWebhook(input: {
secret: string
eventId: string
timestamp: number
rawBody: string
signature: string
now?: number
}): boolean {
const now = input.now ?? Math.floor(Date.now() / 1000)
if (!input.secret || !input.eventId || input.eventId.includes('.')) return false
if (!Number.isSafeInteger(input.timestamp) || Math.abs(now - input.timestamp) > 300) return false
const signed = `${input.timestamp}.${input.eventId}.${input.rawBody}`
const expected = Buffer.from(
`v1=${createHmac('sha256', input.secret).update(signed).digest('hex')}`,
)
return input.signature.split(',').some((value) => {
const candidate = Buffer.from(value.trim())
return candidate.length === expected.length && timingSafeEqual(candidate, expected)
})
}
Acknowledge durably
Process each request in this order:
- Limit the request body size.
- Read the exact raw body and required headers.
- Reject timestamps outside a five-minute tolerance.
- Verify the signature in constant time.
- Parse JSON and validate
id,type,api_version, anddata.order. - Insert
Lifepeaks-Webhook-Idinto durable storage with a unique constraint. - Commit the event to your queue or inbox table.
- Return
204only after that durable commit.
Repeated event IDs are normal. A duplicate that is already durable should also receive 204, without running business side effects again. Return a non-2xx response when the event was not made durable so Lifepeaks can retry it.
Event types
| Event | Meaning |
|---|---|
order.created | An order was accepted. It fires before any payment, immediately after the order commits, so it never describes an order that rolled back. A retried create fires no second event. Queueing it can never refuse the order: an outbox failure is logged and the order still succeeds, so on the rare occasion one is lost, reconcile with GET /v2/orders. |
order.payment_authorized | Lifepeaks confirmed payment authorization. Fulfillment may still be processing. |
order.fulfilled | Lifepeaks completed gift-card fulfillment. |
order.payment_failed | Payment failed; no gift card should be presented as delivered. |
order.refunded | The order payment was fully refunded. |
order.partially_refunded | The order payment was partially refunded. |
order.expired | Nobody paid for the order in time. Lifepeaks wrote it off and set its payment status to cancelled. Terminal: an expired order cannot be paid, and no further event follows it. |
webhook.ping | A test event you asked for. It is not subscribable, so it never appears in an endpoint's events list. |
order.expired closes a basket the buyer walked away from. An order expires when its hosted checkout link lapsed more than an hour ago, or when it never started checkout at all and is more than a day old. An order whose card was declined expires on the same two windows: a declined payment is not terminal on its own, because the buyer can retry checkout on the same order, so it is given the same grace as an untouched one before being written off. Both windows are deliberately generous, so a buyer still sitting on the payment page is never written off, and an order whose payment lands before the sweep runs is never expired. Subscribe to it if you keep your own basket state: before it existed, an abandoned checkout stayed pending_payment for ever and could not be told from one still being paid.
A webhook payload carries no buyer detail. It embeds the order object without sender, recipient, greeting or the postal address, because it is delivered to a URL you configured rather than answered to an authenticated caller. That boundary is the one that actually protects the buyer: the authenticated read carries the greeting the buyer wrote, and the outbound payload never does. Both sides are pinned by tests. reference, delivery.method and delivery.send_at are present, which is what a subscriber needs to reconcile an order and to tell a posted card from an e-mailed one.
Webhook payloads have this envelope:
{
"id": "evt_0123456789abcdef0123456789abcdef",
"type": "order.fulfilled",
"api_version": "v2",
"occurred_at": "2026-08-22T12:00:00+00:00",
"data": { "order": { "id": "po_0123456789abcdef0123456789abcdef" } }
}
Use the embedded id only after verifying that it matches Lifepeaks-Webhook-Id.
Test an endpoint before it matters
POST /v2/webhook-endpoints/{endpoint_id}/ping — scope webhooks:write
Queues a signed webhook.ping event for one endpoint, so you can prove your connectivity and your signature check work before a real order depends on them.
curl -sS -X POST https://api.lifepeaks.dk/v2/webhook-endpoints/wh_8f2c/ping \
-H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"
The ping is a real delivery. It is signed with the endpoint's current secret, carries the same headers as any other event, retries on the same schedule, and appears in GET /v2/webhook-deliveries beside your order events. If you can verify a ping, you can verify anything.
The body carries nothing, and sending request fields answers 422 rather than ignoring them. data is the endpoint's own id:
{
"id": "evt_0123456789abcdef0123456789abcdef",
"type": "webhook.ping",
"api_version": "v2",
"occurred_at": "2026-09-03T10:14:00+00:00",
"data": { "endpoint": { "id": "wh_8f2c" } }
}
A handler that does not recognise webhook.ping should answer 2xx and ignore it, the same as any other type it does not act on. Naming the endpoint is the request, so a ping arrives whatever the endpoint is subscribed to — you cannot subscribe to it and it is not a valid entry in an endpoint's events list.
The answer is 202, not 200: the delivery is queued, and its outcome arrives later. Read it back from GET /v2/webhook-deliveries rather than inferring success here. Idempotency-Key is required and doubles as the event's identity, so two pings with the same key are one event and one delivery; use a fresh key to send another. A disabled endpoint answers 404, the same as one that never existed.
Rotate without downtime
Lifepeaks runs the overlap, so rotation needs no coordinated switch-over:
- Rotate.
POST /v2/webhook-endpoints/{endpoint_id}/rotate-secretreturns the newwhsec_…secret — once — along withprevious_secret_versionandprevious_secret_expires_at. - Read the deadline.
previous_secret_expires_atis when the old secret stops being accepted, 24 hours after the rotation. - Install the new secret before that instant. Until then every delivery is signed with both secrets, so a receiver still holding only the old one keeps verifying. A receiver that verifies against every value in the signature header — as the one above does — needs no change at all beyond the new secret value.
- Do nothing else. The old secret expires by itself once the window closes, and deliveries go back to a single signature.
{
"id": "wh_9d41b7e0c8524fa63b1e70d5482ac9f6",
"secret_version": 2,
"previous_secret_version": 1,
"previous_secret_expires_at": "2026-08-28T04:13:05+00:00",
"secret": "whsec_Kd8pR1zW6vN3yT5bG9hJ2mQ4sX7cF0aL"
}
Both overlap fields read null when no window is open — on an endpoint that has never
been rotated, and once a window has closed.
Rotating again inside an open window retires the older secret immediately. Only two secrets are ever live, so a second rotation before you have deployed the first one will cut off a receiver still using the original. Deploy, then rotate again.
Operate and recover
GET /v2/webhook-endpointslists endpoint status, failure counts, and any open rotation overlap, without secrets.POST /v2/webhook-endpoints/{endpoint_id}/rotate-secretreturns the new secret on the first execution only. Use a stable, operation-specificIdempotency-Key.GET /v2/webhook-deliveriesshows recent attempts and terminal state.POST /v2/webhook-deliveries/{delivery_id}/replayrequeues an eligible delivery. Its idempotency key must be independent from registration and rotation keys.DELETE /v2/webhook-endpoints/{endpoint_id}disables the endpoint and cancels pending deliveries.
The Lifepeaks storefront starter ships a working receiver with durable deduplication, timestamp checks, and tamper tests. The repository is private, so ask Lifepeaks for access first. Run bun run doctor before exposing your endpoint publicly.