A webhook tells your server as soon as something happens in your leagues: a result entered, a schedule published, a season closed. Rally sends a signed POST to the URL you register.

Register an endpoint

Rally Leagues screens are in French. Labels are quoted as they appear, with a translation.

  1. In the Integrations screen, go to “Déclarer un point de livraison” (Register an endpoint).
  2. In “Adresse à appeler” (URL to call), enter an https URL. Rally never posts in clear text.
  3. Check the events you want. Checking nothing means receiving everything.
  4. Click “Déclarer le point” (Register the endpoint).
  5. Copy the signing secret, which starts with whsec_. Rally shows it only once.

The URL must be public: a local or private address is refused.

What Rally sends

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

Header Content
webhook-id The event identifier, equal to the body’s id field.
webhook-timestamp The send time, in Unix seconds.
webhook-signature One or more v1,… signatures, separated by a space.
content-type application/json
user-agent Rally-Webhooks/1.0

The body is the event. It stays thin: an object, its identifier, its revision. No person’s name appears in it.

{
  "id": "8f6e4d2c-0b9a-4c7e-a5d3-1f9e7c5a3b0d",
  "type": "match.completed",
  "created": "2027-01-13T03:12:45.000Z",
  "organization": "9d3f5a7c-2b1e-4c8d-a6f0-3e2d1c0b9a87",
  "data": {
    "object": "match",
    "id": "f4e3d2c1-b0a9-4f8e-8d7c-6b5a4f3e2d1c",
    "revision": 4
  }
}
  • type: the event type, from the catalogue below.
  • data.object and data.id: the object concerned and its identifier.
  • data.revision: the object’s revision, or null.
  • data.previous: what just changed, for a few types only.
  • data.scheduled: the new slot, for match.rescheduled only.

Read the current state through the API rather than trusting the body: a delivery can arrive late.

Verify the signature

Verify every delivery before processing it. The signature follows the Standard Webhooks specification:

  1. Refuse the delivery if webhook-timestamp is more than 300 seconds away from your clock.
  2. Remove the whsec_ prefix from the secret, then base64-decode the rest: that is the key.
  3. Compute an HMAC-SHA256 of webhook-id, a dot, webhook-timestamp, a dot, then the raw body.
  4. Base64-encode the result and compare it with each v1,… signature in webhook-signature, in constant time.

Verify the raw body, exactly as received. A body parsed then written back as JSON no longer gives the same signature.

The simplest option is an official Standard Webhooks library: standardwebhooks on npm and on PyPI, and other languages on standardwebhooks.com. They accept the whsec_ secret as is.

If you would rather install nothing, here is a complete verification with no dependency. Both files are tested on every change against the signatures Rally produces.

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

/**
 * @param {string} secret The endpoint's `whsec_…` signing secret.
 * @param {Record<string, string | string[] | undefined>} headers The request headers, lowercase keys.
 * @param {string} rawBody The body exactly as received, before any JSON.parse.
 * @param {number} [now] The current Unix time, in seconds.
 * @returns {boolean}
 */
export function verifyWebhook(secret, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (typeof id !== 'string' || typeof timestamp !== 'string' || typeof signatures !== 'string') {
    return false;
  }

  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = Buffer.from(
    createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64'),
  );

  return signatures.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const presented = Buffer.from(signature);
    return presented.length === expected.length && timingSafeEqual(presented, expected);
  });
}

Answer with a 2xx code within 10 seconds, then process the event separately. Past 10 seconds, Rally counts a failure and retries.

Rotate the secret

  1. Under “Points de livraison” (Endpoints), pick the signing grace period: immediate cut-off, 24 hours, 3 days or at most 7 days.
  2. Click “Faire tourner le secret de” (Rotate the secret of), followed by the endpoint’s URL.
  3. Copy the new secret and deploy it.

During the grace period, each delivery carries two signatures, one per secret. Your verification therefore accepts the old secret as well as the new one. One grace period at a time: a second rotation with grace is refused with ROTATION_EN_COURS.

Retries and disabling

Only a 2xx response counts as a successful delivery. A redirect is a failure, like a timeout or an unreachable address.

After a failure, Rally retries after 5 s, 30 s, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then 48 h. That makes 10 attempts over a little less than four days.

After the tenth failed attempt, the endpoint is disabled. The Integrations screen shows it, with the delivery log to understand the outage. Click “Réactiver” (Re-enable), followed by the URL, once your server answers again.

A retry can deliver the same event twice. Ignore a webhook-id you have already processed.

Catch up on missed events

A re-enabled endpoint receives events from that moment on: nothing is replayed. To fill the gap, read GET /v1/events with a read key.

curl https://gorally.games/v1/events \
  -H "Authorization: Bearer $RALLY_API_KEY"

It is the same feed as webhooks, oldest first, with the same identifiers. To read the next page, pass the id of the page’s last event in after.

Event catalogue

There is no standings event: standings are computed on every read. Listen to match.completed, then read the standings again.

Type When Fields in data.previous
league.created A league is created. —
season.created A season is created. —
season.closed A season is closed. seasonId, status, closureRevision, obsolete
season.reopened A closed season is reopened. seasonId, status, closureRevision, obsolete
season.abandoned A season is abandoned. seasonId, status, closureRevision, obsolete
season.renewed A season is carried over into the next one. renewalId, sourceSeasonId, targetSeasonId, categories, teams, skippedTeams, skippedMembers
category.created A category is created. —
category.updated A category’s rules or number of spots change. —
registration.submitted A team asks to register. —
registration.withdrawn A registration is withdrawn. —
registration.reinstated A withdrawn registration is reinstated. —
schedule.published A schedule is published. —
match.rescheduled A match changes slot when the schedule is published. resourceId, startsAt, endsAt, also in data.scheduled
match.completed A result is entered. —
match.awarded A result is awarded, for example on a forfeit. —
match.corrected A result or an award is corrected. —
match.cancelled A match not yet played is cancelled. —
match.voided A started or played match is voided. A replay will be a new match. —
series.opened A playoff series starts. —
series.updated A result moves a series forward. seriesId, nodeKey, winnerTeamId, championTeamId, cancelledMatchIds, releasedMatchIds, scheduleRecomputeRequired

Next guide: Errors →