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

EventFires when
job.succeededA background job finished: whole-book generation (kind: "generateBook"), narration and export jobs, and PDF renders (kind: "productionRender").
job.failedThe same jobs failed, were cancelled, or were reaped by the stuck-job watchdog.
book.publishedA book was published, from any surface (studio, copilot, REST, MCP). Exactly one event per publish.
book.unpublishedA 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

HeaderValue
anthra-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
anthra-webhook-idThe endpoint id the delivery was sent to.
user-agentAnthra-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:

AttemptSent
1Immediately
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

GET/v1/webhooksscope: books:read

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

POST/v1/webhooksscope: books:write

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

GET/v1/webhooks/{webhookId}scope: books:read

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

PATCH/v1/webhooks/{webhookId}scope: books:write

Pass any subset of:

FieldTypeEffect
urlstringNew delivery URL (same public-address check as registration).
eventsstring[]Replace the subscription list.
descriptionstring | nullFree-text label.
activebooleantrue re-enables a disabled endpoint and resets its failure count; false pauses deliveries.
rotateSecretbooleanMint 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

DELETE/v1/webhooks/{webhookId}scope: books:write

Deregisters the endpoint. Deliveries and pending retries stop at once.