# Webhooks · Femtos

> Femtos webhooks: bridges, a wallet’s activity, liquidations, large positions and launches pushed to your HTTPS URL, signed (Standard Webhooks), retried, with a delivery log and replay.

- Base URL: `https://api.femtos.xyz/v1` (REST) · `wss://api.femtos.xyz/v1/ws` (WebSocket) · MCP: `https://api.femtos.xyz/mcp`
- Auth: `Authorization: Bearer $FEMTOS_KEY`. Free key: https://femtos.xyz/accounts/ (browser), or with
  no browser: `GET https://femtos.xyz/accounts/agent/challenge`, solve its proof of work, then `POST https://femtos.xyz/accounts/agent/signup`
  (https://femtos.xyz/agents.md#agent-onboarding)
- Decimals are exact strings, times Unix milliseconds, addresses lowercase `0x`
- Whole API in one file: https://femtos.xyz/agents.md · OpenAPI: https://api.femtos.xyz/v1/openapi.json · Index: https://femtos.xyz/llms.txt
- Page with live calls: https://femtos.xyz/webhooks

## Webhooks

Push instead of polling: we POST the events of your subscriptions to your HTTPS URL, signed. Same channels and filters
as the WebSocket, delivered at least once, retried for ~18 h, with a delivery log you can read and replay.

### Channels

| Channel | Events | Filter |
|---|---|---|
| `bridge.transfers` | HyperCore ↔ HyperEVM transfers, both legs matched (initiated, completed, stuck) | token, direction (evm_to_core / core_to_evm), account, min_amount |
| `elysium.bridge` | HyperEVM ↔ Elysium bridge messages (sent, executed, failed) | token, direction (to_child / to_parent) |
| `wallet` | Everything touching up to 100 addresses: fills, orders, ledger, liquidations, positions, bridges, Elysium swaps | address (required: one, a comma list or an array), channel (event kinds) |
| `hc.liquidations` | HyperCore liquidations | coin, user, min_notional |
| `hc.positions` | Large HyperCore position changes (one per block, user and coin) | coin, user, min_notional (>= 10000, default 100000) |
| `elysium.launches` | New launches on Elysium launchpads (Ascend) | launchpad, include_spam |
| `hyperevm.launches` | New LiquidLaunch tokens on HyperEVM | include_spam |

A subscription is `{channel, filter, batch}`. `batch: "block"` (default): one POST per source block with all its
matching events (at most 100 events or 256 KiB, split beyond); `"none"`: one POST per event. Named wallets appear as
their handles, as everywhere in the API.

### Create

```bash
curl -s -X POST https://api.femtos.xyz/v1/webhooks -H "Authorization: Bearer $FEMTOS_KEY" -H 'content-type: application/json' -d '{
  "url": "https://example.com/femtos",
  "description": "whales",
  "subscriptions": [
    {"channel": "hc.liquidations", "filter": {"min_notional": "100000"}},
    {"channel": "wallet", "filter": {"address": ["0xf122277ba28a959d2c1b675f6f76570696c9d819"], "channel": ["hc.trades", "bridge.transfers"]}}
  ]}'
# 201 {"webhook": {"id": "wh_…", "state": "pending_verification", …}, "secret": "whsec_…"}   (the secret is shown once)
```

- Your URL: `https`, port 443 or 8443, a public address (private, loopback, link-local and our own hosts are refused,
  checked again at every delivery). Redirects are not followed.
- Verification: the first POST is `{"type": "webhook.verification", "data": {"challenge": "…"}}`, signed. Answer it
  2xx with `{"challenge": "<data.challenge>"}` (or the challenge as text) within 10 s. Nothing else is sent before;
  the webhook then turns `active`. Changing the URL, or enabling a disabled webhook, verifies it again.
- The webhook belongs to your account, not to the key: a rotated key keeps it.

### What we POST

```json
{"type": "hc.liquidations", "timestamp": "2026-10-22T09:30:00.123Z", "webhook_id": "wh_3fJ9xQ2mV8kLp0aZ",
 "subscription_id": "sub_Q2mV8kLp0aZ3fJ9x", "channel": "hc.liquidations", "filter": {"min_notional": "100000"},
 "seq": 42, "block": 1175070705,
 "data": [{"coin": "BTC", "px": "107250.5", "sz": "1.2", "notional": "128700.6", "liquidated_user": "0x…", "tid": 154406062234611, "time": 1792236158000, "block": 1175070705}]}
```

- `data`: the channel's WebSocket events, oldest first. `seq` numbers the deliveries of a subscription (+1 each):
  one being retried arrives after later ones, so order by `seq` when it matters.
- `type: "gap"`, `data: {from, to, channels, reason}`: after a restart of our stream (`disconnected`,
  `snapshot_limit`) or events our outbox could not store (`enqueue_failed`), events between `from` and
  `to` (Unix ms) may be missing: read them from the REST API (`/v1/liquidations`, `/v1/bridges`, `/v1/wallet/{address}`…).
  Launches never gap: they resume from where they stopped.
- `type: "webhook.test"`: sent by `POST /v1/webhooks/{id}/test`.

### Verify the signature

Standard Webhooks (https://www.standardwebhooks.com): `webhook-id` (stable across retries: dedupe on it),
`webhook-timestamp` (Unix seconds), `webhook-signature` = `v1,<base64 HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{body}")>`,
the key being the base64 part of your `whsec_` secret, decoded. During the 24 h after `POST /v1/webhooks/{id}/rotate-secret`
it carries two signatures (space-separated), one per secret: accept either. Check the raw body, before any JSON parsing.
The `standardwebhooks` libraries (npm, PyPI, Go, Rust…) do it too.

TypeScript (Node):

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

/** true when one of the signatures is ours and the timestamp is within 5 minutes */
export function verifyFemtos(secret: string, headers: Headers, body: string): boolean {
  const id = headers.get('webhook-id');
  const ts = headers.get('webhook-timestamp');
  const sigs = headers.get('webhook-signature');
  if (!id || !ts || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const want = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest();
  return sigs.split(' ').some((s) => {
    const [version, b64] = s.split(',');
    const got = Buffer.from(b64 ?? '', 'base64');
    return version === 'v1' && got.length === want.length && timingSafeEqual(got, want);
  });
}
```

Python:

```python
import base64, hashlib, hmac, time

def verify_femtos(secret: str, headers, body: bytes) -> bool:
    """True when one of the signatures is ours and the timestamp is within 5 minutes."""
    msg_id = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp")
    sigs = headers.get("webhook-signature")
    if not (msg_id and ts and sigs) or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    want = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    return any(
        version == "v1" and hmac.compare_digest(base64.b64decode(b64), want)
        for version, _, b64 in (s.partition(",") for s in sigs.split(" "))
    )
```

A receiver (Node), verification echo included:

```ts
import http from 'node:http';

http
  .createServer((req, res) => {
    let body = '';
    req.on('data', (c) => (body += c));
    req.on('end', () => {
      if (!verifyFemtos(process.env.FEMTOS_WEBHOOK_SECRET!, new Headers(req.headers as Record<string, string>), body)) return res.writeHead(401).end();
      const msg = JSON.parse(body);
      // the challenge of a new (or re-enabled) webhook: echo it
      if (msg.type === 'webhook.verification') return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ challenge: msg.data.challenge }));
      // answer at once; dedupe on webhook-id (a retry carries the same), then handle msg.data (the events) later
      res.writeHead(204).end();
      queue.push({ id: req.headers['webhook-id'], msg });
    });
  })
  .listen(8080); // behind your TLS proxy: we only POST to https on 443 or 8443
