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. A team-wide subscription fires for every matching event in your team; passtemplate_idto scope it to a single template's render events. Delivery is additive — a render notifies team-wide subscriptions and any subscription scoped to its template. 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
The HMAC key is the secret after stripping the wh_ prefix and base64url-decoding it — not the raw secret string.
// Node.js — verify a webhook delivery
import { createHmac, timingSafeEqual } 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 key = Buffer.from(secret.replace(/^wh_/, ""), "base64url")
const payload = `${id}.${ts}.${rawBody}`
const expected = "v1," + createHmac("sha256", key).update(payload).digest("base64")
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(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")
raw = secret.removeprefix("wh_")
key = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
payload = f"{event_id}.{ts}.{raw_body}".encode()
digest = hmac.new(key, 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 "template_set". 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.
job.completed
A bulk/CSV job sends this once when it finishes, rather than a render.completed per row. A partial run reports status: "completed" with a non-zero failed count.
{
"event_id": "evt_...",
"event": "job.completed",
"job_id": "cm4job5tn0001jr04p3a9s7cd",
"template_id": "cm4tpl8e20001js04xq2v9k3m",
"template_name": "Invoice",
"source": "csv",
"status": "completed",
"total": 5000,
"completed": 4998,
"failed": 2,
"created_at": "2026-04-22T12:00:00.000Z",
"completed_at": "2026-04-22T12:03:20.000Z"
}To act on each document individually, list the template's renders via GET /api/v1/renders — bulk runs do not fire a webhook per row.
form.submitted
Fires once per submission on a hosted form, when the submission lands — independent of any document generation attached to the form.
{
"event_id": "evt_form_cm4sub9rk0001jt04d2h6w8pn",
"event": "form.submitted",
"form_id": "cm4frm3xz0001jw04y7c4m1gh",
"submission_id": "cm4sub9rk0001jt04d2h6w8pn",
"answers": { "email": "alice@example.com", "message": "Hi!" },
"submitted_at": "2026-04-22T12:00:00.000Z"
}event_id is deterministic per submission (evt_form_<submission_id>), so retried deliveries dedupe naturally. If the form generates documents, subscribe to render.completed / render.failed to receive each file's outcome.
Last updated on