Webhooks and events

HookChat delivers events to the webhook endpoints you register. Every delivery is signed, retried on a fixed schedule, and marked as given up (dlq) if it never succeeds. This page is the delivery contract.

The event envelope#

Every delivered event shares one envelope. data is type-specific; see the table below.

JSON
{
  "id": "evt_9f2a1c7d4b8e0a3f6c2d5e91",
  "type": "message.received",
  "created": 1737000000,
  "data": {
    "message": {
      "id": "m_01J9",
      "conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
      "platform": "instagram",
      "direction": "inbound",
      "text": "Do you ship to Perth?"
    }
  }
}

created is logical time in whole unix seconds. id is built only from the event's own source, so a redelivery of the same message carries the same id, byte for byte. That is what makes dedupe on the id correct.

The event vocabulary#

This is the complete list. No other type value is emitted. Some types were once planned and never shipped: message.delivered, message.read, reactions and conversation.started. They are left out on purpose.

typedataStatus
message.received{ message }: inbound message; a button tap arrives as one, its title as the textEmitted today
message.sent{ message }: a message the account sent. direction is outbound for a send through the API and echo for one sent from the Instagram or Messenger appEmitted 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? } }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 } }The name, username or picture of the person in a conversation was first stored. Once per conversation

The full message resource carries id, conversation_id, tenant, platform, direction, text, attachments, timestamp, account, participant, and raw (the exact Meta payload on an inbound message or an echo, null on a send through the API). message.failed deliberately carries no message body, token, or signing material.

direction is inbound on message.received. On message.sent it is outbound for a send through the API and echo for one sent from the Instagram or Messenger app. An echo is delivered a few seconds after it arrives, so a send through the API is never delivered a second time as an echo. There is nothing to set up: an endpoint subscribed to message.sent gets both.

At-least-once, not ordered#

Delivery is at-least-once and order is not guaranteed. Two things your receiver 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.
  • Do not rely on arrival order. Where order matters, sort by the envelope created time, not by the order requests reach your endpoint.

Test-scoped events (produced under a hookchat_test_... key) stay inside HookChat: they never reach an endpoint, so a receiver never sees a colleague's sandbox traffic. Read them from the API with include_test=true.

Verifying a delivery (signing)#

Each delivery is HMAC-SHA256 signed over the exact transmitted bytes. The signature is over t.rawBody (the timestamp, a full stop, then the body), where t is the timestamp in the header. Verify against the raw request bytes. If you parse the JSON and encode it again, the bytes change and the check fails.

Delivery headers
HookChat-Signature: t=1737000000,v1=5f1c...e2a9
HookChat-Timestamp: 1737000000
HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91
HookChat-Event-Type: message.received
HookChat-Delivery-Id: del_4b8e0a3f6c2d5e917d2a1c9f
  • HookChat-Signature: t=<unix>,v1=<hex>. During a secret rotation this carries multiple v1= values; accept if any matches.
  • HookChat-Timestamp: the t used in the signature. Reject a timestamp more than 300 seconds (five minutes) from your clock, so an old delivery cannot be replayed.
  • HookChat-Event-Id: the envelope id; dedupe on this.
  • HookChat-Event-Type: the envelope type.
  • HookChat-Delivery-Id: the per-endpoint delivery id, stable across retries of one attempt-chain.

The @hookchat/node SDK verifyWebhook implements all of this: timing-safe comparison, the 300 second tolerance, and multi-v1= acceptance:

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

createServer(async (req, res) => {
  const chunks: Buffer[] = []
  for await (const chunk of req) chunks.push(chunk as Buffer)
  const raw = Buffer.concat(chunks) // the EXACT bytes: do not re-serialise
  try {
    const event = verifyWebhook(raw, req.headers, process.env.WEBHOOK_SECRET)
    // event is parsed and typed; signature, timestamp tolerance and dedupe headers checked.
    console.log(event.type, event.id)
    res.writeHead(200).end()
  } catch (e) {
    if (e instanceof HookChatSignatureError) return res.writeHead(401).end()
    throw e
  }
}).listen(Number(process.env.PORT))

Retries and giving up#

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 gives up:

Retry schedule
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, gives up (status dlq)

What is retried, and what is not:

  • Retried: 408, 429, 503 (honouring Retry-After), other 5xx, and transport errors or timeouts.
  • Final (never retried): a 2xx succeeds; any other 4xx is a client error you must fix; and an SSRF block, a redirect, or a bad scheme is refused outright.
A failing endpoint pauses itself. After five given-up deliveries in a row with no success between them, the endpoint is paused and the pause is written to the audit trail. One successful delivery resets the count. Fix the endpoint, resume it, and replay.

Inspecting and replaying deliveries#

Every attempt is recorded. From the console or the API you can list the deliveries of an endpoint, 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. A delivery record is kept 90 days, but the event body it carried only 7, so a replay after 7 days is refused with replay_expired (409).

Endpoints and signing secrets#

Register an endpoint in the console (or through the API). Creating an endpoint gives it a signing secret (whs_...), returned exactly once: no read path ever returns it again. Rotating issues 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.

An endpoint gets every event type unless you narrow it. Pass events with any of the names in the vocabulary above when you create or update the endpoint, or untick Deliver every event type in the console and pick from the list. A name outside the vocabulary is refused with 400 invalid_request, so a typo can never leave an endpoint listening for nothing.