Agent skill

Magic Hour Webhooks Skill

Receive and verify Magic Hour webhooks (magichour.ai, the AI video, image and audio generation API). Use when setting up a Magic Hour webhook handler, debugging magic-hour-event-signature verification, or handling render job events like video.completed, video.errored, image.completed or audio.completed. Not Standard Webhooks: do not use the svix or standardwebhooks libraries.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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.

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/, examples/nextjs/, 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).

EventSent When
video.started / image.started / audio.startedJob moves from queued to rendering
video.completed / image.completed / audio.completedJob finished; payload.downloads[] holds URLs that expire
video.errored / image.errored / audio.erroredJob 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

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):

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →