Delivery, signatures and retries¶
Public API webhooks are signed HTTPS POST requests. Verify the signature against the raw body, accept deliveries idempotently and return a 2xx response only after the event has been safely queued or persisted.
Delivery envelope¶
The envelope always contains exactly three top-level fields:
| Field | Type | Meaning |
|---|---|---|
event_type |
string | Exact event name selected by the registration. |
date |
integer | Unix timestamp in seconds of the captured database change. |
data |
object | Event-specific Public API resource documented in the event catalogue. |
The payload version is carried in a header, not in the body. The frozen envelope remains
{event_type, date, data}.
Verify the signature¶
Bsport-Signature uses a Stripe-style timestamped HMAC-SHA256 value:
t is the Unix timestamp of the attempt, so every retry carries a new t and a new v1.
To verify a delivery:
- Read the raw request bytes before JSON parsing or reformatting.
- Extract
tandv1fromBsport-Signature. - Build the signed bytes as
<t>.<raw_request_body>. - Compute HMAC-SHA256 using the registration's complete
signing_secret, including itswhsec_prefix, as the key. - Compare the computed lowercase hexadecimal digest with
v1using a constant-time comparison. - Reject timestamps outside the tolerance chosen by your integration, for example five minutes.
This Python function applies those steps:
import hashlib
import hmac
import os
import time
SIGNING_SECRET = os.environ["BSPORT_WEBHOOK_SIGNING_SECRET"]
TOLERANCE_SECONDS = 300
def verify_bsport_signature(raw_body: bytes, signature_header: str) -> None:
fields = dict(field.split("=", 1) for field in signature_header.split(","))
timestamp, signature = fields.get("t", ""), fields.get("v1", "")
signed_bytes = timestamp.encode() + b"." + raw_body
digest = hmac.new(SIGNING_SECRET.encode(), signed_bytes, hashlib.sha256).hexdigest()
if not hmac.compare_digest(digest.encode(), signature.encode()):
raise ValueError("Invalid signature")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise ValueError("Timestamp outside tolerance")
The v1 token identifies the signature algorithm. It is independent from the dated payload
schema version.
Rotate the signing secret¶
Rotate the secret when it may have leaked. The response is the registration with its new
signing_secret; its event, URL, active state and payload version do not change.
curl --request POST \
--url 'https://YOUR_ENV_DOMAIN/api/v2/management/webhook-registrations/REGISTRATION_ID/rotate-secret/' \
--header 'X-Api-Key: YOUR_API_KEY' \
--header 'X-Client-ID: YOUR_CLIENT_ID' \
--header 'X-Company-ID: YOUR_COMPANY_ID'
The previous secret stops working at once, with no grace period:
- every delivery attempt made after the rotation is signed with the new secret, including retries of events that first failed before it;
- a delivery already being sent at the moment of rotation carries the previous signature.
Deploy the new secret to your endpoint right after rotating. Until then, its signature checks
fail: answer with a 5xx status rather than 401 during that window, because 5xx responses
are retried and other 4xx responses are not (see Automatic retries).
Rotation is not idempotent: each call generates another secret. After an uncertain result, retrieve the registration to read its current secret instead of rotating again.
Delivery headers¶
Every delivery attempt sends these headers:
| Header | Meaning |
|---|---|
Content-Type |
application/json. |
Bsport-Signature |
Timestamp and HMAC-SHA256 signature. |
X-Bsport-Webhook-Payload-Version |
2026-10-06 for V2 registrations; 2026-08-22 for existing V1 registrations. |
X-Bsport-Webhook-Delivery-Id |
Stable UUID for the logical delivery. |
Idempotency-Key |
Same stable delivery UUID. |
X-Bsport-Webhook-Delivery-Attempt |
One-based attempt count, which keeps increasing across a manual replay. |
X-Bsport-Webhook-Retry-Number |
0 on the first attempt and on a manual replay, then the scheduled retry number. |
Respond quickly and idempotently¶
Return a 2xx response after the event has been accepted for processing. Each attempt allows 2 seconds to open the connection and fails if no response byte arrives for 5 seconds, so queue slow work instead of finishing it before you respond. Only the status code counts: the response body is ignored and redirects are not followed.
Delivery is at least once: the same logical delivery is sent again after a timeout or retryable
status. An interruption on the bsport side can also repeat a delivery your endpoint already
accepted with a 2xx response. Use X-Bsport-Webhook-Delivery-Id as the deduplication key. The identifier remains stable across
automatic retries and manual replay, while the attempt and retry headers change.
Deliveries can arrive out of order: a retry can reach your endpoint after a later event for the
same resource. date records when each change happened, to the second, so it cannot order
changes made within the same second. When order matters, for example between client.tag_added
and client.tag_removed, fetch the resource from the matching V2 retrieve endpoint instead of
applying events in arrival order.
Automatic retries¶
A failed attempt gets up to four automatic retries, so a delivery has at most five attempts. Each delay is a minimum, counted from the failure of the previous attempt:
- 10 seconds;
- 1 minute;
- 5 minutes;
- 30 minutes.
The fourth retry therefore comes at least 36 minutes after the first failure. A Retry-After
response header has no effect on these delays.
The result of an attempt decides whether it is retried:
| Attempt result | Retried |
|---|---|
408, 425, 429 or 5xx response |
Yes |
| Timeout or network error | Yes |
Any other 4xx response |
No |
3xx response |
No |
| Rejected TLS certificate, or a URL that is invalid or resolves to a non-public address | No |
Changes between attempts¶
Each attempt reads the registration again, and
current-state events also read data again. A retry
therefore:
- goes to the registration's current URL, signed with its current secret;
- can carry newer
datathan the attempt that failed; - is dropped, with no request sent, when the registration has been disabled, its event has been changed, or the resource of a current-state event no longer exists.
After the last attempt¶
When no attempt is left, the delivery moves to a dead-letter queue. This happens after the fourth retry, or at once after a failure that is not retried.
The Public API has no route to list or replay these deliveries. Replay is run by the bsport team,
so contact them if you need one. A replay sends the same delivery ID with current-state data
read again. Do not rely on replay to recover from an outage: read the state you missed from the
matching V2 list or retrieve endpoints.
Payload evolution¶
The registration's payload_version is immutable. Retries and replay retain its contract.
V2 payloads continue using the v1 signature algorithm token.
Read X-Bsport-Webhook-Payload-Version before decoding the body. Header versions are dates:
2026-10-06 for V2 and 2026-08-22 for V1. Registration versions are v2 and v1. Within a
version, new optional fields can appear without a new date, so ignore fields you do not recognize.
Continue accepting the versions your integration understands and alert
on an unfamiliar value before silently processing it. Do not answer 5xx to an unfamiliar
version: retries carry the same version and only use up the schedule. Store the verified body
with its X-Bsport-Webhook-Payload-Version and X-Bsport-Webhook-Delivery-Id values, answer
2xx, and process it once your integration supports that version. Payloads can contain personal
data, so restrict access to stored bodies and delete them after a set period.