Vérification de la signature du webhook
La notification de paiement arrive à une adresse que seuls votre boutique et notre service connaissent. Mais l'adresse peut fuiter — des journaux, de la configuration, de l'historique de développement. Pour distinguer une notification authentique d'une contrefaçon, chaque requête est signée avec le secret du webhook.
Vérifiez la signature avant de livrer la commande ou de changer son statut.
En-têtes de signature
| En-tête | Valeur |
|---|---|
X-Timestamp | Heure d'envoi, unix time en secondes |
X-Signature | Le préfixe sha256= et le HMAC-SHA256 en hexadécimal |
La chaîne signée est l'heure d'envoi, un point et le corps de la requête :
1756901234.{"wallet":{...},"project":{...},"invoice":{...},"payment":{...}}
La clé de signature est le champ Secret du webhook (Webhook secret) dans les réglages du projet. Vous pouvez le réémettre au même endroit si la valeur est compromise.
Étapes de vérification
- Prenez le corps brut de la requête, avant d'analyser le JSON.
- Concaténez la valeur de
X-Timestamp, un point et le corps. - Calculez le HMAC-SHA256 avec le secret du webhook.
- Comparez le résultat avec
X-Signaturepar une comparaison en temps constant. - Rejetez la requête si
X-Timestamps'écarte trop de l'heure actuelle.
Exemples d'implémentation
- PHP
- TypeScript
- JavaScript
- Python
<?php
$secret = 'webhook secret from your project settings';
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// Guards against a replayed request
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
// hash_equals compares in constant time
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
$data = json_decode($body, true);
// Handle the order
http_response_code(200);
import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { type Request, type Response } from 'express';
const app = express();
const secret = 'webhook secret from your project settings';
// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
const timestamp = req.get('X-Timestamp') ?? '';
const signature = req.get('X-Signature') ?? '';
const body = (req.body as Buffer).toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Check the length first: timingSafeEqual needs buffers of equal size
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const data = JSON.parse(body);
// Handle the order
res.sendStatus(200);
});
Le même gestionnaire sans les types — pour un projet JavaScript pur.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const secret = 'webhook secret from your project settings';
// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Timestamp') ?? '';
const signature = req.get('X-Signature') ?? '';
const body = req.body.toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Check the length first: timingSafeEqual needs buffers of equal size
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const data = JSON.parse(body);
// Handle the order
res.sendStatus(200);
});
import hmac
import hashlib
import time
from flask import Flask, request
app = Flask(__name__)
secret = 'webhook secret from your project settings'
@app.post('/webhook')
def webhook():
timestamp = request.headers.get('X-Timestamp', '')
signature = request.headers.get('X-Signature', '')
body = request.get_data(as_text=True)
if abs(time.time() - int(timestamp or 0)) > 300:
return '', 400
expected = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp}.{body}'.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return '', 403
data = request.get_json()
# Handle the order
return '', 200
À garder à l'esprit
C'est le corps brut qui est signé. Si vous analysez le JSON et le reconstruisez, l'ordre des clés et les espaces changent — la signature ne correspondra pas. Calculez le HMAC avant l'analyse.
Chaque nouvelle tentative a sa propre signature. L'heure d'envoi est nouvelle à chaque tentative, donc la signature diffère aussi. Ne stockez pas une signature pour la comparer aux requêtes futures.
Le secret peut être réémis. L'ancien secret cesse de fonctionner immédiatement après la réémission, mettez donc à jour la valeur dans votre boutique au même moment.
La vérification de la signature ne remplace pas l'idempotence. La signature prouve que la requête est authentique, pas que vous la voyez pour la première fois. Une nouvelle tentative arrive avec une signature valide — vérifiez par invoice.id.