Suivi
Webhooks de livraison avec signatures
Définissez un endpoint HTTPS dans la page Webhooks de la console. Quand un message atteint un statut final, nous envoyons une requête POST avec un événement JSON à cet endpoint.
Événements
| type | Quand |
|---|---|
| message.delivered | Le message a été livré. |
| message.failed | Le message a échoué ; data.error contient le code. Son coût a été remboursé. |
{
"id": "evt_8F3K2Q9TZ1MXD",
"type": "message.delivered",
"created_at": "2026-11-07T09:30:17.902Z",
"data": {
"id": "msg_8F3K2Q9TZ1MX",
"object": "message",
"batch_id": "bat_4M7Q1X0PK2RS",
"to": "+14155550123",
"country": "US",
"text": "Your Northwind code is 482913. It expires in 10 minutes.",
"segments": 1,
"price": "0.00507",
"currency": "USD",
"status": "delivered",
"error": null,
"reference": "login-7781",
"created_at": "2026-11-07T09:30:12.418Z",
"sent_at": "2026-11-07T09:30:13.020Z",
"done_at": "2026-11-07T09:30:17.902Z"
}
}En-têtes et signature
Chaque appel comporte trois en-têtes, au format Standard Webhooks :
| En-tête | Valeur |
|---|---|
| webhook-id | L'identifiant de l'événement (evt_...). Le même identifiant est utilisé pour chaque nouvelle tentative d'un même événement. |
| webhook-timestamp | Horodatage Unix en secondes lorsque cette tentative a été signée. |
| webhook-signature | v1, suivi de la signature HMAC-SHA256 encodée en base64. Plusieurs signatures peuvent être listées, séparées par des espaces : acceptez l'appel si l'une d'elles correspond. |
Pour vérifier un appel : prenez le secret depuis la console (il commence par whsec_), décodez en base64url la partie après whsec_ pour obtenir la clé, calculez HMAC-SHA256 de webhook-id + "." + webhook-timestamp + "." + raw body, encodez-le en base64 et comparez-le avec la signature en temps constant. Rejetez les horodatages éloignés de plus de cinq minutes de votre horloge. Utilisez toujours le corps brut : un corps JSON parsé et resérialisé ne correspondra pas.
// Express: keep the raw body, check the signature, then answer 2xx quickly
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.BULKSMSAPI_WEBHOOK_SECRET; // whsec_...
app.post("/hooks/sms", express.raw({ type: "application/json" }), (req, res) => {
const id = req.header("webhook-id");
const ts = req.header("webhook-timestamp");
const key = Buffer.from(secret.slice("whsec_".length), "base64url");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${req.body}`).digest("base64");
const sent = (req.header("webhook-signature") || "").split(" ").map((s) => s.replace(/^v1,/, ""));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || !sent.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
console.log(event.type, event.data.id, event.data.status);
res.status(204).end();
});
app.listen(3000);# Flask: check the signature on the raw body before parsing it
import base64, hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["BULKSMSAPI_WEBHOOK_SECRET"] # whsec_...
@app.post("/hooks/sms")
def sms_hook():
msg_id = request.headers.get("webhook-id", "")
ts = request.headers.get("webhook-timestamp", "0")
raw = request.get_data()
key = base64.urlsafe_b64decode(SECRET[len("whsec_"):] + "==")
signed = msg_id.encode() + b"." + ts.encode() + b"." + raw
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
sent = [s.split(",", 1)[-1] for s in request.headers.get("webhook-signature", "").split()]
if abs(time.time() - int(ts)) > 300 or not any(hmac.compare_digest(s, expected) for s in sent):
abort(401)
event = json.loads(raw)
print(event["type"], event["data"]["id"], event["data"]["status"])
return "", 204<?php
// Plain PHP endpoint, for example /hooks/sms.php
$secret = getenv("BULKSMSAPI_WEBHOOK_SECRET"); // whsec_...
$raw = file_get_contents("php://input");
$id = $_SERVER["HTTP_WEBHOOK_ID"] ?? "";
$ts = $_SERVER["HTTP_WEBHOOK_TIMESTAMP"] ?? "0";
$key = base64_decode(strtr(substr($secret, 6), "-_", "+/"));
$expected = base64_encode(hash_hmac("sha256", "$id.$ts.$raw", $key, true));
$ok = false;
foreach (explode(" ", $_SERVER["HTTP_WEBHOOK_SIGNATURE"] ?? "") as $part) {
$sig = substr($part, strpos($part, ",") + 1);
if (hash_equals($expected, $sig)) $ok = true;
}
if (!$ok || abs(time() - (int)$ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
error_log($event["type"] . " " . $event["data"]["id"]);
http_response_code(204);package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func smsHook(w http.ResponseWriter, r *http.Request) {
secret := os.Getenv("BULKSMSAPI_WEBHOOK_SECRET") // whsec_...
key, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
raw, _ := io.ReadAll(r.Body)
id, ts := r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp")
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "." + string(raw)))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
sec, _ := strconv.ParseInt(ts, 10, 64)
ok := false
for _, part := range strings.Fields(r.Header.Get("webhook-signature")) {
sig := part[strings.Index(part, ",")+1:]
if hmac.Equal([]byte(sig), []byte(expected)) {
ok = true
}
}
if !ok || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
Type string
Data map[string]any
}
json.Unmarshal(raw, &event)
log.Println(event.Type, event.Data["id"], event.Data["status"])
w.WriteHeader(http.StatusNoContent)
}
func main() {
http.HandleFunc("/hooks/sms", smsHook)
log.Fatal(http.ListenAndServe(":3000", nil))
}Réponse et nouvelles tentatives
- Répondez avec un statut 2xx dans les 10 secondes. Effectuez le travail lent après avoir répondu, depuis une file d'attente.
- Les redirections ne sont pas suivies : pointez le paramètre vers l'adresse finale.
- Un appel qui expire ou répond avec autre chose qu'un statut 2xx est réessayé après 30 secondes, 2 minutes, 10 minutes, 30 minutes, 1 heure, 2 heures, 4 heures, 8 heures, 8 heures : 10 tentatives au total, sur environ 24 heures à partir de la première.
- Chaque tentative est listée sur la page Webhooks avec le statut HTTP et le temps qu'elle a pris.
Doublons et ordre
Un événement peut arriver deux fois, par exemple lorsque votre endpoint l'a enregistré mais que la réponse a été perdue. Stockez webhook-id et ignorez les identifiants que vous avez déjà traités. Les événements pour différents messages peuvent arriver dans n'importe quel ordre ; lisez data.status et data.done_at plutôt que de vous fier à l'ordre d'arrivée.
Règles des endpoints
- HTTPS uniquement, sur un nom d'hôte public (pas d'adresses IP littérales, localhost ou réseaux privés).
- Le secret peut être remplacé sur la page Webhooks à tout moment ; les appels signés après cela utilisent le nouveau secret.
- Supprimer l'endpoint arrête les nouveaux événements ; les messages déjà terminés conservent leur statut pour le polling.