Skip to content

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.

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 again

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:

const delivery = await client.webhooks.test("<endpoint-id>"); // { status, responseStatus, error, … }

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
headercarries
ImageStep-Signaturet=<unix seconds>,v1=<hex HMAC>: when this attempt was signed, and the signature
ImageStep-Event-Idthe event's id — the body's id, the same on every attempt: the key to deduplicate on
ImageStep-Event-Typethe event's type, so a router can dispatch before it parses the body
ImageStep-Attempt1 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:

  1. Takes the raw body, before any JSON parsing — re-serialising changes bytes, and the MAC with them.
  2. Recomputes v1 from t, the body and the secret, and compares in constant time.
  3. Rejects a t outside its tolerance — 300 s is typical, and the SDKs' default. t is 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:

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 });
}

A 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:

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
}

Events

typewhendefault subscription
job.completedevery item settled, none failedyes
job.failedevery item settled and at least one failed (status FAILED) — or the job was cancelled (status CANCELLED)yes
job.item.completedone item finishedno — opt in
job.item.failedone item failed; an item a cancel stops sends none — job.failed (status CANCELLED) says it onceno — opt in
webhook.testyou called POST /webhook-endpoints/{id}/testn/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 datajob.completed · job.failedjob.item.completed · job.item.failedmeaning
jobId · type · status · totalItemsalwaysalwayswhich job, its type, and the status of the job — or of the item, on an item event
completedItems · failedItems · creditsChargedalwaysneverhow the job settled, and what it cost
errorCode · retryablewhen setwhen setthe verdict an agent branches on: job.failed and job.item.failed carry it, and nothing that succeeded does
itemIndex · settledItemsneveralwayswhich item, and how many of the job's items have settled so far
sourceAssetId · resultAssetIdneverwhen setthe input, and the asset the item made — absent when it made none
outputneverwhen setan analyze item's answer, on job.item.completed, where another op has a resultAssetId
step · failedStepneverwhen seta chain's items only: the segment the item is on, and the one it stopped at
errorneverwhen seton job.item.failed: the message, for a person

Writing the receiver

  • Answer 2xx within 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 a 3xx (redirects are not followed), a 4xx or a 5xx — 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.completed can land before the retry of one of its job.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

  1. queuedthe event happened — one delivery per endpoint subscribed to it
  2. attemptsigned afresh, 5 s to connect and 10 s to answer
  3. then one of:
    • DELIVEREDany 2xx — the endpoint's failure count goes back to 0
    • retryanything else, or no answer in time — another attempt after min(2^(n−1) × 1 min, 6 h)
    • FAILEDthe next wait would cross 24 h after queueing — consecutiveFailures + 1; at 5 the endpoint is disabled
Two layers: every event retries on its own clock, and only a run of events that all ran out of retries switches the endpoint off.

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:

attemptafter the firstwait before it
10none
21 min1 min
33 min2 min
47 min4 min
515 min8 min
631 min16 min
71 h 3 min32 min
82 h 7 min1 h 4 min
94 h 15 min2 h 8 min
108 h 31 min4 h 16 min
1114 h 31 min6 h
1220 h 31 min6 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:

const { items, meta } = await client.webhooks.deliveries("<endpoint-id>");

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"
}
fieldtypemeaning
statusstringPENDING 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 · nextAttemptAtinteger · string (date-time)how many attempts were made, and when the next one is due while the status is PENDING
responseStatus · errorinteger · stringwhat 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 · eventTypestringthe event: eventId is the id of the body your receiver was sent, and its ImageStep-Event-Id header
id · createdAt · deliveredAtstring · 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:

const endpoint = await client.webhooks.update("<endpoint-id>", { 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

RESTSDK — JavaScript · PythonCLIwhat
GET /webhook-endpointsclient.webhooks.list()client.webhooks.list()webhook listyour endpoints, secret masked
GET /webhook-endpoints/{id}client.webhooks.get(id)client.webhooks.get(endpoint_id)webhook get <id>one endpoint
POST /webhook-endpointsclient.webhooks.create({ url, events, description, enabled })client.webhooks.create(url, events=, description=, enabled=)webhook createregister; 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-secretclient.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}/testclient.webhooks.test(id)client.webhooks.test(endpoint_id)webhook test <id>a webhook.test delivery now, one attempt
GET /webhook-endpoints/{id}/deliveriesclient.webhooks.deliveries(id, { page, perPage, cursor })client.webhooks.deliveries(endpoint_id, **params)webhook deliveries <id>the audit trail
GET /webhook-endpoints/{id}/deliveriesclient.webhooks.iterateDeliveries(id, { page, perPage, cursor })client.webhooks.iterate_deliveries(endpoint_id, **params)webhook deliveries <id>-a, --allthe 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.