Webhooks
Instead of polling, add an endpoint and we POST each of your account’s events to it as it happens.
Adding an endpoint
Section titled “Adding an endpoint”On your account page under Webhooks, or with a key that has webhooks:manage:
curl https://api.podsworth.com/v1/webhook-endpoints \ -H "Authorization: Bearer $PODSWORTH_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://example.com/podsworth/webhooks", "eventTypes": ["project.completed", "project.failed"]}'The response holds the endpoint and its signing secret (whsec_…), shown only now. eventTypes defaults to
["*"]: every type, including ones we add later. An endpoint gets its key’s mode: test events only reach test
endpoints. Up to 16 endpoints per mode. The URL must be https on a public address.
Events
Section titled “Events”| Type | When |
|---|---|
project.validating |
The project was submitted. |
project.rejected |
Validation refused it (data.object.rejection). |
project.queued |
It’s waiting for the studio (again, after a retry). |
project.processing |
Processing started. |
project.completed |
Done: GET the project for its download links. |
project.failed |
It failed for good (data.object.failure); its credits were refunded. |
project.stopped |
Our team put it on hold. |
project.cancelled |
It was cancelled before processing. |
credits.added |
Credits arrived: a purchase, the trial, a grant, a refund (data.object.reason). |
credits.low |
The balance dropped below the account’s threshold (at most once a day). |
subscription.renewed |
A monthly plan renewed: its month is paid and its hours granted (data.secondsGranted). |
subscription.payment_failed |
A plan’s renewal couldn’t be charged; Stripe retries, and the plan is past_due meanwhile. |
subscription.canceled |
A plan ended. Its hours stay usable. |
billing.topup_failed |
Auto top-up couldn’t buy credits: data.object.reason is declined (auto top-up pauses) or monthly_cap. |
The payload
Section titled “The payload”POST /podsworth/webhooks HTTP/1.1Content-Type: application/jsonUser-Agent: Podsworth-Webhooks/1.0 (+https://docs.podsworth.com/webhooks)Podsworth-Event-Id: evt_3b1f…Podsworth-Event-Type: project.completedPodsworth-Signature: t=1791245100,v1=5f2b…{ "id": "evt_3b1f…", "object": "event", "type": "project.completed", "mode": "live", "createdAt": "2026-10-06T14:05:00Z", "data": { "previousStatus": "processing", "object": { "object": "project", "id": "prj_9f2c…", "status": "completed", "mode": "live", "name": "Episode 12", "clientRef": "ep-12", "metadata": { "show": "Acme Weekly" }, "creditsSeconds": 2512, "rejection": null, "failure": null, "createdAt": "…", "queuedAt": "…", "startedAt": "…", "completedAt": "2026-10-06T14:05:00Z" } }}data.object is the project as it was at that moment, without download links (they expire): fetch
GET /v1/projects/{id} for those.
Verifying the signature
Section titled “Verifying the signature”Podsworth-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your secret>. Compute it
over the raw request body (before parsing JSON), compare in constant time, and reject a t more than five
minutes from now.
import hashlibimport hmacimport time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: parts = dict(item.split("=", 1) for item in header.split(",")) timestamp = int(parts.get("t", "0")) if abs(time.time() - timestamp) > tolerance: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, header: string, body: string | Buffer, toleranceSeconds = 300): boolean { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex"); const given = String(parts.v1 ?? ""); return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));}Answering
Section titled “Answering”Answer with any 2xx within 10 seconds; do slow work afterwards. Anything else (another status, a redirect, a
timeout) is a failure and we try again: after 1 and 5 minutes, 30 minutes, 2, 5 and 10 hours, then about every
10–12 hours until the event is three days old. If an endpoint has had no success for three days we turn it off and
email the account’s owners and admins; fix it and turn it back on from the account page (or PATCH with
"enabled": true).
Events can arrive more than once and out of order. Use the event id to skip ones you’ve handled, and the
project’s own status (or GET it) rather than the order events arrived in.
Testing an endpoint
Section titled “Testing an endpoint”POST /v1/webhook-endpoints/{id}/test sends a ping event right away (not retried), and
GET /v1/webhook-endpoints/{id}/deliveries shows every delivery’s status, attempts, and the start of what your
endpoint answered. Retry one with POST …/deliveries/{deliveryId}/retry. Test keys’ projects make realistic test
events: see Test mode.
Rolling the secret
Section titled “Rolling the secret”POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret, used from the next delivery on. A replayed
request (with the same Idempotency-Key) answers "secret": null: roll it again if you missed it.
The events feed
Section titled “The events feed”GET /v1/events lists the same events, newest first, kept 30 days: catch up after an outage, or poll instead of
receiving webhooks. Filter with type=project.completed or type=project.*; page with cursor. credits.*
events need account:read.