Anjin Media
Docs navigation

Docs

Webhooks

Ingest, planning and rendering all finish on their own schedule. Register an HTTPS endpoint and the platform tells you when they do - six events, each one signed, retried and safe to receive twice.

Every long-running call in this API answers 202 and finishes later. You can poll for that, and the quickstart does, but a webhook is one delivery instead of a polling loop and it is the shape to move to the moment you are running this at any volume.

Attaching an endpoint#

POST/v1/webhook-endpoints

Requires the admin scope - registering a destination for your account's events is an account-level act, not a working one.

FieldTypeDescription
urlstring · requiredWhere deliveries are POSTed. HTTPS only, and the hostname is resolved at request time and refused if it points anywhere private.
eventsstring[] · requiredA non-empty array drawn from the six events below. Anything else fails the whole request - 422 invalid_request, with a detail that lists the six - rather than registering a partial subscription.
curl
curl -sS https://anjin-media-api.fly.dev/v1/webhook-endpoints \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: 4d0e9f31-6b27-4a58-b3c9-51f28e7d0a64" \
  -d '{
    "url": "https://hooks.example.com/anjin",
    "events": ["cut.planned", "render.completed", "render.failed"]
  }'
201 Created
{
  "id": "e58a3c17-0d64-4b92-8f31-97ae2c5b6041",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "url": "https://hooks.example.com/anjin",
  "events": ["cut.planned", "render.completed", "render.failed"],
  "failure_count": 0,
  "disabled_at": null,
  "created_at": "2026-08-17T14:02:48.119Z",
  "secret": "3b7f1e0c9a45d268e3fa17c04b95d8e6720af31c6d4bb95e08c27ad3f61e4b82"
}

The URL is checked before any row is written, with the same guard that vets a source_url at ingest: HTTPS only, and the hostname must resolve to a public address. A private or loopback target is refused.

422 Unprocessable Entity · application/problem+json
{
  "type": "https://anjin.media/errors/ssrf_blocked",
  "title": "Ssrf Blocked",
  "status": 422,
  "detail": "source_url must use https",
  "code": "ssrf_blocked"
}

The detail says source_url even though you sent url - one guard serves both call sites and it names itself after the older one. The code is what to branch on.

Listing and removing

GET/v1/webhook-endpoints

Every endpoint on the account, keyset-paginated, newest first. failure_count is the number of consecutive deliveries that have exhausted their retries, and disabled_at is set once an endpoint has been switched off. Both are worth alerting on.

curl
curl -sS https://anjin-media-api.fly.dev/v1/webhook-endpoints \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK
{
  "data": [
    {
      "id": "e58a3c17-0d64-4b92-8f31-97ae2c5b6041",
      "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
      "url": "https://hooks.example.com/anjin",
      "events": ["cut.planned", "render.completed", "render.failed"],
      "failure_count": 0,
      "disabled_at": null,
      "created_at": "2026-08-17T14:02:48.119Z"
    }
  ],
  "next_cursor": null
}

DELETE/v1/webhook-endpoints/:id

Also admin. Deleting an endpoint drops every delivery still queued against it, so a receiver you have taken down stops accruing retries the moment you remove it.

curl
ENDPOINT_ID=e58a3c17-0d64-4b92-8f31-97ae2c5b6041

curl -sS -X DELETE \
  "https://anjin-media-api.fly.dev/v1/webhook-endpoints/$ENDPOINT_ID" \
  -H "authorization: Bearer $ANJIN_API_KEY"

The six events#

A closed set. There are no others, and subscribing to a name outside it is refused at registration rather than silently ignored:

EventidsWhen it fires
source.readygroup_idA source group finished ingest and perception and can now be used in a cut. summary: duration_ms.
source.failedgroup_idIngest or perception did not complete. summary: stage and message.
cut.plannedcut_idA plan version was committed and the cut is now planned. summary: version and estimated_duration_s. Fires for revisions too - the version is how you tell them apart.
cut.failedcut_idPlanning failed and the cut is failed. summary: stage and message.
render.completedcut_id, render_idOne render finished and its file is downloadable. summary: aspect, duration_ms, qc_passed and render_seconds. A three-aspect batch fires this three times.
render.failedcut_id, render_idOne render did not produce a file. summary: stage and message.

One endpoint can carry any combination of them. Two endpoints can carry the same event - every enabled endpoint subscribed to an event gets its own delivery, retried independently.

The pairs are exhaustive: a source group ends at source.ready or source.failed, a planner run at cut.planned or cut.failed, and each render at render.completed or render.failed. Subscribing to both halves of a pair means nothing gets stuck waiting for an event that will never come.

The payload#

Every delivery carries the same five-field envelope, whatever the event:

