Skip to content

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=1787394600,v1=64-character-lowercase-hex-digest

t is the Unix timestamp of the attempt, so every retry carries a new t and a new v1.

To verify a delivery:

  1. Read the raw request bytes before JSON parsing or reformatting.
  2. Extract t and v1 from Bsport-Signature.
  3. Build the signed bytes as <t>.<raw_request_body>.
  4. Compute HMAC-SHA256 using the registration's complete signing_secret, including its whsec_ prefix, as the key.
  5. Compare the computed lowercase hexadecimal digest with v1 using a constant-time comparison.
  6. 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:

  1. 10 seconds;
  2. 1 minute;
  3. 5 minutes;
  4. 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 data than 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.