Skip to content

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

  1. 01
    Read the raw body

    As it arrives, without parsing it, up to 256 KB, the most Hablame sends.

  2. 02
    Check the time

    X-Hablame-Timestamp must be an integer within 300 seconds of your clock.

  3. 03
    Verify the signature

    In constant time, against X-Hablame-Signature and, if it does not match, X-Hablame-Signature-Prev. How it is computed.

  4. 04
    Drop duplicates

    Store X-Hablame-Delivery-Id with a unique index. If it already exists, respond 2xx without processing again.

  5. 05
    Respond 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 /webhooks/hablame, 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))

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.

Bash
# 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

Do this
Instead of this
Store the notification and its X-Hablame-Delivery-Id in a table with a unique index, then respond.
Calling other systems before responding: if you take longer than 15 s, the notification is retried.
Process from a durable queue that survives a restart.
Processing in memory: if your server restarts after responding 2xx, the notification does not come back.
If you could not store the notification, respond with an error (for example 503).
Responding 2xx “so it stops retrying” without having stored it.
Log X-Hablame-Delivery-Id: support uses it to find the delivery.
Logging the secret or full signatures.

Need help?

Was this page helpful?

See all support channels

Your cookie preferences

You decide what we measure. You can change your choice at any time. Rejecting takes no feature away from the documentation.

Privacy policy