Webhooks
Webhooks let you receive an HTTPS POST whenever something interesting happens during a race — a penalty is approved, a race starts, an athlete's status changes. Subscriptions are scoped to a single event.
Creating, listing, updating, and deleting webhooks lives under
/v1/events/{eventId}/webhooks and requires an org-scoped key with the
manage:webhooks scope.
Subscribing
curl https://api.raceranger.com/v1/events/evt_abc/webhooks \
-H "Authorization: Bearer rr_o_..." \
-H "Idempotency-Key: setup-2026-05-04-spring-sprint" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/raceranger/hook",
"eventTypes": ["penalty.statusChanged", "race.started", "race.ended"]
}'
The response includes a secret field — this is the HMAC signing
secret. It is returned once, on creation. Store it next to the
webhook URL; you will need it to verify incoming deliveries. RaceRanger
stores the secret encrypted at rest (Cloud KMS envelope encryption);
the plaintext is never persisted server-side. If you lose your copy,
call POST /v1/events/{eventId}/webhooks/{webhookId}:rotateSecret
to generate a new one — note that this immediately invalidates the old
one, so any in-flight deliveries signed under the old secret will fail
their verification.
Event types
| Event type | Fires when |
|---|---|
event.created |
A new event is created. |
event.updated |
An event's metadata changes. |
event.over |
An event is marked finished. |
race.created |
A new race is added to an event. |
race.started |
A race transitions to active. |
race.ended |
A race transitions out of active. |
race.updated |
A race's pre-start fields change. |
athlete.imported |
An athlete is added to a race (bulk import or app entry). |
athlete.statusChanged |
An athlete's DNF / DNS / DSQ / lapped flags change. |
penalty.created |
A new penalty is created. |
penalty.statusChanged |
A penalty transitions between pending, approved, and served. |
incident.created |
A new incident is logged. |
incident.statusChanged |
An incident's status changes. |
A subscription must include at least one event type. Unknown event
types are rejected with 400 at subscribe time.
Limits
- 10 webhooks per event. The 11th subscription is rejected with
400 "Event already has the maximum of 10 webhooks."This cap caps the per-change fanout so a misconfigured organisation can't turn RaceRanger into a delivery amplifier against a single endpoint. - No duplicate URLs. Subscribing the same URL to one event twice
is rejected with
400 "A webhook with this URL is already subscribed to this event."Use one webhook per receiver and leteventTypescontrol what it sees. - Per-payload size cap. Each delivery body is bounded by the Firestore document size of the source change (≤ ~1 MB).
Delivery format
Each delivery is an HTTPS POST with a JSON body and four custom
headers:
POST /raceranger/hook HTTP/1.1
Content-Type: application/json
X-RaceRanger-Event: penalty.statusChanged
X-RaceRanger-Delivery: del_8af1c2...
X-RaceRanger-Timestamp: 1746368421
X-RaceRanger-Signature: sha256=<hex>
{
"eventType": "penalty.statusChanged",
"eventId": "evt_abc",
"raceId": "race_xyz",
"data": { ... }
}
X-RaceRanger-Delivery is a stable identifier for the delivery
attempt. Use it for deduplication on your side — a retried delivery
reuses the same id.
Verifying signatures
Signatures protect against tampering and replay. The signature is the
hex-encoded HMAC-SHA256 of "{timestamp}.{raw body}" keyed by the
subscription's secret.
import hmac, hashlib, time
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
if not signature.startswith("sha256="):
return False
payload = f"{timestamp}.".encode() + body
expected = "sha256=" + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return False
# Reject deliveries older than 5 minutes to block replay attacks.
return abs(time.time() - int(timestamp)) <= 300
Always verify against the raw request body, before any JSON parsing or middleware rewriting.
Retries and backoff
A delivery is considered successful when your endpoint returns a 2xx
status. Any other response, or a connection failure, triggers a retry
with exponential backoff. Redirect responses (3xx) are treated as
failures — the delivery engine does not follow redirects. Your endpoint
must return 2xx directly; update your URL if it currently responds
with a redirect.
A subscription that fails repeatedly is
automatically disabled — the API response then has disabledAt and
disabledReason set, and deliveries stop until you re-enable the
subscription by updating its eventTypes.
You can inspect the last 100 attempts for a subscription via
GET /v1/events/{eventId}/webhooks/{webhookId}/deliveries. Each entry
includes the attempt count, the HTTP status returned by your endpoint,
and the time of the last attempt.
Rotating the secret
curl -X POST \
https://api.raceranger.com/v1/events/evt_abc/webhooks/wh_123:rotateSecret \
-H "Authorization: Bearer rr_o_..."
The response contains the new secret. The previous secret is invalidated immediately, so cut over your verification logic before calling this endpoint — or accept that the next handful of deliveries will fail until you redeploy.