Hosted checkout
Webhooks
Signed events are the money truth. Verify, acknowledge, then act.
Where events go

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
| Event | When |
|---|---|
payment.succeeded | Consent given and the initial charge or subscription start succeeded. |
payment.failed | The payment ended in failure. |
entitlement.granted | Access starts: first payment, reactivation or trial conversion paid. |
entitlement.updated | A renewal was collected, or the subscription entered grace. |
entitlement.revoked | Access ended: suspension, cancellation or expiry. |
subscription.trial_started | A trial began. |
subscription.trial_will_end | Reminder before the trial ends. |
subscription.updated | A cancellation at period end was scheduled or withdrawn. |
subscription.suspended · canceled · expired · failed | Subscription status changes. |
Payload
{
"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"
}| Field | Notes |
|---|---|
event | Event name. |
paymentUid | The payment session. |
status | Payment status at the time of the event. |
orderId | Present once money moved. |
productUid · offerUid | What was bought, and through which offer. |
subscriberUid | The payer, scoped to your application. |
amountCollected · periodOutstanding | On renewals: collected so far this period, and what remains. |
metadata | Echo of what you sent at creation. |
occurredAt | Event 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
- 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.
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
});