Skip to content

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