Skip to content

Webhooks

Instead of polling, add an endpoint and we POST each of your account’s events to it as it happens.

On your account page under Webhooks, or with a key that has webhooks:manage:

Terminal window
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.

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.
POST /podsworth/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Podsworth-Webhooks/1.0 (+https://docs.podsworth.com/webhooks)
Podsworth-Event-Id: evt_3b1f…
Podsworth-Event-Type: project.completed
Podsworth-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.

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 hashlib
import hmac
import 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));
}

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.

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.

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.

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.