At-least-once delivery
HookChat uses at-least-once delivery: your endpoint can receive an event more than once and events can arrive out of order. A delivery that fails for good, or runs out of retries, is marked dlq: given up, but kept for inspection and replay. Deduplicate events and track processing in your receiver.
What at-least-once means#
- The same event can arrive more than once. A Meta redelivery, an internal re-receive, a retry after your receiver timed out but had already processed the request, or a manual replay all produce a second delivery of the same event.
- Arrival order is not the event order. Retries and queueing can reorder deliveries; that is how they work, not a bug. Two messages from the same participant can reach you in either order.
- A redelivery is byte-identical in its identity. The event
idis derived only from the immutable source of the event, so every delivery of the same message carries the sameevt_id. That is the property that makes dedupe on the id correct.
The headers you dedupe on#
The envelope id is also sent as HookChat-Event-Id, so you can dedupe before you parse the body. HookChat-Delivery-Id is different: it names this event's delivery to one endpoint, so the same event sent to two endpoints carries two of them. It stays the same across retries and across a replay. Key your own dedupe on the event id, never the delivery id.
HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91
HookChat-Event-Type: message.received
HookChat-Delivery-Id: del_4b8e0a3f6c2d5e917d2a1c9f
HookChat-Timestamp: 1756900000The one exception is test.event, whose id is evt_test_ plus a fresh nonce per send, precisely so that re-testing an endpoint is never deduped against an earlier test.
A receiver that dedupes#
Verify first, then check the id, then act. The in-memory set below only shows the shape. In production, use a table or a cache with a unique constraint on the event id and a TTL of a few days, and check and write it in the same transaction as the side effect.
import { createServer } from 'node:http'
import { verifyWebhook, HookChatSignatureError } from '@hookchat/node'
const secret = process.env.WEBHOOK_SECRET!
const seen = new Set<string>() // in production: a table or cache with a unique constraint on the event id
createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
try {
const event = verifyWebhook(Buffer.concat(chunks), req.headers, secret)
if (seen.has(event.id)) {
console.log('duplicate, already processed', event.id)
res.writeHead(200).end() // acknowledge, do not act again
return
}
seen.add(event.id)
console.log('processing', event.type, event.id, 'created', event.created)
res.writeHead(200).end()
} catch (error) {
if (error instanceof HookChatSignatureError) {
res.writeHead(401).end()
return
}
throw error
}
})
}).listen(Number(process.env.PORT))Ordering#
Where order matters, sort by the envelope's created, the event's logical time in whole unix seconds, not by when requests reached you. A message resource additionally carries its own ISO-8601 timestamp. If your receiver keeps per-conversation state, treat an older created than the one you have stored as stale and skip it.
Actions that are safe to repeat#
Dedupe covers the common case. For the rest, make the action itself safe to repeat:
- Key database writes on the event id (an upsert, or an insert that ignores a unique-constraint conflict) so a duplicate becomes a no-op.
- When you reply to an inbound message automatically, record the event id you replied to before you send. A duplicate
message.receivedthen finds the record and does not send twice. - Do the work after you answer. Queue the event with its id as the job key, return 200, and let the worker dedupe on the same id.