Aller au contenu

Format du message et signature

Ce que reçoit votre serveur avec un webhook générique, et comment vérifier que le message vient bien de RestoreProof.

Ce que vous recevez

POST /votre/adresse
content-type: application/json
user-agent: RestoreProof-Notifications/<version>
webhook-id: 6f1c0d7e-…
webhook-timestamp: 1791533551
webhook-signature: v1,K5oZfzN0…
En-tête Contenu
webhook-id Identifiant de l'événement. Il est identique à chaque nouvelle tentative du même événement : servez-vous-en pour ignorer un doublon.
webhook-timestamp Heure de cette tentative, en secondes Unix.
webhook-signature Une ou plusieurs signatures v1,<base64>, séparées par une espace.

Le corps est un document JSON :

{
  "schema_version": 1,
  "id": "6f1c0d7e-…",
  "type": "run.failed",
  "occurred_at": "2026-10-09T08:12:31Z",
  "organization": {"id": "…", "name": "ACME"},
  "link": "https://app.restoreproof.io/runs/…",
  "data": {
    "run_id": "…",
    "plan": {"id": "…", "name": "Postgres production"},
    "status": "failed",
    "runner": {"id": "…", "name": "runner-paris-1"},
    "started_at": "…",
    "finished_at": "…",
    "duration_seconds": 184,
    "summary": "restore_postgres: psql exited with status 3",
    "probes": [{"name": "postgres", "status": "FAIL"}],
    "consecutive_failures": 1
  }
}

type est l'un des identifiants du catalogue. Le contenu de data dépend du type : une exécution, un runner, un abonnement ou un membre. schema_version ne change que si un champ existant change de sens ou disparaît ; l'ajout d'un champ ne le change pas, donc ignorez les champs que vous ne connaissez pas. Le message de test a pour type test.

Vérifier la signature

La signature suit la convention Standard Webhooks. Pour chaque requête :

  1. Refusez-la si webhook-timestamp date de plus de 5 minutes.
  2. Prenez le secret, retirez le préfixe whsec_ et décodez le reste en base64 : le résultat est la clé.
  3. Calculez un HMAC-SHA256, avec cette clé, du texte <webhook-id>.<webhook-timestamp>.<corps>, où <corps> est le corps tel que reçu, octet pour octet. Ne le relisez pas après l'avoir analysé en JSON : la moindre différence d'espace change la signature.
  4. Encodez le résultat en base64 et comparez-le, en temps constant, à chacune des signatures de l'en-tête (la partie après v1,). Une seule suffit.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!id || !timestamp || !headers["webhook-signature"]) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

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

  return headers["webhook-signature"].split(" ").some((candidate) => {
    const [version, signature] = candidate.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

rawBody est un Buffer : avec Express, utilisez express.raw({ type: "application/json" }) sur cette route plutôt que express.json().

import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
    try:
        message_id = headers["webhook-id"]
        timestamp = headers["webhook-timestamp"]
        signatures = headers["webhook-signature"]
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except (KeyError, ValueError):
        return False

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

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

raw_body doit être le corps brut de la requête (request.get_data() avec Flask, await request.body() avec FastAPI).

Pendant les 24 heures qui suivent un changement de secret, l'en-tête porte deux signatures et ce code accepte les deux.