# 13. 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 fires `user.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 ```json { "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= 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: 1. Take `t` from the `hamlet-signature` header. 2. The signed payload is the exact string `${t}.${rawBody}`. 3. 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 ```js 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: ```bash curl -sS https://acme.hamlet.so/api/v1/webhooks//deliveries \ -H 'authorization: Bearer hmlt_live_AN_ADMIN_KEY' ``` ## Registering ```bash 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 ```bash curl -sS -X POST https://acme.hamlet.so/api/v1/webhooks//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. --- This is one section of the Hamlet agent documentation. The whole document: https://docs.hamlet.so/llms Other sections (https://docs.hamlet.so/llms?section=): overview, network, keys, running-a-community, tenancy, authentication, permissions, mcp, tools, rest, approvals, search, errors