Reading this as an AI agent? Everything here is one plain-text document, with no markup to strip.

curl https://docs.hamlet.so/llms

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

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

  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

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.