Receive events
A webhook receiver always does the same five things. Here they are, plus a complete receiver in seven languages that you can copy and try on your machine.
6 min read
What every receiver does
- 01Read the raw body
As it arrives, without parsing it, up to 256 KB, the most Hablame sends.
- 02Check the time
X-Hablame-Timestampmust be an integer within 300 seconds of your clock. - 03Verify the signature
In constant time, against
X-Hablame-Signatureand, if it does not match,X-Hablame-Signature-Prev. How it is computed. - 04Drop duplicates
Store
X-Hablame-Delivery-Idwith a unique index. If it already exists, respond 2xx without processing again. - 05Respond 2xx and process separately
Respond as soon as the notification is stored; you have 15 s in total. The heavy work comes after, in a queue.
Example receivers
Each example is a complete program that listens on /, reads the secret from the HABLAME_WEBHOOK_SECRET environment variable and the port from PORT. Duplicates are kept in memory so the example runs on its own; in production, use your database. The PHP one already does it with PDO, because PHP keeps no memory between requests.
// server.mjs: Node.js 18 or later, no external dependencies.
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
// Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
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('Missing the HABLAME_WEBHOOK_SECRET variable')
// X-Hablame-Delivery-Id does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
// In-memory example: it is lost on restart.
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) {
// The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
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
// Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
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) {
// Your logic goes here. In production, use a durable queue.
try {
const evento = JSON.parse(cuerpo.toString('utf8'))
console.log('event processed', 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
}
// Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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)
// Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
res.writeHead(200).end()
setImmediate(() => procesar(entrega, cuerpo))
})
}).listen(PUERTO, () => console.log('Waiting for Hablame notifications at', 'http://localhost:' + PUERTO + RUTA))
// server.ts: TypeScript on Node.js 18 or later, no external dependencies.
import { createServer, type IncomingHttpHeaders } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
// Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
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('Missing the HABLAME_WEBHOOK_SECRET variable')
// X-Hablame-Delivery-Id does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
// In-memory example: it is lost on restart.
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 {
// The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
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
// Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
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 {
// Your logic goes here. In production, use a durable queue.
try {
const evento = JSON.parse(cuerpo.toString('utf8')) as EventoHablame
console.log('event processed', 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
}
// Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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)
// Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
res.writeHead(200).end()
setImmediate(() => procesar(entrega, cuerpo))
})
}).listen(PUERTO, () => console.log('Waiting for Hablame notifications at', 'http://localhost:' + PUERTO + RUTA))
# servidor.py: Python 3.8 or later, no external dependencies.
import hashlib
import hmac
import json
import os
import queue
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
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("Missing the HABLAME_WEBHOOK_SECRET variable")
# X-Hablame-Delivery-Id does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
# In-memory example: it is lost on restart.
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):
# The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
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
# Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
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)
# Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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)
# Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
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()
# Your logic goes here. In production, use a durable queue.
try:
evento = json.loads(cuerpo)
print("event processed", evento["type"], evento["id"], entrega, flush=True)
except (ValueError, KeyError) as error:
print(error, flush=True)
threading.Thread(target=procesar, daemon=True).start()
print("Waiting for Hablame notifications at", "http://localhost:%d%s" % (PUERTO, RUTA), flush=True)
ThreadingHTTPServer(("", PUERTO), Receptor).serve_forever()
<?php
// webhook.php: PHP 8.1 or later with PHP-FPM and PDO (MySQL or PostgreSQL), no external dependencies.
const MAX_CUERPO = 256 * 1024;
const TOLERANCIA_SEG = 300;
function responder(int $estado): never
{
http_response_code($estado);
exit;
}
// Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
$secreto = getenv('HABLAME_WEBHOOK_SECRET');
if ($secreto === false || $secreto === '') {
responder(500);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
responder(405);
}
// Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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);
}
// The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
$ts = $_SERVER['HTTP_X_HABLAME_TIMESTAMP'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > TOLERANCIA_SEG) {
responder(401);
}
// Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
$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 does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
// Table with a unique index on delivery_id; create the one for your engine:
// 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) {
// Already stored (SQLSTATE 23000 in MySQL, 23505 in PostgreSQL): it is a retry, respond 2xx.
if (in_array($e->getCode(), ['23000', '23505'], true)) {
responder(200);
}
// If you cannot store it, do not respond 2xx: Hablame will retry it.
responder(503);
}
// Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
// Your logic goes here. In production, use a durable queue.
$evento = json_decode($cuerpo, true);
error_log('event processed ' . $evento['type'] . ' ' . $evento['id'] . ' ' . $entrega);
$db->prepare('UPDATE webhook_recibido SET procesado_en = NOW() WHERE delivery_id = ?')->execute([$entrega]);
// main.go: Go 1.19 or later, no external dependencies.
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
)
// Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
var secreto = []byte(os.Getenv("HABLAME_WEBHOOK_SECRET"))
// X-Hablame-Delivery-Id does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
// In-memory example: it is lost on restart.
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 {
// The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
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
}
// Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
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
}
// Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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
}
// Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
select {
case trabajos <- trabajo{entrega: entrega, cuerpo: cuerpo}:
w.WriteHeader(http.StatusOK)
default:
// If you cannot store it, do not respond 2xx: Hablame will retry it.
recibidos.Delete(entrega)
w.WriteHeader(http.StatusServiceUnavailable)
}
}
func procesar() {
for t := range trabajos {
// Your logic goes here. In production, use a durable queue.
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("event processed", evento.Type, evento.ID, t.entrega)
}
}
func main() {
if len(secreto) == 0 {
log.Fatal("Missing the HABLAME_WEBHOOK_SECRET variable")
}
puerto := os.Getenv("PORT")
if puerto == "" {
puerto = "3000"
}
go procesar()
http.HandleFunc(ruta, recibir)
log.Println("Waiting for Hablame notifications at", "http://localhost:"+puerto+ruta)
log.Fatal(http.ListenAndServe(":"+puerto, nil))
}
// Receptor.java: Java 17 or later, no external dependencies.
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;
// Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
static final byte[] SECRETO = System.getenv().getOrDefault("HABLAME_WEBHOOK_SECRET", "").getBytes(StandardCharsets.UTF_8);
// X-Hablame-Delivery-Id does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
// In-memory example: it is lost on restart.
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("Missing the HABLAME_WEBHOOK_SECRET variable");
}
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("Waiting for Hablame notifications at http://localhost:" + puerto + RUTA);
}
static void recibir(HttpExchange x) throws IOException {
try {
if (!"POST".equals(x.getRequestMethod())) {
responder(x, 405);
return;
}
// Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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;
}
// Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
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) {
// The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
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;
}
// Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
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) {
// Your logic goes here. In production, use a durable queue.
System.out.println("event processed " + entrega + " " + new String(cuerpo, StandardCharsets.UTF_8));
}
}
// Program.cs: .NET 6 or later (ASP.NET Core), no external dependencies.
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;
// Your signing secret (whsec_...). Read it from an environment variable or your secrets manager; never hard-code it.
var secreto = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("HABLAME_WEBHOOK_SECRET")
?? throw new InvalidOperationException("Missing the HABLAME_WEBHOOK_SECRET variable"));
// X-Hablame-Delivery-Id does not change between retries: use it to drop what you already received. In production, store it with a unique index in your database, together with the event.
// In-memory example: it is lost on restart.
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) =>
{
// Read the RAW body without parsing it: the signature is computed over those exact bytes. Hablame sends up to 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();
// Respond 2xx right away and process separately. You have 15 s in total, and any non-2xx response is retried.
if (!trabajos.Writer.TryWrite((entrega, cuerpo)))
{
// If you cannot store it, do not respond 2xx: Hablame will retry it.
recibidos.TryRemove(entrega, out _);
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return Results.Ok();
});
_ = Task.Run(async () =>
{
await foreach (var (entrega, cuerpo) in trabajos.Reader.ReadAllAsync())
{
// Your logic goes here. In production, use a durable queue.
using var evento = JsonDocument.Parse(cuerpo);
var tipo = evento.RootElement.GetProperty("type").GetString();
var id = evento.RootElement.GetProperty("id").GetString();
Console.WriteLine($"event processed {tipo} {id} {entrega}");
}
});
app.Run();
static bool Verificar(IHeaderDictionary cabeceras, byte[] cuerpo, byte[] secreto)
{
// The signing time must be an integer within 300 s of your clock; this rejects a replayed old notification.
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;
// Compare in constant time with X-Hablame-Signature and, if it does not match, with X-Hablame-Signature-Prev (sent only during the 24 h after a rotation).
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);
Try it on your machine
With the receiver running, this script signs a notification the way Hablame does and sends it. You need openssl and curl.
# 1. Start your receiver on port 3000 with this example secret in its environment.
export HABLAME_WEBHOOK_SECRET='whsec_TU_SECRETO_DE_FIRMA'
# 2. Sign a notification the way Hablame does and send it. The response must be 200; if you send it again, too, but it is not processed again.
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"
In production
X-Hablame-Delivery-Id in a table with a unique index, then respond.503).X-Hablame-Delivery-Id: support uses it to find the delivery.