Gareth Wilson Gareth Wilson

Guide to Magic Hour Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDeveloper 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 headermagic-hour-event-signature, 64 hex characters
Timestamp headermagic-hour-event-timestamp, Unix seconds
SchemeHMAC-SHA256, hex digest
Signed content{timestamp}.{raw body}
Secretwhsec_..., used whole as a UTF-8 string
Replay guidanceAccept webhooks within 5 minutes of the current time
RetriesAfter 1, 2, 4, 8 and more minutes, capped at 60 minutes between attempts
ExpiryEvents 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

EventFires when
video.started, image.startedProcessing begins: the job moves from queued to rendering
audio.startedAudio processing begins
video.completed, image.completed, audio.completedProcessing finishes successfully
video.errored, image.errored, audio.erroredProcessing 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:

{
  "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.

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:

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 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.

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.

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 verifies every Magic Hour signature and holds each render event until your service has processed it.

Get started with Hookdeck for free and handle Magic Hour webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.