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.

typedataStatus
message.received{ message }: inbound messageEmitted today
message.sent{ message }: outbound messageEmitted 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:

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_…

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:

Poison endpoints auto-pause. After a number of consecutive dead-lettered deliveries with no success in between (default 5), the endpoint is automatically paused and the pause is audited. A single successful delivery resets the streak. Fix the endpoint, resume it, and replay.

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.