Webhooks

Vérifier les signatures

Chaque livraison est signée avec HMAC-SHA256. Vérifiez la signature sur le corps brut avant de faire confiance au contenu.

Vérifier les signatures

Chaque livraison porte les entêtes X-HonkIO-Signature, X-HonkIO-Timestamp et X-HonkIO-Event. Vérifiez la signature avant de faire confiance au contenu, et rejetez tout ce qui ne correspond pas. Le secret de signature est retourné une seule fois, à la création du webhook. Renouvelez le secret depuis le tableau de bord ou avec POST /v1/webhooks/:id/rotate-secret; l’ancien secret cesse de signer immédiatement. Répondez par un code autre que 2xx à une signature invalide et la nouvelle tentative arrivera signée avec le nouveau secret.

bash
signed_string = timestamp + "." + body
signature     = HMAC_SHA256(key = signing_secret, message = signed_string)

# timestamp: the X-HonkIO-Timestamp value as sent, decimal Unix epoch seconds
# body:      the raw request bytes, read before any JSON parsing
# key:       the UTF-8 bytes of the secret as issued, not hex decoded
  • Séparateur : un point littéral se trouve entre l’horodatage et le premier octet du corps.
  • Horodatage : la valeur X-HonkIO-Timestamp exactement telle qu’envoyée, en secondes Unix décimales.
  • Corps : les octets bruts de la requête, lus avant toute analyse JSON. Tout ce qui analyse puis resérialise le JSON modifie les octets, et l’empreinte ne correspondra jamais.
  • Signature : hexadécimal minuscule, 64 caractères. Rejetez tout ce qui ne fait pas 64 caractères hexadécimaux avant de décoder, puis comparez en temps constant.
  • Secret : les octets UTF-8 du secret de signature exactement tel qu’émis. Ne le décodez pas au préalable.
⚠️ Le secret de signature est composé de 32 octets aléatoires représentés en 64 caractères hexadécimaux, il ressemble donc à quelque chose qu’il faudrait décoder en 32 octets. Ce n’est pas le cas. La clé HMAC est la chaîne de 64 caractères elle-même, et il n’y a aucun préfixe à retirer. Décoder le secret est la cause la plus fréquente d’un vérificateur qui ne correspond jamais.

Avec le SDK Node.js, honkio.webhooks.verify fait toutes les vérifications ci-dessus et renvoie l’événement analysé :

Node.js
const webhookSecret = process.env.HONKIO_WEBHOOK_SECRET ?? ''
if (!webhookSecret) throw new Error('Set HONKIO_WEBHOOK_SECRET')

// A fetch-style handler (Next.js route handlers, Hono, Bun and so on).
// With Express, pass the body from express.raw() and req.headers instead.
export async function POST(request: Request) {
  // The raw text, read before any JSON parsing: it is what was signed.
  const { data: event, error } = honkio.webhooks.verify(await request.text(), request.headers, webhookSecret)
  if (error) return new Response(null, { status: 400 }) // invalid_signature or invalid_argument
  console.log(event.id, event.type, event.livemode)
  return new Response(null, { status: 200 })
}

Sans le SDK, ou pour transposer la vérification dans un autre langage, voici un vérificateur complet, qui refuse par défaut, en Node pur :

javascript
import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody must be the unparsed body. Most frameworks hand you a parsed object
// by default; reach for the raw buffer (express.raw, request.text(), and so on).
export function verifyHonkioWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers['x-honkio-timestamp']
  const signature = headers['x-honkio-signature']
  if (!timestamp || !signature) return false

  const ts = Number.parseInt(timestamp, 10)
  if (!Number.isFinite(ts)) return false
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false

  // Buffer.from(x, 'hex') drops invalid bytes without complaining, which can
  // truncate two different values into a match. Check the shape first.
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'))
}

Validez votre implémentation avec ce vecteur. Si vous reproduisez la signature, c’est terminé :

bash
secret     deadbeef00112233445566778899aabbccddeeffdeadbeef0123456789abcdef
timestamp  1788920816
body       {"id":"00000000-0000-4000-8000-000000000000","type":"message.delivered","created":"2026-09-09T02:26:57Z","account_id":"acc_test","data":{"message_id":"msg_test","to":"+16135550123","status":"delivered"}}

signature  058aca981d55947bb71d2ba4ff98487d5f5c6caf01189af72266cb3f1e22ccbf

Le secret ci-dessus est jetable et n’appartient à aucun compte, vous pouvez donc le placer dans un test. Votre propre secret n’a jamais besoin de quitter votre serveur, et nous ne l’enverrons jamais par courriel, pas plus qu’une signature.

Rejetez tout ce qui sort d’une fenêtre de 300 secondes par rapport à X-HonkIO-Timestamp, et dédupliquez sur l’id de l’évènement. La livraison se fait au moins une fois, donc le même id peut légitimement arriver plusieurs fois.

Les nouvelles tentatives et les rejeux sont signés au moment de leur envoi, un même id d’évènement peut donc arriver avec un horodatage et une signature différents à chaque fois. Les livraisons rejouées portent aussi X-HonkIO-Replay: true. Vérifiez toujours avec l’entête d’horodatage arrivé avec cette requête, jamais avec un horodatage conservé auparavant.

Il n’existe pas de point de terminaison pour la rotation des secrets. Pour changer un secret, enregistrez un second point de terminaison, confirmez qu’il vérifie correctement, puis supprimez le premier. Les deux reçoivent les évènements tant qu’ils sont actifs, ce que votre déduplication par id absorbe.