ติดตาม
Webhooks การส่งพร้อมลายเซ็น
ตั้งค่า HTTPS endpoint ในหน้า Webhooks ของคอนโซล เมื่อข้อความถึงสถานะสุดท้าย เราจะส่ง POST หนึ่งครั้งพร้อมเหตุการณ์ JSON ไปยัง endpoint นั้น
เหตุการณ์
| type | เมื่อ |
|---|---|
| message.delivered | ข้อความถูกส่งแล้ว |
| message.failed | ข้อความล้มเหลว; data.error มีรหัส จำนวนเงินถูกคืนแล้ว |
{
"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"
}
}ส่วนหัวและลายเซ็น
ทุกการเรียกมีสามส่วนหัว ในรูปแบบ Standard Webhooks:
| ส่วนหัว | ค่า |
|---|---|
| webhook-id | รหัสเหตุการณ์ (evt_...). รหัสเดียวกันนี้จะถูกใช้สำหรับการลองใหม่ทุกครั้งของเหตุการณ์หนึ่ง |
| webhook-timestamp | Unix time ในหน่วยวินาทีเมื่อการลองครั้งนี้ถูกเซ็น |
| webhook-signature | v1, ตามด้วยลายเซ็น base64 HMAC-SHA256 อาจมีลายเซ็นหลายรายการแยกด้วยช่องว่าง: ยอมรับการเรียกเมื่อใดก็ตามที่ตรงกัน |
เพื่อตรวจสอบการเรียก: นำคีย์ลับจากคอนโซล (เริ่มต้นด้วย whsec_), base64url-decode ส่วนที่อยู่หลัง whsec_ เพื่อรับคีย์, คำนวณ HMAC-SHA256 ของ webhook-id + "." + webhook-timestamp + "." + raw body, base64-encode และเปรียบเทียบกับลายเซ็นในเวลาคงที่ ปฏิเสธ timestamp ที่ห่างจากนาฬิกาของคุณเกินห้านาที ใช้ raw body เสมอ: JSON body ที่ถูกแยกและจัดเรียงใหม่จะไม่ตรงกัน
// 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))
}การตอบสนองและการลองใหม่
- ตอบกลับด้วยสถานะ 2xx ใดๆ ภายใน 10 วินาที ทำงานที่ใช้เวลานานหลังจากตอบกลับ จากคิว
- ไม่ทำตามการเปลี่ยนเส้นทาง: ชี้การตั้งค่าไปที่ที่อยู่สุดท้าย
- การเรียกที่หมดเวลาหรือตอบกลับด้วยอะไรก็ตามที่ไม่ใช่ 2xx จะถูกลองอีกครั้งหลังจาก 30 วินาที, 2 นาที, 10 นาที, 30 นาที, 1 ชั่วโมง, 2 ชั่วโมง, 4 ชั่วโมง, 8 ชั่วโมง, 8 ชั่วโมง: ทั้งหมด 10 ครั้ง ประมาณ 24 ชั่วโมงจากครั้งแรก
- ทุกครั้งที่ลองจะถูกแสดงในหน้า Webhooks พร้อมกับสถานะ HTTP และเวลาที่ใช้
รายการซ้ำและลำดับ
เหตุการณ์อาจมาถึงสองครั้ง เช่น เมื่อ endpoint ของคุณบันทึกไว้ แต่คำตอบหายไป จัดเก็บ webhook-id และข้าม id ที่คุณจัดการไปแล้ว เหตุการณ์สำหรับข้อความต่าง ๆ อาจมาถึงในลำดับใดก็ได้ อ่าน data.status และ data.done_at แทนที่จะพึ่งพาลำดับการมาถึง
กฎของ endpoint
- HTTPS เท่านั้น บนชื่อโฮสต์สาธารณะ (ไม่ใช่ IP literal, localhost หรือเครือข่ายส่วนตัว)
- คีย์ลับสามารถถูกแทนที่ในหน้า Webhooks ได้ตลอดเวลา การเรียกที่เซ็นหลังจากนั้นจะใช้คีย์ลับใหม่
- การลบ endpoint จะหยุดเหตุการณ์ใหม่ ข้อความที่เสร็จสิ้นแล้วจะคงสถานะไว้สำหรับการ polling