Webhooks
Into a loop
A loop that declares an event trigger with source: webhook accepts
POST /api/inbound/{publicId}, which starts a headless run and answers 202 with its
run_id. Sign each request:
Pio-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "{t}.{raw body}">
with the inbound secret you stored on the loop’s page in the console. t must be within 300 seconds of the
server’s clock, and each signature is accepted once. The body is JSON of at most 256 KiB. Every
refusal carries a reason: signature_malformed, timestamp_outside_window,
payload_not_json, signature_mismatch, loop_archived, loop_not_published,
webhook_not_declared, replayed, payload_too_large, subject_missing,
concurrency_full (with Retry-After), or a 503 reason when the platform cannot check it.
Out of a loop
A loop that declares a webhook output for run.ended POSTs to your URL when a run ends. The
body is JSON and carries identifiers only, never text a model wrote:
{
"event": "run.ended",
"loop_id": "…",
"loop_version": 3,
"task_id": "…",
"root_task_id": "…",
"end_condition": "…"
}
For run.ended, task_id and root_task_id both name the run: the same id as a run’s runId.
Read the run itself with GET /api/runs/{runId}.
A webhook output for record.written POSTs each time a run saves a record. Its body has no
end_condition, and names the record instead:
{
"event": "record.written",
"loop_id": "…",
"loop_version": 3,
"task_id": "…",
"root_task_id": "…",
"collection": "repair_requests",
"record_id": "…"
}
Read the record through the collection’s API (Collections).
The body never carries what a person typed into a card. When a collection’s key is filled
from one of those values (a bind on the key), record_id is [sealed:<input>], so the
delivery cannot say which record was written. Saving the draft warns about it. To read the record
back, key the collection on a generated id (<key>: generated) and keep the person’s value in a
bound field: the delivery then names the record, and the value stays in it.
Verifying the signature
Every delivery carries the same header as an inbound webhook:
Pio-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "{t}.{raw body}">
The secret is under Webhook signing secret on the loop’s On your site tab for a loop placed on a site, or its Keys tab for a loop used only through the API. A webhook output that names a stored secret of yours is signed with that one instead. Compute the HMAC over the raw bytes you received, before you parse them:
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received, as a string.
export function verifyPioSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
t is when the platform signed this attempt, so a retry carries a fresh one.
Rotating the secret stops the old one at once, deliveries already waiting for a retry included. Put the new secret in your receiver first, then rotate.
Each loop’s secret is its own account’s: a loop with the same name in another account is signed with a different one. A secret you read before 17 October 2026 no longer verifies: read it again from the same tab and put the new one in your receiver.
Retries
Only a 2xx answer counts as sent; a redirect is not followed. A refused delivery is tried
up to six times in all, waiting 30 seconds, then 2, 8, 32 and 128 minutes; each wait can run up
to a minute longer. A delivery that fails never changes how the run ended.