Webhooks
Subscribe to Hatch events. At-least-once delivery, HMAC-signed payloads, encrypted-at-rest secrets.
See also: ADR 0008 for the delivery semantics + encryption rationale.
Events
| Event | Fires when |
|---|---|
score.created |
A scoring request completes (any band, any stub state) |
enrollment.created |
A creator signs enrollment |
attestation.published |
An attestation lands on-chain |
launch.scheduled |
A scheduled launch time is set or edited |
(More events land with E.*: hatching.started, graduation.occurred,
fee.captured.)
Subscribing
curl -X POST https://api.gohatch.fun/v1/webhooks \
-H "Authorization: Bearer hk_live_..." \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://yourapp.com/webhooks/hatch",
"events": ["score.created", "enrollment.created"],
"secret": "whsec_a_strong_random_string_you_generate"
}'
Response:
{ "id": "sub_abc123", "endpoint": "...", "events": [...], "active": true }
The secret is used to HMAC-sign every payload. Generate a high-entropy
random string (>= 256 bits). We store it encrypted at rest (AES-256-GCM,
key from WEBHOOK_ENCRYPTION_KEY).
Payload format
Every delivery looks like:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Hatch-Event: score.created
X-Hatch-Delivery: dlv_abc123
X-Hatch-Signature: sha256=<hex hmac>
X-Hatch-Timestamp: 1729252800
{
"id": "evt_abc123",
"event": "score.created",
"timestamp": "2026-04-18T12:34:56Z",
"data": {
"scoreId": "...",
"aggregate": 67,
"band": "amber",
"hasStubs": true,
"contractAddress": "0x..."
}
}
Signature verification (required)
import { createHmac, timingSafeEqual } from 'crypto';
function verify(req: Request, secret: string): boolean {
const ts = req.headers.get('X-Hatch-Timestamp');
const signature = req.headers.get('X-Hatch-Signature');
if (!ts || !signature) return false;
// Reject old timestamps to prevent replay
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const body = await req.text();
const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
If verification fails, return 401 and log. Never process an unverified payload.
Delivery guarantees
- At-least-once. An endpoint may receive the same event twice.
De-duplicate using
X-Hatch-Delivery. - Exponential backoff — failed deliveries retry at 1m, 5m, 30m, 2h, 6h.
After 5 failures, the delivery is marked
failedand not retried. - 24h retention — we keep delivery logs (success + failure) for 24h for debugging via admin dashboard.
- Order not guaranteed. Your endpoint may receive
enrollment.createdbeforescore.createdfor the same scoreId.
Your endpoint's SLA
- Respond within 10 seconds. Slow endpoints get retried as "failed."
- Return 2xx on success. 4xx = don't retry; 5xx = retry with backoff.
- Idempotent processing. We deduplicate best-effort; you must too.
Listing + deleting subscriptions
# List (requires the bearer used when subscribing)
curl https://api.gohatch.fun/v1/webhooks \
-H "Authorization: Bearer hk_live_..."
# Delete
curl -X DELETE https://api.gohatch.fun/v1/webhooks/sub_abc123 \
-H "Authorization: Bearer hk_live_..."
Event shape reference
score.created
{
"event": "score.created",
"data": {
"scoreId": "uuid",
"aggregate": 67,
"band": "green" | "amber" | "red",
"hasStubs": false,
"contractAddress": "0x...",
"createdAt": "2026-04-18T12:34:56Z"
}
}
enrollment.created
{
"event": "enrollment.created",
"data": {
"enrollmentId": "uuid",
"scoreRequestId": "uuid",
"creatorAddress": "0x...",
"scheduledFor": "2026-04-20T15:00:00Z" | null,
"enrolledAt": "2026-04-18T12:34:56Z"
}
}
attestation.published
{
"event": "attestation.published",
"data": {
"attestationId": "0x...",
"scoreRequestId": "uuid",
"subject": "0x...",
"schemaId": "hatch.score.v1",
"digest": "0x...",
"txHash": "0x...",
"chainId": 56
}
}
launch.scheduled
{
"event": "launch.scheduled",
"data": {
"scoreRequestId": "uuid",
"scheduledFor": "2026-04-20T15:00:00Z",
"updatedAt": "2026-04-18T12:34:56Z"
}
}
Testing your webhook
Use a service like webhook.site or smee.io to capture deliveries while you build.
# smee.io for local dev
npm i -g smee-client
smee -u https://smee.io/your-token -t http://localhost:3000/webhooks/hatch
Then subscribe endpoint=https://smee.io/your-token and trigger a
score.created by scoring a token.
Troubleshooting
Signature always fails
- Use the raw request body, not a re-serialized version. JSON libraries may reorder keys.
- Compare on bytes, not strings — trailing newlines can bite you.
- Use
timingSafeEqual, not===.
We're not getting events
- Check the admin dashboard at
/admin/stats(internal only) for delivery status. - Confirm your endpoint returned 2xx for recent deliveries. A single 5xx pauses further retries for ~6 hours.
- If you see 401s logged at our side, your endpoint is rejecting signatures — check your secret and verification code.
We're getting duplicate events
Expected. Deduplicate on X-Hatch-Delivery. Your side is the
source of truth for "already processed."