Webhooks
Signed event delivery with retries, logs and replay.
Webhooks push events to your HTTPS endpoint as they happen. Every delivery is signed, retried on failure, logged with status and latency, and replayable from the dashboard or API.
Create a webhook
In the dashboard open Webhooks → Add endpoint, or use the API with a key that has webhooks:write. A webhook belongs to the key's project and environment: test webhooks only receive test events, live webhooks only live events.
/v1/webhookswebhooks:writeurlstringrequired- Public HTTPS endpoint. Private, loopback, link-local and metadata addresses are refused.
eventsstring[]required- One or more of
thesis.created,profile.updated,token.activity,token.thesis_velocity. filtersobjecttoken,handle,chainarrays. An event must match every given dimension; values within a dimension are OR-ed. Token filters accept$SYMBOL(case-insensitive,$optional), an address or atk_id.descriptionstring | null- Your label, shown in the dashboard.
curl -X POST 'https://fomodata.dev/v1/webhooks' \
-H "Authorization: Bearer $FOMODATA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/fomodata/webhook",
"events": ["thesis.created"],
"filters": { "token": ["$RUN"] },
"description": "Race entries"
}'The response includes the signing secret (whsec_…) exactly once. Afterwards only its prefix is visible. Rotate it with POST /v1/webhooks/{id}/rotate-secret.
Payload
Each delivery is an HTTP POST with a JSON body containing the Event object, the same JSON as REST and streams.
{
"object": "event",
"id": "evt_2Nf8QpLx0VbT6mRz4kWc1S",
"type": "thesis.created",
"created_at": "2026-10-07T10:04:12.000Z",
"livemode": false,
"source": "sandbox",
"data": {
"thesis_id": "th_7Hq2LmZx9RkT4bVn0sYc3D",
"profile": {
"id": "fp_4KZq8mXw2T0aN6rB1cYd9E",
"handle": "@sbx_milo",
"display_name": "Milo (sandbox)",
"avatar_url": null
},
"token": {
"id": "tk_9bF2kLmQ0sVx7RtY3nHc1A",
"symbol": "$RUN",
"name": "Run (sandbox)",
"chain": "sandbox",
"address": "0x393e5c8b655042f2409351fc00c2714947537a12"
},
"text": "Sandbox thesis: course looks fast today.",
"created_at": "2026-10-07T10:04:12.000Z",
"public_url": null
}
}Headers
| Header | Value |
|---|---|
FomoData-Signature | t=1759831452,v1=5f2b… |
FomoData-Event-Id | The event id (evt_…), stable across retries and replays. |
FomoData-Event-Type | e.g. thesis.created |
FomoData-Delivery-Id | This delivery (del_…). |
FomoData-Delivery-Attempt | Attempt number, starting at 1. |
FomoData-Replay | true for manual replays, otherwise false. |
FomoData-Replay-Of | Replays only: the id of the original delivery (del_…) being re-sent. |
FomoData-Test | true on test sends (dashboard Send test event or POST /v1/webhooks/{id}/test) and their replays; absent otherwise. |
User-Agent | FomoData-Webhooks/1.0 |
Content-Type | application/json |
Verify signatures
FomoData-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" keyed with your webhook secret. To verify:
- Split the header on
,and readtand everyv1. - Reject if
tis more than 300 seconds from your clock (replay protection). - Compute HMAC-SHA256 of
`${t}.${rawBody}`with the secret and compare to eachv1in constant time.
import { FomoData } from "@fomodata/sdk";
const fomo = new FomoData({ webhookSecret: process.env.FOMODATA_WEBHOOK_SECRET });
// Fetch-API runtimes (Next.js route handlers, Hono, Bun, Workers):
// verifies the signature, dispatches to on() handlers, answers 200 / 400 / 500.
fomo.webhooks.on("thesis.created", async (event) => {
console.log(event.id, event.data.profile.handle);
});
export const POST = (request: Request) => fomo.webhooks.handle(request);
// Anywhere else: verify + parse yourself (throws FomoDataError INVALID_SIGNATURE).
const event = await fomo.webhooks.constructEvent(rawBody, signatureHeader);Use the raw body
Compute the HMAC over the exact bytes you received. Re-serializing parsed JSON changes whitespace and key order and the signature will not match.
Retries
A delivery succeeds when your endpoint returns any 2xx within the timeout. Anything else (non-2xx, timeout, connection error) is retried on this schedule:
| Attempt | Delay after the previous failure |
|---|---|
| 1 | Immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
- After the 6th failed attempt the delivery is marked
failed. You can still replay it. - A
410 Goneresponse disables the endpoint immediately. - Endpoints with 50 consecutive failed delivery attempts spanning at least 24 hours are disabled (
disabled_failing). Re-enabling resets the streak. Test sends and replays never count toward the streak. - Redirects are not followed. Point the URL at the final destination.
- Each attempt (DNS lookup, connect and response) must finish within 5 seconds; answer with a
2xxfirst and do slow work afterwards. Endpoints whose recent attempts keep failing or timing out are retried with lower priority, so one slow endpoint can't hold up other deliveries. - Live deliveries still waiting for a retry are cancelled if the project's live access is revoked.
Idempotency and ordering
Delivery is at-least-once. The same event can arrive more than once (a retry after a timeout you actually processed, or a replay), always with the same event.id and FomoData-Event-Id. Store processed ids and skip duplicates. Events may arrive out of order; use created_at when order matters.
Logs and replay
Every attempt is logged with status (delivered, failed, retrying, pending), response code, latency, timestamp and event id. Replay any delivery from the dashboard or the API. Replays reuse the same event id and send FomoData-Replay: true.
/v1/webhooks/{id}/deliverieswebhooks:writePOST/v1/webhook-deliveries/{id}/replaywebhooks:writeTest events
Send test event in the dashboard, or POST /v1/webhooks/{id}/test, delivers a clearly labelled thesis.created to that one endpoint: test: true, a sandbox handle and token, and the webhook's own environment. It is never confused with a real Fomo event. Test sends and replays are limited to 10 per minute per project (429 RATE_LIMITED beyond that).
/v1/webhooks/{id}/testwebhooks:writeURL security
To protect against SSRF, webhook URLs must be public HTTPS endpoints. FomoData resolves DNS when you create the webhook and again before every delivery, and refuses:
localhost, loopback,*.internaland0.0.0.0/8- Private ranges
10/8,172.16/12,192.168/16,fc00::/7, and CGNAT100.64/10 - Link-local
169.254/16(including the169.254.169.254metadata endpoint) andfe80::/10 - Multicast addresses and non-HTTP(S) schemes
*.localand other internal suffixes, single-label hostnames, and URLs with embedded credentials- Ports other than
443,80and1024–65535, and well-known database or infrastructure ports in that range (for example5432,6379,9200,27017)
Refused URLs return 422 WEBHOOK_URL_FORBIDDEN with details.reason (for example private, loopback, linkLocal, scheme). Deliveries connect only to the address that was just validated, so a hostname that later re-resolves to a private address (DNS rebinding) is refused at delivery time and the attempt fails. Repeated refusals from one project raise an abuse review.
Management endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/webhooks | List webhooks |
| POST | /v1/webhooks | Create a webhook |
| GET | /v1/webhooks/{id} | Retrieve |
| PATCH | /v1/webhooks/{id} | Update URL, events, filters, status |
| DELETE | /v1/webhooks/{id} | Delete |
| POST | /v1/webhooks/{id}/rotate-secret | Rotate the signing secret |
| POST | /v1/webhooks/{id}/test | Send a test event |
| GET | /v1/webhooks/{id}/deliveries | Delivery log |
| POST | /v1/webhook-deliveries/{id}/replay | Replay a delivery |
The number of endpoints per project and environment is limited by your plan's webhook_endpoints. Creating one more returns 403 WEBHOOK_LIMIT_REACHED with the limit and environment in details.