# 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.registered` fires once per person. `ticket.registered` fires 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.updated` also fires for new registrations, so a new guest triggers both `guest.registered` and `guest.updated`. It does not fire when a guest edits their Luma profile.
* `event.updated` does not fire for guest registrations.
* `guest.refunded` does not fire for refunds issued directly from the Stripe dashboard.
* `calendar.person.unsubscribed` covers 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:

```json
{
  "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

1. Open the Calendars Home and select your calendar.
2. Go to **Settings > Developer**.
3. In the **Webhooks** section, click **Create**.
4. Enter your HTTPS endpoint URL.
5. Select the action types you want, or **All Actions**.
6. Click **Create**, and store the generated `whsec_` secret server-side, for example as `LUMA_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.

```javascript
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:

```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](/event-gateway) verifies Luma's `Webhook-Signature` header against the timestamp and raw body, answers Luma with an immediate `200 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 `410` unless 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.registered` for 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, checks every Luma signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Luma webhooks reliably in minutes.