Skip to content
JamFrame
Docs menu

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

  1. In the sidebar, under Developers, click Webhooks.
  2. Click New webhook. (With no webhooks yet, click + Create your first webhook.)
  3. In Endpoint URL, paste the https URL that should get the events.
  4. Under Events, tick the events you want. See Events.
  5. Click Create subscription.
  6. Click Copy to copy the signing secret, and keep it with your server’s settings.
  7. 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:

  1. Read t and v1 from X-Webhook-Signature.
  2. Reject the request if t is more than 5 minutes from your clock. This stops old requests from being sent again.
  3. Join t, a dot (.) and the raw body. Compute the HMAC-SHA256 of that with your secret, as hex.
  4. Compare it with v1 using 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 "", 200

Delivery and retries

  • Answer fast. Any 2xx answer 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-Delivery ids you handled, and skip repeats.
  • Order. Events can arrive out of order. Use createdAt if order matters.
  • 410 Gone deletes 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.

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.