docs / webhooks
Completion webhooks
Add webhook_url (public https) to POST /v1/renders and PostImager sends one event when the render finishes: render.completed or render.failed. Polling still works; webhooks only save you the waiting.
Signing
Events follow the Standard Webhooks format. Headers: webhook-id (the event id, the same on every retry), webhook-timestamp (Unix seconds) and webhook-signature: v1, plus the base64 HMAC-SHA256 of {id}.{timestamp}.{raw body}, keyed with your signing secret from Settings → Webhooks. After you rotate the secret, events carry two signatures for 24 hours so you can switch over without downtime.
Delivery and retries
Any 2xx response counts as delivered. Anything else, a timeout (10 s) or a redirect is retried up to 8 times with growing gaps (30 s doubling, up to 6 hours apart). Handle each webhook-id once: retries are normal. Webhook URLs must be public https addresses; private, loopback and cloud-metadata addresses are refused when you create the render and again when we deliver.
The payload has ids and status only, never a download link: call GET /v1/renders/{render_id} with your key for a fresh one.
Verify an event
// Node 22 receiver (no dependencies). Rejects forged, altered and stale events, and dedupes retries.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const secret = Buffer.from(process.env.POSTIMAGER_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const seen = new Set(); // use a database table in production
createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const id = req.headers['webhook-id'];
const ts = Number(req.headers['webhook-timestamp']);
if (!id || Math.abs(Date.now() / 1000 - ts) > 300) return res.writeHead(400).end();
const expected = Buffer.from(createHmac('sha256', secret).update(`${id}.${ts}.${body}`).digest('base64'));
const ok = String(req.headers['webhook-signature'] ?? '').split(' ').some((s) => {
const given = Buffer.from(s.replace(/^v1,/, ''));
return given.length === expected.length && timingSafeEqual(given, expected);
});
if (!ok) return res.writeHead(401).end();
if (seen.has(id)) return res.writeHead(200).end(); // a retry of an event already handled
seen.add(id);
const event = JSON.parse(body); // { id, type, timestamp, data: { render_id, status, ... } }
// Fetch GET /v1/renders/{event.data.render_id} with your API key for a fresh download link.
res.writeHead(204).end();
});
}).listen(8080);