# Luma Webhooks

Luma is the events and calendar platform (lu.ma / luma.com), not Luma AI. Webhooks are available to Luma Plus subscribers.

## When to Use This Skill

* How do I receive Luma webhooks?
* How do I verify the Luma `Webhook-Signature` header?
* How do I handle `guest.registered`, `ticket.registered`, or `event.created` events?
* How do I sync Luma unsubscribes (`calendar.person.unsubscribed`) to my CRM or email tool?
* Why is my Luma webhook signature verification failing?

## Essential Code (USE THIS)

Luma sends `Webhook-Signature: t=<unix seconds>,v1=<hex>`, plus `Webhook-Id` and `Webhook-Timestamp`. Despite the `webhook-*` header names and the `whsec_` secret, this is **not** Standard Webhooks: do not use the `standardwebhooks` or `svix` libraries, and do not strip or base64-decode the `whsec_` prefix.

* Signed string: `"<t>.<raw request body>"`
* Key: the **whole** secret string (including `whsec_`) as UTF-8
* `v1` = hex HMAC-SHA256, compared in constant time
* `Webhook-Id` is not part of the signed content (use it for idempotency)

### Verification (Node.js)

```javascript
const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw, not express.json)
function verifyLumaWebhook(rawBody, header, secret) {
  if (!header || !secret) return false;
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = parts.find(([k]) => k === 't')?.[1];
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || !/^\d+$/.test(t) || sigs.length === 0) return false;
  // Optional replay window, off by default: Hookdeck retries and replays keep the original t
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  return sigs.some((s) => {
    try { return crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)); } catch { return false; }
  });
}

```

### Verification (Python)

```python
import hashlib, hmac

def verify_luma_webhook(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header or not secret:
        return False
    pairs = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in pairs if k == "t"), None)
    sigs = [v for k, v in pairs if k == "v1"]
    if not t or not t.isdigit() or not sigs:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in sigs)

```

Luma suggests rejecting timestamps more than a few minutes old. The examples leave that off by default, because Hookdeck's retries and replays keep the original `t`, and Luma doesn't say whether its own retries send a new one. Set `LUMA_TIMESTAMP_TOLERANCE_SECONDS` to a positive number of seconds to opt in.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/luma-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/luma-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/luma-webhooks/examples/fastapi/).

## Common Event Types

The body is `{ "type": "<event type>", "data": { ... } }`. Subscribe to specific types (or all) when creating the webhook.

| Event | Description |
| --- | --- |
| `calendar.event.added` | An event was added to your calendar (created on it, approved, transferred in, or made public) |
| `calendar.event.submitted` | An event was submitted to your calendar and awaits approval |
| `calendar.person.subscribed` | Someone followed your calendar |
| `calendar.person.unsubscribed` | A person opted out of hearing from your calendar |
| `event.created` | A new event was created on your calendar |
| `event.updated` | Event details or a ticket type changed |
| `event.canceled` | An event was canceled |
| `guest.registered` | Someone registered, or a host added a guest |
| `guest.updated` | A registration changed (check-in, approval status, answers, tickets) |
| `guest.refunded` | A host refunded a guest's payment through Luma |
| `ticket.registered` | One ticket was purchased (3 tickets = 3 events) |

> **For payload schemas**, see [Luma API webhook types](https://docs.luma.com/reference/webhooks) and [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/luma-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `Webhook-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `Webhook-Id` | Unique ID for the webhook event (idempotency key) |
| `Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |

## Environment Variables

```bash
LUMA_WEBHOOK_SECRET=whsec_xxxxx            # Shown when you create the webhook; used whole as the HMAC key
LUMA_TIMESTAMP_TOLERANCE_SECONDS=0         # Optional; a positive value opts in to the replay window

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 luma --path /webhooks/luma

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/luma-webhooks/references/overview.md) - Luma webhook concepts, events, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/luma-webhooks/references/setup.md) - Creating a webhook in Luma
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/luma-webhooks/references/verification.md) - Signature verification details and gotchas