Webhooks
Get a signed POST to your own URL when templates change, render jobs finish or workflow runs end. Set one up, check the signature, and handle retries.
A webhook sends events from your workspace to a URL you own, as they happen. You don’t need to keep asking the API. JamFrame signs each request with a secret, so your server can check that it really came from JamFrame.
Create a webhook in the app
- In the sidebar, under Developers, click Webhooks.
- Click New webhook. (With no webhooks yet, click + Create your first webhook.)
- In Endpoint URL, paste the
httpsURL that should get the events. - Under Events, tick the events you want. See Events.
- Click Create subscription.
- Click Copy to copy the signing secret, and keep it with your server’s settings.
- Click I’ve saved this — close.
The URL must use https and point to a public address. Each workspace can have up to 20 webhooks. You need
editor access to the workspace to add or change them.
Manage webhooks
Each webhook on the Webhooks page has these buttons:
| Button | What it does |
|---|---|
| Test | Sends a sample of the webhook’s first event to your URL within a few seconds. The sample uses made-up ids. |
| Rotate | Makes a new signing secret and shows it once. The old one stops working: events from then on are signed with the new one, so update your server right away. |
| Disable | Stops sending events. The webhook moves to Disabled. |
| Re-enable | On a disabled webhook: starts sending events again. |
| Delete | Removes the webhook for good. |
The Failures column counts failed tries in a row, retries included. After 20 failed tries in a row, JamFrame disables the webhook by itself. Fix your server, then click Re-enable. One successful delivery sets the count back to 0.
A webhook sends events as the person who created it. If that person loses editor access to the workspace, the webhook stops for good. Create a new one.
Events
| Event | When it fires | data |
|---|---|---|
design.created |
A template was created in the workspace, also with Generate a template with AI. | The template |
design.updated |
A template was saved. | The template |
design.deleted |
A template was deleted. | The template as it was |
render.completed |
A render job finished: a render sent with "async": true. |
The render job |
render.failed |
A render job failed. No credits were charged. | The render job |
workflow.run.completed |
A workflow run ended as succeeded or partial, or was canceled. |
The run |
workflow.run.failed |
A workflow run failed: no row went through, or the source couldn’t be read. | The run |
Normal renders that answer at once don’t send render.* events. To hear about a render, send it with
"async": true. See Async renders.
What JamFrame sends
Each event is a POST with a JSON body. Every body has the same four fields:
| Field | What it is |
|---|---|
id |
The event’s id, such as whe_…. The same for every webhook that gets this event. |
type |
The event, such as design.updated. |
createdAt |
When the event happened. |
data |
The details. Its shape depends on the event. |
New fields can be added over time. Ignore fields you don’t know.
Template events
{
"id": "whe_abcd1234EFGH56789ijklMNOP",
"type": "design.updated",
"createdAt": "2026-11-14T10:15:00.000Z",
"data": {
"id": "clx8m2k3p0002abcd1234efgh",
"name": "Coffee tips carousel",
"orientation": "square",
"pages": 3,
"createdAt": "2026-11-01T09:12:44.000Z",
"updatedAt": "2026-11-14T10:15:00.000Z"
}
}
data has the same fields as an item of GET /v1/designs.
Render events
{
"id": "whe_Qm9wZXJ0eUlzVGhlZnQx2a3b",
"type": "render.completed",
"createdAt": "2026-11-14T10:20:07.000Z",
"data": {
"id": "cmw9x1y2z0001abcd1234efgh",
"status": "succeeded",
"progress": 1,
"format": "png",
"templateId": "clx8m2k3p0002abcd1234efgh",
"createdAt": "2026-11-14T10:20:01.000Z",
"startedAt": "2026-11-14T10:20:02.000Z",
"finishedAt": "2026-11-14T10:20:07.000Z",
"result": {
"content": "https://cdn.jamframe.com/renders/…/5f0c6b0e.png?Expires=…",
"expiresAt": "2026-11-14T10:25:07.000Z",
"type": "url",
"format": "png",
"pages": [1],
"credits": 1,
"renderId": "clx8n0q1r0004abcd1234efgh",
"responseTime": 4810
},
"error": null
}
}
data is the render job, as GET /v1/render-jobs/{id} returns it. In render.failed, result is null and
error holds the error code and message.
The file links in result work for the render’s expiresIn (5 minutes by default), counted from when the
event was sent. If a retry arrives after that, get fresh links with GET /v1/render-jobs/{id}. See
Async renders.
Workflow events
{
"id": "whe_V29ya2Zsb3dSdW5FbmRlZA9x",
"type": "workflow.run.completed",
"createdAt": "2026-11-14T09:00:09.000Z",
"data": {
"id": "cmw9v2k1a0003abcd1234efgh",
"workflowId": "cmw9uoeb9000binw52g648888",
"workflowName": "Quote cards",
"trigger": "schedule",
"status": "partial",
"rowsTotal": 3,
"rowsSucceeded": 2,
"rowsFailed": 1,
"rowsSkipped": 0,
"credits": 2,
"error": null,
"warnings": [],
"createdAt": "2026-11-14T09:00:00.000Z",
"startedAt": "2026-11-14T09:00:01.000Z",
"finishedAt": "2026-11-14T09:00:09.000Z"
}
}
data is the run, as in Run workflows from the API but without rows,
plus workflowName.
Headers
| Header | What it is |
|---|---|
X-Webhook-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
X-Webhook-Timestamp |
When this attempt was signed, in Unix seconds. The same as t. |
X-Webhook-Delivery |
The delivery’s id. It stays the same on every retry. Use it to skip events you already handled. |
X-Webhook-Event |
The event, such as design.updated, so you can route it without reading the body. |
Content-Type |
application/json |
User-Agent |
JamFrame-Webhook/1 |
Check the signature
v1 is the HMAC-SHA256 of the timestamp t, a dot, and the raw request body, made with your signing secret
(wh_sec_…). To check a request:
- Read
tandv1fromX-Webhook-Signature. - Reject the request if
tis more than 5 minutes from your clock. This stops old requests from being sent again. - Join
t, a dot (.) and the raw body. Compute the HMAC-SHA256 of that with your secret, as hex. - Compare it with
v1using a constant-time compare.
Use the raw body, exactly as it arrived. If your framework parses the JSON first and you turn it back into text, the bytes change and the check fails.
// Node.js 18+ with Express
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.JAMFRAME_WEBHOOK_SECRET; // wh_sec_…
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, header, secret) {
if (!header) return false;
const parts = {};
for (const piece of header.split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
if (!/^\d+$/.test(parts.t ?? "") || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw keeps the body as a Buffer, exactly as it arrived.
app.post("/webhooks/jamframe", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.body, req.get("X-Webhook-Signature"), SECRET)) {
return res.status(400).send("Bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
const deliveryId = req.get("X-Webhook-Delivery");
// Skip deliveryId if you handled it before, then do your work.
console.log(deliveryId, event.type, event.data.id);
res.sendStatus(200);
});
app.listen(3000);# Python 3.8+ with Flask 2+
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["JAMFRAME_WEBHOOK_SECRET"] # wh_sec_…
TOLERANCE_SECONDS = 300
def verify_signature(raw_body, header, secret):
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), v1.encode())
app = Flask(__name__)
@app.post("/webhooks/jamframe")
def jamframe_webhook():
raw_body = request.get_data() # the raw bytes, before any JSON parsing
if not verify_signature(raw_body, request.headers.get("X-Webhook-Signature"), SECRET):
abort(400)
event = request.get_json()
delivery_id = request.headers.get("X-Webhook-Delivery")
# Skip delivery_id if you handled it before, then do your work.
print(delivery_id, event["type"], event["data"]["id"])
return "", 200Delivery and retries
- Answer fast. Any
2xxanswer counts as delivered. JamFrame waits up to 10 seconds. Answer first, then do slow work. - Retries. A timeout, a network error or any other status (redirects too, which are not followed) is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours. After those 7 tries, JamFrame gives up on that event.
- At least once. The same event can arrive more than once, for example when your server answered after
the 10 seconds. Keep the
X-Webhook-Deliveryids you handled, and skip repeats. - Order. Events can arrive out of order. Use
createdAtif order matters. 410 Gonedeletes the webhook. Use it to unsubscribe from your side.- Disabled after 20 failures. 20 failed tries in a row disable the webhook. See Manage webhooks.
Manage webhooks with the API
These calls use your API key and act on the key’s workspace:
| Call | What it does |
|---|---|
POST /v1/webhooks/subscriptions |
Create a webhook. Body: url and events (an array of event names). Answers 201 with subscription and signingSecret, shown only this once. |
GET /v1/webhooks/subscriptions |
List webhooks, newest first. Takes limit and cursor. |
GET /v1/webhooks/subscriptions/{id} |
Get one webhook. |
DELETE /v1/webhooks/subscriptions/{id} |
Delete a webhook. Answers 204. |
POST /v1/webhooks/subscriptions/{id}/test |
Send a sample event. Optional body: eventType, one of the webhook’s events. Answers 202. |
POST /v1/webhooks/subscriptions/{id}/rotate-secret |
Make a new signing secret. Answers with subscription and the new signingSecret. |
curl https://api.jamframe.com/v1/webhooks/subscriptions \
-H "Authorization: Bearer $JAMFRAME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/jamframe",
"events": ["render.completed", "render.failed"]
}'{
"subscription": {
"id": "cmwa1b2c30001abcd1234efgh",
"url": "https://example.com/webhooks/jamframe",
"events": ["render.completed", "render.failed"],
"status": "active",
"secretTail": "x7Qa",
"failureCount": 0,
"lastFiredAt": null,
"createdAt": "2026-11-14T10:00:00.000Z",
"updatedAt": "2026-11-14T10:00:00.000Z"
},
"signingSecret": "wh_sec_…"
}status is active, suspended (disabled) or revoked (its creator lost editor access). To re-enable a
disabled webhook, use Re-enable on the Webhooks page.
| Status | error |
Why |
|---|---|---|
400 |
WEBHOOK_URL_INVALID |
The URL isn’t https, isn’t valid, or points to a private address. |
400 |
VALIDATION_ERROR |
events is empty or names an unknown event. |
402 |
WEBHOOK_LIMIT_REACHED |
The workspace already has 20 webhooks. Delete one first. |
404 |
NOT_FOUND |
No webhook with this id in the workspace. |
Related
- Renders that send
render.*events: Async renders - Workflow runs: Run workflows from the API
- Every endpoint in detail: API reference (opens in a new tab)
Last updated
Still stuck?
Email [email protected] and a real person will reply. Tell us the template you were using and what you expected to happen.