Receiving Webhooks
The events Kalpana sends, the headers on each request, and how to verify the signature.
A webhook endpoint receives a POST with a JSON body each time one of its events happens. Add endpoints in the Studio under Webhooks (guide) or with the endpoints API.
The request
Endpoints in the json format receive this envelope:
{
"id": "0192f8a4-6c1e-7c3a-9b0e-4d2f1a7e5c11",
"type": "batch.completed",
"api_version": "2026-10-20",
"created_at": "2026-10-20T09:41:07.512Z",
"workspace_id": "ws_123",
"data": { }
}| Field | Description |
|---|---|
id | The event's id. A resend carries the same id, so use it to ignore duplicates. |
type | One of the events below. |
api_version | The payload version the endpoint was created on. Payloads keep this shape for the endpoint. |
created_at | When the event happened. |
workspace_id | The workspace the event belongs to. |
data | The event's fields. |
Each request also carries these headers:
| Header | Description |
|---|---|
Kalpana-Signature | t=<unix seconds>,v1=<signature>. See Verify the signature. |
Kalpana-Event-Id | Same as id. |
Kalpana-Event-Type | Same as type. |
Kalpana-Delivery-Id | This delivery. A resend has a new delivery id. |
Kalpana-Delivery-Attempt | 1 for the first try, 2 and 3 for retries. |
Endpoints in the slack, discord and google_chat formats receive a one-line message instead of the envelope, with the same headers, including the signature over the body sent:
| Format | Body |
|---|---|
slack, google_chat | { "text": "..." }, with *bold* names and <url|download> links. |
discord | { "content": "...", "allowed_mentions": { "parse": [] } }, with **bold** names and [download](url) links. |
| Event | Message |
|---|---|
batch.completed | Batch Diwali sale finished: 477 creatives rendered, 3 failed. The failed and cancelled counts appear only when above zero. |
job.completed | New creative diwali-sale_1.png: download, where download links to the file. |
webhook.test | Test event from Kalpana: this endpoint is connected. |
Batch and file names are escaped and cut to 200 characters, so a name cannot format the message or mention anyone.
Events
batch.completed
Every job of a batch run has finished, by rendering, failing or being cancelled.
{
"batch": { "id": "bat_123", "name": "Diwali sale" },
"counts": { "total": 480, "succeeded": 477, "failed": 3, "cancelled": 0 }
}Fetch the outputs with Download a batch or List creatives.
job.completed
One job rendered.
{
"creative": {
"id": "cre_123",
"name": "diwali-sale_1.png",
"url": "https://cdn.kalpana.one/...",
"content_type": "image/png",
"format": "png",
"kind": "image",
"width": 1080,
"height": 1080,
"size_bytes": 284113
},
"job": {
"id": "7b0c2d1e-4f5a-4c8b-9e3d-2a6f1b8c0d11",
"page_id": "k7fq2m8a",
"row": { "<variable id>": { "node_type": "text", "spans": [{ "span_index": 0, "value": "50% off" }] } },
"modifications": { "Headline": "50% off", "Photo": { "asset": "0b0f5a3e-5b1c-4d65-9a5e-3f7d2f2f6c11", "name": "shoe.png" } }
},
"batch": { "id": "bat_123", "name": "Diwali sale" },
"template": { "id": "t4hz8c1n" }
}creative.urlis a download link signed when the request is sent. It stays valid for at least six days. Copy the file if you need it longer.job.modificationsis what the job was made from, keyed by layer and in the form the API takes: the same shape as Modifications, as the server read it. It isnullfor a job made in the Kalpana app. A value larger than 64 KB is left out (null) andjob.modifications_omittedistrue.job.rowis the engine's data the job stores: variable id to value object. It is there besidemodificationsfor callers that already read it. A value larger than 64 KB is left out (null) andjob.row_omittedistrue. Prefermodificationsfor new code.job.page_idis the page the job renders, ornullfor a video.batchandtemplatearenullfor a creative made outside a batch.
webhook.test
Sent by Send test event or Send a test event, whatever the endpoint subscribes to. Its data has endpoint (id, name) and message.
Callback URLs
Pass webhookUrl when you create jobs or a batch with an API key, and Kalpana sends each of those jobs' events to the URL. Only those jobs' events are sent, and only these two:
job.completed, with the same payload asjob.completedabove.job.failed, when the render fails.
job.failed has this data:
{
"job": {
"id": "7b0c2d1e-4f5a-4c8b-9e3d-2a6f1b8c0d11",
"page_id": "k7fq2m8a",
"row": { "<variable id>": { "node_type": "text", "spans": [{ "span_index": 0, "value": "50% off" }] } },
"modifications": { "Headline": "50% off" }
},
"error": "The reason the render failed.",
"batch": null,
"template": { "id": "t4hz8c1n" }
}erroris a string that says why the job failed.jobhas the same fields as injob.completed, includingmodificationsandmodifications_omitted.batchandtemplatearenullwhen the job has none.
A callback uses the same envelope, headers, retries and answering rules as an endpoint. It differs in these ways:
- It does not need an endpoint under Webhooks, and it does not appear in the endpoint list.
- It ignores template filters and a batch's webhook switch.
- It requires an active subscription, like other webhooks. Without one, the create call fails with
402. - A request without an API key, such as one from the Studio, cannot use
webhookUrl. It fails with400. - The URL must be
httpsand public, with no username or password in it.
Signing secret
A callback has no endpoint, so the signing secret comes from the API key used in the request. It is the lowercase hex of an HMAC-SHA256 with the API key as the key and the text kalpana-webhook-signing-v1 as the message.
const secret = crypto.createHmac("sha256", apiKey).update("kalpana-webhook-signing-v1").digest("hex");secret = hmac.new(api_key.encode(), b"kalpana-webhook-signing-v1", hashlib.sha256).hexdigest()Verify Kalpana-Signature with that secret exactly as in Verify the signature.
If the receiver cannot compute an HMAC, use a URL that is unique and secret for each request. The resume URL of an n8n Wait node is one. Then the URL itself is the protection.
Verify the signature
Kalpana-Signature is an HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with the endpoint's signing secret and written as hex. Every attempt is signed when it is sent, so you can reject old timestamps without rejecting retries.
Verify against the raw body exactly as received, before parsing the JSON.
import crypto from "node:crypto";
export const verifyKalpanaSignature = (rawBody: string, header: string, secret: string) => {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
// Reject requests older than five minutes.
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
};import hashlib
import hmac
import time
def verify_kalpana_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(parts.get("t", "0"))
# Reject requests older than five minutes.
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Tools that cannot compute an HMAC, such as Zapier's Catch Hook, rely on their URL being secret. To check a fixed token instead, give the endpoint an auth header such as Authorization: Bearer <token>.
Answering
- Answer with any
2xxstatus within 15 seconds. Do slow work after answering. - Any other status, a timeout or a connection error is retried up to two more times, after about 12 seconds and then about 2.5 minutes. A
Retry-Afterheader on a429or503is honoured. 410 Goneor405 Method Not Allowedturns the endpoint off at once, and nothing already queued for it is sent. Answer410when the endpoint should never receive events again.- After 10 failed attempts in a row the endpoint is paused: new events are logged as not sent and one try goes through every 5 minutes, until one succeeds.
- Redirects are not followed. Give the final URL.
Events can arrive more than once and out of order. Use id to skip duplicates, and for batch.completed read the counts rather than counting job.completed events.
Updated 9 Nov 2026