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.