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

Identity headers
HookChat-Event-Id: evt_9f2a1c7d4b8e0a3f6c2d5e91
HookChat-Event-Type: message.received
HookChat-Delivery-Id: 01J8ZK3M2N4P5Q6R7S8T9V0W1X
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 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.

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

Next#