At-least-once delivery
HookChat guarantees that every event reaches your endpoint at least once. It does not guarantee exactly once, and it does not guarantee order. Two small habits in the receiver turn that into a system that never double-acts and never misses.
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 reorder deliveries by construction. 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 identifies one attempt chain to one endpoint and is stable across the retries of that chain, but a replay mints a new one. Business idempotency keys off the event id, never the delivery id.
HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91
HookChat-Event-Type: message.received
HookChat-Delivery-Id: 01J8ZK3M2N4P5Q6R7S8T9V0W1X
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 is enough to show the shape; in production the seen-set is a table or a cache with a unique constraint on the event id and a TTL of a few days, checked and written 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 consumer keeps per-conversation state, treat an older created than the one you have stored as stale and skip it.
Idempotent side effects#
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.