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.

bash
curl -sS -X POST https://api.lifepeaks.dk/v2/webhook-endpoints \
  -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
  -H "Idempotency-Key: webhook_endpoint_primary_v1" \
  -H "Content-Type: application/json" \
  -d '{
    "url":"https://partner.example/webhooks/lifepeaks",
    "events":["order.payment_authorized","order.fulfilled","order.payment_failed"]
  }'

The first successful 201 response includes a whsec_… signing secret. It is shown once; list responses never return it. Store it in a managed secret store, outside source control and logs.

An idempotent replay returns 200 without the secret. If the first response was lost before you stored it, rotate the endpoint secret and store the new one-time value. Do not create duplicate endpoints to recover a missing secret.

Verify before parsing

Lifepeaks sends these headers:

HeaderPurpose
Lifepeaks-Webhook-IdImmutable evt_… event ID and deduplication key
Lifepeaks-Webhook-TimestampUnix timestamp in seconds
Lifepeaks-Webhook-Secret-VersionThe 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-SignatureOne or more v1= HMAC-SHA256 hex digests. During a rotation overlap it carries comma-separated values, newest first

The signed bytes are exactly:

text
timestamp + "." + event_id + "." + raw_request_body

Read the raw request body before JSON parsing or middleware changes whitespace. Reject stale timestamps, compare signatures in constant time, and only then parse the JSON.

This complete verifier uses Node/Bun's standard crypto implementation:

ts
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyLifepeaksWebhook(input: {
  secret: string
  eventId: string
  timestamp: number
  rawBody: string
  signature: string
  now?: number
}): boolean {
  const now = input.now ?? Math.floor(Date.now() / 1000)
  if (!input.secret || !input.eventId || input.eventId.includes('.')) return false
  if (!Number.isSafeInteger(input.timestamp) || Math.abs(now - input.timestamp) > 300) return false

  const signed = `${input.timestamp}.${input.eventId}.${input.rawBody}`
  const expected = Buffer.from(
    `v1=${createHmac('sha256', input.secret).update(signed).digest('hex')}`,
  )

  return input.signature.split(',').some((value) => {
    const candidate = Buffer.from(value.trim())
    return candidate.length === expected.length && timingSafeEqual(candidate, expected)
  })
}

Acknowledge durably

Process each request in this order:

  1. Limit the request body size.
  2. Read the exact raw body and required headers.
  3. Reject timestamps outside a five-minute tolerance.
  4. Verify the signature in constant time.
  5. Parse JSON and validate id, type, api_version, and data.order.
  6. Insert Lifepeaks-Webhook-Id into durable storage with a unique constraint.
  7. Commit the event to your queue or inbox table.
  8. Return 204 only after that durable commit.

Repeated event IDs are normal. A duplicate that is already durable should also receive 204, without running business side effects again. Return a non-2xx response when the event was not made durable so Lifepeaks can retry it.

Event types

EventMeaning
order.createdAn 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_authorizedLifepeaks confirmed payment authorization. Fulfillment may still be processing.
order.fulfilledLifepeaks completed gift-card fulfillment.
order.payment_failedPayment failed; no gift card should be presented as delivered.
order.refundedThe order payment was fully refunded.
order.partially_refundedThe order payment was partially refunded.
order.expiredNobody 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.pingA test event you asked for. It is not subscribable, so it never appears in an endpoint's events list.

order.expired closes a basket the buyer walked away from. An order expires when its hosted checkout link lapsed more than an hour ago, or when it never started checkout at all and is more than a day old. An order whose card was declined expires on the same two windows: a declined payment is not terminal on its own, because the buyer can retry checkout on the same order, so it is given the same grace as an untouched one before being written off. Both windows are deliberately generous, so a buyer still sitting on the payment page is never written off, and an order whose payment lands before the sweep runs is never expired. Subscribe to it if you keep your own basket state: before it existed, an abandoned checkout stayed pending_payment for ever and could not be told from one still being paid.

A webhook payload carries no buyer detail. It embeds the order object without sender, recipient, greeting or the postal address, because it is delivered to a URL you configured rather than answered to an authenticated caller. That boundary is the one that actually protects the buyer: the authenticated read carries the greeting the buyer wrote, and the outbound payload never does. Both sides are pinned by tests. reference, delivery.method and delivery.send_at are present, which is what a subscriber needs to reconcile an order and to tell a posted card from an e-mailed one.

