PayLumiaDevelopers

Hosted checkout

Webhooks

Signed events are the money truth. Verify, acknowledge, then act.

Where events go

Partner console — webhook endpoint and signing secret

Each environment of your application has one webhook URL and one signing secret, set in the console. You don’t send a URL when creating a payment. Sandbox and production are configured separately.

Events

EventWhen
payment.succeededConsent given and the initial charge or subscription start succeeded.
payment.failedThe payment ended in failure.
entitlement.grantedAccess starts: first payment, reactivation or trial conversion paid.
entitlement.updatedA renewal was collected, or the subscription entered grace.
entitlement.revokedAccess ended: suspension, cancellation or expiry.
subscription.trial_startedA trial began.
subscription.trial_will_endReminder before the trial ends.
subscription.updatedA cancellation at period end was scheduled or withdrawn.
subscription.suspended · canceled · expired · failedSubscription status changes.

Payload

payment.succeeded
{
  "event": "payment.succeeded",
  "paymentUid": "pay_4mK9pQeRw2Ns6TfXb1Zy0a",
  "status": "SUCCEEDED",
  "orderId": "0b8f3f5e-3f55-4c1a-9a44-2f0c7f0b9d61",
  "productUid": "premium",
  "offerUid": "premium_daily",
  "subscriberUid": "sbr_7Hq2LmN4pR8sT1vW",
  "metadata": { "orderRef": "order-8891" },
  "occurredAt": "2026-09-20T15:12:44Z"
}
FieldNotes
eventEvent name.
paymentUidThe payment session.
statusPayment status at the time of the event.
orderIdPresent once money moved.
productUid · offerUidWhat was bought, and through which offer.
subscriberUidThe payer, scoped to your application.
amountCollected · periodOutstandingOn renewals: collected so far this period, and what remains.
metadataEcho of what you sent at creation.
occurredAtEvent time.

Verify the signature

Every request carries PayLumia-Signature: t={unix},v1={hmac}. The HMAC is computed with your environment’s secret over {t}.{rawBody}. Reject events older than five minutes, and compare in constant time.

import crypto from 'node:crypto';

// Header: PayLumia-Signature: t={unix},v1={hmac}
// Signed payload: `${t}.${rawBody}` — use the raw bytes, not re-serialised JSON.
export function verifyPayLumia(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Use the raw body

Verify against the exact bytes you received. Parsing and re-serialising the JSON changes them and breaks the signature.

Respond and process

Webhook delivery — up to five attempts within 48 hours
  • Answer with a 2xx as soon as the signature checks out; do slow work asynchronously.
  • Make handlers idempotent: delivery is at least once, so the same event can arrive more than once.
  • No 2xx? PayLumia retries, up to five attempts within 48 hours of the first. After that the event is dead-lettered and can be replayed from the partner console.
  • Prefer the status in the signed body. You can always confirm with a read.
Express handler
app.post('/paylumia/events', express.raw({ type: 'application/json' }), async (req, res) => {
  const ok = verifyPayLumia(req.body.toString('utf8'), req.get('PayLumia-Signature'), process.env.PAYLUMIA_WEBHOOK_SECRET);
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  // Idempotent: the same event may arrive more than once.
  await db.events.upsert({ key: `${event.event}:${event.paymentUid}:${event.occurredAt}` }, async () => {
    if (event.event === 'payment.succeeded') await grantAccess(event.metadata.orderRef, event.productUid);
    if (event.event === 'entitlement.revoked') await revokeAccess(event.subscriberUid, event.productUid);
  });

  res.sendStatus(200); // answer fast, work async
});