Vérifier la signature
Chaque requête de Kivoo est signée : vérifie l’en-tête Kivoo-Signature avant de croire son contenu, et traite chaque événement une seule fois.
N’importe qui peut envoyer une requête à ton URL. Avant d’agir sur un événement webhook, vérifie qu’il vient bien de Kivoo et qu’il n’a pas été modifié : c’est le rôle de l’en-tête Kivoo-Signature.
L’en-tête Kivoo-Signature
Kivoo-Signature: t=1790416800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtest l’heure de l’envoi, en secondes depuis le 1er janvier 1970 (UTC).v1est la signature : le HMAC-SHA256, en hexadécimal, de la chaîne<t>.<corps brut>(l’horodatage, un point, puis le corps exact de la requête), calculé avec le secret du webhook (la chaîne complète affichée à la création,whsec_compris).
Pour vérifier :
- Lis le corps brut de la requête, avant tout décodage JSON : la signature porte sur ces octets exacts, un JSON re-sérialisé ne donnerait pas la même.
- Découpe l’en-tête :
tet la ou les valeursv1. - Refuse une signature trop ancienne : Kivoo recommande une tolérance de 300 secondes (5 minutes) entre
tet ton horloge. Cela empêche de rejouer une vieille requête interceptée. - Calcule le HMAC-SHA256 de
<t>.<corps brut>avec ton secret et compare-le à chaquev1en temps constant. Une seule correspondance suffit. - Si rien ne correspond, réponds
400et ignore la requête.
Node.js
Ce module n’utilise que node:crypto :
import { createHmac, timingSafeEqual } from 'node:crypto';
// Signatures acceptées jusqu’à cinq minutes après leur envoi.
const TOLERANCE_SECONDS = 300;
// HMAC-SHA256 de "<t>.<corps brut>" avec le secret du webhook, en hexadécimal.
export function computeSignature(secret, timestamp, rawBody) {
return createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
}
// Vrai si l’en-tête Kivoo-Signature signe ce corps brut, et n’est pas trop ancien.
export function verifyKivooSignature(secret, header, rawBody, now = Date.now()) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value = ''] = part.trim().split('=');
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(now / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(computeSignature(secret, timestamp, rawBody), 'hex');
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}Avec Express, garde le corps brut sur la route du webhook :
import express from 'express';
import { verifyKivooSignature } from './verify-kivoo-signature.mjs';
const app = express();
app.post('/webhooks/kivoo', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.get('Kivoo-Signature');
if (!verifyKivooSignature(process.env.KIVOO_WEBHOOK_SECRET, signature, rawBody)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody);
res.sendStatus(200); // réponds d’abord, en moins de 10 secondes
// … puis traite `event`, une seule fois par `event.id` (voir plus bas).
});
app.listen(3000);PHP
<?php
function verifyKivooSignature(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_KIVOO_SIGNATURE'] ?? '';
if (!verifyKivooSignature(getenv('KIVOO_WEBHOOK_SECRET'), $header, $rawBody)) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
// … traite $event, une seule fois par $event['id'].Traiter chaque événement une seule fois
Un même événement peut t’arriver plusieurs fois : après une tentative dont ta réponse s’est perdue, ou quand quelqu’un clique Renvoyer dans le journal des livraisons. L’en-tête Kivoo-Event-Id (égal à l’id de l’enveloppe) reste le même d’une livraison à l’autre : c’est ta clé d’idempotence.
- Enregistre l’
idde chaque événement traité, avec une contrainte d’unicité, dans la même transaction que ton traitement. - Si l’
idest déjà connu, réponds200sans rien refaire. - Garde ces identifiants au moins trente jours, la durée du journal des livraisons.
Garde le secret secret
Le secret signe les requêtes à ta place : il ne doit vivre que sur ton serveur. S’il a fuité, régénère-le depuis la fiche du webhook, puis mets à jour ton serveur : l’ancien secret cesse aussitôt de signer.