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.
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- 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
tmore than 300 seconds from its clock. The gateway stamps a freshton 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.
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))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.
| type | data | When |
|---|---|---|
message.received | { message }, an inbound message | A participant sent your account a DM. |
message.sent | { message }, an outbound message | A 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. |
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.
hookchat webhooks test "$WEBHOOK_ID"