Handle webhooks
Verify the HMAC signature, respect the replay window, and respond before Hatched retries.
Webhooks are how your backend reacts to buddy events. Hatched signs every request so you can trust the payload came from us and hasn't been tampered with.
Subscribe
- Dashboard → Settings → Webhooks → Add endpoint.
- Pick the event types you care about (catalogue).
- Copy the signing secret once — we don't show it again.
Programmatically:
await hatched.webhooks.create({
url: 'https://your-app.com/api/webhooks/hatched',
events: ['buddy.evolved', 'badge.awarded', 'streak.milestone'],
});Verify with the SDK helper
@hatched/sdk-js ships a static helper for signature verification:
import { WebhooksResource } from '@hatched/sdk-js';
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-hatched-signature') ?? '';
const timestamp = req.headers.get('x-hatched-timestamp') ?? '';
const valid = WebhooksResource.verifySignature(
rawBody,
signature,
process.env.HATCHED_WEBHOOK_SECRET!,
{ timestamp },
);
if (!valid) return new Response('invalid signature', { status: 400 });
const event = JSON.parse(rawBody);
const deliveryId = req.headers.get('x-hatched-delivery');
const eventType = req.headers.get('x-hatched-event');
if (!deliveryId || !eventType) return new Response('missing metadata', { status: 400 });
await handle({ deliveryId, eventType, payload: event });
return new Response(null, { status: 202 });
}The signature arrives in X-Hatched-Signature: sha256=<hmac_sha256_hex> and
the timestamp in its own X-Hatched-Timestamp: <unix_seconds> header. Pass
the timestamp via options.timestamp — without it the helper fails closed.
The helper verifies the HMAC over `${timestamp}.${rawBody}`, rejects
timestamps older than toleranceSeconds (default 300), and uses
timingSafeEqual under the hood.
The framework adapters in
@hatched/sdk-js/webhooks(verifyExpressRequest,verifyNextAppRequest, …) extract both the signature and the timestamp header for you — reach for them first.
Sign over raw body bytes. A JSON
parse→stringifyround-trip reorders keys and breaks the signature. Read the body asBufferorstringbefore any framework middleware parses it as JSON.
Manual verification (without the SDK)
The signing scheme is identical across languages: HMAC-SHA256 over
`${unix_timestamp}.${raw_body_bytes}` using the webhook secret. Read
the signature from X-Hatched-Signature (strip the sha256= prefix to get
the hex) and the timestamp from X-Hatched-Timestamp. Reject anything older
than 5 minutes; compare digests in constant time.
import crypto from 'node:crypto';
// signatureHeader = req.headers['x-hatched-signature'] → "sha256=<hex>"
// timestampHeader = req.headers['x-hatched-timestamp'] → "<unix_seconds>"
export function verifyHatchedSignature(
signatureHeader: string,
timestampHeader: string,
rawBody: Buffer,
secret: string,
) {
const ts = timestampHeader;
const sig = signatureHeader.replace(/^sha256=/, '');
if (!ts || !sig) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody.toString('utf8')}`)
.digest('hex');
if (sig.length !== expected.length) return false;
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex'),
);
}import hashlib, hmac, time
# signature_header = request.headers['X-Hatched-Signature'] → "sha256=<hex>"
# timestamp_header = request.headers['X-Hatched-Timestamp'] → "<unix_seconds>"
def verify_hatched_signature(
signature_header: str,
timestamp_header: str,
raw_body: bytes,
secret: str,
) -> bool:
ts = timestamp_header
sig = signature_header.removeprefix('sha256=')
if not ts or not sig:
return False
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(
secret.encode('utf-8'),
f"{ts}.".encode('utf-8') + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// signatureHeader = r.Header.Get("X-Hatched-Signature") → "sha256=<hex>"
// timestampHeader = r.Header.Get("X-Hatched-Timestamp") → "<unix_seconds>"
func VerifyHatchedSignature(signatureHeader, timestampHeader string, rawBody []byte, secret string) bool {
ts := timestampHeader
sig := strings.TrimPrefix(signatureHeader, "sha256=")
if ts == "" || sig == "" {
return false
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if delta := time.Now().Unix() - tsInt; delta > 300 || delta < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}Respond quickly
- Return a 2xx within 10 seconds, or Hatched retries the delivery.
- Up to 4 attempts (initial + 3 retries at +5s, +30s, +5min). After the
fourth attempt fails the delivery is marked
failedin the delivery log, but the state in Hatched is already correct. - If your handler is expensive, ack fast and push to a queue.
Idempotency
Every webhook carries a unique delivery id in the X-Hatched-Delivery
header. Dedupe against it before side-effects:
if (await alreadyHandled(deliveryId)) return ack();
await recordHandled(deliveryId);
await doTheWork(payload);Replay from the delivery log
Dashboard → Developers → Webhook deliveries shows every attempt with payload, headers, and response. Replay failed deliveries once your endpoint is healthy:
await hatched.webhooks.replay(endpointId, deliveryId);Framework examples
- Next.js route handler — App Router, raw body, signature verify.
- Express middleware —
express.raw- signature verify before JSON parsing.
- Edge runtimes — Workers/Vercel Edge notes.
Configure rules
Tune the coin economy, skill progression, badge conditions, and streak milestones in the dashboard.
Verify webhooks end-to-end
Copy-paste handlers that capture the raw body, verify the HMAC signature, and acknowledge fast — for Express, Fastify, Hono, Next.js App Router, Next.js Pages Router, and Cloudflare Workers.