Receive and verify events

Every delivery HookChat sends is HMAC-SHA256 signed over the exact bytes on the wire. A receiver has one job before anything else: verify that signature against the raw body, then act on the typed event.

How signing works#

Each delivery carries five headers. HookChat-Signature holds a timestamp and one or more hex digests; each digest is HMAC-SHA256(secret, "<t>.<raw body>") under one of the endpoint's live signing secrets.

Delivery headers
POST /hooks/hookchat HTTP/1.1
Content-Type: application/json
HookChat-Signature: t=1756900000,v1=3f9c1a0b2d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8
HookChat-Timestamp: 1756900000
HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91
HookChat-Event-Type: message.received
HookChat-Delivery-Id: del_4b8e0a3f6c2d5e917d2a1c9f
  • Verify the raw bytes. Parsing and re-serialising the JSON reorders keys and changes whitespace, which changes the digest. Read the body as bytes and hand those bytes to the verifier.
  • Accept any matching digest. After a secret rotation the header carries two v1= values for 24 hours, one per live secret. A receiver holding either verifies.
  • Enforce the timestamp. The verifier rejects a t more than 300 seconds from its clock. The gateway stamps a fresh t on every attempt, replays included, so a rejection means clock skew or a captured request posted later.
  • Compare in constant time. Every SDK verifier does; if you roll your own, use your platform's timing-safe compare.

Write the receiver#

The verifier takes the raw body, the request headers and the whs_ secret from when you created or rotated the endpoint. It throws on any failure and never returns unverified data. Answer 401 on a bad signature so the failure is visible in the delivery log without being retried.

TypeScript
import { createServer } from 'node:http'
import { verifyWebhook, HookChatSignatureError } from '@hookchat/node'

const secret = process.env.WEBHOOK_SECRET!

createServer((req, res) => {
  const chunks: Buffer[] = []
  req.on('data', (chunk: Buffer) => chunks.push(chunk))
  req.on('end', () => {
    const raw = Buffer.concat(chunks)
    try {
      const event = verifyWebhook(raw, req.headers, secret)
      console.log('verified', event.type, event.id)
      res.writeHead(200).end()
    } catch (error) {
      if (error instanceof HookChatSignatureError) {
        res.writeHead(401).end()
        return
      }
      throw error
    }
  })
}).listen(Number(process.env.PORT))
Framework body parsers are the usual cause of a signature mismatch. Express's express.json(), Flask's request.get_json() and similar helpers hand you an object, not the bytes. Read the raw body on the webhook route (express.raw, request.get_data(), io.ReadAll(r.Body)) and verify that.

Handle each event type#

The verifier returns the event envelope: id, type, created (unix seconds) and a data payload whose shape follows type. The complete vocabulary is seven types; nothing else is ever delivered.

typedataWhen
message.received{ message }, an inbound messageA participant sent your account a DM.
message.sent{ message }, a message the account sentA message left your account. direction is outbound for a send through the API and echo for one sent from the Instagram or Messenger app, which arrives a few seconds after it is sent.
message.failed{ failure: { conversation_id, tenant, reason } }A send the policy allowed was refused downstream. Carries no body or token.
test.event{ test: { message, tenant, nonce } }You called the test route. A fresh nonce each time, so it is never deduped.
account.connected{ account: { external_id, platform, tenant, handle } }An account was linked.
account.disconnected{ account: { external_id, platform, tenant, handle } }An account was disconnected, or its token stopped working and it needs reconnecting.
participant.updated{ participant: { id, conversation_id, tenant, platform, account, name, username, picture } }HookChat stored the name, username or picture of the person in a conversation. Once per conversation; see show who you are talking to.
TypeScript
import { createServer } from 'node:http'
import { verifyWebhook, HookChatSignatureError, type WebhookEvent } from '@hookchat/node'

const secret = process.env.WEBHOOK_SECRET!

createServer((req, res) => {
  const chunks: Buffer[] = []
  req.on('data', (chunk: Buffer) => chunks.push(chunk))
  req.on('end', () => {
    let event: WebhookEvent
    try {
      event = verifyWebhook(Buffer.concat(chunks), req.headers, secret)
    } catch (error) {
      if (error instanceof HookChatSignatureError) {
        res.writeHead(401).end()
        return
      }
      throw error
    }

    switch (event.type) {
      case 'message.received':
        console.log('inbound', event.data.message.conversation_id, event.data.message.text)
        break
      case 'message.sent':
        // outbound: sent through the API. echo: sent from the Instagram or Messenger app.
        console.log(event.data.message.direction, event.data.message.id)
        break
      case 'message.failed':
        console.log('send failed', event.data.failure.conversation_id, event.data.failure.reason)
        break
      case 'test.event':
        console.log('test', event.data.test.nonce)
        break
      case 'account.connected':
      case 'account.disconnected':
        console.log(event.type, event.data.account.platform, event.data.account.external_id)
        break
      case 'participant.updated':
        console.log('profile', event.data.participant.conversation_id, event.data.participant.name)
        break
    }
    res.writeHead(200).end()
  })
}).listen(Number(process.env.PORT))

The full message resource carries id, conversation_id, tenant, platform, direction, text (null for an attachment-only message), attachments, timestamp, account, participant and raw, the untouched Meta payload (null on a send through the API). The schema is published at docs/events/schema.json in the repository.

Respond fast, work later#

Return a 2xx as soon as the signature verifies and the event is queued or stored; do the real work off the request path. Anything but a 2xx, or a slow response, is a failed attempt: the gateway retries on a fixed schedule and gives up after eight attempts, and an endpoint whose deliveries keep giving up is paused automatically. The rules are on Inspect and replay deliveries.

Delivery is at-least-once and unordered. The same event can arrive twice, so record HookChat-Event-Id and skip anything you have already processed; where order matters, sort by the envelope's created. See At-least-once delivery.

Test it#

Point an endpoint at the receiver and send a test.event through the real signing path. Watch for verified test.event in the receiver's log, and for delivered in the delivery list.

Shell
hookchat webhooks test "$WEBHOOK_ID"

Next#