# Webhooks

> Signed, retried deliveries of every event (Standard Webhooks) — create endpoints, verify signatures in Node or Python, understand the retry schedule and auto-disable, redeliver, and dedupe.

Source: https://developers.processcart.com/webhooks

Operations: [Webhooks endpoints](https://developers.processcart.com/reference/webhooks) · [Events](https://developers.processcart.com/reference/events).

A webhook is an https endpoint subscribed to events. Every event arrives in one signed JSON POST, retried on failure, and carries what it is about: a run event the full Run, a workflow event the workflow, a [configuration event](#configuration-events) (a webhook, schedule or automation rule created, changed or deleted) the webhook, schedule or rule as the API returns it. The one exception: the cascade variants of `run.trashed` / `run.restored` (a workflow moved to or from Trash, `data.withWorkflow: true`) carry only the lite header `{ id, name, revision, workflow, deletedAt }` — fetch `GET /v1/runs/{id}` when you need the body. Webhooks are managed in the app (Automations → Webhooks) and through the API with the `webhooks:manage` scope.

## Creating an endpoint

```bash
curl -X POST https://app.processcart.com/api/v1/webhooks \
  -H "Authorization: Bearer pc_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/processcart", "description": "Order sync",
        "events": ["*"], "workflowIds": null, "filters": {} }'
```

```json
{
  "id": "w1b2…", "url": "https://hooks.example.com/processcart", "description": "Order sync",
  "events": ["*"], "workflowIds": null, "filters": {},
  "enabled": true, "disabledReason": null, "disabledAt": null,
  "consecutiveFailures": 0, "lastDeliveryAt": null,
  "createdAt": "2026-10-07T18:00:00.000Z", "createdBy": null, "updatedAt": "2026-10-07T18:00:00.000Z",
  "secret": "whsec_…"
}
```

- `events` lists concrete event types and/or `*`. **`*` covers everything except the opt-in types** `field.updated` and `guest.task_viewed`, which must be named explicitly. The catalog is on [Events](https://developers.processcart.com/reference/events). A `*` subscription is chatty by design: one run start delivers `run.created`, then a `task.ready` for every task that became actionable and, for each of those that has assignees, a `task.assigned` (an approval task sends `approval.requested` in its place) — a dozen signed requests within a second or two on a five-task workflow. Subscribe to the events you act on.
- `workflowIds` narrows the subscription to some workflows (`null` or `[]` = every workflow): run, workflow and schedule events about other workflows are not sent, nor are the events of a rule scoped to another workflow (a rule for every workflow passes any filter); webhook events have no workflow and pass it. `filters.fieldKeys` narrows `field.updated` to some fields.
- An automation rule's `call_webhook` action can also send its triggering event to this endpoint, outside its `events`, `workflowIds` and `filters` ([Automation rules](https://developers.processcart.com/guides/automation-rules#call_webhook)) — by design, so a rule can route one event wherever it is needed. Route on `type` and `data.run.workflow` rather than trusting the subscription alone. Such a delivery is the one a subscription would send: one per event per endpoint, the same payload and `webhook-id`, and nothing that names the rule. A receiver cannot tell which of the two sent it, and an endpoint that both subscribes and is called by a rule gets the event once; dedupe on `webhook-id` as for any delivery.
- The **`secret` is shown once**, in the 201 — store it now. Rotate to get a new one. Every API read omits it (in the app, an Admin or Builder can reveal it again on the webhook's page under Automations, and every reveal is audited). The one exception is an idempotent retry: for 24 hours, a byte-identical retry of the create (or of `POST …/rotate-secret`) with the same `Idempotency-Key`, from the same API key or its rotation, replays the stored response — secret included ([Idempotency](https://developers.processcart.com/guides/idempotency)). Treat that `Idempotency-Key` as secret material.
- The URL must be a public https address: the SSRF policy refuses private and link-local networks, loopback, cloud metadata addresses, Process Cart's own hosts, credentials in the URL and `#fragments`, and re-checks the resolved address before every attempt.
- A refused body is a 422 `validation_failed` whose `errors[]` names the member: `url_policy` on `url`, `unknown_workflow` on a `workflowIds` entry that is not a workflow of your workspace, `unknown_key` on `enabled` (use the enable / disable operations), and `unrecognized_keys` on `body` (the message names the key) for any other member the body does not take. Nothing is created.
- Then: `GET`, `PATCH` (any subset of the configuration; `enabled` is not part of it), `DELETE` (204 — pending deliveries are dropped), `POST …/enable`, `POST …/disable` (reason `manual`; events during the pause are not queued), `POST …/rotate-secret`, `POST …/test` (202; queues a `webhook.test` delivery and sends it right away — read the delivery for the outcome; refused while disabled).

Allowlisted keys: a key restricted by **folder** cannot use the webhook operations at all (403); a key restricted by **workflow** may create or update a webhook only when its `workflowIds` is a non-empty subset of the key's workflows, lists and reads only such webhooks, and sees only deliveries about those workflows (plus test events).

## Configuration events

Your automations announce their own lifecycle, so an integration learns by webhook when an endpoint, a schedule or a rule appears, changes, is switched off or goes away:

| Resource | Created | Changed | Deleted | `data.change` on the change event |
|---|---|---|---|---|
| Webhook | `webhook.created` | `webhook.updated` | `webhook.deleted` | `settings`, `enabled`, `disabled`, `secret_rotated`, `auto_disabled` |
| Schedule | `schedule.created` | `schedule.updated` | `schedule.deleted` | `settings`, `paused`, `resumed`, `auto_disabled` |
| Automation rule | `rule.created` | `rule.updated` | `rule.deleted` | `settings`, `enabled`, `disabled`, `auto_disabled` |

- `data.webhook`, `data.schedule` or `data.rule` is the resource exactly as `GET` returns it; a `*.deleted` event carries its last state. **No payload ever carries a signing secret or key material** — `secret_rotated` says that the secret changed, never what it is.
- `settings` is any configuration edit; `enabled` / `disabled` and `paused` / `resumed` are switches made by hand (in the app or through the API); `auto_disabled` means Process Cart turned it off: a webhook after a `410` or repeated exhausted deliveries, a rule after repeated failures, a schedule that ended, could not start its runs (three failures in a row, or at once when a start can never succeed), or whose workflow was moved to Trash, archived or unpublished. The resource's `disabledReason` says which. Switching something to the state it is already in announces nothing.
- `actor` is whoever made the change: `user` in the app, `api` (with the key's name) through the API, `system` for an automatic change.
- They are in `*` and can be subscribed to by name like every other type. Automation rules never trigger on them.
- Like every delivery they are unordered: when two changes to one resource arrive close together, `GET` the resource for its current state.
- A webhook receives the events about itself, too — except that a disabled or deleted endpoint is not delivered to. Its own `disabled` / `auto_disabled` change and its `webhook.deleted` reach the workspace's **other** enabled endpoints only, so to hear about an endpoint switching off, subscribe a second one.

## The delivery

Every delivery is a `POST` of one JSON envelope ([Events](https://developers.processcart.com/reference/events) has the shape) with the Standard Webhooks headers:

```http
POST /processcart HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ProcessCart-Webhooks/1.0 (+https://developers.processcart.com/webhooks)
webhook-id: evt_7f2c1c2e-0f3a-4f0e-9d6c-2b7a4c9e1f00
webhook-timestamp: 1791830531
webhook-signature: v1,K2Z…=
x-processcart-event: task.completed
```

- `webhook-id` is the event id (`evt_<uuid>`) — **stable across retries and redeliveries**; dedupe on it.
- `webhook-timestamp` is the Unix time (seconds) the attempt was signed. Reject a delivery more than **300 seconds** from your clock as a replay.
- `webhook-signature` is `v1,<base64 HMAC-SHA256>` of `<webhook-id>.<webhook-timestamp>.<raw body>`, keyed with the secret's bytes (base64-decode the part after `whsec_`). After a secret rotation it carries two space-separated entries for 24 hours; accept any one that matches, and compare in constant time.
- `x-processcart-event` repeats the body's `type` for cheap routing.

Answer **any 2xx within 10 seconds**. Do the work after answering, not before — a slow handler is a failed attempt.

### Verifying in Node

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyProcessCart(secret, headers, rawBody, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
  );
  return signature.split(" ").some((entry) => {
    const [version, value] = entry.split(",", 2);
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value);
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

`rawBody` must be the exact bytes received (read the body as text before any JSON parsing — a re-serialized body will not verify).

In TypeScript, `verifyWebhookSignature` from `@processcart/sdk/webhooks` runs the same check on WebCrypto ([TypeScript SDK](https://developers.processcart.com/guides/sdk#webhooks)).

### Verifying in Python

```python
import base64, hashlib, hmac, time

def verify_processcart(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature):
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in signature.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
```

## Retries, failures and auto-disable

- First attempt immediately; after a failure, retries at **1 min → 5 min → 15 min → 30 min → 60 min** after the previous attempt — 6 attempts in all over about 1 h 51 min; then the delivery is `exhausted`.
- Per-attempt timeout 10 seconds; any 2xx counts as delivered, anything else (or a timeout, or a refused connection) as a failed attempt.
- A `410 Gone` disables the endpoint at once (`disabledReason: "gone"`); 5 consecutive exhausted deliveries disable it (`"failures"`). Either way the workspace's Builders and Admins are notified; enable it again from Automations → Webhooks or `POST /v1/webhooks/{id}/enable`.
- Deliveries are kept 30 days.

Deliveries are kept with their status (`pending`, `delivered`, `failed`, `exhausted`), attempt count, last response code (`lastStatusCode`), last and next attempt times (`lastAttemptAt`, `nextAttemptAt`): `GET /v1/webhooks/{id}/deliveries` (newest first, filter by `status`) and `GET /v1/webhooks/{id}/deliveries/{deliveryId}` (with the full `payload` as it was or will be POSTed). `POST …/deliveries/{deliveryId}/redeliver` queues a finished delivery (delivered, failed or exhausted) again with the **same `webhook-id`** and sends it right away (202); a delivery still in flight is 409 `delivery_not_terminal`, a disabled webhook 409 `webhook_disabled`.

When an endpoint is disabled automatically, the workspace's Builders and Admins are notified, and every other enabled endpoint subscribed to `webhook.updated` (or `*`) receives it with `data.change: "auto_disabled"` ([Configuration events](#configuration-events)); `POST /v1/webhooks/{id}/enable` (or enabling it from Automations → Webhooks) turns it back on and resets the failure counter. Events that happened while it was disabled were not queued — catch up with `GET /v1/runs?updatedSince=…` ([Incremental sync](https://developers.processcart.com/guides/incremental-sync)).

## Consuming deliveries safely

- **Dedupe on `webhook-id`** (`id` in the body): a retry and a redelivery carry the same id.
- **Deliveries are unordered.** When order matters, order by `data.run.revision` and treat a revision older than the one you hold as stale.
- **Overlap and dedupe runs by `(id, revision)`** when you combine webhooks with `updatedSince` polling — the same rule the sync recipe uses.
- **Both `task.completed` (with a guest actor) and `guest.task_completed` fire** when a guest submits their task; handle one of them, or both idempotently.
- **`field.updated` is opt-in** (excluded from `*`); subscribe to it explicitly, and narrow it with `filters.fieldKeys` on a busy workflow.

- **A formula moves too.** A write that changes the result of a formula field also emits `field.updated` for the computed field, so one request can produce more than one `field.updated`.
- **An automation rule can send you events outside your subscription** ([`call_webhook`](https://developers.processcart.com/guides/automation-rules#call_webhook)), `field.updated` included: route on `type` and `data.run.workflow`.
- **Not every event has a run.** `workflow.published`, `webhook.test` and the [configuration events](#configuration-events) carry no `data.run`; route on `type` before reading it.
- Verify the signature **before** parsing JSON, and return 2xx before doing slow work.

## Events

| Event | When | `data` | In `*` |
| --- | --- | --- | --- |
| `run.created` | A run was started (by a member, the API, a schedule or a public kickoff form). | `run`; `kickoffLinkId` when a public form started it. | yes |
| `run.updated` | A run's name, priority, labels or due date changed. | `run` + `changes` keyed by attribute (`name`, `priority`, `labels`, `dueAt`), each `{ from, to }`. | yes |
| `run.status_changed` | A run's status changed (active / completed / on_hold / archived), by hand or automatically. | `run` + `changes.status { from, to }`. | yes |
| `run.completed` | Every task is done, or the run was marked completed by hand. | `run`. | yes |
| `run.trashed` | A run moved to Trash — on its own, or with its workflow. | `run` (the lite header `{ id, name, revision, workflow, deletedAt }` when a workflow trash cascaded) + `withWorkflow`. | yes |
| `run.restored` | A run came back from Trash — on its own, or with its workflow. | `run` (lite when a workflow restore cascaded) + `withWorkflow`. | yes |
| `task.ready` | A task became actionable: visible, unlocked and not done. | `run` + `task`. | yes |
| `task.assigned` | A task's assignees were resolved or changed, or the task became actionable for them. | `run` + `task` + `assignees`. | yes |
| `task.completed` | A task was completed. | `run` + `task`. | yes |
| `task.uncompleted` | A done task was reopened, by hand or by a rejected approval. | `run` + `task`; `byApproval` names the approval task whose rejection reopened it. | yes |
| `field.updated` | A field value changed (opt-in: excluded from `*`; `filters.fieldKeys` narrows it). | `run` + `field` (the resource's field entry: key, label, type, format, value, formatted, …) + `field.previous`. | no (opt-in) |
| `approval.requested` | An approval task became actionable for its approvers (again when a decided approval is voided by a reviewed-value change). | `run` + `task`. | yes |
| `approval.decided` | An approval was approved or rejected. | `run` + `task` + `approval { id, decision, comment, decidedBy, decidedByEmail, decidedAt }`. | yes |
| `comment.created` | A comment was posted on a task. | `run` + `task` + `comment { id, authorId, text, mentions }`. | yes |
| `attachment.created` | A file finished uploading to a field or a comment. | `run` + `task` + `attachment { id, filename, size, mime, fieldKey, taskKey, commentId }`. | yes |
| `guest.task_viewed` | A guest first opened their task link (opt-in: excluded from `*`). | `run` + `task` + `guest { email, linkId }`. | no (opt-in) |
| `guest.task_completed` | A guest submitted their task (fires beside `task.completed`, whose actor is the guest). | `run` + `task` + `guest { email, linkId }`. | yes |
| `workflow.published` | A workflow version was published. | `workflow { id, name }` + `version { id, number }`. | yes |
| `schedule.run_created` | A recurrence schedule started a run. | `run` + `schedule { id, name }`. | yes |
| `schedule.created` | A recurrence schedule was created. | `schedule` — the Schedule resource, as `GET /v1/schedules/{id}` returns it. | yes |
| `schedule.updated` | A schedule changed: its settings were edited, it was paused or resumed, or the system paused it. | `schedule` + `change`: `settings`, `paused`, `resumed` or `auto_disabled` (`auto_disabled`: the system paused it, and `schedule.disabledReason` says why). | yes |
| `schedule.deleted` | A schedule was deleted. | `schedule` — its last state. | yes |
| `rule.created` | An automation rule was created. | `rule` — the AutomationRule resource, as `GET /v1/automation-rules/{id}` returns it. | yes |
| `rule.updated` | An automation rule changed: its settings were edited, it was turned on or off, or the system turned it off after repeated failures. | `rule` + `change`: `settings`, `enabled`, `disabled` or `auto_disabled` (`auto_disabled`: the system turned it off, and `rule.disabledReason` says why). | yes |
| `rule.deleted` | An automation rule was deleted. | `rule` — its last state. | yes |
| `webhook.created` | A webhook endpoint was created. | `webhook` — the Webhook resource, as `GET /v1/webhooks/{id}` returns it (never the signing secret). | yes |
| `webhook.updated` | A webhook endpoint changed: its settings were edited, it was enabled or disabled, its secret was rotated, or the system disabled it. | `webhook` + `change`: `settings`, `enabled`, `disabled`, `secret_rotated` or `auto_disabled` (`auto_disabled`: the system disabled it, and `webhook.disabledReason` says why). Never the secret. | yes |
| `webhook.deleted` | A webhook endpoint was deleted. | `webhook` — its last state. | yes |
| `webhook.test` | A test event sent from Settings or `POST /v1/webhooks/{id}/test`. | `webhook { id }`. | yes |
