# Guide to Magic Hour Webhooks: Features and Best Practices

Magic Hour webhooks tell your application when an AI render job changes state. Magic Hour generates video, images and audio through an API, and jobs can take from seconds to several minutes. Instead of polling each job, you register a webhook and Magic Hour posts to it when processing starts, when it finishes with download links, and when it fails.

This guide covers how to register a Magic Hour webhook, the job events and their payloads, how deliveries are signed and retried, and the practices that keep a Magic Hour receiver reliable.

## What are Magic Hour webhooks?

A Magic Hour webhook is an HTTPS endpoint plus the events it should receive, created in the Developer Hub. When a video, image or audio job you started through the API changes state, Magic Hour sends a JSON `POST` to the endpoint and expects a `2xx`.

Every delivery has two security headers. `magic-hour-event-timestamp` is the Unix time in seconds, and `magic-hour-event-signature` is a 64-character hex HMAC-SHA256 of the timestamp, a full stop and the raw body. The webhook secret starts with `whsec_`, but this is not Standard Webhooks: the whole string, prefix included, is the HMAC key, and nothing is base64-decoded.

## Magic Hour webhook features

| Feature | Details |
| --- | --- |
| Configuration | Developer Hub: **Create Webhook**, with the endpoint URL and events |
| Events | `{resource}.{action}`: `video`, `image` or `audio`, and `started`, `completed` or `errored` |
| Payload | `{ "type", "payload" }`, where `payload` matches the job's API response |
| Signature header | `magic-hour-event-signature`, 64 hex characters |
| Timestamp header | `magic-hour-event-timestamp`, Unix seconds |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | `{timestamp}.{raw body}` |
| Secret | `whsec_...`, used whole as a UTF-8 string |
| Replay guidance | Accept webhooks within 5 minutes of the current time |
| Retries | After 1, 2, 4, 8 and more minutes, capped at 60 minutes between attempts |
| Expiry | Events more than 24 hours old are marked as failed |

Magic Hour publishes no response timeout or source IP list, and its payloads have no event ID.

## Common events

| Event | Fires when |
| --- | --- |
| `video.started`, `image.started` | Processing begins: the job moves from `queued` to `rendering` |
| `audio.started` | Audio processing begins |
| `video.completed`, `image.completed`, `audio.completed` | Processing finishes successfully |
| `video.errored`, `image.errored`, `audio.errored` | Processing fails |

There is no `canceled` event. When someone cancels a job, for example in the Magic Hour web app, the job's `status` becomes `canceled` and no webhook is sent.

The `payload` has the same shape as `GET /v1/video-projects/:id`, `GET /v1/image-projects/:id` or `GET /v1/audio-projects/:id` for the job, so code that reads job details from the API can read webhooks too. Magic Hour's documentation shows the key fields of a `video.completed` event:

```json
{
  "type": "video.completed",
  "payload": {
    "id": "clx7uu86w0a5qp55yxz315r6r",
    "status": "complete",
    "type": "ANIMATION",
    "credits_charged": 450,
    "downloads": [
      {
        "url": "https://videos.magichour.ai/clx7uu86w0a5qp55yxz315r6r/output.mp4",
        "expires_at": "2024-10-19T05:16:19.027Z"
      }
    ],
    "error": null
  }
}

```

* The top-level `type` is the event. `payload.type` is the kind of job, such as `ANIMATION`, which is a different thing.
* `payload.id` is the project ID you got back when you created the job.
* `downloads` holds the output URLs, each with an `expires_at`. Magic Hour says video URLs typically expire after 24 hours.
* On `*.errored` events, `downloads` is empty and `error` has a `code`, such as `invalid_video_file`, and a `message`.

## Setting up Magic Hour webhooks

1. Go to the Magic Hour **Developer Hub** and click **Create Webhook**.
2. Enter your public HTTPS endpoint URL and select the events to receive.
3. Click **Create Webhook**, then copy the webhook secret and store it, for example as `MAGIC_HOUR_WEBHOOK_SECRET`.
4. Start a small job through the API, such as an image, and check that the `started` and `completed` events reach your endpoint.

If you use Hookdeck, create a source with the Magic Hour source type and use its URL as the endpoint URL. Paste the whole secret, including the `whsec_` prefix: Magic Hour uses it as a plain HMAC key. The source accepts `POST` only, which is what Magic Hour sends.

## Securing Magic Hour webhooks

Read both headers. Build the signed content from the timestamp header exactly as received, a `.` and the raw body. Compute an HMAC-SHA256 keyed with the full secret string, encode it as hex and compare it with the signature header in constant time.

Magic Hour recommends accepting webhooks only within five minutes of the current time, in either direction. Below, that check is optional and off by default.

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

