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 id is derived only from the immutable source of the event, so every delivery of the same message carries the same evt_ 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.

Identity headers
HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91
HookChat-Event-Type: message.received
HookChat-Delivery-Id: del_4b8e0a3f6c2d5e917d2a1c9f
HookChat-Timestamp: 1756900000

The 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.

TypeScript
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))
Acknowledge a duplicate with a 2xx. Answering an error would make the gateway retry a delivery you have already handled, and enough of those in a row would pause the endpoint.

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.received then 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.

Next#