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.
Operations: Webhooks endpoints · 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 (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
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": {} }'{
"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_…"
}eventslists concrete event types and/or*.*covers everything except the opt-in typesfield.updatedandguest.task_viewed, which must be named explicitly. The catalog is on Events. A*subscription is chatty by design: one run start deliversrun.created, then atask.readyfor every task that became actionable and, for each of those that has assignees, atask.assigned(an approval task sendsapproval.requestedin its place) — a dozen signed requests within a second or two on a five-task workflow. Subscribe to the events you act on.workflowIdsnarrows the subscription to some workflows (nullor[]= 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.fieldKeysnarrowsfield.updatedto some fields.- An automation rule's
call_webhookaction can also send its triggering event to this endpoint, outside itsevents,workflowIdsandfilters(Automation rules) — by design, so a rule can route one event wherever it is needed. Route ontypeanddata.run.workflowrather than trusting the subscription alone. Such a delivery is the one a subscription would send: one per event per endpoint, the same payload andwebhook-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 onwebhook-idas for any delivery. - The
secretis 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 ofPOST …/rotate-secret) with the sameIdempotency-Key, from the same API key or its rotation, replays the stored response — secret included (Idempotency). Treat thatIdempotency-Keyas 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_failedwhoseerrors[]names the member:url_policyonurl,unknown_workflowon aworkflowIdsentry that is not a workflow of your workspace,unknown_keyonenabled(use the enable / disable operations), andunrecognized_keysonbody(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;enabledis not part of it),DELETE(204 — pending deliveries are dropped),POST …/enable,POST …/disable(reasonmanual; events during the pause are not queued),POST …/rotate-secret,POST …/test(202; queues awebhook.testdelivery 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.scheduleordata.ruleis the resource exactly asGETreturns it; a*.deletedevent carries its last state. No payload ever carries a signing secret or key material —secret_rotatedsays that the secret changed, never what it is.settingsis any configuration edit;enabled/disabledandpaused/resumedare switches made by hand (in the app or through the API);auto_disabledmeans Process Cart turned it off: a webhook after a410or 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'sdisabledReasonsays which. Switching something to the state it is already in announces nothing.actoris whoever made the change:userin the app,api(with the key's name) through the API,systemfor 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,
GETthe 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_disabledchange and itswebhook.deletedreach 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 has the shape) with the Standard Webhooks headers:
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.completedwebhook-idis the event id (evt_<uuid>) — stable across retries and redeliveries; dedupe on it.webhook-timestampis the Unix time (seconds) the attempt was signed. Reject a delivery more than 300 seconds from your clock as a replay.webhook-signatureisv1,<base64 HMAC-SHA256>of<webhook-id>.<webhook-timestamp>.<raw body>, keyed with the secret's bytes (base64-decode the part afterwhsec_). 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-eventrepeats the body'stypefor 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
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).
Verifying in 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 FalseRetries, 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 Gonedisables 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 orPOST /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); 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).
Consuming deliveries safely
Dedupe on
webhook-id(idin the body): a retry and a redelivery carry the same id.Deliveries are unordered. When order matters, order by
data.run.revisionand treat a revision older than the one you hold as stale.Overlap and dedupe runs by
(id, revision)when you combine webhooks withupdatedSincepolling — the same rule the sync recipe uses.Both
task.completed(with a guest actor) andguest.task_completedfire when a guest submits their task; handle one of them, or both idempotently.field.updatedis opt-in (excluded from*); subscribe to it explicitly, and narrow it withfilters.fieldKeyson a busy workflow.A formula moves too. A write that changes the result of a formula field also emits
field.updatedfor the computed field, so one request can produce more than onefield.updated.An automation rule can send you events outside your subscription (
call_webhook),field.updatedincluded: route ontypeanddata.run.workflow.Not every event has a run.
workflow.published,webhook.testand the configuration events carry nodata.run; route ontypebefore 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; kickoff 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, due), 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, deleted when a workflow trash cascaded) + with. |
yes |
run.restored |
A run came back from Trash — on its own, or with its workflow. | run (lite when a workflow restore cascaded) + with. |
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; by names the approval task whose rejection reopened it. |
yes |
field.updated |
A field value changed (opt-in: excluded from *; filters.field 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, decided. |
yes |
comment.created |
A comment was posted on a task. | run + task + comment { id, author. |
yes |
attachment.created |
A file finished uploading to a field or a comment. | run + task + attachment { id, filename, size, mime, field. |
yes |
guest.task_viewed |
A guest first opened their task link (opt-in: excluded from *). |
run + task + guest { email, link. |
no (opt-in) |
guest.task_completed |
A guest submitted their task (fires beside task.completed, whose actor is the guest). |
run + task + guest { email, link. |
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 / 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.disabled 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 / 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.disabled 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 / 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.disabled 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 /. |
webhook { id }. |
yes |