FieldTypeDescription
eventstringWhich of the six this is. Branch on it.
account_idstring (uuid)The account the event belongs to - useful when one receiver serves several accounts.
idsobjectThe identifiers for this event, as listed above. Always an object, and always the same keys for a given event.
summaryobjectA handful of small facts about what happened. Never media, never URLs, never the plan itself.
occurred_atstring (ISO 8601)When the event was emitted - not when this attempt was signed. The two differ on a retry, and only the signature timestamp is checked against your clock.
render.completed
{
  "event": "render.completed",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "ids": {
    "cut_id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
    "render_id": "4b1f8c07-92ad-4e35-b6c1-7d0a3e58f214"
  },
  "summary": {
    "aspect": "9:16",
    "duration_ms": 58400,
    "qc_passed": true,
    "render_seconds": 96.4
  },
  "occurred_at": "2026-08-17T14:11:39.702Z"
}
cut.planned
{
  "event": "cut.planned",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "ids": { "cut_id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64" },
  "summary": { "version": 2, "estimated_duration_s": 58.4 },
  "occurred_at": "2026-08-17T14:07:02.884Z"
}

Payloads are deliberately thin. There are no signed URLs in them and no plan documents - a webhook tells you something happened and hands you the ids to go and read it. Take the render_id and cut_id above to GET /v1/cuts/:id/renders for the download URLs, which are minted fresh on each read and would be expired by the time a retried delivery landed anyway. See Composition results.

Verifying a delivery#

Each delivery is a POST with content-type: application/json and an anjin-signature header:

request
POST /anjin HTTP/1.1
host: hooks.example.com
content-type: application/json
anjin-signature: t=1786975899,v1=6c1d0a4f7b25e3980af6c41d8b7e2503a94f6d18c05b2e7739ad81c46f0b53e2

Two components, comma-separated:

  • t - the unix timestamp, in seconds, at which this attempt was signed. A retry is signed again, so t is always fresh even when occurred_at is hours old.
  • v1 - the HMAC-SHA256 of the string `${t}.${body}`, keyed with your endpoint secret and hex-encoded. The signed string is the timestamp, a literal full stop, then the raw request body.

Verification is three checks, and all three matter:

  1. Recompute the digest over the raw body, read before any JSON parsing. Parsing and re-serialising changes bytes and the comparison will fail on payloads that are perfectly valid.
  2. Compare in constant time. A byte-by-byte === leaks how much of a forged signature was right.
  3. Reject anything where t is more than five minutes from your own clock, in either direction. That is what bounds the replay window for a captured request - the signature check alone does not, because a captured request carries a genuine signature.
Node · verify
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_S = 300;

export function verifyAnjinSignature(header, rawBody, secret) {
  const parsed = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header ?? "");
  if (!parsed) return false;
  const [, t, v1] = parsed;

  // 1. Bound the replay window against your own clock, in both directions.
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;

  // 2. Recompute over the RAW body. Re-serialising a parsed object changes
  //    bytes (key order, spacing, unicode escapes) and breaks the compare.
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const received = Buffer.from(v1, "hex");

  // 3. Constant-time compare. timingSafeEqual throws on a length mismatch,
  //    so check the length first.
  return expected.length === received.length && timingSafeEqual(expected, received);
}
Node · a receiver
import { createServer } from "node:http";

const SECRET = process.env.ANJIN_WEBHOOK_SECRET;

function enqueue(event) {
  // Your work goes here - on a queue, not on this request.
  console.log(event.event, event.ids);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks).toString("utf8");
    if (!verifyAnjinSignature(req.headers["anjin-signature"], rawBody, SECRET)) {
      res.writeHead(401).end();
      return;
    }
    // Acknowledge first: anything slower than 10 seconds is a retry, and the
    // same event may arrive twice regardless. Make the work idempotent.
    res.writeHead(204).end();
    enqueue(JSON.parse(rawBody));
  });
}).listen(3000);

Reject silently - a failed verification should not be answered with a helpful explanation of which check failed.

Delivery behaviour#

Deliveries are at-least-once. Design your handler so that receiving the same event twice is harmless: key your work on ids and treat a repeat as a no-op. A duplicate is not an error condition, it is the normal cost of a retry that succeeded after your first attempt had already written something.

  • Ten seconds. An attempt that has not answered within ten seconds is abandoned and retried. Acknowledge first and do the work afterwards.
  • Any 2xx is success. 200, 202, 204 - all fine. Anything else, and any connection failure or timeout, counts as a failed attempt.
  • Twenty attempts per delivery. After the twentieth the delivery is marked failed and is not retried again.

Backoff

Retries are scheduled at min(2^attempts × 30s, 1h) - each wait twice the last, capped at an hour:

After attemptNext retryNotes
160 sFirst retry, one minute after the failure.
2120 sDoubling each time.
3240 sFour minutes.
61,920 sThirty-two minutes - the last one under the cap.
7+3,600 sCapped at an hour for every attempt from the seventh onwards.

Twenty attempts on that curve span about fourteen hours, so a receiver that is down for a deploy loses nothing.

Endpoint auto-disable

Failures are also counted at the endpoint level, and the two counters are different things. The twenty above are attempts within one delivery. An endpoint carries its own failure_count of consecutive deliveries that each exhausted all twenty:

  • Every delivery that exhausts its attempts increments failure_count.
  • Any successful delivery resets it to zero. A receiver that is flaky rather than gone never accumulates.
  • At twenty consecutive exhausted deliveries the endpoint is disabled: disabled_at is stamped and nothing further is sent to it. Deliveries already queued for it are marked failed rather than attempted.

Everything on this page assumes the events themselves are the thing you act on. Creating a composition covers what produces them, Composition results covers what to read once one arrives, and Errors & limits covers the problem documents named here.