Kalpana Docs
Resources

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": { }
}
FieldDescription
idThe event's id. A resend carries the same id, so use it to ignore duplicates.
typeOne of the events below.
api_versionThe payload version the endpoint was created on. Payloads keep this shape for the endpoint.
created_atWhen the event happened.
workspace_idThe workspace the event belongs to.
dataThe event's fields.

Each request also carries these headers:

HeaderDescription
Kalpana-Signaturet=<unix seconds>,v1=<signature>. See Verify the signature.
Kalpana-Event-IdSame as id.
Kalpana-Event-TypeSame as type.
Kalpana-Delivery-IdThis delivery. A resend has a new delivery id.
Kalpana-Delivery-Attempt1 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:

FormatBody
slack, google_chat{ "text": "..." }, with *bold* names and <url|download> links.
discord{ "content": "...", "allowed_mentions": { "parse": [] } }, with **bold** names and [download](url) links.
EventMessage
batch.completedBatch Diwali sale finished: 477 creatives rendered, 3 failed. The failed and cancelled counts appear only when above zero.
job.completedNew creative diwali-sale_1.png: download, where download links to the file.
webhook.testTest 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.url is 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.modifications is 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 is null for a job made in the Kalpana app. A value larger than 64 KB is left out (null) and job.modifications_omitted is true.
  • job.row is the engine's data the job stores: variable id to value object. It is there beside modifications for callers that already read it. A value larger than 64 KB is left out (null) and job.row_omitted is true. Prefer modifications for new code.
  • job.page_id is the page the job renders, or null for a video.
  • batch and template are null for 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 as job.completed above.
  • 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" }
}
  • error is a string that says why the job failed.
  • job has the same fields as in job.completed, including modifications and modifications_omitted.
  • batch and template are null when 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 with 400.
  • The URL must be https and 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.

Node.js
const secret = crypto.createHmac("sha256", apiKey).update("kalpana-webhook-signing-v1").digest("hex");
Python
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.

Node.js
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);
};
Python
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 2xx status 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-After header on a 429 or 503 is honoured.
  • 410 Gone or 405 Method Not Allowed turns the endpoint off at once, and nothing already queued for it is sent. Answer 410 when 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.

Was this page helpful?

Updated 9 Nov 2026

On this page