Webhooks
The four events, the envelope, and the signature scheme, with verification code that works.
A webhook lets an agent react instead of poll. An admin registers a URL and the
events it wants, and Hamlet POSTs a signed JSON envelope when they happen.
Worth knowing if you run a community on the network: a question asked over the network is an
ordinary topic, so it fires topic.created like any other, and the person who asked firesuser.joined the first time.
Events
topic.created { topicId, title, url, categorySlug, authorLabel }
post.created { postId, topicId, topicTitle, url, authorLabel, excerpt }
flag.raised { flagId, targetType, targetId, reason, reporterLabel }
user.joined { userId, name, email }
Plus ping, which only the test endpoint sends. Those five are the complete list; a
subscription must name at least one.
The envelope
{
"id": "5c1a9d7e-3f28-4b60-9e14-8a72d0c6b3f5",
"type": "topic.created",
"createdAt": "2026-07-15T09:12:44.108Z",
"community": { "id": "0b3e7d19-4c52-4f8a-91d6-5e2a7c40b8f3", "slug": "acme" },
"data": {
"topicId": "1c9e4f80-2b6a-4d33-9f57-0ea8c1b45d92",
"title": "Verifying the hamlet-signature header",
"url": "https://acme.hamlet.so/t/verifying-the-hamlet-signature-header",
"categorySlug": "support",
"authorLabel": "Ada Lovelace"
}
}
id is the delivery id, and it matches the hamlet-delivery-id header.
Headers
content-type: application/json
hamlet-signature: t=1768467164,v1=<hex hmac-sha256>
hamlet-event: topic.created
hamlet-delivery-id: 5c1a9d7e-3f28-4b60-9e14-8a72d0c6b3f5
user-agent: Hamlet-Webhooks/1
The signature scheme
Stripe-style, so it is familiar and replay-resistant:
- Take
tfrom thehamlet-signatureheader. - The signed payload is the exact string
${t}.${rawBody}. - HMAC-SHA256 it with the webhook's signing secret, hex encoded. That is
v1.
Three things decide whether your verification works:
- Verify against the raw body, before any JSON parse. Re-serialising changes key order
and number formatting, which changes the bytes, which changes the MAC. Hamlet signs the
exact bytes it sends, for the same reason. - Compare in constant time. A byte-by-byte compare that returns early leaks the
signature one byte at a time. - Reject old timestamps. The timestamp is inside the MAC precisely so you can. Five
minutes is a reasonable tolerance. Without this check, a captured request replays forever.
Verifying
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyHamletSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = new Map(
header.split(',').map((pair) => {
const eq = pair.indexOf('=')
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()]
}),
)
const t = Number(parts.get('t'))
if (!Number.isInteger(t)) return false
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest()
const received = Buffer.from(parts.get('v1') ?? '', 'hex')
return expected.length === received.length && timingSafeEqual(expected, received)
}
In Express, that means express.raw({ type: 'application/json' }) on this route. In a
Next route handler, await req.text() before JSON.parse.
Delivery
Fire and forget, from the request that caused the event. It never delays or fails that
request.
attempts up to 3
backoff 1s after the first failure, 4s after the second
timeout 10s per attempt
success any 2xx
redirects not followed; a 3xx is a failure
Redirects are refused deliberately: following one would let a public URL walk a request
into an internal address.
Every attempt re-computes the timestamp and the signature, so the same delivery can
arrive with different values of t. Deduplicate on hamlet-delivery-id, never on the
signature.
Deliveries and their outcomes are recorded:
curl -sS https://acme.hamlet.so/api/v1/webhooks/<id>/deliveries \
-H 'authorization: Bearer hmlt_live_AN_ADMIN_KEY'
Registering
curl -sS https://acme.hamlet.so/api/v1/webhooks \
-H 'content-type: application/json' \
-H 'authorization: Bearer hmlt_live_AN_ADMIN_KEY' \
-d '{
"url": "https://hooks.example.com/hamlet",
"events": ["topic.created", "post.created"],
"description": "Triage queue"
}'
The response carries the signing secret, a whsec_… string, in full and exactly
once. Every later read masks it. Rotating means deleting the webhook and creating
another.
Receivers must be public HTTPS. Private, loopback and link-local addresses are refused, as
are credentials embedded in the URL. In a non-production deployment, localhost is allowed
so you can test against your own machine.
Testing
curl -sS -X POST https://acme.hamlet.so/api/v1/webhooks/<id>/test \
-H 'authorization: Bearer hmlt_live_AN_ADMIN_KEY'
Sends a signed ping with one attempt and no retries, and returns{ ok, status?, error? } so an admin watching a button sees the failure now rather than
after the retry ladder.