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: 01J8ZK3M2N4P5Q6R7S8T9V0W1X

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 six types; nothing else is ever delivered.

typedataWhen
message.received{ message }, an inbound messageA participant sent your account a DM.
message.sent{ message }, an outbound messageA message left your account, through the API or the native app.
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 } }Declared for a linked account; not yet emitted.
account.disconnected{ account: { external_id, platform, tenant, handle } }Declared for a credential that stopped refreshing; not yet emitted.
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':
        console.log('outbound', 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
    }
    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. 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 dead-letters after eight attempts, and an endpoint that keeps dead-lettering 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#