# Webhooks

https://gorally.games/en/developers/webhooks

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.

```json
{
  "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](https://www.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.

**JavaScript**

```js
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);
  });
}
```

**Python**

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_webhook(secret, headers, raw_body, now=None):
    """secret: the endpoint's whsec_ signing secret.
    headers: the request headers (a dict, or your framework's headers object).
    raw_body: the body exactly as received, as bytes, before any json.loads.
    """
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False

    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - sent_at) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False
```

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**

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

**JavaScript**

```js
const response = await fetch('https://gorally.games/v1/events', {
  headers: {
    Authorization: `Bearer ${process.env.RALLY_API_KEY}`,
  },
});
const data = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://gorally.games/v1/events",
    headers={"Authorization": f"Bearer {os.environ['RALLY_API_KEY']}"},
    timeout=10,
)
data = response.json()
```

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` |
