Rastreamento
Webhooks de entrega com assinaturas
Defina um endpoint HTTPS na página Webhooks do console. Quando uma mensagem atinge um status final, enviamos um POST com um evento JSON para esse endpoint.
Eventos
| type | Quando |
|---|---|
| message.delivered | A mensagem foi entregue. |
| message.failed | A mensagem falhou; data.error contém o código. Seu valor foi reembolsado. |
{
"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"
}
}Cabeçalhos e assinatura
Cada chamada possui três cabeçalhos, no formato Standard Webhooks:
| Cabeçalho | Valor |
|---|---|
| webhook-id | O ID do evento (evt_...). O mesmo ID é usado para cada nova tentativa de um evento. |
| webhook-timestamp | Horário Unix em segundos quando esta tentativa foi assinada. |
| webhook-signature | v1, seguido pela assinatura base64 HMAC-SHA256. Várias assinaturas podem ser listadas, separadas por espaços: aceite a chamada quando qualquer uma corresponder. |
Para verificar uma chamada: pegue o segredo do console (ele começa com whsec_), decodifique a parte após whsec_ usando base64url para obter a chave, calcule o HMAC-SHA256 de webhook-id + "." + webhook-timestamp + "." + raw body, codifique-o em base64 e compare-o com a assinatura em tempo constante. Rejeite timestamps com mais de cinco minutos de diferença do seu relógio. Sempre use o corpo bruto: um corpo JSON analisado e reserializado não corresponderá.
// 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))
}Resposta e tentativas
- Responda com qualquer status 2xx em até 10 segundos. Faça o trabalho lento após responder, a partir de uma fila.
- Redirecionamentos não são seguidos: aponte a configuração para o endereço final.
- Uma chamada que expira ou responde com algo diferente de 2xx é tentada novamente após 30 segundos, 2 minutos, 10 minutos, 30 minutos, 1 hora, 2 horas, 4 horas, 8 horas, 8 horas: 10 tentativas no total, cerca de 24 horas a partir da primeira.
- Cada tentativa é listada na página de Webhooks com o status HTTP e o tempo que levou.
Duplicatas e ordem
Um evento pode chegar duas vezes, por exemplo, quando seu endpoint o salvou, mas a resposta foi perdida. Armazene webhook-id e pule IDs que você já processou. Eventos para diferentes mensagens podem chegar em qualquer ordem; leia data.status e data.done_at em vez de confiar na ordem de chegada.
Regras de endpoint
- Apenas HTTPS, em um nome de host público (sem literais de IP, localhost ou redes privadas).
- O segredo pode ser substituído na página de Webhooks a qualquer momento; chamadas assinadas após isso usam o novo segredo.
- Remover o endpoint interrompe novos eventos; mensagens que já foram concluídas mantêm seu status para polling.