Skip to content
Realtime

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.

POST/v1/webhooks
Request body
urlstringrequired
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.
filtersobject
token, handle, chain arrays. An event must match every given dimension; values within a dimension are OR-ed. Token filters accept $SYMBOL (case-insensitive, $ optional), an address or a tk_ 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.

POST body · sandbox
{
  "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

HeaderValue
FomoData-Signaturet=1759831452,v1=5f2b…
FomoData-Event-IdThe event id (evt_…), stable across retries and replays.
FomoData-Event-Typee.g. thesis.created
FomoData-Delivery-IdThis delivery (del_…).
FomoData-Delivery-AttemptAttempt number, starting at 1.
FomoData-Replaytrue for manual replays, otherwise false.
FomoData-Replay-OfReplays only: the id of the original delivery (del_…) being re-sent.
FomoData-Testtrue on test sends (dashboard Send test event or POST /v1/webhooks/{id}/test) and their replays; absent otherwise.
User-AgentFomoData-Webhooks/1.0
Content-Typeapplication/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:

  1. Split the header on , and read t and every v1.
  2. Reject if t is more than 300 seconds from your clock (replay protection).
  3. Compute HMAC-SHA256 of `${t}.${rawBody}` with the secret and compare to each v1 in 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:

AttemptDelay after the previous failure
1Immediately
21 minute
35 minutes
430 minutes
52 hours
612 hours
  • After the 6th failed attempt the delivery is marked failed. You can still replay it.
  • A 410 Gone response 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 2xx first 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.

GET/v1/webhooks/{id}/deliveriesPOST/v1/webhook-deliveries/{id}/replay

Test 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).

POST/v1/webhooks/{id}/test

URL 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, *.internal and 0.0.0.0/8
  • Private ranges 10/8, 172.16/12, 192.168/16, fc00::/7, and CGNAT 100.64/10
  • Link-local 169.254/16 (including the 169.254.169.254 metadata endpoint) and fe80::/10
  • Multicast addresses and non-HTTP(S) schemes
  • *.local and other internal suffixes, single-label hostnames, and URLs with embedded credentials
  • Ports other than 443, 80 and 1024–65535, and well-known database or infrastructure ports in that range (for example 5432, 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

MethodPathPurpose
GET/v1/webhooksList webhooks
POST/v1/webhooksCreate 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-secretRotate the signing secret
POST/v1/webhooks/{id}/testSend a test event
GET/v1/webhooks/{id}/deliveriesDelivery log
POST/v1/webhook-deliveries/{id}/replayReplay 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.