Guide · 2026-10-11

Webhooks for every render

Subscribe a URL once and hear about every finished image, video and collection on your account, signed so you know it came from us and retried when your server is down.

Two kinds of webhook

Per request. Add webhook_url to a render and that one render runs in the background and posts its record there when done. Good for a job that wants its own answer.

Per account. Subscribe a URL with POST /v1/webhooks and it hears about every render on the account, wherever it came from: your code, a CSV collection, the editor, Zapier. Deliveries are signed and retried. Use this one for anything that reacts to renders: posting them, saving them, indexing them, alerting on failures.

Both are free.

Subscribe

curl -X POST https://api.imageapis.com/v1/webhooks -H 'X-API-Key: YOUR_KEY' -H 'Content-Type: application/json' \
  -d '{"url": "https://example.com/hooks/imageapis", "events": ["render.completed", "video.completed"], "description": "post to the team channel"}'
{
  "id": "hook_8m3kq2xv7zpw4rcn6bdt",
  "object": "webhook",
  "url": "https://example.com/hooks/imageapis",
  "events": ["render.completed", "video.completed"],
  "description": "post to the team channel",
  "secret": "whsec_5f0c…",
  "created": "2026-10-11T18:20:00Z",
  "last_delivery": null
}

Leave out events to get all of them. Keep the secret: it signs every delivery, and GET /v1/webhooks/{id} shows it again. An account has up to 20 webhooks; GET /v1/webhooks lists them with each one's last delivery, and DELETE /v1/webhooks/{id} removes one. A new subscription hears the very next render.

Events

Event When
render.completed An image, screenshot, PDF, OG card, chart, QR code or conversion is done
render.failed A background render (one with webhook_url) failed. A render you waited for fails in its own response instead
video.completed A video is done
video.failed A video failed
collection.completed Every row of a collection is done, including when some failed (status is then partial)
collection.failed Every row failed

Images inside a collection are not events of their own; the collection is, once, with every image in it. Signed-URL renders are not events either, since they happen whenever someone loads the image.

What arrives

A POST with a JSON body: the event, its id and time, and the record under its own kind, exactly what GET on that record returns.

{
  "id": "evt_2c9hx4mbq7tw8zk3nvrd",
  "event": "render.completed",
  "created": "2026-10-11T18:24:09Z",
  "image": {
    "id": "img_7hk2mq9ztbw4e6nrcxpd", "object": "image", "status": "completed",
    "url": "https://img.imageapis.com/i/img_7hk2mq9ztbw4e6nrcxpd.png",
    "format": "png", "width": 1200, "height": 630, "template": "tpl_4nq8vzk2hdyw7bxme3rc",
    "metadata": { "post": "1042" }, "units": 1,
    "created": "2026-10-11T18:24:08Z", "completed": "2026-10-11T18:24:09Z", "expires": "2026-11-10T18:24:08Z"
  }
}

The key is image, screenshot, pdf, video or collection. Put your own ids in a render's metadata and they come back here, so you know which post or row the image belongs to.

Headers: X-ImageAPIs-Event (the event), X-ImageAPIs-Delivery (the event id, the same on every retry, so you can ignore one you have already handled) and X-ImageAPIs-Signature.

Check the signature

X-ImageAPIs-Signature: t=1760206649,v1=5c1f… is the time the delivery was signed and the HMAC-SHA256 of t, a dot and the raw body, with the whole secret, whsec_ included, as the key. Compute it over the body exactly as it arrived, before any JSON parsing, and reject old timestamps so a captured delivery cannot be replayed.

Node (Express, with the raw body kept):

import crypto from "node:crypto";
import express from "express";

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header).split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = String(parts.v1 || "");
  return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

const app = express();
app.post("/hooks/imageapis", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body.toString("utf8"), req.get("X-ImageAPIs-Signature"), process.env.IMAGEAPIS_WEBHOOK_SECRET)) {
    return res.status(400).send("bad signature");
  }
  const event = JSON.parse(req.body);
  if (event.event === "render.completed") console.log("new image", event.image.url);
  res.sendStatus(200);
});

Python (Flask):

import hashlib, hmac, json, os, time
from flask import Flask, request, abort

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    t = int(parts.get("t", "0") or 0)
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

app = Flask(__name__)

@app.post("/hooks/imageapis")
def hook():
    if not verify(request.get_data(), request.headers.get("X-ImageAPIs-Signature"), os.environ["IMAGEAPIS_WEBHOOK_SECRET"]):
        abort(400)
    event = json.loads(request.get_data())
    if event["event"] == "video.completed":
        print("new video", event["video"]["url"])
    return "", 200

To check one by hand, paste the secret, the header and the body into the free webhook signature checker.

Answer fast; retries

Answer with any 2xx within 10 seconds and the delivery counts. Do slow work after answering, or in a queue.

Your answer What happens
2xx Delivered
408, 429, 5xx, no answer in 10 s, connection refused Tried again after 1 minute, 10 minutes and 1 hour
410 Gone The webhook is removed: the standard way to unsubscribe from your end
Any other 4xx Not retried

A retry carries the same event id and a fresh signature.

Test it and see what happened

Send a sample of any event to a webhook now, and see what your server answered:

curl -X POST https://api.imageapis.com/v1/webhooks/hook_8m3kq2xv7zpw4rcn6bdt/test -H 'X-API-Key: YOUR_KEY' \
  -H 'Content-Type: application/json' -d '{"event": "video.completed"}'
{ "webhook": "hook_8m3kq2xv7zpw4rcn6bdt", "url": "https://example.com/hooks/imageapis", "event": "video.completed", "delivered": true, "status": 200, "ms": 164 }

The sample record has "test": true and costs nothing.

GET /v1/events lists the account's last 50 events of the past 30 days, newest first, whether or not anything was subscribed, each with deliveries: every attempt, its webhook, status and time. Filter with ?type=render.failed. It is the first place to look when a webhook seems quiet, and a way to catch up on events your server missed.

Get a free key: 150 images a month API reference