Guide to Luma Webhooks: Features and Best Practices
Luma webhooks tell your systems what happens on your Luma calendar: an event is created or canceled, a guest registers or is checked in, a ticket is bought, or someone follows or unsubscribes from the calendar. If you run events on Luma, the event hosting and ticketing platform (not Luma AI), webhooks push that activity to your CRM, email tool or database as it happens.
This guide covers how Luma webhooks are created and signed, the events and payloads you will receive, the short retry window that makes Luma unforgiving of downtime, and the practices that keep a Luma receiver reliable.
What are Luma webhooks?
A Luma webhook belongs to a calendar. You create it in the calendar's settings with a URL and the action types you want, and Luma sends a JSON POST to the URL whenever one of them happens. Webhooks are available only on Luma Plus.
Each body has two top-level fields: type, the event name, and data, the event, guest, ticket or person it concerns.
Every request carries three headers: Webhook-Signature, Webhook-Id and Webhook-Timestamp. The names and the whsec_ secret look like Standard Webhooks, but the scheme is different. Webhook-Signature has the form t=<timestamp>,v1=<signature>, the signature is a hex HMAC-SHA256 of the timestamp and the raw body, and the key is the whole secret string. A Standard Webhooks library will reject every request.
Luma webhook features
| Feature | Details |
|---|---|
| Availability | Luma Plus subscribers |
| Configuration | Calendar Settings > Developer > Webhooks |
| Subscription | Selected action types, or All Actions |
| Signature header | Webhook-Signature: t=<unix seconds>,v1=<hex> |
| Other headers | Webhook-Id (unique per event), Webhook-Timestamp (Unix seconds) |
| Scheme | HMAC-SHA256, hex |
| Signed content | <t>.<raw body> |
| Secret | whsec_..., generated when you create the webhook, used whole as the key |
| Event type | type in the body |
| Response window | 5 seconds |
| Retries | Up to 3, at 1, 2 and 4 minutes |
| Auto-pause | A 410 Gone response pauses the webhook |
| Management | Pause, resume, delete, and view recent deliveries |
Luma does not publish source IPs.
Common events
Luma documents 11 event types.
| Area | Event | Fires when |
|---|---|---|
| Calendar | calendar.event.added | An event appears on your calendar, including syndicated and external events |
| Calendar | calendar.event.submitted | An event is submitted to your calendar and is pending approval |
| Calendar | calendar.person.subscribed | Someone follows your calendar |
| Calendar | calendar.person.unsubscribed | A person opts out of hearing from your calendar |
| Events | event.created | A new event is created on your calendar |
| Events | event.updated | Event details change, or a ticket type is added, edited or removed |
| Events | event.canceled | An event is canceled |
| Guests | guest.registered | Someone registers, a host adds a guest, or a guest registers again after canceling |
| Guests | guest.updated | A registration is created or changes, such as check-in, approval status, answers or tickets |
| Guests | guest.refunded | A host refunds a guest through Luma, including partial refunds |
| Tickets | ticket.registered | Each individual ticket is purchased or registered |
A few of these behave in ways worth planning for:
guest.registeredfires once per person.ticket.registeredfires once per ticket, so a guest who buys three tickets produces three. Use the first for welcome emails and the second for per-ticket badges or QR codes.guest.updatedalso fires for new registrations, so a new guest triggers bothguest.registeredandguest.updated. It does not fire when a guest edits their Luma profile.event.updateddoes not fire for guest registrations.guest.refundeddoes not fire for refunds issued directly from the Stripe dashboard.calendar.person.unsubscribedcovers opt-outs only from the moment you create the webhook. Its payload includes the person's email address, which makes it useful for mirroring suppressions to other email tools.- The help centre's list of action types does not include Calendar Event Submitted, although the API reference documents it. Check that it is offered when you create the webhook.
Luma publishes a schema for each event in its API reference, but no complete example body. Every body has the same envelope:
{
"type": "guest.registered",
"data": { }
}
data is a guest object for guest.* events, an event object for event.* and calendar.event.*, a guest with its ticket for ticket.registered, and a calendar person for calendar.person.*. For the mapping between dashboard labels such as Going or Pending and the approval_status values in guest payloads, see Luma's Guest Status Values page.
Setting up Luma webhooks
- Open the Calendars Home and select your calendar.
- Go to Settings > Developer.
- In the Webhooks section, click Create.
- Enter your HTTPS endpoint URL.
- Select the action types you want, or All Actions.
- Click Create, and store the generated
whsec_secret server-side, for example asLUMA_WEBHOOK_SECRET.
From the same page you can open a webhook to see its recent deliveries (delivered, pending or failed), pause and resume it, or delete it.
Securing Luma webhooks
Split Webhook-Signature on commas and read t and v1. Build the string <t>.<raw body>, compute an HMAC-SHA256 with the full whsec_... secret as the key, hex-encode it, and compare it with v1 in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyLumaSignature(rawBody, header, secret) {
if (!header || !secret) return false;
let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
if (!/^\d+$/.test(timestamp ?? '') || signatures.length === 0) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
'utf8'
);
return signatures.some((sig) => {
const received = Buffer.from(sig.toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/luma', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.LUMA_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyLumaSignature(req.body, req.get('webhook-signature'), secret)) {
return res.sendStatus(401);
}
// Optional: reject t values far from the current time. Choose a window
// generous enough for Luma's retries and for any replay you run yourself.
const event = JSON.parse(req.body.toString('utf8'));
// Dedupe on req.get('webhook-id'), route on event.type, process async.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import re
def verify_luma_signature(raw_body: bytes, header, secret: str) -> bool:
if not header or not secret:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not re.fullmatch(r"[0-9]+", timestamp) or not signatures:
return False
expected = hmac.new(
secret.encode("utf-8"), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return any(
hmac.compare_digest(sig.lower().encode("utf-8"), expected.encode("utf-8"))
for sig in signatures
)
# In FastAPI: raw_body = await request.body()
# verify_luma_signature(raw_body, request.headers.get("webhook-signature"),
# os.environ["LUMA_WEBHOOK_SECRET"])
Luma's own samples build the signed string from the body as a string. Hashing the raw bytes, as above, avoids any difference introduced by decoding. Luma recommends rejecting timestamps "more than a few minutes old" but does not set a number, so treat any window as your own choice and keep it wide enough not to reject deliveries that arrive late for ordinary reasons.
Make Luma webhooks production-ready. Hookdeck Event Gateway verifies Luma's
Webhook-Signatureheader against the timestamp and raw body, answers Luma with an immediate200 OK, and durably queues every guest, ticket and event update before your service sees it.
Luma webhook limitations and pain points
Seven minutes of retries
The Problem: A short outage loses events for good.
Why It Happens: Luma retries a failed delivery at most three times, at 1, 2 and 4 minutes. If your endpoint is down for longer than about seven minutes, the event is not sent again, and Luma documents no way to replay it.
Workarounds:
- Keep the receiver highly available and its work minimal: verify, store, return
2xx. - After an outage, reconcile registrations and events through the Luma API.
- Check the webhook's recent deliveries for failures.
How Hookdeck Can Help: Hookdeck accepts each delivery within Luma's 5-second window and stores it, then retries delivery to your service for as long as you configure and lets you replay any event. Luma's short retry schedule stops being your outage budget.
Standard Webhooks look-alike
The Problem: A Standard Webhooks or Svix verifier rejects every Luma request.
Why It Happens: Luma uses Webhook-* header names and a whsec_ secret, but signs <t>.<body> with the whole secret string and sends hex in a t=,v1= header. Standard Webhooks signs <id>.<timestamp>.<body> with a decoded key and sends base64.
Workarounds:
- Use a plain HMAC-SHA256 as shown above.
- Do not strip
whsec_or base64-decode the secret.
How Hookdeck Can Help: Hookdeck's Luma source type implements Luma's scheme, so you paste the secret as issued and Hookdeck checks the signature.
A 5-second response window
The Problem: Handlers that call your CRM or send email time out and are retried.
Why It Happens: Luma expects a response within 5 seconds, which is shorter than many providers allow.
Workarounds:
- Acknowledge first and move every external call into a background job.
- Never return
410unless you want the webhook paused.
How Hookdeck Can Help: Hookdeck responds to Luma at once and delivers to your service with its own timeout, so slow downstream calls no longer cause Luma retries.
Overlapping events
The Problem: One registration triggers several deliveries, and naive handlers act on each of them.
Why It Happens: guest.registered and guest.updated both fire for a new registration, and ticket.registered fires once per ticket. Luma also notes that the same event can occasionally arrive twice.
Workarounds:
- Subscribe only to the events you handle.
- Choose one event per action, such as
guest.registeredfor welcome emails. - Deduplicate on
Webhook-Id.
How Hookdeck Can Help: Hookdeck filters can route each type to a different destination, and deduplication can drop repeats.
Best practices
Verify against the raw body
The signature covers the exact bytes after the timestamp. Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI.
Deduplicate on Webhook-Id
Webhook-Id is a unique ID for the event. Record it before processing and skip ids you have already handled. See our guide to webhook idempotency.
Pick guest or ticket events deliberately
Use guest.registered for once-per-person actions and ticket.registered for once-per-ticket actions. Handling both for the same purpose sends duplicates.
Fetch profile details when you need them
guest.updated does not fire for profile edits, so names and emails in your copy can go stale. Fetch the guest through the API when current profile data matters.
Acknowledge within 5 seconds
Verify, store the event, return 200, and process afterwards. See why to process webhooks asynchronously.
Conclusion
Luma signs each webhook with Webhook-Signature: t=...,v1=..., a hex HMAC-SHA256 of the timestamp and raw body keyed with the whole whsec_ secret. Verify with a plain HMAC rather than a Standard Webhooks library, deduplicate on Webhook-Id, choose between guest and ticket events on purpose, and answer within 5 seconds, because three retries over seven minutes leave little room for downtime.
Hookdeck Event Gateway sits in front of your receiver, checks every Luma signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Luma webhooks reliably in minutes.