function verifyMagicHourSignature(rawBody, timestamp, signature, secret, toleranceSeconds = 0) {
  if (!timestamp || !signature || !secret) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestamp)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  }

  // The whole whsec_... string is the key. Do not strip or decode it.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp, 'latin1')
    .update('.')
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/magic-hour', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyMagicHourSignature(
    req.body,
    req.get('magic-hour-event-timestamp'),
    req.get('magic-hour-event-signature'),
    process.env.MAGIC_HOUR_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.payload.id + event.type, then route on event.type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_magic_hour_signature(raw_body: bytes, timestamp, signature, secret: str,
                                tolerance_seconds: int = 0) -> bool:
    if not timestamp or not signature or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0:
        if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
            return False

    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_magic_hour_signature(raw_body, request.headers.get("magic-hour-event-timestamp"),
#                             request.headers.get("magic-hour-event-signature"),
#                             os.environ["MAGIC_HOUR_WEBHOOK_SECRET"])

```

Hookdeck's Magic Hour source type verifies HMAC-SHA256 over `{magic-hour-event-timestamp}.{body}`, hex, keyed with the whole secret, and applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

> **Make Magic Hour webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `magic-hour-event-signature` header on every render event, stores each one, and delivers it to your services with retries you control.

## Magic Hour webhook limitations and pain points

### A `whsec_` secret that isn't Standard Webhooks

**The Problem:** Verification fails on every delivery, even with the right secret.

**Why It Happens:** The `whsec_` prefix suggests Standard Webhooks or Svix, where you strip the prefix and base64-decode the rest. Magic Hour uses the whole string as the key, signs `{timestamp}.{body}` rather than `{id}.{timestamp}.{body}`, and sends hex rather than base64. A Standard Webhooks library will never accept these requests.

**Workarounds:**

* Use a plain HMAC-SHA256 with the full secret string, as in the samples above.
* Don't install a Standard Webhooks or Svix library for Magic Hour.

**How Hookdeck Can Help:** Hookdeck's Magic Hour source uses the full secret as Magic Hour does, so the scheme is handled in one place.

### Cancelled jobs go silent

**The Problem:** A job stays "rendering" in your system forever.

**Why It Happens:** Cancelling a job changes its `status` to `canceled` but sends no webhook. There is no `video.canceled`, `image.canceled` or `audio.canceled` event.

**Workarounds:**

* Poll the job details endpoint for any job that has had no final event after a reasonable time.
* Treat a `canceled` status from the API as final.

**How Hookdeck Can Help:** Hookdeck keeps every event it receives, so when a job looks stuck you can check whether anything after `started` ever arrived for that project ID.

### Retries and the five-minute window

**The Problem:** A delivery that you retry or replay from Hookdeck fails your own timestamp check.

**Why It Happens:** Magic Hour regenerates the timestamp and signature on each of its own retries, so its retries pass a five-minute window. Hookdeck retries and replays resend the original headers, so a redelivery more than five minutes later carries an old timestamp.

**Workarounds:**

* Leave the window off in your service when Hookdeck sits in front, and deduplicate instead.
* If you keep a window, make it longer than any retry or replay delay you allow.

**How Hookdeck Can Help:** Hookdeck applies no timestamp tolerance, so its retries and replays still pass verification at the source.

### Events expire after 24 hours

**The Problem:** After a long outage, completed jobs never reach your system.

**Why It Happens:** Magic Hour retries at growing intervals, capped at 60 minutes, and marks events more than 24 hours old as failed. Download URLs typically expire after 24 hours as well.

**Workarounds:**

* Keep the endpoint available and return `2xx` quickly.
* After an outage, list recent jobs through the API and fetch fresh download URLs.

**How Hookdeck Can Help:** Hookdeck accepts each event when Magic Hour sends it and stores it, so an outage on your side doesn't run down Magic Hour's 24 hours.

## Best practices

### Verify against the raw body

Magic Hour warns not to parse and re-stringify the JSON. Read raw bytes: `express.raw({ type: '*/*' })` in Express, `Buffer.from(await request.arrayBuffer())` in Next.js and `await request.body()` in FastAPI.

### Deduplicate on project ID and event type

Payloads have no event ID, so Magic Hour suggests a key built from the project ID and the event type, such as `clx7uu86w0a5qp55yxz315r6r:video.completed`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Persist before you acknowledge

Magic Hour advises saving the verified event to a database or durable queue before returning `2xx`, because in-process background tasks can be lost on a restart. Then return `200` and do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Download outputs promptly

Copy completed outputs to your own storage when you handle `*.completed`, because download URLs expire. If one has expired, fetch the job details for a fresh URL.

### Reuse your API models

The `payload` matches the job details response, so parse it with the same types you use for the API.

## Conclusion

Magic Hour signs job events with a hex HMAC-SHA256 of the timestamp and raw body, keyed with the whole `whsec_` secret. Use plain HMAC rather than a Standard Webhooks library, deduplicate on project ID and event type, poll for cancelled jobs, and copy outputs before their URLs expire.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Magic Hour signature and holds each render event until your service has processed it.

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