---
title: Webhooks
url: https://base_url.placeholder/docs/webhooks
group: Reference
---

# 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

```js
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
```

#### Python

```python
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 again
```

#### CLI

```sh
imagestep webhook create --url https://example.com/hooks/imagestep --events job.completed,job.failed --description prod -o json
```

#### curl

```sh
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`:

```json
{
  "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

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

#### Python

```python
delivery = client.webhooks.test("<endpoint-id>")  # {"status", "responseStatus", "error", …}
```

#### CLI

```sh
imagestep webhook test <endpoint-id>   # exits 1 unless the receiver answered 2xx
```

#### curl

```sh
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:

```http
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:

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:

#### JavaScript

```js
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

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

#### JavaScript

```js
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

```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 time
```

## Events

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

```json
{
  "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](https://base_url.placeholder/docs/jobs#failure) on:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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 | — | 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 | — | always | which item, and how many of the job's items have settled so far |
| sourceAssetId · resultAssetId | — | when set | the input, and the asset the item made — absent when it made none |
| output | — | when set | an analyze item's answer, on job.item.completed, where another op has a resultAssetId |
| step · failedStep | — | when set | a chain's items only: the segment the item is on, and the one it stopped at |
| error | — | when set | on 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. `queued` — the event happened — one delivery per endpoint subscribed to it
2. `attempt` — signed afresh, 5 s to connect and 10 s to answer
3. then one of:

   - `DELIVERED` — any 2xx — the endpoint's failure count goes back to 0
   - `retry` — 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

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

| attempt | after the first | wait before it |
| --- | --- | --- |
| 1 | 0 | — |
| 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

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

#### Python

```python
page = client.webhooks.deliveries("<endpoint-id>")  # page.items, page.meta
```

#### CLI

```sh
imagestep webhook deliveries <endpoint-id> -o json
```

#### curl

```sh
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.

```json
{
  "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](https://base_url.placeholder/docs/api/jobs), filtered by `createdFrom` and `status`). To turn it back on, fix the receiver, check it with a test delivery, then:

#### JavaScript

```js
const endpoint = await client.webhooks.update("<endpoint-id>", { enabled: true });
```

#### Python

```python
endpoint = client.webhooks.update("<endpoint-id>", {"enabled": True})
```

#### CLI

```sh
imagestep webhook update <endpoint-id> --enable
```

#### curl

```sh
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](https://base_url.placeholder/docs/errors#limits).

## Managing endpoints

| REST | SDK — JavaScript · Python | CLI | what |
| --- | --- | --- | --- |
| [GET /webhook-endpoints](https://base_url.placeholder/docs/api/webhooks#get-webhook-endpoints) | `client.webhooks.list()``client.webhooks.list()` | `webhook list` | your endpoints, secret masked |
| [GET /webhook-endpoints/{id}](https://base_url.placeholder/docs/api/webhooks#get-webhook-endpoints-id) | `client.webhooks.get(id)``client.webhooks.get(endpoint_id)` | `webhook get <id>` | one endpoint |
| [POST /webhook-endpoints](https://base_url.placeholder/docs/api/webhooks#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}](https://base_url.placeholder/docs/api/webhooks#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}](https://base_url.placeholder/docs/api/webhooks#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](https://base_url.placeholder/docs/api/webhooks#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](https://base_url.placeholder/docs/api/webhooks#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](https://base_url.placeholder/docs/api/webhooks#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](https://base_url.placeholder/docs/api/webhooks#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](https://base_url.placeholder/webhooks) page is the same list for a person.
