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 :
- Refusez-la si
webhook-timestampdate de plus de 5 minutes. - Prenez le secret, retirez le préfixe
whsec_et décodez le reste en base64 : le résultat est la clé. - 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. - 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.