Beatra

Callbacks

Terminal-state notifications: signature verification, headers, retry policy, and payload structure.

Pass callback_url (HTTPS required) when creating a task; once the task reaches a terminal state, Beatra POSTs a notification to you. Callbacks and polling are not mutually exclusive; GET /v1/tasks/{task_id} is always the source of truth for task status.

What you receive

POST {callback_url}; responding 2xx within 10 seconds counts as delivered.

HeaderDescription
X-Event-IdUnique event ID, unchanged across retries — use it for idempotent dedupe
X-Delivery-IdUnique per delivery attempt
X-Delivery-AttemptAttempt number (starts at 1)
X-Task-IdTask ID
X-Event-Typetask.succeeded / task.failed / task.canceled
X-TimestampUnix timestamp in seconds
X-Signature-Key-IdSelected signing-key ID; present only for signed delivery
X-SignatureSignature; present only for signed delivery, see below
{
  "event_id": "evt_01JX...",
  "event_type": "task.succeeded",
  "event_time": "2026-06-11T10:30:00.123456Z",
  "task": { "task_id": "task_01JX...", "status": "succeeded", "output": { "...": "..." } }
}

task is the full Task envelope.

Choose signed or unsigned delivery

Callback signing keys are independent of API keys. Create, rotate, and retire them in Console → Developer → Webhooks or with the /account/callback-signing-keys endpoints. The plaintext secret is returned only once when a signing key is created or rotated.

  • Pass only callback_url for unsigned delivery. The two signature headers are omitted.
  • Pass both callback_url and an active callback_signing_key_id for signed delivery.
  • If the selected key cannot be used, delivery fails; Beatra never silently downgrades it to unsigned.

Verify a signed delivery

X-Signature: t=<unix_ts>,v1=<hex>
v1 = HMAC-SHA256( secret, "<unix_ts>." + raw_body )
import hmac, hashlib, time
 
def verify(signature_header: str, raw_body: bytes, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    ts, sig = int(parts["t"]), parts["v1"]
    if abs(time.time() - ts) > 300:          # reject timestamps older than 5 minutes
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Note: compute over the raw request body bytes — parsing and re-serializing first will make verification fail.

Retry policy

After a failed delivery (non-2xx or timeout), retries follow these intervals — 6 delivery attempts in total (1 initial + 5 retries):

1 minute → 5 minutes → 30 minutes → 2 hours → 12 hours
  • X-Event-Id stays the same across retries; dedupe on it;
  • A failed delivery does not affect the task itself — it is already terminal, and polling remains a fallback at any time;
  • Each delivery's status is visible in the envelope's callback field (pending / dispatching / delivered / retrying / failed, attempt_count, last_error) and in the console callback logs.

On this page