Verfolgung
Zustellungs-Webhooks mit Signaturen
Legen Sie einen HTTPS-Endpunkt auf der Webhooks-Seite der Konsole fest. Wenn eine Nachricht einen endgültigen Status erreicht, senden wir einen POST mit einem JSON-Ereignis an diesen Endpunkt.
Ereignisse
| type | Wann |
|---|---|
| message.delivered | Die Nachricht wurde zugestellt. |
| message.failed | Die Nachricht ist fehlgeschlagen; data.error enthält den Code. Der Betrag wurde erstattet. |
{
"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"
}
}Header und Signatur
Jeder Aufruf enthält drei Header im Standard-Webhooks-Format:
| Header | Wert |
|---|---|
| webhook-id | Die Event-ID (evt_...). Dieselbe ID wird für jeden Wiederholungsversuch eines Events verwendet. |
| webhook-timestamp | Unix-Zeit in Sekunden, zu der dieser Versuch signiert wurde. |
| webhook-signature | v1, gefolgt von der base64 HMAC-SHA256-Signatur. Mehrere Signaturen können aufgelistet sein, durch Leerzeichen getrennt: akzeptiere den Aufruf, wenn eine davon übereinstimmt. |
Um einen Aufruf zu überprüfen: Nehmen Sie das Geheimnis aus der Konsole (es beginnt mit whsec_), dekodieren Sie den Teil nach whsec_ mit Base64URL, um den Schlüssel zu erhalten, berechnen Sie HMAC-SHA256 von webhook-id + "." + webhook-timestamp + "." + raw body, kodieren Sie es mit Base64 und vergleichen Sie es in konstanter Zeit mit der Signatur. Lehnen Sie Zeitstempel ab, die mehr als fünf Minuten von Ihrer Uhrzeit entfernt sind. Verwenden Sie immer den Rohkörper: Ein geparster und neu serialisierter JSON-Körper wird nicht übereinstimmen.
// 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))
}Antworten und Wiederholungen
- Antworte mit einem beliebigen 2xx-Status innerhalb von 10 Sekunden. Führe die langsame Arbeit nach der Antwort aus einer Warteschlange aus.
- Weiterleitungen werden nicht verfolgt: Richte die Einstellung auf die endgültige Adresse aus.
- Ein Aufruf, der ein Timeout hat oder etwas anderes als 2xx antwortet, wird nach 30 Sekunden, 2 Minuten, 10 Minuten, 30 Minuten, 1 Stunde, 2 Stunden, 4 Stunden, 8 Stunden, 8 Stunden erneut versucht: insgesamt 10 Versuche, etwa 24 Stunden nach dem ersten.
- Jeder Versuch wird auf der Webhooks-Seite mit dem HTTP-Status und der benötigten Zeit aufgelistet.
Duplikate und Reihenfolge
Ein Event kann zweimal ankommen, zum Beispiel wenn Ihr Endpunkt es gespeichert hat, die Antwort aber verloren ging. Speichern Sie webhook-id und überspringen Sie IDs, die Sie bereits verarbeitet haben. Events für verschiedene Nachrichten können in beliebiger Reihenfolge eintreffen; lesen Sie data.status und data.done_at, anstatt sich auf die Ankunftsreihenfolge zu verlassen.
Endpunkt-Regeln
- Nur HTTPS, auf einem öffentlichen Hostnamen (keine IP-Literale, localhost oder private Netzwerke).
- Das Secret kann jederzeit auf der Webhooks-Seite ersetzt werden; Aufrufe, die danach signiert werden, verwenden das neue Secret.
- Das Entfernen des Endpunkts stoppt neue Events; Nachrichten, die bereits abgeschlossen sind, behalten ihren Status für das Polling.