Webhook payloads
This page documents the JSON Automette POSTs to your endpoint and how to verify it came from us.
Delivery model
There are two paths an event can reach your server:
- Per-request webhook — Set
webhook_urlonPOST /api/v1/renders. Fires once, only for that render. Not signed — verify based on the source IP, a known token in the URL, or a shared secret in the payload yourself. - Subscription webhook — Create with
POST /api/v1/webhooks. Fires for every matching event in your team. Signed with HMAC-SHA256 using the subscription's secret.
Both deliveries POST the same JSON body for a given event type. The difference is in the HTTP headers.
Headers (subscription webhooks only)
| Header | Value |
|---|---|
Content-Type | application/json |
webhook-id | Unique event ID. Use this to dedupe retries. |
webhook-timestamp | Unix seconds at send time. Reject anything older than 5 minutes. |
webhook-signature | v1,<base64-hmac> — see verification below. |
Signature verification
// Node.js — verify a webhook delivery
import { createHmac } from "crypto"
function verify(secret, headers, rawBody) {
const id = headers["webhook-id"]
const ts = headers["webhook-timestamp"]
const sig = headers["webhook-signature"]
if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) {
throw new Error("Timestamp too old")
}
const payload = `${id}.${ts}.${rawBody}`
const expected = "v1," + createHmac("sha256", secret).update(payload).digest("base64")
if (expected !== sig) throw new Error("Invalid signature")
}# Python — verify a webhook delivery
import hmac, hashlib, base64, time
def verify(secret: str, headers: dict, raw_body: str) -> None:
event_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
signature = headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
raise ValueError("Timestamp too old")
payload = f"{event_id}.{ts}.{raw_body}".encode()
digest = hmac.new(secret.encode(), payload, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid signature")Always verify against the raw request body before JSON parsing — re-serializing changes whitespace and breaks the signature.
Retries
Subscription webhooks retry with exponential backoff up to 5 attempts, all within the first few minutes (delays of roughly 1s, 2s, 4s, 8s between attempts). A delivery is considered successful when your server returns any 2xx status within 10 seconds.
Each failed delivery attempt increments the subscription's failure_count; after 10 consecutive failures the subscription is auto-disabled. Re-enable it with PATCH /api/v1/webhooks/{id} (enabled: true), which also resets the count.
Per-request webhooks retry on the same schedule but do not disable anything on failure.
Payloads
render.completed
{
"event_id": "evt_...",
"event": "render.completed",
"render_id": "cm4rnd7qh0001jx04w8e2t5va",
"job_id": "cm4job5tn0001jr04p3a9s7cd",
"self": "https://automette.com/api/v1/renders/cm4rnd7qh0001jx04w8e2t5va",
"status": "completed",
"url": "https://cdn.automette.com/renders/cm4rnd7qh0001jx04w8e2t5va.pdf",
"format": "pdf",
"visibility": "public",
"source": "api",
"template_id": "cm4tpl8e20001js04xq2v9k3m",
"template_name": "Invoice",
"data": { "name": "Alice" },
"created_at": "2026-04-22T12:00:00.000Z"
}source identifies what triggered the render: "api", "csv", "ui", "form", "ondemand", or "collection". Useful for routing inside your handler.
render.failed
{
"event_id": "evt_...",
"event": "render.failed",
"render_id": "cm4rnd7qh0001jx04w8e2t5va",
"job_id": "cm4job5tn0001jr04p3a9s7cd",
"self": "https://automette.com/api/v1/renders/cm4rnd7qh0001jx04w8e2t5va",
"status": "failed",
"error": "Typst compile error: unknown variable 'amout'",
"source": "api",
"template_id": "cm4tpl8e20001js04xq2v9k3m",
"template_name": "Invoice",
"data": { "name": "Alice" },
"created_at": "2026-04-22T12:00:00.000Z"
}error is truncated to 500 characters.
form.submitted
{
"event_id": "evt_...",
"event": "form.submitted",
"form_id": "cm4frm3xz0001jw04y7c4m1gh",
"form_name": "Certificate Request",
"render_id": "cm4rnd7qh0001jx04w8e2t5va",
"url": "https://cdn.automette.com/renders/cm4rnd7qh0001jx04w8e2t5va.pdf",
"data": { "name": "Alice" },
"created_at": "2026-04-22T12:00:00.000Z"
}Fires once per submission, after every document in the submission has rendered successfully — url always points at a finished file. For submissions that produce multiple documents (template-set forms, multi-format forms), render_id and url refer to the last completed document; subscribe to render.completed to receive each file individually.
If the render fails, form.submitted does not fire — listen for render.failed instead.