Webhooks and events
HookChat delivers events to the webhook endpoints you register. Every delivery is signed, retried on a fixed schedule, and dead-lettered if it never succeeds. This page is the delivery contract.
The event envelope
Every delivered event shares one envelope:
{
"id": "evt_9f2a1c7d4b8e0a3f6c2d5e91", // deterministic idempotency key
"type": "message.received",
"created": 1737000000, // logical time, whole unix SECONDS
"data": { /* type-specific; see the table below */ }
}created is logical time in whole unix seconds. id is a deterministic idempotency key derived only from the immutable source of the event, so a redelivery of the same underlying message carries the byte-identical id: which is exactly what makes dedupe-on-id correct.
The event vocabulary
This is the complete list. No other type value is emitted. Types once contemplated that HookChat does not emit, message.delivered, message.read, reactions, and conversation.started: are absent by design.
| type | data | Status |
|---|---|---|
message.received | { message }: inbound message | Emitted today |
message.sent | { message }: outbound message | Emitted today |
message.failed | { failure: { conversation_id, tenant, reason } } | Emitted today |
test.event | { test: { message, tenant, nonce } } | Emitted today |
account.connected | { account: { external_id, platform, tenant, handle? } } | Declared, not yet emitted |
account.disconnected | { account: { external_id, platform, tenant, handle? } } | Declared, not yet emitted |
The full message resource carries id, conversation_id, tenant, platform, direction, text, attachments, timestamp, account, participant, and raw (the exact Meta payload, always included). message.failed deliberately carries no message body, token, or signing material.
At-least-once, not ordered
Delivery is at-least-once and order is not guaranteed. Two things your consumer must do:
- Dedupe on
HookChat-Event-Id. The same event can arrive more than once (a Meta redelivery, an internal re-receive, or a manual replay). If you have already processed a given event id, acknowledge and ignore the duplicate. - Don't rely on arrival order. Where order matters, sort by the envelope's
createdtime, not by the order requests reach your endpoint.
Test-scoped events (produced under a hookchat_test_… key) are not delivered to production endpoints unless an endpoint explicitly opts in: a production consumer never sees a colleague's sandbox traffic by default.
Verifying a delivery (signing)
Each delivery is HMAC-SHA256 signed over the exact transmitted bytes. The signature is over `${t}.${rawBody}`, where t is the timestamp in the header. Verify against the raw request bytes. Re-serialising parsed JSON changes the digest.
HookChat-Signature: t=1737000000,v1=<hex hmac>[,v1=…] HookChat-Timestamp: 1737000000 HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91 HookChat-Event-Type: message.received HookChat-Delivery-Id: dlv_…
HookChat-Signature:t=<unix>,v1=<hex>. During a secret rotation this carries multiplev1=values; accept if any matches.HookChat-Timestamp: thetused in the signature. Enforce a tolerance of about 300 seconds to reject replays.HookChat-Event-Id: the envelopeid; dedupe on this.HookChat-Event-Type: the envelopetype.HookChat-Delivery-Id: the per-endpoint delivery id, stable across retries of one attempt-chain.
The @hookchat/node SDK's verifyWebhook implements all of this: timing-safe comparison, the 300-second tolerance, and multi-v1= acceptance:
import { verifyWebhook, HookChatSignatureError } from '@hookchat/node'
const raw = await readRawBody(req) // the EXACT bytes: do not re-serialise
try {
const event = verifyWebhook(raw, req.headers, process.env.HOOKCHAT_WEBHOOK_SECRET)
// event is parsed + typed; signature, timestamp tolerance and dedupe headers checked.
} catch (e) {
if (e instanceof HookChatSignatureError) return res.status(401).end()
throw e
}Retries and the dead-letter queue
A delivery that fails retryably is retried on a fixed backoff schedule: seven legs summing to about 33 hours, so a delivery gets eight attempts in total before it is dead-lettered:
attempt 1 fails → wait 5s → attempt 2 attempt 2 fails → wait 30s → attempt 3 attempt 3 fails → wait 5m → attempt 4 attempt 4 fails → wait 30m → attempt 5 attempt 5 fails → wait 2h → attempt 6 attempt 6 fails → wait 6h → attempt 7 attempt 7 fails → wait 24h → attempt 8 attempt 8 fails → dead-letter (terminal)
What is retried, and what isn't:
- Retried: a
2xxis success;408,429,503(honouringRetry-After), other5xx, and transport errors / timeouts. - Terminal (never retried): a
2xxsucceeds; any other4xxis a client error you must fix; and an SSRF block, a redirect, or a bad scheme is refused outright.
Inspecting and replaying deliveries
Every attempt is recorded. From the console or the API you can list an endpoint's deliveries, inspect why one failed, and replay a single delivery or replay in bulk. Replaying a delivery that already succeeded is refused with replay_conflict (409) unless you force it, and a forced replay re-sends the same event id, which is exactly why dedupe-on-id matters.
Endpoints and signing secrets
Register an endpoint in the console (or via the API). Creating an endpoint mints its signing secret (whs_…), returned exactly once: no read path ever returns it again. Rotating mints a new primary secret and keeps the previous one valid for a 24-hour overlap, so in-flight deliveries keep verifying while you roll the secret out. Deleting an endpoint tombstones it and erases its signing material.