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.

You can manage an event's webhooks in two places. Both work on the same subscriptions:

  • The API, under /v1/events/{eventId}/webhooks, with an org-scoped key that holds the manage:webhooks scope.
  • The app: open the event, then Edit Event → Webhooks tab. Anyone who can edit the event can subscribe, rotate a secret, switch a disabled webhook back on or delete a webhook there. The tab appears once your organisation has API access.

The examples below use the $RR_BASE and $RR_KEY shell variables from the Quickstart.

Subscribing

curl -X POST "$RR_BASE/v1/events/evt_abc/webhooks" \
  -H "Authorization: Bearer $RR_KEY" \
  -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 url must start with https://, must resolve to a public address, and can't contain a user name or password.

The response (201) 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 keeps only an encrypted copy, so it can't show you the secret again. 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.

In the app, New webhook opens Subscribe webhook: enter the Endpoint URL, pick the Event types and tap Subscribe. The signing secret is shown once, in the Webhook signing secret dialog.

Changing or removing a subscription

PATCH /v1/events/{eventId}/webhooks/{webhookId} changes a subscription's event types (a new eventTypes list) and switches it on or off (enabled, see Retries and backoff). To change the URL, delete the webhook and subscribe again. DELETE /v1/events/{eventId}/webhooks/{webhookId} returns 204; in the app, use Delete on the webhook.

GET /v1/events/{eventId}/webhooks lists the event's subscriptions, with the time of the last delivery, the last success and the last failure, and the number of failures in a row.

Event types

Event type Fires when
event.created A new event is created.
event.updated Any other change to an event.
event.over An event is marked as over.
race.created A new race is added to an event.
race.started A race becomes active.
race.ended A race stops being active.
race.updated Any other change to a race.
athlete.imported An athlete is added to a race — by bulk import, a start-list sync or in the app.
athlete.statusChanged An athlete's DNF, DNS, DSQ, lapped or eliminated flag changes.
penalty.created A penalty becomes approved or served from another status, or is created that way.
penalty.statusChanged An approved penalty is served, a served one goes back to approved, or a published penalty is cancelled, declined or put under protest.
penalty.merged A walk-in served at the penalty box is merged into the referee's report for the same offence.
penalty.unmerged A walk-in merge is undone.
penalty.serveStarted An approved penalty's serve starts or resumes, at a penalty box or a stop-and-go.
penalty.serveStopped A running serve is paused or reset. Served sends penalty.statusChanged instead.
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.

Penalties are published the way the API lists them: only approved and served penalties count. A penalty in any other status (for example submitted, declined or cancelled) sends no event until it is published. A published penalty that later leaves the list sends penalty.statusChanged with its new penaltyStatus — cancelled, declined or protest — so drop it from yours; if it is published again, you get penalty.created. Demo penalties and incidents never send events.

Walk-ins. On races with walk-in serves, an athlete can serve at the penalty box before the referee's report arrives. That walk-in is published as a served penalty (penalty.created). When it is merged with the referee's report, you get penalty.merged for the walk-in, which is now cancelled; data.mergedInto is the id of the report, which now carries the serve. The report itself sends penalty.created or penalty.statusChanged as it becomes served. Count the offence once, as the report. penalty.unmerged means race control undid the merge: the walk-in is a penalty of its own again, and data.mergedInto names the report it was merged into.

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.

Deliveries only go out while your organisation has API access.

Delivery format

Each delivery is an HTTPS POST with a JSON body and these headers:

POST /raceranger/hook HTTP/1.1
Content-Type: application/json
User-Agent: RaceRanger-Webhook/1.0
X-RaceRanger-Event: penalty.statusChanged
X-RaceRanger-Delivery: 5d1f7c9a-2b1e-4c55-9d0e-7a8f3c2b1e44
X-RaceRanger-Timestamp: 1746368421
X-RaceRanger-Signature: sha256=<hex>

{
  "id": "5d1f7c9a-2b1e-4c55-9d0e-7a8f3c2b1e44",
  "type": "penalty.statusChanged",
  "createdAt": "2026-05-04T14:20:21.512Z",
  "orgId": "your-org",
  "eventId": "evt_abc",
  "raceId": "race_xyz",
  "data": { ... }
}
Body field What it is
id This delivery attempt's id — the same as X-RaceRanger-Delivery.
type The event type — the same as X-RaceRanger-Event.
createdAt When this attempt was sent.
orgId The organisation the change belongs to.
eventId The event the subscription belongs to.
raceId The race the change belongs to; null for event.* types.
data The resource that changed.

data carries these fields. Times are ISO 8601 strings.

Event types data fields
event.* id, name, sport, isOver, dateFrom, dateTo, organizationId
race.* id, name, startTime, isActive, alreadyStarted, status, isDraftingAllowed, gender
athlete.* id, raceId, raceNumber, raceNumberString, name, nationality, gender, isDNF, isDNS, isDSQ, isLapped
penalty.* id, raceId, penaltyStatus, penaltyType (the type's id), penaltyTypeName, timestamp, athleteId, athleteNumber, athleteNumberString, gender, walkIn, mergedWalkInId, serving, serveStartedAt; penalty.merged and penalty.unmerged add mergedInto
incident.* id, raceId, status, typeName, timestamp, athleteId, athleteNumber, gender

A race's status is notActive, active, running or finished. Ignore any field that isn't listed here — it may change. For anything else, read the resource from the API.

At least once, in any order

The same change can arrive more than once, and deliveries can arrive out of order. Every attempt carries a new X-RaceRanger-Delivery id — a retry of the same change too — so you can't use it to spot duplicates. Make your handler safe to run twice: keep the latest state per data.id, and take the state from data (for example penaltyStatus) rather than from the order deliveries arrive in.

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. Use the secret exactly as you received it.

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 within 10 seconds. Any other response, a timeout or a connection failure triggers a retry with exponential backoff: up to five attempts in all, with the wait between them doubling from about 10 seconds. 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.

After 50 failed attempts in a row, the subscription is automatically disabled: the API response then has disabledAt and disabledReason set, the app shows the webhook as Disabled, and deliveries stop. A successful delivery resets the count. To switch it back on, fix your endpoint, then send PATCH /v1/events/{eventId}/webhooks/{webhookId} with {"enabled": true}, or tap Re-enable on the webhook in the app. It keeps its secret, and the count starts again from zero. Changes that happened while it was disabled aren't sent afterwards. {"enabled": false} switches a subscription off by hand.

You can inspect the last 100 attempts for a subscription, newest first, via GET /v1/events/{eventId}/webhooks/{webhookId}/deliveries. Each entry has:

  • id — the attempt's X-RaceRanger-Delivery id.
  • eventType — the event type delivered.
  • attemptCount — which attempt this was (1 for the first).
  • status — delivered or failed.
  • responseStatus — the HTTP status your endpoint returned, or null when there was no response.
  • lastAttemptAt — when the attempt was made.

Attempts are kept for about 30 days.

Rotating the secret

curl -X POST \
  "$RR_BASE/v1/events/evt_abc/webhooks/wh_123:rotateSecret" \
  -H "Authorization: Bearer $RR_KEY"

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. Rotating doesn't need an Idempotency-Key. In the app, Rotate secret on the webhook does the same.