Webhooks
Vérifier les signatures
Vérifiez qu’une livraison vient de AdProMaxPay et n’a été ni modifiée ni rejouée, dans dix langages.
Sur cette page
N’importe qui peut envoyer une requête à votre adresse de webhook. Avant de croire une livraison, vérifiez sa signature : elle prouve que AdProMaxPay a envoyé ce corps exact, récemment, avec le secret de votre endpoint.
L’en-tête de signature
Chaque livraison porte un en-tête X-AdProMaxPay-Signature :
test l’heure de signature, en secondes Unix ;v1est le HMAC-SHA256, en hexadécimal, du textet+.+ le corps brut, avec pour clé le secret de signature de votre endpoint (apx_whsec_…, utilisé en entier comme clé).
Pendant le renouvellement d’un secret, l’en-tête porte deux valeurs v1, une par secret. Une
livraison est authentique quand l’une des valeurs v1 correspond.
X-AdProMaxPay-Signature: t=1790851200,v1=d2e39688034d…9a9879eVérifier une livraison
- Lisez le corps brut, exactement tel que reçu, avant tout décodage JSON. Un corps décodé puis réécrit ne correspond plus à sa signature.
- Découpez l’en-tête sur
,, puis chaque partie sur le premier=: gardeztet chaquev1. - Refusez une livraison dont le
ts’écarte de plus de cinq minutes de votre horloge : une ancienne livraison peut être un rejeu. - Calculez le HMAC-SHA256 de
t+.+ le corps brut avec votre secret, en hexadécimal. - Comparez-le à chaque
v1en temps constant. Si l’un correspond, la livraison est authentique.
Répondez 400 à une livraison qui échoue, et n’en faites rien. Les fonctions de cette section font les cinq
étapes ; chacune est vérifiée sur le même exemple signé.
# Usage: verify_signature <file holding the raw body> "<header>" "<secret>"
# A check by hand; your server should verify in its own language.
# NOW replaces the clock, for tests.
verify_signature() {
local body_file="$1" header="$2" secret="$3"
local now="${NOW:-$(date +%s)}" timestamp expected
timestamp=$(printf '%s\n' "$header" | tr ',' '\n' \
| sed -n 's/^ *t=//p' | head -n 1)
case "$timestamp" in '' | *[!0-9]*) return 1 ;; esac
[ $((now - timestamp)) -le 300 ] || return 1
[ $((timestamp - now)) -le 300 ] || return 1
expected=$({ printf '%s.' "$timestamp"; cat "$body_file"; } \
| openssl dgst -sha256 -hmac "$secret" | sed 's/^.*= //')
printf '%s\n' "$header" | tr ',' '\n' | sed 's/^ *//' \
| grep -qx "v1=$expected"
}import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
// True when `header` (X-AdProMaxPay-Signature) signs `rawBody`
// (a Buffer or a string) with `secret` and is under five minutes old.
export function verifySignature(rawBody, header, secret, now) {
let timestamp = null
const signatures = []
for (const part of String(header).split(',')) {
const [key, value] = part.trim().split('=', 2)
if (key === 't')
timestamp = value
else if (key === 'v1' && value !== undefined)
signatures.push(value)
}
if (timestamp === null || !/^\d+$/.test(timestamp))
return false
const clock = now ?? Math.floor(Date.now() / 1000)
if (Math.abs(clock - Number(timestamp)) > TOLERANCE_SECONDS)
return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest()
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex')
return given.length === expected.length
&& timingSafeEqual(given, expected)
})
}import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
// True when `header` (X-AdProMaxPay-Signature) signs `rawBody`
// with `secret` and is under five minutes old.
export function verifySignature(
rawBody: Buffer | string,
header: string,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): boolean {
let timestamp: string | null = null
const signatures: string[] = []
for (const part of header.split(',')) {
const [key, value] = part.trim().split('=', 2)
if (key === 't' && value !== undefined)
timestamp = value
else if (key === 'v1' && value !== undefined)
signatures.push(value)
}
if (timestamp === null || !/^\d+$/.test(timestamp))
return false
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS)
return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest()
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex')
return given.length === expected.length
&& timingSafeEqual(given, expected)
})
}import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_signature(raw_body, header, secret, now=None):
"""True when header (X-AdProMaxPay-Signature) signs raw_body
(bytes) with secret and is under five minutes old."""
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not (timestamp and timestamp.isascii() and timestamp.isdigit()):
return False
now = int(time.time()) if now is None else now
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256)
digest = expected.hexdigest().encode()
return any(hmac.compare_digest(digest, s.encode()) for s in signatures)<?php
const TOLERANCE_SECONDS = 300;
// True when $header (X-AdProMaxPay-Signature) signs $rawBody
// with $secret and is under five minutes old.
function verify_signature(
string $rawBody,
string $header,
string $secret,
?int $now = null
): bool {
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if ($pair[0] === 't' && isset($pair[1])) {
$timestamp = $pair[1];
} elseif ($pair[0] === 'v1' && isset($pair[1])) {
$signatures[] = $pair[1];
}
}
if ($timestamp === null || !ctype_digit($timestamp)) {
return false;
}
if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$signed = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}require 'openssl'
TOLERANCE_SECONDS = 300
# True when header (X-AdProMaxPay-Signature) signs raw_body
# with secret and is under five minutes old. Ruby 3.0 or later.
def verify_signature(raw_body, header, secret, now = Time.now.to_i)
timestamp = nil
signatures = []
header.split(',').each do |part|
key, value = part.strip.split('=', 2)
timestamp = value if key == 't'
signatures << value if key == 'v1' && value
end
return false unless timestamp&.match?(/\A\d+\z/)
return false if (now - timestamp.to_i).abs > TOLERANCE_SECONDS
signed = "#{timestamp}.".b + raw_body.b
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)
signatures.any? do |signature|
OpenSSL.secure_compare(expected, signature)
end
endpackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
// VerifySignature reports whether header (X-AdProMaxPay-Signature)
// signs rawBody with secret and is under five minutes old.
func VerifySignature(rawBody []byte, header, secret string, now time.Time) bool {
var timestamp string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, found := strings.Cut(strings.TrimSpace(part), "=")
switch {
case found && key == "t":
timestamp = value
case found && key == "v1":
signatures = append(signatures, value)
}
}
if timestamp == "" || strings.Trim(timestamp, "0123456789") != "" {
return false
}
signedAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
age := now.Unix() - signedAt
if age > toleranceSeconds || age < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, signature := range signatures {
given, err := hex.DecodeString(signature)
if err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookSignature {
private static final long TOLERANCE_SECONDS = 300;
// True when header (X-AdProMaxPay-Signature) signs rawBody
// with secret and is under five minutes old. Java 17 or later.
public static boolean verify(byte[] rawBody, String header, String secret, long now)
throws GeneralSecurityException {
String timestamp = null;
List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) {
String[] pair = part.trim().split("=", 2);
if (pair.length < 2) {
continue;
}
if (pair[0].equals("t")) {
timestamp = pair[1];
} else if (pair[0].equals("v1")) {
signatures.add(pair[1]);
}
}
if (timestamp == null || !timestamp.matches("\\d{1,18}")) {
return false;
}
if (Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
for (String signature : signatures) {
try {
byte[] given = HexFormat.of().parseHex(signature);
if (MessageDigest.isEqual(expected, given)) {
return true;
}
} catch (IllegalArgumentException notHexadecimal) {
// Not one of our signatures.
}
}
return false;
}
public static boolean verify(byte[] rawBody, String header, String secret)
throws GeneralSecurityException {
return verify(rawBody, header, secret, System.currentTimeMillis() / 1000);
}
}using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static class WebhookSignature
{
private const long ToleranceSeconds = 300;
// True when header (X-AdProMaxPay-Signature) signs rawBody
// with secret and is under five minutes old.
public static bool Verify(byte[] rawBody, string header, string secret, long? now = null)
{
string? timestamp = null;
var signatures = new List<string>();
foreach (var part in header.Split(','))
{
var pair = part.Trim().Split('=', 2);
if (pair.Length < 2) continue;
if (pair[0] == "t") timestamp = pair[1];
else if (pair[0] == "v1") signatures.Add(pair[1]);
}
if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var signedAt))
return false;
var clock = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(clock - signedAt) > ToleranceSeconds)
return false;
var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
foreach (var signature in signatures)
{
try
{
var given = Convert.FromHexString(signature);
if (CryptographicOperations.FixedTimeEquals(expected, given))
return true;
}
catch (FormatException)
{
// Not one of our signatures.
}
}
return false;
}
}// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4"
use hmac::{Hmac, Mac};
use sha2::Sha256;
const TOLERANCE_SECONDS: i64 = 300;
/// True when `header` (X-AdProMaxPay-Signature) signs `raw_body` with
/// `secret` and is under five minutes old. `now` is in Unix seconds.
pub fn verify_signature(raw_body: &[u8], header: &str, secret: &str, now: i64) -> bool {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in header.split(',') {
match part.trim().split_once('=') {
Some(("t", value)) => timestamp = Some(value),
Some(("v1", value)) => signatures.push(value),
_ => {}
}
}
let Some(timestamp) = timestamp else {
return false;
};
if timestamp.is_empty() || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) {
return false;
}
let Ok(signed_at) = timestamp.parse::<i64>() else {
return false;
};
if (now - signed_at).abs() > TOLERANCE_SECONDS {
return false;
}
signatures.iter().any(|signature| {
let Ok(given) = hex::decode(signature) else {
return false;
};
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC takes a key of any length");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(raw_body);
// Compares in constant time.
mac.verify_slice(&given).is_ok()
})
}Lire le corps brut
La plupart des frameworks décodent le JSON avant que votre code ne s’exécute. Demandez plutôt les octets bruts, sur la seule route du webhook.
- Node : avec Express, montez
express.raw({ type: 'application/json' })sur la route ;req.bodyest alors unBuffer. Avecnode:http, rassemblez les morceaux de la requête, comme dans le démarrage rapide carte. - PHP : lisez
php://input. Dans Laravel,$request->getContent(). - Python : dans Flask,
request.get_data(); dans Django,request.body. - Ruby : dans Rails,
request.raw_post; dans Sinatra,request.body.read. - Go :
io.ReadAll(r.Body). - Java : dans Spring, un paramètre
@RequestBody byte[]. - C# : copiez
Request.Bodydans unMemoryStreamavant toute liaison de modèle. - Rust : dans axum, l’extracteur
Bytes.
# From a terminal, send the bytes exactly as they are: --data-binary.
# -d would strip the line breaks, and the signature would not match.
curl -i http://localhost:4242/webhooks/payments \
-H "Content-Type: application/json" \
-H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
--data-binary @event.jsonapp.post('/webhooks/payments',
express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-AdProMaxPay-Signature') ?? ''
const secret = process.env.APX_WEBHOOK_SECRET
if (!verifySignature(req.body, header, secret))
return res.sendStatus(400)
const event = JSON.parse(req.body.toString('utf8'))
res.sendStatus(200)
handle(event) // your own code, after answering
})app.post('/webhooks/payments',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
const rawBody = req.body as Buffer
const header = req.get('X-AdProMaxPay-Signature') ?? ''
const secret = process.env.APX_WEBHOOK_SECRET ?? ''
if (!verifySignature(rawBody, header, secret))
return res.sendStatus(400)
const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent
res.sendStatus(200)
handle(event) // your own code, after answering
})@csrf_exempt
@require_POST
def webhook(request):
header = request.headers.get("X-AdProMaxPay-Signature", "")
secret = os.environ["APX_WEBHOOK_SECRET"]
if not verify_signature(request.body, header, secret):
return HttpResponse(status=400)
event = json.loads(request.body)
# Your own code, quickly: answer within 10 seconds.
return HttpResponse(status=200)Route::post('/webhooks/payments', function (Request $request) {
$valid = verify_signature(
$request->getContent(),
$request->header('X-AdProMaxPay-Signature', ''),
getenv('APX_WEBHOOK_SECRET'),
);
abort_unless($valid, 400);
$event = json_decode($request->getContent(), true);
// Your own code, quickly: answer within 10 seconds.
return response()->noContent();
});class WebhooksController < ApplicationController
skip_forgery_protection
def create
valid = verify_signature(
request.raw_post,
request.headers['X-AdProMaxPay-Signature'].to_s,
ENV.fetch('APX_WEBHOOK_SECRET')
)
return head :bad_request unless valid
event = JSON.parse(request.raw_post)
# Your own code, quickly: answer within 10 seconds.
head :ok
end
endfunc webhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
header := r.Header.Get("X-AdProMaxPay-Signature")
secret := os.Getenv("APX_WEBHOOK_SECRET")
if !VerifySignature(rawBody, header, secret, time.Now()) {
w.WriteHeader(http.StatusBadRequest)
return
}
// Your own code, quickly: answer within 10 seconds.
w.WriteHeader(http.StatusOK)
}@RestController
public class WebhookController {
@PostMapping("/webhooks/payments")
public ResponseEntity<Void> receive(
@RequestBody byte[] rawBody,
@RequestHeader("X-AdProMaxPay-Signature") String header)
throws GeneralSecurityException {
String secret = System.getenv("APX_WEBHOOK_SECRET");
if (!WebhookSignature.verify(rawBody, header, secret)) {
return ResponseEntity.badRequest().build();
}
// Your own code, quickly: answer within 10 seconds.
return ResponseEntity.ok().build();
}
}app.MapPost("/webhooks/payments", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var valid = WebhookSignature.Verify(
buffer.ToArray(),
request.Headers["X-AdProMaxPay-Signature"].ToString(),
Environment.GetEnvironmentVariable("APX_WEBHOOK_SECRET")!);
if (!valid)
return Results.BadRequest();
// Your own code, quickly: answer within 10 seconds.
return Results.Ok();
});// axum: `Bytes` is the body exactly as it was received.
async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
let header = headers
.get("x-adpromaxpay-signature")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let secret = std::env::var("APX_WEBHOOK_SECRET").unwrap_or_default();
if !verify_signature(&body, header, &secret, unix_now()) {
return StatusCode::BAD_REQUEST;
}
// Your own code, quickly: answer within 10 seconds.
StatusCode::OK
}Quand vous renouvelez le secret
Renouvelez le secret d’un endpoint sur le tableau de bord quand il a pu fuiter, ou par habitude.
Vous choisissez combien de temps l’ancien secret continue de signer : pas du tout, une heure ou un
jour. Pendant ce temps, chaque livraison porte deux valeurs v1, si bien que votre ancienne et
votre nouvelle configuration l’acceptent toutes deux. Installez le nouveau secret avant la fin du
chevauchement.
Tester votre gestionnaire
Signez vous-même un corps et envoyez-le à votre gestionnaire : un 2xx veut dire que votre
vérification accepte une bonne livraison. Changez un caractère du corps et renvoyez-le : votre
gestionnaire doit répondre 400.
SECRET="$APX_WEBHOOK_SECRET"
BODY='{"id":"evt_handler_test","object":"event",
"type":"payment_intent.succeeded","data":{"object":{}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -i http://localhost:4242/webhooks/payments \
-H "Content-Type: application/json" \
-H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
--data-binary "$BODY"