Pelacakan
Webhooks pengiriman dengan tanda tangan
Atur endpoint HTTPS di halaman Webhooks pada konsol. Ketika pesan mencapai status final, kami mengirim satu POST dengan event JSON ke endpoint tersebut.
Event
| type | Ketika |
|---|---|
| message.delivered | Pesan telah terkirim. |
| message.failed | Pesan gagal; data.error berisi kode. 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 tanda tangan
Setiap panggilan membawa tiga header, dalam format Standard Webhooks:
| Header | Nilai |
|---|---|
| webhook-id | ID event (evt_...). ID yang sama digunakan untuk setiap percobaan ulang dari satu event. |
| webhook-timestamp | Waktu Unix dalam detik saat percobaan ini ditandatangani. |
| webhook-signature | v1, diikuti oleh tanda tangan HMAC-SHA256 base64. Beberapa tanda tangan dapat dicantumkan, dipisahkan oleh spasi: terima panggilan ketika salah satu cocok. |
Untuk memeriksa panggilan: ambil rahasia dari konsol (dimulai dengan whsec_), base64url-decode bagian setelah whsec_ untuk mendapatkan kunci, hitung HMAC-SHA256 dari webhook-id + "." + webhook-timestamp + "." + raw body, base64-encode dan bandingkan dengan tanda tangan dalam waktu konstan. Tolak timestamp yang lebih dari lima menit dari jam Anda. Selalu gunakan raw body: JSON body yang diurai dan diserialisasi ulang tidak akan cocok.
// 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))
}Menjawab dan percobaan ulang
- Jawab dengan status 2xx apa pun dalam 10 detik. Lakukan pekerjaan lambat setelah menjawab, dari antrian.
- Pengalihan tidak diikuti: arahkan pengaturan ke alamat akhir.
- Panggilan yang timeout atau merespons selain 2xx akan dicoba lagi setelah 30 detik, 2 menit, 10 menit, 30 menit, 1 jam, 2 jam, 4 jam, 8 jam, 8 jam: total 10 percobaan, sekitar 24 jam dari percobaan pertama.
- Setiap percobaan tercantum di halaman Webhooks dengan status HTTP dan waktu yang dibutuhkan.
Duplikat dan urutan
Sebuah event bisa tiba dua kali, misalnya ketika endpoint Anda menyimpannya tetapi jawabannya hilang. Simpan webhook-id dan lewati id yang sudah Anda tangani. Event untuk pesan berbeda bisa tiba dalam urutan apa pun; baca data.status dan data.done_at daripada mengandalkan urutan kedatangan.
Aturan endpoint
- Hanya HTTPS, pada nama host publik (bukan IP literal, localhost, atau jaringan pribadi).
- Rahasia dapat diganti di halaman Webhooks kapan saja; panggilan yang ditandatangani setelah itu menggunakan rahasia baru.
- Menghapus endpoint menghentikan event baru; pesan yang sudah selesai mempertahankan statusnya untuk polling.