# Magic Hour Webhooks

Magic Hour (magichour.ai) is an AI video, image and audio generation API. It sends a webhook
when a render job starts, completes or errors. Every delivery is signed with HMAC-SHA256 using
the webhook secret from the Magic Hour Developer Hub.

## When to Use This Skill

* How do I receive Magic Hour webhooks?
* How do I verify the `magic-hour-event-signature` header?
* How do I handle `video.completed`, `image.completed` or `audio.completed` events?
* Why does my Magic Hour webhook signature verification fail?
* Why can't I use the Svix or Standard Webhooks library for Magic Hour?

## Verification (core)

Two headers arrive on every delivery: `magic-hour-event-signature` (64 hex characters, no
prefix) and `magic-hour-event-timestamp` (Unix seconds). The signature is the hex
HMAC-SHA256 of `{timestamp header}.{raw request body}`. The key is the secret's UTF-8 bytes,
used whole including `whsec_`: nothing is base64- or hex-decoded.

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

function verifyMagicHour(rawBody, headers, secret) {   // rawBody: Buffer, exactly as received
  const sig = headers['magic-hour-event-signature'];
  const ts = headers['magic-hour-event-timestamp'];
  if (!secret || !sig || !ts || !/^[0-9a-fA-F]{64}$/.test(sig)) return false;

  // Build the message as bytes: header latin1, "." as UTF-8, then the untouched body bytes.
  const message = Buffer.concat([Buffer.from(ts, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(message).digest();
  const received = Buffer.from(sig, 'hex');            // safe: format validated above
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

```

Return `401` for a missing, malformed or mismatched signature. Never decode the body to a
string and re-encode it, and never `JSON.parse` before verifying (Express:
`express.raw({ type: '*/*' })`; Next.js: `await request.arrayBuffer()`; FastAPI:
`await request.body()`).

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

### Timestamp tolerance is opt-in

Magic Hour recommends rejecting timestamps more than 5 minutes old, but this skill does **not**
do that by default. Retries (and Hookdeck replays) resend the original headers, and Hookdeck
itself applies no tolerance, so a fixed window would reject legitimate redeliveries. To opt in,
set `MAGIC_HOUR_WEBHOOK_TOLERANCE_SECONDS` (unset or `0` = off, e.g. `300`). The examples then
require a numeric timestamp within that many seconds of now.

### Hookdeck source setup

Hookdeck's source type is `MAGIC_HOUR` ("Magic Hour"). When you create it, Hookdeck shows this
note, and it applies here too:

> Paste the whole secret, including the whsec_ prefix. Magic Hour uses it as a plain HMAC key.

## Event Types

Dispatch on the body's `type` field (`{type, payload}`; the payload matches the corresponding
`GET /v1/{video,image,audio}-projects/:id` response).

| Event | Sent When |
| --- | --- |
| `video.started` / `image.started` / `audio.started` | Job moves from `queued` to `rendering` |
| `video.completed` / `image.completed` / `audio.completed` | Job finished; `payload.downloads[]` holds URLs that expire |
| `video.errored` / `image.errored` / `audio.errored` | Job failed; `payload.error` has `code` and `message` |

A canceled job fires no webhook (`canceled` is a status only). Payloads have no event ID:
dedupe on `{payload.id}:{type}`.

## Delivery Behavior

Network failures, timeouts and non-2xx responses are retried at 1, 2, 4, 8, ... minutes
(capped at 60); events older than 24 hours are marked failed. The timestamp and signature are
regenerated for each attempt. No timeout value is documented, so acknowledge quickly. Webhooks
are `POST` requests.

## Environment Variables

```bash
MAGIC_HOUR_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Optional: reject timestamps older/newer than this many seconds (unset or 0 = off)
# MAGIC_HOUR_WEBHOOK_TOLERANCE_SECONDS=300

```

## Local Development

Magic Hour can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 magic-hour --path /webhooks/magic-hour

```

Register the printed URL as the webhook Endpoint URL in the Magic Hour Developer Hub.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/magic-hour-webhooks/references/overview.md) - What Magic Hour webhooks are, events, payloads, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/magic-hour-webhooks/references/setup.md) - Create the webhook and get the secret in the Developer Hub
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/magic-hour-webhooks/references/verification.md) - Signature details, gotchas, debugging
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/magic-hour-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests