# Webhooks

https://gorally.games/developpeurs/webhooks

Un webhook prévient ton serveur dès qu’un fait se produit dans tes ligues : un résultat noté, un horaire publié, une saison close. Rally envoie un `POST` signé à l’adresse que tu déclares.

## Déclarer un point de livraison

1. Dans l’écran Intégrations, va à « Déclarer un point de livraison ».
2. Dans « Adresse à appeler », entre une adresse `https`. Rally ne poste jamais en clair.
3. Coche les événements voulus. Ne rien cocher revient à tout recevoir.
4. Clique sur « Déclarer le point ».
5. Copie le secret de signature, qui commence par `whsec_`. Rally ne le montre qu’une fois.

L’adresse doit être publique : une adresse locale ou privée est refusée.

## Ce que Rally envoie

Chaque livraison est un `POST` avec un corps JSON et ces en-têtes :

| En-tête | Contenu |
|---|---|
| `webhook-id` | L’identifiant de l’événement, égal au champ `id` du corps. |
| `webhook-timestamp` | L’heure d’envoi, en secondes Unix. |
| `webhook-signature` | Une ou plusieurs signatures `v1,…`, séparées par une espace. |
| `content-type` | `application/json` |
| `user-agent` | `Rally-Webhooks/1.0` |

Le corps est l’événement. Il reste mince : un objet, son identifiant, sa révision. Aucun nom de personne n’y figure.

```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` : le type d’événement, dans le catalogue plus bas.
- `data.object` et `data.id` : l’objet concerné et son identifiant.
- `data.revision` : la révision de l’objet, ou `null`.
- `data.previous` : ce qui vient de changer, pour quelques types seulement.
- `data.scheduled` : la nouvelle place, pour `match.rescheduled` seulement.

Relis l’état courant par l’API plutôt que de te fier au corps : une livraison peut arriver en retard.

## Vérifier la signature

Vérifie chaque livraison avant de la traiter. La signature suit la spécification Standard Webhooks :

1. Refuse la livraison si `webhook-timestamp` s’écarte de ton heure de plus de 300 secondes.
2. Retire le préfixe `whsec_` du secret, puis décode le reste en base64 : c’est la clé.
3. Calcule un HMAC-SHA256 de `webhook-id`, un point, `webhook-timestamp`, un point, puis du corps brut.
4. Encode le résultat en base64 et compare-le à chaque signature `v1,…` de `webhook-signature`, à temps constant.

Vérifie le corps brut, tel que reçu. Un corps décodé puis réécrit en JSON ne donne plus la même signature.

Le plus simple est une bibliothèque officielle Standard Webhooks : `standardwebhooks` sur npm comme sur PyPI, et d’autres langages sur [standardwebhooks.com](https://www.standardwebhooks.com/). Elles acceptent le secret `whsec_` tel quel.

Si tu préfères ne rien installer, voici une vérification complète, sans dépendance. Ces deux fichiers sont testés à chaque changement contre les signatures que Rally produit.

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

Réponds par un code `2xx` en moins de 10 secondes, puis traite l’événement à part. Au-delà de 10 secondes, Rally compte un échec et relance.

## Faire tourner le secret

1. Dans « Points de livraison », choisis la grâce de signature : coupure immédiate, 24 heures, 3 jours ou 7 jours au plus.
2. Clique sur « Faire tourner le secret de », suivi de l’adresse du point.
3. Copie le nouveau secret et déploie-le.

Pendant la grâce, chaque livraison porte deux signatures, une par secret. Ta vérification accepte donc l’ancien comme le nouveau. Une seule grâce à la fois : une seconde rotation avec grâce est refusée par `ROTATION_EN_COURS`.

## Relances et désactivation

Seule une réponse `2xx` compte comme une livraison réussie. Une redirection est un échec, comme un délai dépassé ou une adresse injoignable.

Après un échec, Rally relance au bout de 5 s, 30 s, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, puis 48 h. Cela fait 10 tentatives en un peu moins de quatre jours.

Après la dixième tentative ratée, le point est désactivé. L’écran Intégrations le montre, avec le journal des livraisons pour comprendre la panne. Clique sur « Réactiver », suivi de l’adresse, quand ton serveur répond de nouveau.

Une relance peut livrer deux fois le même événement. Ignore un `webhook-id` déjà traité.

## Rattraper les événements manqués

Un point réactivé reçoit les événements à partir de ce moment : rien n’est rejoué. Pour combler le trou, lis `GET /v1/events` avec une clé `read`.

**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()
```

C’est le même flux que les webhooks, du plus ancien au plus récent, avec les mêmes identifiants. Pour lire la page suivante, passe l’`id` du dernier événement de la page dans `after`.

## Catalogue des événements

Il n’existe pas d’événement de classement : le classement est calculé à chaque lecture. Écoute `match.completed`, puis relis le classement.

| Type | Quand | Champs de `data.previous` |
|---|---|---|
| `league.created` | Une ligue est créée. | — |
| `season.created` | Une saison est créée. | — |
| `season.closed` | Une saison est close. | `seasonId`, `status`, `closureRevision`, `obsolete` |
| `season.reopened` | Une saison close est rouverte. | `seasonId`, `status`, `closureRevision`, `obsolete` |
| `season.abandoned` | Une saison est abandonnée. | `seasonId`, `status`, `closureRevision`, `obsolete` |
| `season.renewed` | Une saison est reconduite dans la suivante. | `renewalId`, `sourceSeasonId`, `targetSeasonId`, `categories`, `teams`, `skippedTeams`, `skippedMembers` |
| `category.created` | Une catégorie est créée. | — |
| `category.updated` | Les règles ou le nombre de places d’une catégorie changent. | — |
| `registration.submitted` | Une équipe demande à s’inscrire. | — |
| `registration.withdrawn` | Une inscription est retirée. | — |
| `registration.reinstated` | Une inscription retirée est rétablie. | — |
| `schedule.published` | Un horaire est publié. | — |
| `match.rescheduled` | Une rencontre change de place à la publication de l’horaire. | `resourceId`, `startsAt`, `endsAt`, aussi dans `data.scheduled` |
| `match.completed` | Un résultat est noté. | — |
| `match.awarded` | Un résultat est attribué, par exemple sur forfait. | — |
| `match.corrected` | Un résultat ou une attribution est corrigé. | — |
| `match.cancelled` | Une rencontre pas encore jouée est annulée. | — |
| `match.voided` | Une rencontre commencée ou jouée est invalidée. Une reprise sera une nouvelle rencontre. | — |
| `series.opened` | Une série éliminatoire commence. | — |
| `series.updated` | Un résultat fait avancer une série. | `seriesId`, `nodeKey`, `winnerTeamId`, `championTeamId`, `cancelledMatchIds`, `releasedMatchIds`, `scheduleRecomputeRequired` |
