Webhooks

Verifying signatures

Every delivery is signed with HMAC-SHA256. Verify the signature against the raw body before you trust the payload.

Verifying signatures

Every delivery carries X-HonkIO-Signature, X-HonkIO-Timestamp and X-HonkIO-Event. Verify the signature before you trust the payload, and reject anything that does not match. The signing secret is returned once, when you create the webhook. Rotate the secret from the dashboard or with POST /v1/webhooks/:id/rotate-secret; the old secret stops signing at once. Return a non-2xx for a bad signature and the retry arrives signed with the new 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
  • Separator: a literal period sits between the timestamp and the first byte of the body.
  • Timestamp: the X-HonkIO-Timestamp value exactly as sent, in decimal Unix epoch seconds.
  • Body: the raw request bytes, read before any JSON parsing. Anything that parses and re-serializes the JSON first will change the bytes, and the digest will never match.
  • Signature: lowercase hex, 64 characters. Reject anything that is not 64 hex characters before you decode it, then compare in constant time.
  • Secret: the UTF-8 bytes of the signing secret exactly as issued. Do not decode it first.
⚠️ The signing secret is 32 random bytes rendered as 64 hex characters, so it looks like something you should decode back to 32 bytes. It is not. The HMAC key is the 64 character string itself, and there is no prefix to strip. Decoding it first is the most common reason a verifier never matches.

With the Node.js SDK, honkio.webhooks.verify makes every check above and returns the parsed event:

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 })
}

Without the SDK, or to port the check to another language, here is a complete verifier, failing closed, in plain Node:

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'))
}

Check your implementation against this vector. If you reproduce the signature, you are done:

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

The secret above is a throwaway that belongs to no account, so you can commit it to a test. Your own secret never needs to leave your server, and we will not send it, or any signature, by email.

Reject anything outside a 300 second window against X-HonkIO-Timestamp, and deduplicate on the event id. Delivery is at least once, so the same id can legitimately arrive more than once.

Retries and replays are signed fresh at the moment they go out, so one event id can arrive with a different timestamp and a different signature each time. Replayed deliveries also carry X-HonkIO-Replay: true. Always verify against the timestamp header that arrived with that request, never one you stored earlier.

There is no secret rotation endpoint. To change a secret, register a second endpoint, confirm it verifies, then delete the first. Both fire while both are active, which your event id deduplication absorbs.