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.

View this page as Markdown

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_…"
}
  • 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. 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) — 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). 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 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.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

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 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); 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 (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), field.updated included: route on type and data.run.workflow.

  • Not every event has a run. workflow.published, webhook.test and the 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