Webhooks
Instead of polling /v1/jobs, register an HTTPS endpoint and Anthra POSTs a signed JSON event to it when background work settles or a book is published. Endpoints can also be managed from Account → Webhooks, or over MCP (create_webhook, list_webhooks, delete_webhook, ping_webhook).
Webhooks cover every book on the account, so the endpoints below refuse book-scoped API keys with 403 forbidden.
Events
| Event | Fires when |
|---|---|
job.succeeded | A background job finished: whole-book generation (kind: "generateBook"), narration and export jobs, and PDF renders (kind: "productionRender"). |
job.failed | The same jobs failed, were cancelled, or were reaped by the stuck-job watchdog. |
book.published | A book was published, from any surface (studio, copilot, REST, MCP). Exactly one event per publish. |
book.unpublished | A published book was taken offline. |
* | Subscribe to every event, including ones added later. |
Payload
Every delivery is a POST with content-type: application/json and this envelope. id is stable across retries of the same event — use it to deduplicate.
{
"id": "evt_Qm9yZ2V0LW1lLW5vdA",
"event": "job.succeeded",
"createdAt": "2026-11-02T09:14:03.000Z",
"data": {
"kind": "productionRender",
"jobId": "6c1e…",
"bookId": "8f1c…",
"format": "pdf",
"status": "succeeded",
"resultUrl": "https://…/book.pdf",
"error": null
}
}book.published carries { bookId, title, slug, visibility, readerUrl }; book.unpublished carries { bookId, title }. Job events carry kind, bookId, status and error, plus kind-specific fields.
Headers
| Header | Value |
|---|---|
anthra-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
anthra-webhook-id | The endpoint id the delivery was sent to. |
user-agent | Anthra-Webhooks/1.0 |
Verify the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>") in hex, where secret is the whsec_… value returned when you created the endpoint (or last rotated its secret). The timestamp is part of the signed material, so reject deliveries whose t is more than five minutes from your clock — that stops a captured delivery from being replayed. Always verify against the raw request bytes, before any JSON parsing, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyAnthra(rawBody, header, secret, toleranceSecs = 300) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
const t = Number(parts.t)
if (!Number.isFinite(t) || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - t) > toleranceSecs) 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)
}import hashlib, hmac, time
def verify_anthra(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t, v1 = int(parts.get("t", 0)), parts.get("v1", "")
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Retries and auto-disable
Respond with any 2xx within 8 seconds to acknowledge a delivery. Anything else — a non-2xx status, a timeout, a connection error — counts as a failure and the same body is re-sent (with a fresh t and signature) on this schedule:
| Attempt | Sent |
|---|---|
| 1 | Immediately |
| 2 | ~1 minute after attempt 1 fails |
| 3 | ~5 minutes later |
| 4 | ~30 minutes later |
| 5 | ~2 hours later |
| 6 | ~8 hours later (last attempt) |
Retries are swept every five minutes, so expect a few minutes of jitter. After 10 consecutive failed attempts (across all events) the endpoint is disabled: active becomes false, disabledAt is set, and pending retries are dropped. Fix the receiver, then re-enable it with PATCH /v1/webhooks/{webhookId} and { "active": true }, which also resets the failure streak. Deliveries are never sent to private, loopback or link-local addresses, and redirects are not followed.
List endpoints
Returns { webhooks, availableEvents }. Each webhook carries id, url, events, description, active, lastDeliveryAt, lastStatus, lastError, failureCount and disabledAt. The signing secret is never returned here.
Register an endpoint
Body: url (a publicly reachable http(s) URL), events (names from the table above, or ["*"]), and an optional description. The response includes the signing secret — store it now, it is not returned again.
curl -X POST "$ANTHRA_BASE/webhooks" \
-H "authorization: Bearer $ANTHRA_TOKEN" \
-H "content-type: application/json" \
-d '{ "url": "https://example.com/anthra", "events": ["job.succeeded", "job.failed", "book.published"] }'
{
"data": {
"webhook": { "id": "3d0f…", "url": "https://example.com/anthra", "active": true, … },
"secret": "whsec_…"
},
"meta": { "warning": "Store this secret now — it is not returned again." }
}Read an endpoint and its delivery log
Returns the endpoint plus its most recent delivery attempts, newest first (?limit= 1–100, default 20). Each attempt has eventId, event, attempt, status (pending | succeeded | failed), statusCode, latencyMs, error, nextRetryAt (when another attempt is scheduled) and createdAt. The log keeps 30 days.
Update, re-enable or rotate the secret
Pass any subset of:
| Field | Type | Effect |
|---|---|---|
url | string | New delivery URL (same public-address check as registration). |
events | string[] | Replace the subscription list. |
description | string | null | Free-text label. |
active | boolean | true re-enables a disabled endpoint and resets its failure count; false pauses deliveries. |
rotateSecret | boolean | Mint a new signing secret, returned once as secret. The old secret stops verifying immediately, including for retries already scheduled. |
curl -X PATCH "$ANTHRA_BASE/webhooks/$WEBHOOK" \
-H "authorization: Bearer $ANTHRA_TOKEN" \
-H "content-type: application/json" \
-d '{ "active": true, "rotateSecret": true }'Delete an endpoint
Deregisters the endpoint. Deliveries and pending retries stop at once.