# Webhooks

Pass a `webhook_url` when you create a submission or a batch, and 0bull POSTs to it when
that submission reaches a terminal status. No polling, no missed transitions.

## Asking for one

`webhook_url` is accepted by `POST /v1/submissions` and `POST /v1/submissions/batches`. It
must be an **https** URL on the **public internet**: plain http, private addresses,
loopback and link-local hosts are rejected with a `422`.

On a batch, the URL is copied to every submission in it, so a three-destination batch
produces three deliveries, one per post, as each finishes. There is no batch-level event.

A submission without a `webhook_url` never produces a delivery.

## What arrives

One `POST` per submission, the first time it reaches `published`, `drafted`, `failed` or
`cancelled`. The body is JSON:

```json
{
  "event": "submission.completed",
  "submission": {
    "id": 4821,
    "platform": "tiktok",
    "account_id": "019feb70-07f7-71a4-a84e-e33b56837a1d",
    "batch_id": 731,
    "caption": "new drop",
    "draft": false,
    "status": "published",
    "attempts": 1,
    "failure": null,
    "scheduled_at": null,
    "webhook_url": "https://pipeline.example.com/hooks/0bull",
    "started_at": "2026-09-22T16:41:03+00:00",
    "finished_at": "2026-09-22T16:43:19+00:00",
    "created_at": "2026-09-22T16:40:12+00:00",
    "updated_at": "2026-09-22T16:43:19+00:00"
  }
}
```

`submission` is exactly what `GET /v1/submissions/{submission}` returns, so one parser
covers both. When `status` is `failed`, `failure` is `{"step", "message"}`.

Every delivery carries these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `0bull-Webhooks/1.0` |
| `X-0bull-Event` | `submission.completed` |
| `X-0bull-Timestamp` | Unix seconds at which the delivery was signed |
| `X-0bull-Signature` | `sha256=` followed by the hex HMAC-SHA256 described below |

## Verifying a delivery

Every delivery to your account is signed with one secret. Read it with a token that has the
`submissions` ability:

```bash
curl https://0bull.net/api/user \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

```json
{"data": {"id": 42, "name": "...", "email": "...", "email_verified_at": "...", "webhook_secret": "Qm7v...48 characters..."}}
```

The secret is created the first time it is read and does not change afterward. Tokens
without the `submissions` ability do not see it.

The signature is the HMAC-SHA256, keyed with that secret, of the timestamp header, a dot,
and the **raw request body**, byte for byte as received:

```
signature = HMAC_SHA256(secret, "{X-0bull-Timestamp}.{raw body}")
```

To verify:

1. Read `X-0bull-Timestamp` and the raw body. Do not re-serialize the JSON first.
2. Compute the HMAC and compare it to `X-0bull-Signature` (after `sha256=`) in constant
   time.
3. Reject deliveries whose timestamp is more than a few minutes off your clock, which
   blocks replays of a captured request.

```python
import hashlib, hmac, time

def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    timestamp = headers["X-0bull-Timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    received = headers["X-0bull-Signature"].removeprefix("sha256=")
    return hmac.compare_digest(expected, received)
```

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Headers, rawBody: Buffer, tolerance = 300): boolean {
  const timestamp = headers.get("x-0bull-timestamp") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  const received = Buffer.from((headers.get("x-0bull-signature") ?? "").replace(/^sha256=/, ""), "hex");
  return expected.length === received.length && timingSafeEqual(expected, received);
}
```

Most frameworks parse JSON bodies before your handler runs. Make sure you can still get at
the raw bytes; a re-encoded body will not match.

## Responding, and retries

Answer with any `2xx` within 10 seconds. Do the work afterward: a handler that posts to
another service before replying will hit the timeout under load.

Anything else, including a timeout or a connection error, is retried on a widening backoff:
after 30 seconds, then 2, 10 and 30 minutes, for five attempts in all. After that the
delivery is dropped. The submission itself is never affected by a failed delivery; its
status is still there to poll.

Redirects are not followed. A `3xx` counts as a failed attempt.

A delivery can, rarely, arrive twice, for instance when your handler replied after 0bull
gave up waiting. Key your processing on `submission.id` and treat a repeat as a no-op.

## Testing locally

The URL must be public https, so a local receiver needs a tunnel (Cloudflare Tunnel, ngrok,
or similar) in front of it. Point `webhook_url` at the tunnel's https address, create a
submission on an account you control, and watch for the delivery when it finishes. A
`draft: true` submission is the cheapest way to get a `drafted` event without publishing
anything.
