Webhooks
Porch fires signed POSTs at endpoints you register on every track lifecycle event — kick-off, start, completion, failure. Stripe-flavored signatures, automatic retries, 30-day delivery log.
When to use them
Webhooks turn a track into a stream of events your stack can react to in real time. The two highest-value moments are different shapes of pitch:
- Kick-off —
track.createdfires the instant a track exists. Use it to push the live URL into a Slack channel, an SMS, or any surface where someone can watchthe work happen. The URL is clickable + live; this is Porch’s unique value-add over plain email. - Completion / failure —
track.completed/track.failedfire when work lands. Use them to send “your thing is done” emails, pulse internal alerting channels, or trigger downstream workflows. Most relevant when the work runs long enough that the customer disengaged.
You don’t have to use webhooks if your agent and your notification code share a request boundary — you can read track.url off the SDK return value and fetch() Slack or send via Resend on the same line. See the Slack tutorial and the Resend tutorial for both shapes (inline + webhook-driven) of each. Webhooks earn their complexity when the two halves can’t share that boundary — Inngest jobs, Trigger.dev tasks, queue consumers, detached workers.
Register an endpoint
Endpoints live per-workspace. Add as many as you need — one for production, one for staging, one for integration tests is the common case. Each endpoint gets its own signing secret so rotating one doesn’t affect the others.
Open Settings → Webhooks in the dashboard, paste your handler URL (https only), give it a label, and click Create endpoint. The signing secret reveals once — store it as PORCH_WEBHOOK_SECRET in your env immediately.
You can pause an endpoint without deleting it (URL + secret + history are preserved) and rotate the secret with one click. Limit of 10 endpoints per workspace.
Event types
Seven types fire across a track’s lifecycle — four for the track itself, three for any track.ask()calls inside it. Every consumer gets every event; there’s no per-endpoint filter. Ignore the types you don’t care about by checking event.type in your handler.
track.createdKick-off — a new track exists. Use to push the live URL into Slack / SMS / chat so the watcher can follow along.track.startedThe track moves from pending → running (first step or first narration). Most consumers ignore this and pair track.created with track.completed instead.track.completedThe agent called track.complete(). Carries summary, cta, duration_ms. Use to send 'done' notifications or trigger downstream workflows.track.failedThe agent called track.fail(). Carries failureReason, failedStepIndex, retryable, userAction. Use to alert internally and pulse the customer.input_request.createdThe agent called track.ask() and is waiting on a customer answer. Use to pulse the customer (email / SMS / Slack DM) when you need a faster response than passive page-watching.input_request.answeredThe customer responded. Carries answerValue + duration_ms. Use to clear any 'waiting on customer' alerting state or log decisions for an audit trail downstream.input_request.cancelledThe agent called cancelInputRequest() — it decided it didn't need the answer after all. Mirror to your own queue so a stale 'we need your input' notification doesn't keep nagging.
Every payload carries a common data.metadata field round-tripped from the original createTrack({ metadata }) call. Stash customerEmail / customerId / whatever your handler needs at create-time; read it off any subsequent event.
Example: track.completed
{
"type": "track.completed",
"timestamp": "2026-05-08T22:14:01.823Z",
"data": {
"trackId": "trk_01jx9kc...",
"url": "https://www.porch.so/t/trk_01jx9kc...",
"title": "Importing your Salesforce contacts",
"metadata": { "customerEmail": "alex@acme.com", "customerId": "cus_42" },
"summary": "Imported 12,043 contacts. Skipped 32 duplicates.",
"cta": { "label": "Open contacts", "url": "https://app.acme.com/contacts" },
"additionalContext": null,
"duration_ms": 47213,
"completedAt": "2026-05-08T22:14:01.802Z"
}
}Example: input_request.created
Fired when an agent calls track.ask() (or one of its wrappers). All AskDisplayMeta fields you passed in config — description, summary, details, risk— round-trip on this event alongside the per-kind fields (options for choice, validate for text, etc.).
{
"type": "input_request.created",
"timestamp": "2026-05-25T18:42:11.103Z",
"data": {
"inputRequestId": "req_01k0t9...",
"trackId": "trk_01jx9kc...",
"url": "https://www.porch.so/t/trk_01jx9kc...",
"trackTitle": "Vendor invoice batch",
"kind": "confirm",
"prompt": "Acme Office Supplies invoice is 8% above contract. Approve?",
"stepIndex": 2,
"metadata": { "customerEmail": "cfo@acme.com" },
"config": {
"description": "Q3 paper-cost passthrough flagged.",
"summary": {
"Previous rate": "$2,400/mo",
"New rate": "$2,592/mo"
},
"acceptLabel": "Approve the new rate",
"rejectLabel": "Hold for negotiation"
},
"timeoutAt": "2026-05-26T18:42:11.103Z",
"createdAt": "2026-05-25T18:42:11.103Z"
}
}Example: input_request.answered
Fired when the customer submits an answer. Use duration_ms for SLA dashboards or “average time-to-decision” reporting.
{
"type": "input_request.answered",
"timestamp": "2026-05-25T18:48:33.012Z",
"data": {
"inputRequestId": "req_01k0t9...",
"trackId": "trk_01jx9kc...",
"url": "https://www.porch.so/t/trk_01jx9kc...",
"trackTitle": "Vendor invoice batch",
"kind": "confirm",
"prompt": "Acme Office Supplies invoice is 8% above contract. Approve?",
"stepIndex": 2,
"metadata": { "customerEmail": "cfo@acme.com" },
"answerValue": true,
"answeredAt": "2026-05-25T18:48:33.012Z",
"duration_ms": 381909
}
}Verify the signature
Every POST carries a porch-signature header in Stripe-flavored format:
porch-signature: t=<unix_seconds>,v1=<hex_hmac_sha256>Where the HMAC is computed over `${t}.${rawBody}` using the per-endpoint secret. Verifiers also check tagainst a tolerance window (default 5 min) so captured requests don’t replay later.
The TypeScript SDK ships a verify helper at the /webhooks subpath — just the verifier, no full Porch client pulled into your handler bundle.
// app/webhooks/porch/route.ts (Next.js App Router)
import { verifyPorchSignature } from "@porchso/sdk/webhooks";
export async function POST(req: Request) {
// CRITICAL: read raw text BEFORE parsing JSON. Round-tripping
// through JSON.parse can reorder bytes and break the signature.
const body = await req.text();
const ok = verifyPorchSignature({
signature: req.headers.get("porch-signature"),
body,
secret: process.env.PORCH_WEBHOOK_SECRET!,
});
if (!ok) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(body);
// ... handle event.type
return new Response("ok");
}Retry behavior
Porch retries automatically on transport errors and consumer 5xx responses. Default schedule: ~5 attempts over ~30 minutes with exponential backoff (handled by Upstash QStash under the hood).
Consumer 4xx responses (e.g. 401 bad signature, 422 validation error) are notretried — the same payload won’t start working if we send it again. Check the delivery log on the dashboard for the response code on each attempt.
Your handler should be idempotent: a successful 200 followed by a network blip can produce a duplicate retry. Key your work off the data.trackId + type pair and treat re-receipts as no-ops.
Test from the dashboard
Each endpoint row has a Send test action that fires a synthetic track.created event through the real signing + delivery path. Useful for wiring up a new handler without having to trigger a real track first. Test events are tagged with data.metadata.test = true so your handler can ignore them or branch on them.
Watch the response code on the dashboard’s delivery log — green 200 means your handler accepted the signed payload. Anything else, click to expand the row and inspect the response body and transport error.
Pair with your messaging stack
Two named integrations today, both with full end-to-end walkthroughs:
- Slack — kick-off pattern + cross-process webhooks. Best for surfacing a live URL into a channel the customer or team is already watching.
- Resend — inline pattern + cross-process webhooks. Best for sending completion / failure emails out of your existing transactional pipeline.
The webhook payload shape is the same for both. The underlying capability is general — any HTTPS endpoint that accepts a signed POST works (Discord, Postmark, Teams, your own internal API, anything). Use this reference for the abstract event shapes; use the integration tutorials when wiring up a named partner.