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:
Code
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:
Code
Code
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:
Code
To verify:
- Read
X-0bull-Timestampand the raw body. Do not re-serialize the JSON first. - Compute the HMAC and compare it to
X-0bull-Signature(aftersha256=) in constant time. - Reject deliveries whose timestamp is more than a few minutes off your clock, which blocks replays of a captured request.
Code
Code
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.

