Відстеження
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 | ID події (evt_...). Цей самий ID використовується для кожної спроби повторного виконання однієї події. |
| webhook-timestamp | Час Unix у секундах, коли ця спроба була підписана. |
| webhook-signature | v1, за яким йде підпис base64 HMAC-SHA256. Може бути перераховано кілька підписів, розділених пробілами: приймайте виклик, якщо збігається будь-який з них. |
Для перевірки виклику: візьміть секрет із консолі (він починається з whsec_), декодуйте частину після whsec_ за допомогою base64url, щоб отримати ключ, обчисліть HMAC-SHA256 для webhook-id + "." + webhook-timestamp + "." + raw body, закодуйте його в base64 та порівняйте з підписом за постійний час. Відхиляйте часові мітки, які відрізняються більш ніж на п'ять хвилин від вашого часу. Завжди використовуйте raw body: розібране та повторно серіалізоване JSON-тіло не збігатиметься.
// 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 та часом, який вона зайняла.
Дублікати та порядок
Подія може надійти двічі, наприклад, коли ваш кінцевий пункт зберіг її, але відповідь була втрачена. Зберігайте webhook-id і пропускайте ідентифікатори, які ви вже обробили. Події для різних повідомлень можуть надходити в будь-якому порядку; читайте data.status і data.done_at, а не покладайтеся на порядок надходження.
Правила endpoint
- Лише HTTPS, на публічному імені хоста (без літералів IP, localhost або приватних мереж).
- Секрет можна замінити на сторінці Webhooks у будь-який час; виклики, підписані після цього, використовують новий секрет.
- Видалення кінцевого пункту зупиняє нові події; повідомлення, які вже завершилися, зберігають свій статус для опитування.