Webhook payloads have this envelope:

json
{
  "id": "evt_0123456789abcdef0123456789abcdef",
  "type": "order.fulfilled",
  "api_version": "v2",
  "occurred_at": "2026-08-22T12:00:00+00:00",
  "data": { "order": { "id": "po_0123456789abcdef0123456789abcdef" } }
}

Use the embedded id only after verifying that it matches Lifepeaks-Webhook-Id.

Test an endpoint before it matters

POST /v2/webhook-endpoints/{endpoint_id}/ping — scope webhooks:write

Queues a signed webhook.ping event for one endpoint, so you can prove your connectivity and your signature check work before a real order depends on them.

bash
curl -sS -X POST https://api.lifepeaks.dk/v2/webhook-endpoints/wh_8f2c/ping \
  -H "Authorization: Bearer $LIFEPEAKS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

The ping is a real delivery. It is signed with the endpoint's current secret, carries the same headers as any other event, retries on the same schedule, and appears in GET /v2/webhook-deliveries beside your order events. If you can verify a ping, you can verify anything.

The body carries nothing, and sending request fields answers 422 rather than ignoring them. data is the endpoint's own id:

json
{
  "id": "evt_0123456789abcdef0123456789abcdef",
  "type": "webhook.ping",
  "api_version": "v2",
  "occurred_at": "2026-09-03T10:14:00+00:00",
  "data": { "endpoint": { "id": "wh_8f2c" } }
}

A handler that does not recognise webhook.ping should answer 2xx and ignore it, the same as any other type it does not act on. Naming the endpoint is the request, so a ping arrives whatever the endpoint is subscribed to — you cannot subscribe to it and it is not a valid entry in an endpoint's events list.

The answer is 202, not 200: the delivery is queued, and its outcome arrives later. Read it back from GET /v2/webhook-deliveries rather than inferring success here. Idempotency-Key is required and doubles as the event's identity, so two pings with the same key are one event and one delivery; use a fresh key to send another. A disabled endpoint answers 404, the same as one that never existed.

Rotate without downtime

Lifepeaks runs the overlap, so rotation needs no coordinated switch-over:

  1. Rotate. POST /v2/webhook-endpoints/{endpoint_id}/rotate-secret returns the new whsec_… secret — once — along with previous_secret_version and previous_secret_expires_at.
  2. Read the deadline. previous_secret_expires_at is when the old secret stops being accepted, 24 hours after the rotation.
  3. Install the new secret before that instant. Until then every delivery is signed with both secrets, so a receiver still holding only the old one keeps verifying. A receiver that verifies against every value in the signature header — as the one above does — needs no change at all beyond the new secret value.
  4. Do nothing else. The old secret expires by itself once the window closes, and deliveries go back to a single signature.
json
{
  "id": "wh_9d41b7e0c8524fa63b1e70d5482ac9f6",
  "secret_version": 2,
  "previous_secret_version": 1,
  "previous_secret_expires_at": "2026-08-28T04:13:05+00:00",
  "secret": "whsec_Kd8pR1zW6vN3yT5bG9hJ2mQ4sX7cF0aL"
}

Both overlap fields read null when no window is open — on an endpoint that has never been rotated, and once a window has closed.

Rotating again inside an open window retires the older secret immediately. Only two secrets are ever live, so a second rotation before you have deployed the first one will cut off a receiver still using the original. Deploy, then rotate again.

Operate and recover

  • GET /v2/webhook-endpoints lists endpoint status, failure counts, and any open rotation overlap, without secrets.
  • POST /v2/webhook-endpoints/{endpoint_id}/rotate-secret returns the new secret on the first execution only. Use a stable, operation-specific Idempotency-Key.
  • GET /v2/webhook-deliveries shows recent attempts and terminal state.
  • POST /v2/webhook-deliveries/{delivery_id}/replay requeues an eligible delivery. Its idempotency key must be independent from registration and rotation keys.
  • DELETE /v2/webhook-endpoints/{endpoint_id} disables the endpoint and cancels pending deliveries.

The Lifepeaks storefront starter 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.