# 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.

| Header | Description |
| --- | --- |
| `X-Event-Id` | Unique event ID, unchanged across retries — **use it for idempotent dedupe** |
| `X-Delivery-Id` | Unique per delivery attempt |
| `X-Delivery-Attempt` | Attempt number (starts at 1) |
| `X-Task-Id` | Task ID |
| `X-Event-Type` | `task.succeeded` / `task.failed` / `task.canceled` |
| `X-Timestamp` | Unix timestamp in seconds |
| `X-Signature-Key-Id` | Selected signing-key ID; present only for signed delivery |
| `X-Signature` | Signature; present only for signed delivery, see below |

```json
{
  "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](https://docs.beatra.ai/en/docs/mechanisms/async-tasks).

## 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

```text
X-Signature: t=<unix_ts>,v1=<hex>
v1 = HMAC-SHA256( secret, "<unix_ts>." + raw_body )
```

```python
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):

```text
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.