```

### Delivery, retries, failures

- Delivered = any 2xx within 10 s (connect 5 s): the status decides, your answer's body is read 1 s at most (its first
  4 KiB: the delivery log, the verification echo). Anything else, a 3xx included, is retried after 5 s, 5 min, 30 min,
  2 h, 5 h and 10 h (7 attempts); a `Retry-After` on a 429 / 503 is honoured. Then the delivery is `dead`: kept 7
  days, replayable. One request at a time per webhook.
- Disabled: at once on `410 Gone`, or after 50 failures in a row spanning more than 24 h. `GET /v1/webhooks/{id}` says
  so (`state: "disabled"`, `disabled.reason`), and every API answer of your key carries `X-Femtos-Notice` (MCP:
  `notice`) until you fix the receiver and `POST /v1/webhooks/{id}/enable` (verified again; pending deliveries are kept 7 days, like every message).
- Deliveries leave from a fixed egress address, to allow-list if your firewall needs it: it will be listed here.

### Manage

| Call | Does |
|---|---|
| `GET /v1/webhooks` | Your account's webhooks |
| `GET /v1/webhooks/{id}` | One webhook: state, failures, subscriptions, deliveries today |
| `PATCH /v1/webhooks/{id}` | Change the URL (verified again), description or subscriptions (the whole list) |
| `DELETE /v1/webhooks/{id}` | Delete a webhook and its pending deliveries |
| `POST /v1/webhooks/{id}/test` | Send a webhook.test delivery (not billed) |
| `GET /v1/webhooks/{id}/deliveries?status=pending\|delivered\|dead` | Deliveries, newest first, with every attempt (kept 7 days) |
| `POST /v1/webhooks/{id}/deliveries/{msg}/replay` | Send one delivered or dead delivery again (same webhook-id) |
| `POST /v1/webhooks/{id}/replay?since=<ms>&status=dead\|all` | Send every dead (or every) delivery since a time again (counted in the deliveries a day, each metered again) |
| `POST /v1/webhooks/{id}/enable` | Turn a disabled webhook on again: a new verification challenge first |
| `POST /v1/webhooks/{id}/rotate-secret` | A new secret; the previous one keeps signing (a second signature) for 24 h |

MCP: `create_webhook`, `list_webhooks`, `delete_webhook`, `test_webhook`, `get_webhook_deliveries`, `replay_webhook`.

### Limits

| Tier | Webhooks | Subscriptions | Deliveries a day |
|---|---|---|---|
| free | 2 | 5 | 10,000 |
| builder | 10 | 50 | 500,000 |
| pro | 50 | 500 | 5,000,000 |
| scale | 200 | 5000 | unlimited |

A `wallet` subscription takes up to 100 addresses. Past the daily deliveries, events are dropped until 00:00 UTC
(`droppedToday` on the webhook). A replay counts in the deliveries a day: past them it is refused whole
(`limit_reached`, 403). Each management call counts like any REST call; a delivery counts once delivered (a replayed
one again), failed attempts never.

Full contract: https://api.femtos.xyz/v1/openapi.json (tag Webhooks, and `webhooks.delivery` for the POST we send) · https://femtos.xyz/webhooks.md
