Webhooks
Register an https:// URL and the service POSTs signed events to it when a job finishes — the automation's answer to "tell me when it is done", where a terminal's is --wait. Up to 10 endpoints per account. This page follows a receiver from registering it to verifying what arrives, handling each event, and what happens when it does not answer.
Registering an endpoint
Name the URL and the events it wants. Leave events out for every job-level event; the per-item ones are opt-in by name, because a 500-item job would otherwise turn one registration into 500 deliveries nobody asked for. The URL must be https:// on port 443 and resolve to a public address — where an endpoint may point has the whole rule.
JavaScript
const endpoint = await client.webhooks.create({"url":"https://example.com/hooks/imagestep","events":["job.completed","job.failed"],"description":"prod"});
console.log(endpoint.secret); // store it now — it is not shown againPython
endpoint = client.webhooks.create("https://example.com/hooks/imagestep", events=["job.completed","job.failed"], description="prod")
print(endpoint["secret"]) # store it now — it is not shown againCLI
imagestep webhook create --url https://example.com/hooks/imagestep --events job.completed,job.failed --description prod -o jsoncurl
curl -s https://api.imagestep.dev/api/v1/webhook-endpoints -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/imagestep","events":["job.completed","job.failed"],"description":"prod"}'The answer — 201, and the only response that ever carries secret:
{
"id": "whe_998fed6d72bd4b9bb5fbd66ed386b516",
"url": "https://example.com/hooks/imagestep",
"description": "prod",
"events": [
"job.completed",
"job.failed"
],
"enabled": true,
"createdAt": "2026-09-17T16:25:57.144262013Z",
"updatedAt": "2026-09-17T16:25:57.144262013Z",
"consecutiveFailures": 0,
"secret": "whsec_HU55buvTtDgrSzg4rJgs5CGS5xG-Bz7VAS1sdiaYKzQ",
"secretHint": "whsec_H…YKzQ"
}The signing secret is returned once, by create and by POST /webhook-endpoints/{id}/rotate-secret; every read shows a masked secretHint. A lost secret is rotated, not recovered, and there is no overlap: every attempt after a rotation — retries of earlier events included — is signed with the new secret, so deliveries fail verification until your receiver has it, and are retried rather than lost. The n8n trigger registers an endpoint for you when the workflow is activated.
Then send it a webhook.test event and read what your receiver answered. It is one attempt, made now and never retried — its answer is the response — and it works on a disabled endpoint, which is how you check a fixed receiver before turning it back on:
JavaScript
const delivery = await client.webhooks.test("<endpoint-id>"); // { status, responseStatus, error, … }Python
delivery = client.webhooks.test("<endpoint-id>") # {"status", "responseStatus", "error", …}CLI
imagestep webhook test <endpoint-id> # exits 1 unless the receiver answered 2xxcurl
curl -s -X POST https://api.imagestep.dev/api/v1/webhook-endpoints/<endpoint-id>/test -H "Authorization: ApiKey $IMAGESTEP_API_KEY"Verifying a delivery
Every delivery is a POST of the event as JSON, with these headers:
ImageStep-Signature: t=1757318400,v1=185cd63a1256266ee9b63b641e768f5c75223ebc27d19d9aaa89414568990e32
ImageStep-Event-Id: evt_4d5b552267e64eef916ed3f45f9cf260
ImageStep-Event-Type: job.completed
ImageStep-Attempt: 1| header | carries |
|---|---|
| ImageStep-Signature | t=<unix seconds>,v1=<hex HMAC>: when this attempt was signed, and the signature |
| ImageStep-Event-Id | the event's id — the body's id, the same on every attempt: the key to deduplicate on |
| ImageStep-Event-Type | the event's type, so a router can dispatch before it parses the body |
| ImageStep-Attempt | 1 on the first attempt, 2 on the first retry, and so on |
v1 is HMAC-SHA256("<t>.<raw request body>", secret) in lowercase hex — the same construction Stripe uses, so an existing verifier ports over. The key is the secret exactly as you were given it, the whsec_ prefix included, as UTF-8 bytes; nothing is base64-decoded. A receiver does three things:
- Takes the raw body, before any JSON parsing — re-serialising changes bytes, and the MAC with them.
- Recomputes
v1fromt, the body and the secret, and compares in constant time. - Rejects a
toutside its tolerance — 300 s is typical, and the SDKs' default.tis when this attempt was signed, not when the event happened: every retry is signed afresh, so the window never turns away a late retry, only a captured delivery replayed later.
Both SDKs do all three:
JavaScript
import { constructWebhookEvent } from "imagestep";
export async function POST(request) {
const raw = await request.text(); // the raw body, not request.json()
let event;
try {
event = await constructWebhookEvent(raw, request.headers.get("ImageStep-Signature"), secret);
} catch {
return new Response("bad signature", { status: 400 });
}
if (event.type === "job.completed") await collect(event.data); // an error here is a 500, and a retry
return new Response(null, { status: 204 });
}Python
from imagestep import WebhookSignatureError, construct_webhook_event
def handle(raw_body: bytes, signature: str | None, secret: str) -> int:
try:
event = construct_webhook_event(raw_body, signature, secret)
except WebhookSignatureError:
return 400
if event["type"] == "job.completed":
collect(event["data"])
return 204A receiver in a language with no SDK — Go, PHP, a Code node in a workflow tool — ports these dozen lines. All three steps are in them:
JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body as a string, exactly as it arrived — before any JSON.parse
export function verifyImageStepSignature(rawBody, header, secret, { toleranceSeconds = 300, now = Date.now() / 1000 } = {}) {
if (!secret) throw new Error("no webhook secret configured"); // an empty key's HMAC is one anybody can compute
const parts = Object.fromEntries(String(header || "").split(",").map((kv) => kv.trim().split("=")));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(now - timestamp) > toleranceSeconds) return false; // stale — or from the future
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1.toLowerCase());
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected)); // constant time
}Python
import hashlib
import hmac
import time
# raw_body: the request body as bytes, exactly as it arrived — before any json.loads
def verify_imagestep_signature(raw_body: bytes, header: str | None, secret: str, tolerance_seconds: int = 300, now: float | None = None) -> bool:
if not secret:
raise ValueError("no webhook secret configured") # an empty key's HMAC is one anybody can compute
parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(",") if "=" in kv)
try:
timestamp, given = int(parts["t"]), parts["v1"].lower()
except (KeyError, ValueError):
return False
if abs((time.time() if now is None else now) - timestamp) > tolerance_seconds:
return False # stale — or from the future
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given) # constant timeEvents
| type | when | default subscription |
|---|---|---|
| job.completed | every item settled, none failed | yes |
| job.failed | every item settled and at least one failed (status FAILED) — or the job was cancelled (status CANCELLED) | yes |
| job.item.completed | one item finished | no — opt in |
| job.item.failed | one item failed; an item a cancel stops sends none — job.failed (status CANCELLED) says it once | no — opt in |
| webhook.test | you called POST /webhook-endpoints/{id}/test | n/a |
The body is always id · type · createdAt · data, and data is a summary: read GET /api/v1/jobs/{jobId} for the items and outputs. A webhook.test event has only a message in data. A job that finished — job.completed:
{
"id": "evt_4d5b552267e64eef916ed3f45f9cf260",
"type": "job.completed",
"createdAt": "2026-09-17T16:25:57.619594170Z",
"data": {
"jobId": "job_6183ee1d2b2b42fd983dbcdc93188ffb",
"type": "process",
"status": "COMPLETED",
"totalItems": 1,
"completedItems": 1,
"failedItems": 0,
"creditsCharged": 0
}
}A job with a failed item — job.failed. errorCode and retryable are the job's verdict on its failed items, the same pair GET /jobs/{id} answers with, and what a receiver decides a resume on:
{
"id": "evt_1816043197354a4ea5de6ec4b81d2aa7",
"type": "job.failed",
"createdAt": "2026-09-17T16:25:57.847224878Z",
"data": {
"jobId": "job_a7cd6bddfb09450f8bb76999ab28f5e0",
"type": "process",
"status": "FAILED",
"totalItems": 1,
"completedItems": 0,
"failedItems": 1,
"creditsCharged": 0,
"errorCode": "invalid_param",
"retryable": false
}
}One item that finished — job.item.completed, opt-in:
{
"id": "evt_f00cfc585ea4426e81ada067bffc2e05",
"type": "job.item.completed",
"createdAt": "2026-09-17T16:25:57.615560503Z",
"data": {
"jobId": "job_6183ee1d2b2b42fd983dbcdc93188ffb",
"type": "process",
"itemIndex": 0,
"status": "COMPLETED",
"settledItems": 1,
"totalItems": 1,
"resultAssetId": "ast_c2ca0ef3785134a98e41965a98f13cb4",
"sourceAssetId": "ast_28b79e93a59846e4b8c10e8ec6ef3cf9"
}
}One item that failed — job.item.failed, opt-in, with the item's own verdict:
{
"id": "evt_1aee53a37bd74314b74b1035bc1f7ca5",
"type": "job.item.failed",
"createdAt": "2026-09-17T16:25:57.842963670Z",
"data": {
"jobId": "job_a7cd6bddfb09450f8bb76999ab28f5e0",
"type": "process",
"itemIndex": 0,
"status": "FAILED",
"settledItems": 1,
"totalItems": 1,
"sourceAssetId": "ast_28b79e93a59846e4b8c10e8ec6ef3cf9",
"error": "Pipeline processing failed: crop area 100x100 at 5000,5000 does not fit inside the image",
"errorCode": "invalid_param",
"retryable": false
}
}What each key of data is, and which events carry it:
| in data | job.completed · job.failed | job.item.completed · job.item.failed | meaning |
|---|---|---|---|
| jobId · type · status · totalItems | always | always | which job, its type, and the status of the job — or of the item, on an item event |
| completedItems · failedItems · creditsCharged | always | never | how the job settled, and what it cost |
| errorCode · retryable | when set | when set | the verdict an agent branches on: job.failed and job.item.failed carry it, and nothing that succeeded does |
| itemIndex · settledItems | never | always | which item, and how many of the job's items have settled so far |
| sourceAssetId · resultAssetId | never | when set | the input, and the asset the item made — absent when it made none |
| output | never | when set | an analyze item's answer, on job.item.completed, where another op has a resultAssetId |
| step · failedStep | never | when set | a chain's items only: the segment the item is on, and the one it stopped at |
| error | never | when set | on job.item.failed: the message, for a person |
Writing the receiver
- Answer
2xxwithin 10 s, as soon as the event is durably accepted, and do the work afterwards. The connection gets 5 s and the answer 10 s; past either — or on a3xx(redirects are not followed), a4xxor a5xx— the attempt failed and is retried, and a receiver that was merely slow then has the event twice. - Deduplicate on
ImageStep-Event-Id, which is stable across every attempt of one delivery. - Do not rely on order. One endpoint's deliveries go one at a time, oldest due first, but a retried event arrives after events queued behind it — a
job.completedcan land before the retry of one of itsjob.item.completed. - Treat the payload as a notification, not the data: read
GET /api/v1/jobs/{jobId}for the outputs, and publish or download them from there. - Verify the signature rather than allow-listing addresses: no source-address list is published, and an address would only prove where a request came from, not what it says.
Retries and the audit trail
queued— the event happened — one delivery per endpoint subscribed to itattempt— signed afresh, 5 s to connect and 10 s to answer- then one of:
DELIVERED— any 2xx — the endpoint's failure count goes back to 0retry— anything else, or no answer in time — another attempt after min(2^(n−1) × 1 min, 6 h)FAILED— the next wait would cross 24 h after queueing — consecutiveFailures + 1; at 5 the endpoint is disabled
A delivery is accepted on any 2xx; the body of your answer is not read. The first attempt is made as soon as the event is queued — usually well under a second, at most a few — and a failed one is retried with backoff min(2^(attempt−1) × 1 min, 6 h) until 24 hours after it was queued. That is at most 12 attempts, the last about 20 h 31 min after the first:
| attempt | after the first | wait before it |
|---|---|---|
| 1 | 0 | none |
| 2 | 1 min | 1 min |
| 3 | 3 min | 2 min |
| 4 | 7 min | 4 min |
| 5 | 15 min | 8 min |
| 6 | 31 min | 16 min |
| 7 | 1 h 3 min | 32 min |
| 8 | 2 h 7 min | 1 h 4 min |
| 9 | 4 h 15 min | 2 h 8 min |
| 10 | 8 h 31 min | 4 h 16 min |
| 11 | 14 h 31 min | 6 h |
| 12 | 20 h 31 min | 6 h |
Each wait starts when the previous attempt finished, so the clock runs a few seconds behind the table. When the next wait would cross the 24-hour mark, the delivery is closed as FAILED — which is what counts towards auto-disable. Different endpoints are delivered side by side, so a slow receiver delays only its own events.
GET /api/v1/webhook-endpoints/{id}/deliveries is where to look when an event did not arrive — one record per event, newest first, paged like every list:
JavaScript
const { items, meta } = await client.webhooks.deliveries("<endpoint-id>");Python
page = client.webhooks.deliveries("<endpoint-id>") # page.items, page.metaCLI
imagestep webhook deliveries <endpoint-id> -o jsoncurl
curl -s "https://api.imagestep.dev/api/v1/webhook-endpoints/<endpoint-id>/deliveries" -H "Authorization: ApiKey $IMAGESTEP_API_KEY"One record — the first event above, after a receiver answered 405 to the first attempt. A row says whether the event arrived, not what was in it: eventId is the id of the document your receiver was sent, the same on every retry, so it is what joins the two.
{
"id": "whd_891caec749664aac9e4b1b5ad09c0669",
"eventId": "evt_4d5b552267e64eef916ed3f45f9cf260",
"eventType": "job.completed",
"status": "PENDING",
"attempts": 1,
"createdAt": "2026-09-17T16:25:57.619625Z",
"nextAttemptAt": "2026-09-17T16:26:58.402113Z",
"responseStatus": 405,
"error": "HTTP 405"
}| field | type | meaning |
|---|---|---|
| status | string | PENDING while attempts remain, DELIVERED once a 2xx came back, FAILED once it gave up — or when it was closed unsent because the endpoint was disabled or deleted |
| attempts · nextAttemptAt | integer · string (date-time) | how many attempts were made, and when the next one is due while the status is PENDING |
| responseStatus · error | integer · string | what the last attempt got: the status your receiver answered, or none when no answer came — and error says why: HTTP 503, DNS: … does not resolve, target refused: …, the exception a timeout raised, endpoint disabled |
| eventId · eventType | string | the event: eventId is the id of the body your receiver was sent, and its ImageStep-Event-Id header |
| id · createdAt · deliveredAt | string · string (date-time) | this record, when the event was queued for this endpoint, and when a 2xx came back |
Records are kept 14 days.
Auto-disable and pausing
Retries are per event; the endpoint itself is switched off when 5 consecutive events each ran out of retries — 5 chains of 24 hours each with no 2xx in between. The endpoint's consecutiveFailures is that count, so a monitor can watch it climb. At 5, enabled becomes false, disabledAt and disabledReason (the count and the last error) are set, and the account owner gets one email. Any accepted delivery resets the count, so a receiver with an overnight outage and a working morning is never disabled.
Pausing is the same switch by hand: {"enabled": false}, or imagestep webhook update <id> --disable. Either way a disabled endpoint receives nothing: deliveries still queued for it are closed as FAILED (endpoint disabled), and events that happen while it is off are never queued — nothing is replayed when it comes back, so a receiver catches up by listing its recent jobs (GET /api/v1/jobs, filtered by createdFrom and status). To turn it back on, fix the receiver, check it with a test delivery, then:
JavaScript
const endpoint = await client.webhooks.update("<endpoint-id>", { enabled: true });Python
endpoint = client.webhooks.update("<endpoint-id>", {"enabled": True})CLI
imagestep webhook update <endpoint-id> --enablecurl
curl -s -X PUT https://api.imagestep.dev/api/v1/webhook-endpoints/<endpoint-id> -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
-d '{"enabled": true}'This PUT is a patch: it changes what you pass and leaves the URL, the events and the secret alone. Turning the endpoint on also clears disabledAt, disabledReason and the count.
Where an endpoint may point
https:// only, on port 443, to a host that resolves to a public address — never a loopback, link-local, private, multicast or unique-local one. The rule is applied at registration, where a URL that breaks it or a host that does not resolve at all is a 400 invalid_param on url, and again immediately before every attempt, against what the host resolves to then: a record re-pointed since is refused on the delivery (target refused: …, no request sent) and retried on the normal schedule in case it flips back. Redirects are not followed. The same address rule guards every URL this service fetches on your behalf — an image from-url, a synchronous url input.
Deliveries leave through an egress that reaches port 443 only, which is why another port is refused at registration — https://example.com:8443/… would otherwise fail every attempt until the endpoint was switched off. The endpoint count is a per-account hard limit (422 resource_limit_exceeded past 10); the other ceilings are on errors, retries & limits.
Managing endpoints
| REST | SDK — JavaScript · Python | CLI | what |
|---|---|---|---|
| GET /webhook-endpoints | client.webhooks.list()client.webhooks.list() | webhook list | your endpoints, secret masked |
| GET /webhook-endpoints/{id} | client.webhooks.get(id)client.webhooks.get(endpoint_id) | webhook get <id> | one endpoint |
| POST /webhook-endpoints | client.webhooks.create({ url, events, description, enabled })client.webhooks.create(url, events=, description=, enabled=) | webhook create | register; the secret, once |
| PUT /webhook-endpoints/{id} | client.webhooks.update(id, patch)client.webhooks.update(endpoint_id, patch) | webhook update <id> | change only what you pass; enabled pauses and resumes |
| DELETE /webhook-endpoints/{id} | client.webhooks.delete(id)client.webhooks.delete(endpoint_id) | webhook delete <id> | delete it; what is still queued is not sent, and its delivery records can no longer be read |
| POST /webhook-endpoints/{id}/rotate-secret | client.webhooks.rotateSecret(id)client.webhooks.rotate_secret(endpoint_id) | webhook rotate-secret <id> | a new secret, once; the old one stops at once |
| POST /webhook-endpoints/{id}/test | client.webhooks.test(id)client.webhooks.test(endpoint_id) | webhook test <id> | a webhook.test delivery now, one attempt |
| GET /webhook-endpoints/{id}/deliveries | client.webhooks.deliveries(id, { page, perPage, cursor })client.webhooks.deliveries(endpoint_id, **params) | webhook deliveries <id> | the audit trail |
| GET /webhook-endpoints/{id}/deliveries | client.webhooks.iterateDeliveries(id, { page, perPage, cursor })client.webhooks.iterate_deliveries(endpoint_id, **params) | webhook deliveries <id>-a, --all | the same, every page walked for you |
The SDK column is both SDKs, JavaScript above Python. MCP has no webhook tools. The console's /webhooks page is the same list for a person.