Skip to content

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 let eventTypes control 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.