Recibir eventos
Un receptor de webhooks hace siempre las mismas cinco cosas. Aquí las tienes, y un receptor completo en siete lenguajes que puedes copiar y probar en tu equipo.
6 min de lectura
Lo que hace todo receptor
- 01Lee el cuerpo crudo
Tal como llega, sin interpretarlo, y hasta 256 KB, que es lo máximo que envía Hablame.
- 02Revisa la hora
X-Hablame-Timestampdebe ser un entero a 300 segundos o menos de tu reloj. - 03Verifica la firma
En tiempo constante, con
X-Hablame-Signaturey, si no coincide, conX-Hablame-Signature-Prev. Cómo se calcula. - 04Descarta lo repetido
Guarda
X-Hablame-Delivery-Idcon un índice único. Si ya existe, responde 2xx sin procesar otra vez. - 05Responde 2xx y procesa aparte
Responde en cuanto el aviso quede guardado; tienes 15 s en total. El trabajo pesado va después, en una cola.
Receptores de ejemplo
Cada ejemplo es un programa completo que escucha en /, lee el secreto de la variable de entorno HABLAME_WEBHOOK_SECRET y el puerto de PORT. Los repetidos se guardan en memoria para que el ejemplo funcione solo; en producción usa tu base de datos. El de PHP ya lo hace con PDO, porque PHP no conserva memoria entre peticiones.
// server.mjs: Node.js 18 o superior, sin dependencias externas.
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
// Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
const SECRETO = process.env.HABLAME_WEBHOOK_SECRET
const PUERTO = Number(process.env.PORT ?? 3000)
const RUTA = '/webhooks/hablame'
const MAX_CUERPO = 256 * 1024
const TOLERANCIA_SEG = 300
if (!SECRETO) throw new Error('Falta la variable HABLAME_WEBHOOK_SECRET')
// X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
// Ejemplo en memoria: se pierde al reiniciar.
const recibidos = new Set()
function firmaValida(recibida, esperada) {
if (typeof recibida !== 'string') return false
const a = Buffer.from(recibida)
const b = Buffer.from(esperada)
return a.length === b.length && timingSafeEqual(a, b)
}
function verificar(cabeceras, cuerpo) {
// La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
const ts = cabeceras['x-hablame-timestamp']
if (typeof ts !== 'string' || !/^[0-9]+$/.test(ts)) return false
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > TOLERANCIA_SEG) return false
// Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
const esperada = 'sha256=' + createHmac('sha256', SECRETO).update(ts + '.').update(cuerpo).digest('hex')
return (
firmaValida(cabeceras['x-hablame-signature'], esperada) ||
firmaValida(cabeceras['x-hablame-signature-prev'], esperada)
)
}
function procesar(entrega, cuerpo) {
// Aquí va tu lógica. En producción, usa una cola durable.
try {
const evento = JSON.parse(cuerpo.toString('utf8'))
console.log('evento procesado', evento.type, evento.id, entrega)
} catch (error) {
console.error(error)
}
}
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== RUTA) {
res.writeHead(404).end()
return
}
// Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
const partes = []
let largo = 0
req.on('data', (parte) => {
largo += parte.length
if (largo <= MAX_CUERPO) partes.push(parte)
})
req.on('end', () => {
if (largo > MAX_CUERPO) {
res.writeHead(413).end()
return
}
const cuerpo = Buffer.concat(partes)
if (!verificar(req.headers, cuerpo)) {
res.writeHead(401).end()
return
}
const entrega = req.headers['x-hablame-delivery-id']
if (typeof entrega !== 'string' || entrega === '') {
res.writeHead(400).end()
return
}
if (recibidos.has(entrega)) {
res.writeHead(200).end()
return
}
recibidos.add(entrega)
// Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
res.writeHead(200).end()
setImmediate(() => procesar(entrega, cuerpo))
})
}).listen(PUERTO, () => console.log('Esperando avisos de Hablame en', 'http://localhost:' + PUERTO + RUTA))
// server.ts: TypeScript sobre Node.js 18 o superior, sin dependencias externas.
import { createServer, type IncomingHttpHeaders } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
// Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
const SECRETO: string = process.env.HABLAME_WEBHOOK_SECRET ?? ''
const PUERTO = Number(process.env.PORT ?? 3000)
const RUTA = '/webhooks/hablame'
const MAX_CUERPO = 256 * 1024
const TOLERANCIA_SEG = 300
if (SECRETO === '') throw new Error('Falta la variable HABLAME_WEBHOOK_SECRET')
// X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
// Ejemplo en memoria: se pierde al reiniciar.
const recibidos = new Set<string>()
function cabecera(cabeceras: IncomingHttpHeaders, nombre: string): string | undefined {
const valor = cabeceras[nombre]
return Array.isArray(valor) ? valor[0] : valor
}
function firmaValida(recibida: string | undefined, esperada: string): boolean {
if (recibida === undefined) return false
const a = Buffer.from(recibida)
const b = Buffer.from(esperada)
return a.length === b.length && timingSafeEqual(a, b)
}
export function verificar(cabeceras: IncomingHttpHeaders, cuerpo: Buffer): boolean {
// La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
const ts = cabecera(cabeceras, 'x-hablame-timestamp')
if (ts === undefined || !/^[0-9]+$/.test(ts)) return false
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > TOLERANCIA_SEG) return false
// Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
const esperada = 'sha256=' + createHmac('sha256', SECRETO).update(ts + '.').update(cuerpo).digest('hex')
return (
firmaValida(cabecera(cabeceras, 'x-hablame-signature'), esperada) ||
firmaValida(cabecera(cabeceras, 'x-hablame-signature-prev'), esperada)
)
}
interface EventoHablame {
id: string
type: string
data: Record<string, unknown>
}
function procesar(entrega: string, cuerpo: Buffer): void {
// Aquí va tu lógica. En producción, usa una cola durable.
try {
const evento = JSON.parse(cuerpo.toString('utf8')) as EventoHablame
console.log('evento procesado', evento.type, evento.id, entrega)
} catch (error) {
console.error(error)
}
}
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== RUTA) {
res.writeHead(404).end()
return
}
// Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
const partes: Buffer[] = []
let largo = 0
req.on('data', (parte: Buffer) => {
largo += parte.length
if (largo <= MAX_CUERPO) partes.push(parte)
})
req.on('end', () => {
if (largo > MAX_CUERPO) {
res.writeHead(413).end()
return
}
const cuerpo = Buffer.concat(partes)
if (!verificar(req.headers, cuerpo)) {
res.writeHead(401).end()
return
}
const entrega = cabecera(req.headers, 'x-hablame-delivery-id')
if (entrega === undefined || entrega === '') {
res.writeHead(400).end()
return
}
if (recibidos.has(entrega)) {
res.writeHead(200).end()
return
}
recibidos.add(entrega)
// Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
res.writeHead(200).end()
setImmediate(() => procesar(entrega, cuerpo))
})
}).listen(PUERTO, () => console.log('Esperando avisos de Hablame en', 'http://localhost:' + PUERTO + RUTA))
# servidor.py: Python 3.8 o superior, sin dependencias externas.
import hashlib
import hmac
import json
import os
import queue
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
SECRETO = os.environ.get("HABLAME_WEBHOOK_SECRET", "").encode()
PUERTO = int(os.environ.get("PORT", "3000"))
RUTA = "/webhooks/hablame"
MAX_CUERPO = 256 * 1024
TOLERANCIA_SEG = 300
if not SECRETO:
raise SystemExit("Falta la variable HABLAME_WEBHOOK_SECRET")
# X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
# Ejemplo en memoria: se pierde al reiniciar.
recibidos = set()
recibidos_candado = threading.Lock()
trabajos = queue.Queue()
def firma_valida(recibida, esperada):
return recibida is not None and hmac.compare_digest(recibida.encode(), esperada.encode())
def verificar(cabeceras, cuerpo):
# La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
ts = cabeceras.get("X-Hablame-Timestamp", "")
if not (ts.isascii() and ts.isdigit()):
return False
if abs(int(time.time()) - int(ts)) > TOLERANCIA_SEG:
return False
# Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
esperada = "sha256=" + hmac.new(SECRETO, ts.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
return firma_valida(cabeceras.get("X-Hablame-Signature"), esperada) or firma_valida(
cabeceras.get("X-Hablame-Signature-Prev"), esperada
)
class Receptor(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != RUTA:
return self.responder(404)
# Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
try:
largo = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self.responder(400)
if largo > MAX_CUERPO:
return self.responder(413)
cuerpo = self.rfile.read(largo)
if not verificar(self.headers, cuerpo):
return self.responder(401)
entrega = self.headers.get("X-Hablame-Delivery-Id", "")
if not entrega:
return self.responder(400)
with recibidos_candado:
repetido = entrega in recibidos
recibidos.add(entrega)
if repetido:
return self.responder(200)
# Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
trabajos.put((entrega, cuerpo))
return self.responder(200)
def responder(self, estado):
self.send_response(estado)
self.send_header("Content-Length", "0")
self.end_headers()
def procesar():
while True:
entrega, cuerpo = trabajos.get()
# Aquí va tu lógica. En producción, usa una cola durable.
try:
evento = json.loads(cuerpo)
print("evento procesado", evento["type"], evento["id"], entrega, flush=True)
except (ValueError, KeyError) as error:
print(error, flush=True)
threading.Thread(target=procesar, daemon=True).start()
print("Esperando avisos de Hablame en", "http://localhost:%d%s" % (PUERTO, RUTA), flush=True)
ThreadingHTTPServer(("", PUERTO), Receptor).serve_forever()
<?php
// webhook.php: PHP 8.1 o superior con PHP-FPM y PDO (MySQL o PostgreSQL), sin dependencias externas.
const MAX_CUERPO = 256 * 1024;
const TOLERANCIA_SEG = 300;
function responder(int $estado): never
{
http_response_code($estado);
exit;
}
// Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
$secreto = getenv('HABLAME_WEBHOOK_SECRET');
if ($secreto === false || $secreto === '') {
responder(500);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
responder(405);
}
// Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_CUERPO) {
responder(413);
}
$cuerpo = file_get_contents('php://input', false, null, 0, MAX_CUERPO + 1);
if ($cuerpo === false || strlen($cuerpo) > MAX_CUERPO) {
responder(413);
}
// La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
$ts = $_SERVER['HTTP_X_HABLAME_TIMESTAMP'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > TOLERANCIA_SEG) {
responder(401);
}
// Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
$esperada = 'sha256=' . hash_hmac('sha256', $ts . '.' . $cuerpo, $secreto);
$firma = $_SERVER['HTTP_X_HABLAME_SIGNATURE'] ?? '';
$anterior = $_SERVER['HTTP_X_HABLAME_SIGNATURE_PREV'] ?? '';
if (!hash_equals($esperada, $firma) && !($anterior !== '' && hash_equals($esperada, $anterior))) {
responder(401);
}
$entrega = $_SERVER['HTTP_X_HABLAME_DELIVERY_ID'] ?? '';
if ($entrega === '') {
responder(400);
}
// X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
// Tabla con índice único en delivery_id; crea la de tu motor:
// MySQL: CREATE TABLE webhook_recibido (delivery_id VARCHAR(40) PRIMARY KEY, cuerpo MEDIUMTEXT NOT NULL, recibido_en DATETIME NOT NULL, procesado_en DATETIME NULL)
// PostgreSQL: CREATE TABLE webhook_recibido (delivery_id VARCHAR(40) PRIMARY KEY, cuerpo TEXT NOT NULL, recibido_en TIMESTAMP NOT NULL, procesado_en TIMESTAMP NULL)
$db = new PDO(getenv('DB_DSN'), getenv('DB_USER') ?: null, getenv('DB_PASSWORD') ?: null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
try {
$db->prepare('INSERT INTO webhook_recibido (delivery_id, cuerpo, recibido_en) VALUES (?, ?, NOW())')
->execute([$entrega, $cuerpo]);
} catch (PDOException $e) {
// Ya estaba guardado (SQLSTATE 23000 en MySQL, 23505 en PostgreSQL): es un reintento, responde 2xx.
if (in_array($e->getCode(), ['23000', '23505'], true)) {
responder(200);
}
// Si no puedes guardarlo, no respondas 2xx: Hablame lo reintenta.
responder(503);
}
// Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Aquí va tu lógica. En producción, usa una cola durable.
$evento = json_decode($cuerpo, true);
error_log('evento procesado ' . $evento['type'] . ' ' . $evento['id'] . ' ' . $entrega);
$db->prepare('UPDATE webhook_recibido SET procesado_en = NOW() WHERE delivery_id = ?')->execute([$entrega]);
// main.go: Go 1.19 o superior, sin dependencias externas.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
)
const (
ruta = "/webhooks/hablame"
maxCuerpo = 256 << 10
toleranciaSeg = 300
)
// Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
var secreto = []byte(os.Getenv("HABLAME_WEBHOOK_SECRET"))
// X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
// Ejemplo en memoria: se pierde al reiniciar.
var recibidos sync.Map
type trabajo struct {
entrega string
cuerpo []byte
}
var trabajos = make(chan trabajo, 1000)
func firmaValida(recibida, esperada string) bool {
return recibida != "" && hmac.Equal([]byte(recibida), []byte(esperada))
}
func verificar(h http.Header, cuerpo []byte, ahora time.Time) bool {
// La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
ts := h.Get("X-Hablame-Timestamp")
n, err := strconv.ParseUint(ts, 10, 63)
if err != nil {
return false
}
if d := ahora.Unix() - int64(n); d > toleranciaSeg || d < -toleranciaSeg {
return false
}
// Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
mac := hmac.New(sha256.New, secreto)
mac.Write([]byte(ts + "."))
mac.Write(cuerpo)
esperada := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return firmaValida(h.Get("X-Hablame-Signature"), esperada) ||
firmaValida(h.Get("X-Hablame-Signature-Prev"), esperada)
}
func recibir(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
cuerpo, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCuerpo))
if err != nil {
var demasiado *http.MaxBytesError
if errors.As(err, &demasiado) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusBadRequest)
return
}
if !verificar(r.Header, cuerpo, time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
entrega := r.Header.Get("X-Hablame-Delivery-Id")
if entrega == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if _, repetido := recibidos.LoadOrStore(entrega, true); repetido {
w.WriteHeader(http.StatusOK)
return
}
// Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
select {
case trabajos <- trabajo{entrega: entrega, cuerpo: cuerpo}:
w.WriteHeader(http.StatusOK)
default:
// Si no puedes guardarlo, no respondas 2xx: Hablame lo reintenta.
recibidos.Delete(entrega)
w.WriteHeader(http.StatusServiceUnavailable)
}
}
func procesar() {
for t := range trabajos {
// Aquí va tu lógica. En producción, usa una cola durable.
var evento struct {
ID string `json:"id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(t.cuerpo, &evento); err != nil {
log.Print(err)
continue
}
log.Println("evento procesado", evento.Type, evento.ID, t.entrega)
}
}
func main() {
if len(secreto) == 0 {
log.Fatal("Falta la variable HABLAME_WEBHOOK_SECRET")
}
puerto := os.Getenv("PORT")
if puerto == "" {
puerto = "3000"
}
go procesar()
http.HandleFunc(ruta, recibir)
log.Println("Esperando avisos de Hablame en", "http://localhost:"+puerto+ruta)
log.Fatal(http.ListenAndServe(":"+puerto, nil))
}
// Receptor.java: Java 17 o superior, sin dependencias externas.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Receptor {
static final String RUTA = "/webhooks/hablame";
static final int MAX_CUERPO = 256 * 1024;
static final long TOLERANCIA_SEG = 300;
// Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
static final byte[] SECRETO = System.getenv().getOrDefault("HABLAME_WEBHOOK_SECRET", "").getBytes(StandardCharsets.UTF_8);
// X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
// Ejemplo en memoria: se pierde al reiniciar.
static final Set<String> RECIBIDOS = ConcurrentHashMap.newKeySet();
static final ExecutorService TRABAJOS = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws IOException {
if (SECRETO.length == 0) {
throw new IllegalStateException("Falta la variable HABLAME_WEBHOOK_SECRET");
}
int puerto = Integer.parseInt(System.getenv().getOrDefault("PORT", "3000"));
HttpServer servidor = HttpServer.create(new InetSocketAddress(puerto), 0);
servidor.createContext(RUTA, Receptor::recibir);
servidor.start();
System.out.println("Esperando avisos de Hablame en http://localhost:" + puerto + RUTA);
}
static void recibir(HttpExchange x) throws IOException {
try {
if (!"POST".equals(x.getRequestMethod())) {
responder(x, 405);
return;
}
// Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
final byte[] cuerpo = leerCuerpo(x);
if (cuerpo.length > MAX_CUERPO) {
responder(x, 413);
return;
}
if (!verificar(x, cuerpo)) {
responder(x, 401);
return;
}
final String entrega = x.getRequestHeaders().getFirst("X-Hablame-Delivery-Id");
if (entrega == null || entrega.isEmpty()) {
responder(x, 400);
return;
}
if (!RECIBIDOS.add(entrega)) {
responder(x, 200);
return;
}
// Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
responder(x, 200);
TRABAJOS.submit(() -> procesar(entrega, cuerpo));
} finally {
x.close();
}
}
static byte[] leerCuerpo(HttpExchange x) throws IOException {
try (InputStream entrada = x.getRequestBody()) {
return entrada.readNBytes(MAX_CUERPO + 1);
}
}
static boolean verificar(HttpExchange x, byte[] cuerpo) {
// La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
String ts = x.getRequestHeaders().getFirst("X-Hablame-Timestamp");
if (ts == null || !ts.matches("[0-9]{1,18}")) {
return false;
}
long ahora = System.currentTimeMillis() / 1000;
if (Math.abs(ahora - Long.parseLong(ts)) > TOLERANCIA_SEG) {
return false;
}
// Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
byte[] esperada = ("sha256=" + hmacHex(ts, cuerpo)).getBytes(StandardCharsets.UTF_8);
return iguales(x.getRequestHeaders().getFirst("X-Hablame-Signature"), esperada)
|| iguales(x.getRequestHeaders().getFirst("X-Hablame-Signature-Prev"), esperada);
}
static boolean iguales(String recibida, byte[] esperada) {
return recibida != null && MessageDigest.isEqual(recibida.getBytes(StandardCharsets.UTF_8), esperada);
}
static String hmacHex(String ts, byte[] cuerpo) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRETO, "HmacSHA256"));
mac.update((ts + ".").getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(mac.doFinal(cuerpo));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
static void responder(HttpExchange x, int estado) throws IOException {
x.sendResponseHeaders(estado, -1);
}
static void procesar(String entrega, byte[] cuerpo) {
// Aquí va tu lógica. En producción, usa una cola durable.
System.out.println("evento procesado " + entrega + " " + new String(cuerpo, StandardCharsets.UTF_8));
}
}
// Program.cs: .NET 6 o superior (ASP.NET Core), sin dependencias externas.
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
const string Ruta = "/webhooks/hablame";
const int MaxCuerpo = 256 * 1024;
const long ToleranciaSeg = 300;
// Tu secreto de firma (whsec_...). Léelo de una variable de entorno o de tu gestor de secretos; nunca lo escribas en el código.
var secreto = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("HABLAME_WEBHOOK_SECRET")
?? throw new InvalidOperationException("Falta la variable HABLAME_WEBHOOK_SECRET"));
// X-Hablame-Delivery-Id no cambia entre reintentos: con él descartas lo que ya recibiste. En producción, guárdalo con un índice único en tu base, junto con el evento.
// Ejemplo en memoria: se pierde al reiniciar.
var recibidos = new ConcurrentDictionary<string, bool>();
var trabajos = Channel.CreateBounded<(string Entrega, byte[] Cuerpo)>(1000);
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = MaxCuerpo);
var app = builder.Build();
app.MapPost(Ruta, async (HttpRequest req) =>
{
// Lee el cuerpo CRUDO, sin interpretarlo: la firma se calcula sobre esos bytes exactos. Hablame envía hasta 256 KB.
byte[] cuerpo;
try
{
using var memoria = new MemoryStream();
await req.Body.CopyToAsync(memoria);
cuerpo = memoria.ToArray();
}
catch (BadHttpRequestException)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
if (!Verificar(req.Headers, cuerpo, secreto)) return Results.Unauthorized();
var entrega = req.Headers["X-Hablame-Delivery-Id"].ToString();
if (entrega.Length == 0) return Results.BadRequest();
if (!recibidos.TryAdd(entrega, true)) return Results.Ok();
// Responde 2xx de inmediato y procesa aparte. Tienes 15 s en total, y cualquier respuesta distinta de 2xx se reintenta.
if (!trabajos.Writer.TryWrite((entrega, cuerpo)))
{
// Si no puedes guardarlo, no respondas 2xx: Hablame lo reintenta.
recibidos.TryRemove(entrega, out _);
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return Results.Ok();
});
_ = Task.Run(async () =>
{
await foreach (var (entrega, cuerpo) in trabajos.Reader.ReadAllAsync())
{
// Aquí va tu lógica. En producción, usa una cola durable.
using var evento = JsonDocument.Parse(cuerpo);
var tipo = evento.RootElement.GetProperty("type").GetString();
var id = evento.RootElement.GetProperty("id").GetString();
Console.WriteLine($"evento procesado {tipo} {id} {entrega}");
}
});
app.Run();
static bool Verificar(IHeaderDictionary cabeceras, byte[] cuerpo, byte[] secreto)
{
// La hora de la firma debe ser un entero a 300 s o menos de tu reloj; así se rechaza un aviso viejo repetido.
var ts = cabeceras["X-Hablame-Timestamp"].ToString();
if (!long.TryParse(ts, NumberStyles.None, CultureInfo.InvariantCulture, out var segundos)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - segundos) > ToleranciaSeg) return false;
// Compara en tiempo constante con X-Hablame-Signature y, si no coincide, con X-Hablame-Signature-Prev (llega solo durante las 24 h siguientes a una rotación).
var mensaje = Encoding.UTF8.GetBytes(ts + ".").Concat(cuerpo).ToArray();
var hex = Convert.ToHexString(HMACSHA256.HashData(secreto, mensaje)).ToLowerInvariant();
var esperada = Encoding.UTF8.GetBytes("sha256=" + hex);
return Iguales(cabeceras["X-Hablame-Signature"].ToString(), esperada)
|| Iguales(cabeceras["X-Hablame-Signature-Prev"].ToString(), esperada);
}
static bool Iguales(string recibida, byte[] esperada) =>
recibida.Length > 0 && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(recibida), esperada);
Pruébalo en tu equipo
Con el receptor corriendo, este script firma un aviso igual que Hablame y lo envía. Necesitas openssl y curl.
# 1. Arranca tu receptor en el puerto 3000 con este secreto de ejemplo en su entorno.
export HABLAME_WEBHOOK_SECRET='whsec_TU_SECRETO_DE_FIRMA'
# 2. Firma un aviso como lo hace Hablame y envíalo. La respuesta debe ser 200; si lo envías otra vez, también, pero no se procesa de nuevo.
CUERPO='{"id":"0192f0c47b1e7a3d9c553e2a1b4c5d6e","type":"sms.mo.87654","data":{"shortCode":"87654","from":"573001234567","text":"Hola, quiero más información","receivedAt":"2026-10-24T15:04:05Z","messageId":"0192f0c4-7b1e-7a3d-9c55-3e2a1b4c5d6e"}}'
TS=$(date +%s)
FIRMA="sha256=$(printf '%s' "$TS.$CUERPO" | openssl dgst -sha256 -hmac "$HABLAME_WEBHOOK_SECRET" | sed 's/^.* //')"
curl -i -X POST http://localhost:3000/webhooks/hablame \
-H 'Content-Type: application/json' \
-H "X-Hablame-Timestamp: $TS" \
-H "X-Hablame-Signature: $FIRMA" \
-H 'X-Hablame-Delivery-Id: wdl_01K8F3Q2Z9X7V4N6B5C2D1E0FA' \
--data-binary "$CUERPO"
En producción
X-Hablame-Delivery-Id en una tabla con índice único, y responde.503).X-Hablame-Delivery-Id en tus logs: con él soporte encuentra la entrega.