Penjejakan
Webhooks penghantaran dengan tandatangan
Tetapkan endpoint HTTPS pada halaman Webhooks konsol. Apabila mesej mencapai status akhir, kami menghantar satu POST dengan peristiwa JSON ke endpoint tersebut.
Peristiwa
| type | Bila |
|---|---|
| message.delivered | Mesej telah dihantar. |
| message.failed | Mesej gagal; data.error mengandungi kod. Harganya telah dikembalikan. |
{
"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 dan tandatangan
Setiap panggilan membawa tiga header, dalam format Standard Webhooks:
| Header | Nilai |
|---|---|
| webhook-id | ID acara (evt_...). ID yang sama digunakan untuk setiap percubaan semula bagi satu acara. |
| webhook-timestamp | Masa Unix dalam saat apabila percubaan ini ditandatangani. |
| webhook-signature | v1, diikuti dengan tandatangan base64 HMAC-SHA256. Beberapa tandatangan mungkin disenaraikan, dipisahkan oleh ruang: terima panggilan apabila mana-mana satu padan. |
Untuk memeriksa panggilan: ambil rahsia dari konsol (yang bermula dengan whsec_), base64url-decode bahagian selepas whsec_ untuk mendapatkan kunci, kira HMAC-SHA256 bagi webhook-id + "." + webhook-timestamp + "." + raw body, base64-encode dan bandingkan dengan tandatangan dalam masa tetap. Tolak cap masa yang lebih daripada lima minit dari jam anda. Sentiasa gunakan badan mentah: badan JSON yang dihurai dan diserial semula tidak akan sepadan.
// 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))
}Jawapan dan percubaan semula
- Jawab dengan status 2xx dalam masa 10 saat. Lakukan kerja perlahan selepas menjawab, dari barisan.
- Pengalihan tidak diikuti: arahkan tetapan ke alamat akhir.
- Panggilan yang tamat masa atau menjawab selain daripada 2xx akan dicuba semula selepas 30 saat, 2 minit, 10 minit, 30 minit, 1 jam, 2 jam, 4 jam, 8 jam, 8 jam: 10 percubaan secara keseluruhan, kira-kira 24 jam dari yang pertama.
- Setiap percubaan disenaraikan pada halaman Webhooks dengan status HTTP dan masa yang diambil.
Duplikat dan susunan
Acara boleh tiba dua kali, contohnya apabila endpoint anda menyimpannya tetapi jawapannya hilang. Simpan webhook-id dan langkau id yang telah anda uruskan. Acara untuk mesej yang berbeza boleh tiba dalam sebarang susunan; baca data.status dan data.done_at daripada bergantung pada susunan ketibaan.
Peraturan endpoint
- HTTPS sahaja, pada nama hos awam (tiada literal IP, localhost atau rangkaian persendirian).
- Rahsia boleh diganti pada halaman Webhooks pada bila-bila masa; panggilan yang ditandatangani selepas itu menggunakan rahsia baru.
- Membuang endpoint menghentikan acara baru; mesej yang telah selesai mengekalkan status mereka untuk pengundian.