Seguimiento
Webhooks de entrega con firmas
Configura un endpoint HTTPS en la página de Webhooks de la consola. Cuando un mensaje alcanza un estado final, enviamos un POST con un evento JSON a ese endpoint.
Eventos
| type | Cuando |
|---|---|
| message.delivered | El mensaje fue entregado. |
| message.failed | El mensaje falló; data.error tiene el código. Su precio ha sido 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"
}
}Encabezados y firma
Cada llamada lleva tres encabezados, en el formato Standard Webhooks:
| Encabezado | Valor |
|---|---|
| webhook-id | El ID del evento (evt_...). El mismo ID se utiliza para cada reintento de un evento. |
| webhook-timestamp | Tiempo Unix en segundos cuando se firmó este intento. |
| webhook-signature | v1, seguido de la firma HMAC-SHA256 en base64. Pueden aparecer varias firmas, separadas por espacios: acepta la llamada cuando alguna coincida. |
Para verificar una llamada: toma el secreto de la consola (comienza con whsec_), decodifica en base64url la parte después de whsec_ para obtener la clave, calcula HMAC-SHA256 de webhook-id + "." + webhook-timestamp + "." + raw body, codifícalo en base64 y compáralo con la firma en tiempo constante. Rechaza marcas de tiempo que estén a más de cinco minutos de tu reloj. Siempre usa el cuerpo crudo: un cuerpo JSON analizado y re-serializado no coincidirá.
// 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))
}Respuesta y reintentos
- Responde con cualquier estado 2xx dentro de 10 segundos. Haz el trabajo lento después de responder, desde una cola.
- No se siguen redirecciones: apunta la configuración a la dirección final.
- Una llamada que se agota el tiempo o responde con algo distinto a 2xx se intenta nuevamente después de 30 segundos, 2 minutos, 10 minutos, 30 minutos, 1 hora, 2 horas, 4 horas, 8 horas, 8 horas: 10 intentos en total, aproximadamente 24 horas desde el primero.
- Cada intento se enumera en la página de Webhooks con el estado HTTP y el tiempo que tomó.
Duplicados y orden
Un evento puede llegar dos veces, por ejemplo, cuando tu endpoint lo guardó pero la respuesta se perdió. Almacena webhook-id y omite los IDs que ya hayas manejado. Los eventos para diferentes mensajes pueden llegar en cualquier orden; lee data.status y data.done_at en lugar de confiar en el orden de llegada.
Reglas del endpoint
- Solo HTTPS, en un nombre de host público (sin literales de IP, localhost o redes privadas).
- El secreto puede ser reemplazado en la página de Webhooks en cualquier momento; las llamadas firmadas después de eso usan el nuevo secreto.
- Eliminar el endpoint detiene nuevos eventos; los mensajes que ya terminaron mantienen su estado para ser consultados.