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}.
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 on the loop’s Keys tab, under Webhook signing secret. 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.
Retries
Only a 2xx answer counts as delivered; 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.