# Hypersnap — Full documentation Hypersnap is a decentralized social network built by a global community of contributors. No company, no VC. The evolution of Farcaster: same wire format and identities, but every node is run by someone different. Source repository: https://github.com/farcasterorg/hypersnap-docs-web Last synced: 2026-05-20T05:16:55Z Site index: https://hypersnap.org/llms.txt Operator guide: https://hypersnap.org/run-a-node Install helper: https://hypersnap.org/install.sh Operator toolkit source: https://github.com/arcabotai/hypersnap --- # Getting Started --- # Introduction Hypersnap is a Rust implementation of a Farcaster node. Alongside the core consensus + gossip layer, it exposes an HTTP API at `/v2/farcaster/*` that mirrors the shape of the broader Farcaster v2 API ecosystem so existing client SDKs and patterns work unchanged. ## What's on the wire There are **four** families of endpoint you care about as a developer: 1. **Public reads** (no auth). Casts, users, feeds, channels, reactions, follows, search. Shape matches common Farcaster v2 client contracts so serializers are interchangeable. 2. **Webhook management** (EIP-712 signed). Register a webhook owned by your FID, filter events, receive HMAC-signed deliveries. 3. **Mini-app management** (EIP-712 signed) + **send endpoint** (per-app bearer) + **client token webhook** (JFS-signed). The full Farcaster Mini App spec. 4. **Public batch reads** (no auth, POST body). Hydrate a list of FIDs with follows, reactions, signers, or registration metadata. ## What Hypersnap is not - It is **not** a gRPC endpoint for message submission through this doc site. Message submission continues to use Farcaster's protobuf wire format over gRPC — see the upstream [Farcaster protocol spec](https://github.com/farcasterxyz/protocol) for that path. - It is **not** an identity system. Hypersnap reads identity state from the on-chain `IdRegistry` contract. Your FID lives on L2, not in Hypersnap. - It is **not** a walled garden. Every public read endpoint is unauthenticated and served from local RocksDB indexes over the data the node has ingested. ## Where Hypersnap lives A Hypersnap node runs two listeners: - **HTTP** (default `:3381`) — everything in this documentation. - **gRPC** (default `:3383`) — internal consensus/gossip + protobuf message submission. All URLs in this reference are relative to the HTTP listener. **A public Hypersnap node** — supporting every endpoint documented here — **is live at `https://haatz.quilibrium.com`**. Every example in this book uses that host so you can run the `curl` snippets verbatim and get real responses. Substitute your own hostname any time you're pointing at a self-hosted node. ## Versioning - The HTTP API paths start with `/v2/farcaster/`. Hypersnap does not ship unstable endpoints in this prefix — if something is documented here, it's part of the stable contract. - Subscription and event-schema changes to webhooks are additive: new event fields can appear, existing ones are not renamed or removed. - Mini-app send and registration contracts track the upstream Farcaster Mini App spec at . --- # Your first request The shortest path from zero to a real response: fetch a user by FID. ## 1. Pick a node Hypersnap is self-hostable, but you don't have to run one yourself to follow along — **there is a public Hypersnap node you can point at right now:** ```text https://haatz.quilibrium.com ``` Every example, every try-it panel, and the [interactive playground](./playground.md) all target this endpoint by default. Substitute your own hostname (e.g. `http://localhost:3381` for a local `hypersnap` process, or whatever operator URL you're working with) anywhere you see it. ## 2. Hit a public read ```bash curl -s "https://haatz.quilibrium.com/v2/farcaster/user?fid=3" | jq . ``` Response: ```json { "user": { "fid": 3, "username": "dwr.eth", "display_name": "Dan Romero", "pfp_url": "https://i.imgur.com/...", "profile": { "bio": { "text": "..." } }, "follower_count": 12345, "following_count": 200, "verified_addresses": { "eth_addresses": ["0x..."] } } } ``` No auth, no signed headers, no API key. Every endpoint under [Read API reference](./reference/reads/index.md) works this way. ## 3. Hit something paginated Follower lists and feeds return a `next.cursor` you can pass back to walk the result set: ```bash curl -s "https://haatz.quilibrium.com/v2/farcaster/user/followers?fid=3&limit=50" | jq '.next' # { "cursor": "50" } curl -s "https://haatz.quilibrium.com/v2/farcaster/user/followers?fid=3&limit=50&cursor=50" | jq '.users | length' # 50 ``` See [Pagination & cursors](./concepts/pagination.md) for the full contract. ## 4. What to read next - **Want to fetch casts/users/feeds?** → [Read API reference](./reference/reads/index.md) - **Want realtime events?** → [Webhooks](./reference/webhooks/index.md) - **Building a mini app?** → [Mini-app notifications](./reference/miniapps/index.md) - **Want an end-to-end walkthrough?** → [Build a Farcaster client](./guides/build-a-client.md) - **Have an LLM write the code?** → [Using these docs with an LLM](./agents/index.md) --- # API playground Run real requests against a Hypersnap node without leaving this page. Every "Try it" panel in the docs uses the same machinery you see here — this page just collects one example per endpoint family in one place. ## How it works - **Host.** The panels default to `https://haatz.quilibrium.com`, the public node. Change the Host field on any panel and it's remembered for the rest of your session. - **FID.** Signed endpoints need to know which FID you're acting as. Set it once in the host bar and every signed panel picks it up. - **Wallet.** The **Connect wallet** button talks to any EIP-1193 provider: MetaMask, Frame, Rabby, a Ledger via Frame, or the wallet embedded in a Farcaster client if you've loaded this page as a mini app. Signed requests use `eth_signTypedData_v4` — the library never sees your private key. Nothing is sent anywhere except directly to the node you configured. - **Keccak-256.** The `requestHash` field inside the EIP-712 payload is computed locally with a vendored pure-JS Keccak-256 (self-tested at load time). There is no external JS dependency loaded at runtime. > **Everything happens in your browser.** The docs are a static site. "Connect wallet" opens your wallet extension directly. "Run" issues a `fetch` from your browser to the configured Hypersnap host. The docs site is never in the middle and never sees your keys, signatures, or responses. ## Public reads — no wallet needed ### User lookup
### Cast by hash
### Following feed
### Trending feed
### User search
## Signed management — wallet required These are the endpoints your custody key signs for. Click **Connect wallet** once, set your FID in the host bar, and every panel below will sign + submit when you click Run. ### Create a webhook
### List your webhooks
### Rotate a webhook signing secret
### Delete a webhook
### Register a mini app
### List your mini apps
### Rotate mini-app send secret
## Send a notification — per-app x-api-key This endpoint authenticates with your mini app's `send_secret`, not your custody key. Paste the secret directly into the `x-api-key` field below — nothing is persisted, nothing is sent anywhere except to the Hypersnap host you configured. > Be careful pasting production secrets into a web page. For development + testing it's fine; for production sends, call the endpoint from your own backend.
## Running as a mini app The docs themselves can run inside a Farcaster client as a mini app. When loaded that way, Connect wallet talks to the client's embedded wallet via the Farcaster mini-app SDK, and your FID is already known. See [Run as a mini app](./guides/run-as-miniapp.md) for the one-line manifest that makes this work. ## Troubleshooting - **"No Ethereum provider found"** — install a wallet extension (MetaMask / Frame / Rabby) or open the page inside a Farcaster client. - **`401 signature mismatch`** — your FID's custody address on-chain doesn't match the key you just signed with. Check your wallet is on the right account. - **`401 clock skew too large`** — your device clock is off. Fix the clock and retry. - **`403 not the owner`** — you're trying to look up or mutate a webhook/app owned by a different FID. Set the FID that owns the resource, or create your own. - **`429`** — you hit the per-FID cap (default 25 webhooks / 25 mini apps). Delete something or ask your operator to raise the cap. - **Browser CORS error** — the host you configured either isn't CORS-enabled or isn't a Hypersnap node. `haatz.quilibrium.com` is CORS-open for all documented routes. # Concepts --- # Concepts Before diving into endpoints, it helps to have the mental model that the rest of this documentation assumes. - **[Farcaster identity (FIDs)](./fids.md)** — what an FID is, how custody works, how Hypersnap resolves it. - **[Signed operations (EIP-712)](./authentication.md)** — how management requests (webhooks, mini-app registration) are authenticated using the FID's custody key. - **[JSON Farcaster Signatures (JFS)](./jfs.md)** — the Ed25519 envelope Farcaster clients use to report mini-app token events. - **[Pagination & cursors](./pagination.md)** — the `next.cursor` contract used across list endpoints. - **[Rate limits](./rate-limits.md)** — what the server enforces and what it doesn't. - **[Errors](./errors.md)** — HTTP status codes and response shapes you should expect. None of these are long reads — you can skim them in a few minutes and come back as a reference when something in the endpoint pages doesn't make sense. --- # Farcaster identity (FIDs) Every Farcaster user has a **Farcaster ID (FID)** — a `u64` assigned by the on-chain `IdRegistry` contract. An FID is permanent and non-transferable, but the **custody address** that controls it can change via the `IdGateway` contract. ## The three moving parts 1. **FID** — `u64`. Stable. Public. This is what appears in every API response. 2. **Custody address** — the Ethereum address currently in charge of the FID. Signing a Hypersnap management request (creating a webhook, registering a mini app) means producing an EIP-712 signature with this key. 3. **Signers** — Ed25519 keys registered to an FID via the `KeyRegistry` contract. These sign Farcaster *messages* (casts, reactions, follows). Mini-app token webhooks (JFS) are validated against the active signer set for an FID. Hypersnap reads (1)–(3) from on-chain state, indexed locally. The custody lookup is available everywhere EIP-712 verification happens; the signer lookup is used when verifying JFS token webhooks. ## How Hypersnap derives custody When you sign a webhook-management request, Hypersnap: 1. Recovers the signer address from your EIP-712 signature. 2. Calls its `CustodyAddressLookup::get_custody_address(fid)`, which reads local `IdRegistry` event state. 3. Rejects the request if the recovered address doesn't match. If your custody address changes on-chain, the next request signed with the new key just works — Hypersnap follows the on-chain state, it does not maintain a separate registration step. ## Usernames vs FIDs The API accepts both where it makes sense: - `GET /v2/farcaster/user?fid=3` — look up by FID. - `GET /v2/farcaster/user/by-username?username=dwr.eth` — look up by username. Usernames are either: - **fnames** — off-chain usernames managed by the Farcaster name service. - **ENS names** — verified on-chain. - **Basenames** — the `.base.eth` space. All three resolve through the same `username_proof` table and surface on the `User` object in your responses. When you display a user, prefer `display_name` → `username` → `fid` as fallbacks. ## Why the API has no logins There are no session tokens, no API keys for reads, no OAuth dance. Public reads are genuinely public: Hypersnap serves them from local indexes, and the authoritative data is already public on-chain (signers, registrations) and in the Farcaster node network (messages). Anything that mutates state (webhooks, mini app registrations) is authenticated directly by the FID's custody key via EIP-712 — see [Signed operations](./authentication.md) for the details. --- # Signed operations (EIP-712) Management requests — creating or modifying webhooks, registering a mini app, rotating a secret — are authenticated by having the **FID's custody address** sign a small structured payload using EIP-712 typed data. Hypersnap reads the custody address from on-chain `IdRegistry` state, so whoever controls the key controls the FID's resources, no extra enrollment step. ## What gets signed All signed endpoints use the same typed-data shape: ```text Domain: { name: "Hypersnap", version: "1", chainId: 10 } Type: HypersnapSignedOp( string op, // operation name (see list below) uint64 fid, uint256 signedAt, // unix seconds bytes32 nonce, bytes32 requestHash // keccak256(raw HTTP body bytes) ) ``` The `requestHash` is `keccak256` of the **raw bytes** of the HTTP body — no JSON re-canonicalization. This means the client and server hash the same bytes exactly. ## Required HTTP headers | Header | Value | |---|---| | `X-Hypersnap-Fid` | Your FID (decimal string). | | `X-Hypersnap-Op` | The operation name — see the table below. | | `X-Hypersnap-Signed-At` | Unix seconds at time of signing. Must be within `signed_at_window_secs` (default 5 min) of the server clock. | | `X-Hypersnap-Nonce` | `0x`-prefixed 32-byte random nonce. Deduped in-memory for the duration of the signed_at window so a replay is impossible. | | `X-Hypersnap-Signature` | `0x`-prefixed 65-byte EIP-712 signature over the typed data above. | ## Operation names The `X-Hypersnap-Op` header (and the `op` field inside the signed typed data) must match the HTTP method + path of the request. The server cross-checks them — a signed `webhook.create` cannot be replayed against a `DELETE` route. | Endpoint | Op string | |---|---| | `POST /v2/farcaster/webhook/` | `webhook.create` | | `PUT /v2/farcaster/webhook/` | `webhook.update` | | `DELETE /v2/farcaster/webhook/` | `webhook.delete` | | `GET /v2/farcaster/webhook/` (lookup) | `webhook.read` | | `GET /v2/farcaster/webhook/list` | `webhook.read` | | `POST /v2/farcaster/webhook/secret/rotate` | `webhook.rotate_secret` | | `POST /v2/farcaster/frame/app/` | `app.create` | | `PUT /v2/farcaster/frame/app/` | `app.update` | | `DELETE /v2/farcaster/frame/app/` | `app.delete` | | `GET /v2/farcaster/frame/app/` (lookup) | `app.read` | | `GET /v2/farcaster/frame/app/list` | `app.read` | | `POST /v2/farcaster/frame/app/secret/rotate` | `app.rotate_secret` | ## Server-side verification The server performs these checks, in order, before routing a request to its handler: 1. **Clock skew** — reject if `|now − signed_at| > signed_at_window_secs`. 2. **Nonce replay** — reject if `(fid, nonce)` was used within the signed_at window. 3. **Typed data recovery** — compute the EIP-712 hash, recover the signer address via `ecrecover`. 4. **Custody match** — look up `custodyOf(fid)` on-chain and reject if the recovered address doesn't match. 5. **Op ↔ route match** — reject if the signed `op` doesn't match the actual HTTP method/path. If any step fails you get `401 Unauthorized` with a short message body. ## Why this shape - **Body bytes are signed directly**, so JSON canonicalization is a non-issue. You hash what you send. - **Typed data is EIP-712-native**, so every Ethereum wallet library can produce the signature with a small adapter. - **No session tokens.** Each request is individually signed; there is no login state the server has to track. - **The nonce prevents replay** inside the signed_at window. Outside the window, clock skew rejects the request anyway. ## Minimal JavaScript example ```javascript import { ethers } from "ethers"; import { randomBytes } from "crypto"; const wallet = new ethers.Wallet(privateKey); const body = JSON.stringify({ name: "my webhook", url: "https://receiver.example.com/hook", subscription: { cast_created: { author_fids: [3] } }, }); const signedAt = Math.floor(Date.now() / 1000); const nonce = "0x" + randomBytes(32).toString("hex"); const requestHash = ethers.keccak256(ethers.toUtf8Bytes(body)); const domain = { name: "Hypersnap", version: "1", chainId: 10 }; const types = { HypersnapSignedOp: [ { name: "op", type: "string" }, { name: "fid", type: "uint64" }, { name: "signedAt", type: "uint256" }, { name: "nonce", type: "bytes32" }, { name: "requestHash", type: "bytes32" }, ], }; const value = { op: "webhook.create", fid: 3n, signedAt: BigInt(signedAt), nonce, requestHash, }; const signature = await wallet.signTypedData(domain, types, value); const resp = await fetch("https://haatz.quilibrium.com/v2/farcaster/webhook/", { method: "POST", headers: { "Content-Type": "application/json", "X-Hypersnap-Fid": "3", "X-Hypersnap-Op": "webhook.create", "X-Hypersnap-Signed-At": String(signedAt), "X-Hypersnap-Nonce": nonce, "X-Hypersnap-Signature": signature, }, body, }); ``` See the [Sign an EIP-712 request](../guides/sign-eip712.md) guide for Python and Rust equivalents. --- # JSON Farcaster Signatures (JFS) **JFS** is the signing envelope Farcaster *clients* (Warpcast et al.) use when they POST mini-app webhook events to a server. It is defined in the Farcaster Mini App spec at and Hypersnap implements the server side of it to verify `miniapp_added` / `notifications_enabled` / ... events. You almost certainly don't need to generate JFS yourself — clients do that. This page explains the wire format and verification rules so you understand what's happening under the hood and can debug if something goes wrong. ## Wire format A JFS envelope is a JSON object with three base64url-encoded fields: ```json { "header": "", "payload": "", "signature": "" } ``` - **`header`** — base64url-encoded JSON containing the signer metadata: ```json { "fid": 12345, "type": "app_key", "key": "0x" } ``` The `key` is the Ed25519 public key the signer used, and `fid` is the Farcaster ID claiming it. - **`payload`** — the event being signed, base64url-encoded. For mini-app webhooks, the decoded JSON is one of: - `{ "event": "miniapp_added", "notificationDetails"?: { "url": "...", "token": "..." } }` - `{ "event": "miniapp_removed" }` - `{ "event": "notifications_enabled", "notificationDetails": { "url": "...", "token": "..." } }` - `{ "event": "notifications_disabled" }` - **`signature`** — base64url-encoded 64-byte Ed25519 signature over the UTF-8 bytes of `.` (yes, including the dot). ## How Hypersnap verifies it When a Farcaster client POSTs a JFS envelope to `/v2/farcaster/frame/webhook/`, Hypersnap: 1. Decodes the three base64url fields. 2. Recomputes `signing_input = header_b64 + "." + payload_b64`. 3. Verifies the Ed25519 signature against the `key` in the header. 4. Looks up the active signers for the `fid` in the on-chain `KeyRegistry` and confirms the signing key is currently active for that FID. 5. (If `signer_fid_allowlist` is set on the mini app) confirms `fid` is in the allowlist. 6. Applies the decoded event to the token store. Steps 4–5 are the important ones: a valid Ed25519 signature is not enough on its own. The key must still be registered to a live FID on-chain, and (if the app opted in) must belong to a whitelisted signer FID. ## What you should do on the server side If you're building a mini app and receiving JFS events on your own server (for cases where you aren't proxying through Hypersnap), the same rules apply: 1. **Verify signatures** using the Ed25519 public key in the header. 2. **Check that the signing key is currently active** for the claimed FID, by reading the `KeyRegistry`. 3. **Treat `notifications_disabled` and `miniapp_removed` as idempotent delete signals** — same token, repeated events, always converge to "gone". Hypersnap does all of this for you; you only need to worry about it if you process JFS events yourself. --- # Pagination & cursors List endpoints return up to `limit` items (default `10`) plus a `next` object with a cursor for the next page: ```json { "users": [ /* ... */ ], "next": { "cursor": "50" } } ``` ## Contract - **`limit`** is a query parameter. If you omit it, you get 10 items. - **`cursor`** is an opaque string. You pass whatever the previous response gave you in `next.cursor` back as the `cursor` query param. - **End of results** is signaled either by an empty `next` (null cursor) or by a response shorter than `limit`. ```bash # Page 1 curl -s "https://haatz.quilibrium.com/v2/farcaster/user/followers?fid=3&limit=50" | jq '.next.cursor' # "50" # Page 2 curl -s "https://haatz.quilibrium.com/v2/farcaster/user/followers?fid=3&limit=50&cursor=50" | jq '.next.cursor' # "100" ``` ## What cursors actually are For most list endpoints in Hypersnap, cursors today are stringified u64 offsets into the underlying index. **Treat them as opaque anyway** — different endpoints may encode cursors differently, and future versions may switch to a more complex scheme. If you need to persist a position for later (resume scrolling), store the full cursor string verbatim. ## Limit caps Each endpoint has its own sanity cap — we don't document them exhaustively but in practice anything above `100` is unlikely to be honored. If you need to walk an entire result set, loop with a reasonable page size (e.g. `50`) rather than asking for thousands at once. ## List endpoints that accept `cursor` - `GET /v2/farcaster/feed`, `/feed/following`, `/feed/trending`, `/feed/channels` - `GET /v2/farcaster/user/followers`, `/user/following` - `GET /v2/farcaster/cast/search` - `GET /v2/farcaster/channel/all`, `/channel/bulk`, `/channel/members`, `/channel/user-active` - `GET /v2/farcaster/notifications` - `GET /v2/farcaster/reaction/cast`, `/reaction/user` Endpoints like `/user/bulk`, `/cast/bulk`, or channel lookups that take a comma-separated id list don't paginate — you pass the full set of ids and get back everything in a single response. --- # Rate limits Hypersnap is designed to be run by node operators for their own apps, not as a centralized API vendor, so the server-side rate-limiting story is modest. Here's the full picture: ## Public reads There is **no built-in per-IP rate limit** on the public read endpoints. Your operator may run a reverse proxy (nginx, Cloudflare, envoy, etc.) in front of the node with its own rate limiting — check their operations docs. In practice the node will happily serve tens of thousands of requests per second off RocksDB as long as disk/CPU hold up. Be a good citizen: cache heavy responses (feeds, search), obey your operator's posted limits, back off on 5xx. ## Webhook deliveries (outbound) Each webhook has a per-webhook rate limit enforced by the dispatcher: - Default: **1000 events per 60 seconds**, per webhook. - Configured at node level via `default_rate_limit` / `default_rate_limit_duration_secs`. - Events above the limit are dropped (counted in the `webhooks.delivery.rate_limited` metric), **not** queued. If you expect high-volume event streams, either bump the rate limit with your operator or narrow your subscription filters so fewer events match. ## Mini-app notification send The send endpoint has three applicable limits: 1. **Per-app dedupe window.** `(fid, notificationId)` is deduped for 24 hours by default. Sending the same notification twice within that window is a no-op for the recipient. This follows the Farcaster Mini App spec exactly. 2. **Per-token client limits.** The Farcaster client (Warpcast, etc.) enforces the spec-defined `1 notification per 30 seconds` and `100 per day` ceilings. Tokens that exceed those limits come back in `rate_limited_tokens` from the client, and Hypersnap returns the corresponding `fids` in `retryable_fids` so your code can retry later. 3. **Per-app token cap.** No hard limit — the registered token store is bounded by however many Farcaster users have added your mini app. ## Per-FID management caps When signing management requests: - **Webhooks:** `max_webhooks_per_owner` (default **25**). Creating a 26th returns `429 Too Many Requests`. - **Mini apps:** `max_apps_per_owner` (default **25**). Same behavior. Both caps are operator-configurable. ## Summary | Surface | Limit | Enforced where | |---|---|---| | Public reads | None by default | Your operator's reverse proxy | | Webhook delivery | 1000 events / 60 sec (default) | Hypersnap dispatcher | | Send → client batch | 100 tokens per POST | Hypersnap sender (spec-mandated) | | Send → per token | 1 / 30 sec, 100 / day | Farcaster client (spec-mandated), surfaced as `retryable_fids` | | Send → dedupe | `(fid, notificationId)` for 24h | Hypersnap (spec-mandated) | | Webhooks per FID | 25 (default) | Hypersnap store | | Mini apps per FID | 25 (default) | Hypersnap store | --- # Errors All error responses have the same shape: ```json { "message": "human-readable explanation" } ``` ## HTTP status codes | Code | Meaning | Example causes | |---|---|---| | `200 OK` | Success | Normal read, successful management write. | | `400 Bad Request` | Malformed input | Missing required query param, invalid JSON body, invalid regex in a subscription filter, URL fails SSRF check, missing `?owner_fid=` when creating a webhook. | | `401 Unauthorized` | Bad signature / auth headers | EIP-712 signature doesn't recover to the custody address, clock skew > 5min, nonce replayed, JFS signature invalid, send-endpoint `x-api-key` wrong, unknown FID. | | `403 Forbidden` | Not the owner | You signed a valid EIP-712 request but the resource (webhook/app) belongs to a different FID. | | `404 Not Found` | Resource doesn't exist | `webhook_id` / `app_id` / FID not found, unknown route. | | `429 Too Many Requests` | Per-owner cap hit | Creating webhook #26 when `max_webhooks_per_owner=25`, or mini app #26. | | `500 Internal Server Error` | Unexpected server fault | RocksDB error, panic in a handler, transient storage failure. Please file a bug. | ## Debugging auth failures If you get a `401` on a signed request, work down this list: 1. **Is the raw body identical to what you hashed?** The server computes `keccak256(body_bytes)` on the literal HTTP body it received. If your HTTP client re-serializes JSON between signing and sending (pretty-print, key reordering), the hash won't match. Send raw bytes. 2. **Is your clock correct?** `|now − signed_at|` must be ≤ `signed_at_window_secs` (default 5 min). `date -u` on the client should match the server. 3. **Is the nonce fresh?** A replayed `(fid, nonce)` pair rejects for up to `2 × signed_at_window_secs`. 4. **Is your op string exactly right?** `webhook.create` not `webhook_create` or `createWebhook`. See [Signed operations](./authentication.md) for the full list. 5. **Does the op match the HTTP method/path?** A signed `webhook.create` on `DELETE /.../webhook/` returns `400 "signed op does not match the HTTP method/path"`. 6. **Is your custody address up to date?** Hypersnap reads the current custody from on-chain `IdRegistry` state. If you recently transferred the FID, give the indexer a few seconds to catch up. ## Debugging 429 Per-owner caps are opt-in and operator-controlled. If you're hitting a cap, either: - Delete unused webhooks/apps via `DELETE /v2/farcaster/webhook/` or `DELETE /v2/farcaster/frame/app/` to free up slots, or - Ask your operator to raise `max_webhooks_per_owner` / `max_apps_per_owner`. ## Debugging webhook delivery failures Webhook delivery errors don't come back on your management-API call — they come back on the webhook delivery. See [Delivery contract](../reference/webhooks/delivery.md) for how successes and failures propagate. # Read API reference --- # Read API reference Every endpoint in this section is public: no auth, no signed headers, no API key. Responses are served from local indexes over the data the Hypersnap node has ingested. ## Conventions - **Base URL:** all paths are relative to the Hypersnap HTTP listener. Examples use `https://haatz.quilibrium.com` as a placeholder. - **Content type:** responses are always `application/json`. - **Pagination:** cursor-based; see [Pagination & cursors](../../concepts/pagination.md). - **Errors:** see [Errors](../../concepts/errors.md). All errors return `{ "message": "..." }`. ## Endpoint groups - **[Users](./users.md)** — lookup by FID, username, address, custody address, X/Twitter username, location; bulk; search; interactions; best friends; storage; FID listing; verifications. - **[Casts](./casts.md)** — lookup by hash or URL; bulk; search; conversation threads; quotes; metrics. - **[Feeds](./feeds.md)** — default, following, trending, for-you, channel, parent-url, topic, user-casts, popular, replies-and-recasts. - **[Channels](./channels.md)** — lookup, list, bulk, search, trending, members, followers, active-for-user, invites. - **[Reactions](./reactions.md)** — generic lookup, by-cast, by-user (with spec-compat aliases). - **[Follows](./follows.md)** — followers, following, reciprocal (mutual), suggested (friends-of-friends), relevant. - **[User notifications](./notifications.md)** — mentions/replies/reactions/follows for a FID, filterable by channel or parent URL. - **[Usernames & proofs](./usernames.md)** — fname availability, username proofs. - **[Signers & on-chain events](./signers.md)** — signer key registry, ID registry history. - **[Blocks, mutes, bans](./blocks-mutes.md)** — block/mute lists from link messages. - **[Batch reads](./batch.md)** — POST-body hydration for a list of FIDs (follows, reactions, signers, id-registrations, interactions). --- # Users All user endpoints return (or contain) the shared `User` object. See `src/api/types.rs` for the full field list; common fields include `fid`, `username`, `display_name`, `pfp_url`, `profile.bio`, `follower_count`, `following_count`, `verified_addresses`, and `power_badge`. --- ## GET /v2/farcaster/user Look up a single user by FID. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The FID to fetch. | **Response** ```json { "user": { "fid": 3, "username": "dwr.eth", "..." : "..." } } ``` **Example** ```bash curl -s "https://haatz.quilibrium.com/v2/farcaster/user?fid=3" ```
--- ## GET /v2/farcaster/user/bulk Batch lookup by FID list. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fids` | string | yes | Comma-separated list of FIDs, e.g. `3,5,191` | **Response** ```json { "users": [ { "fid": 3, "..." : "..." }, { "fid": 5, "..." : "..." } ] } ``` Missing FIDs are silently omitted from the response — if you asked for 3 fids and only 2 exist, you get 2 objects back, not a 404.
--- ## GET /v2/farcaster/user/bulk-by-address Batch lookup by verified Ethereum address. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `addresses` | string | yes | Comma-separated 0x-addresses. Matches against `User.verified_addresses.eth_addresses`. | **Response** — same `BulkUsersResponse` shape. Addresses that aren't verified against any FID are silently omitted.
--- ## GET /v2/farcaster/user/by-username Look up a user by username. Accepts fnames, ENS names, and Basenames — whatever resolves through the username-proof table. Also reachable as `GET /v2/farcaster/user/by_username` (underscore variant). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `username` | string | yes | Case-sensitive. Without a leading `@`. | **Response** — single `UserResponse`. `404` if the name isn't registered.
--- ## GET /v2/farcaster/user/custody-address Reverse-lookup: find the user whose custody Ethereum address matches. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `custody_address` | string | yes | `0x`-prefixed Ethereum address. | **Response** — `UserResponse`. `404` if no FID is registered to that address. If multiple FIDs share the address (legacy), the first is returned.
--- ## GET /v2/farcaster/user/by_x_username Look up an FID by the user's self-declared X/Twitter username. Backed by a reverse index built from `UserDataAdd` messages of type `USER_DATA_TYPE_TWITTER`. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `username` | string | yes | Case-insensitive match against the stored X username. | **Response** — `UserResponse`. `404` if no user has that X username registered.
--- ## GET /v2/farcaster/user/by_location Find users whose declared location matches a prefix. Backed by a reverse index built from `UserDataAdd` messages of type `USER_DATA_TYPE_LOCATION`. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `location` | string | yes | Prefix-match against the stored location string (case-insensitive). | | `limit` | usize | no | Default `10`. | **Response** — `BulkUsersResponse`.
--- ## GET /v2/farcaster/user/search Prefix-style search against the username index. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `q` | string | yes | Search query. | | `limit` | usize | no | Default `10`. | **Response** — `BulkUsersResponse`.
--- ## GET /v2/farcaster/user/verifications Returns the same `User` object as `/v2/farcaster/user`, with the `verified_addresses` section populated. Provided as an alias for callers that want to signal intent. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes |
--- ## GET /v2/farcaster/user/storage-allocations How much Farcaster storage an FID currently has allocated to it. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | **Response** ```json { "total_active_units": 2, "allocations": [ { "object": "storage_allocation", "fid": 3, "units": 2, "expiry": 1760000000, "timestamp": 1700000000 } ] } ```
--- ## GET /v2/farcaster/user/storage-usage How much of the allocated storage the FID has actually consumed, broken down by message type. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | **Response** ```json { "object": "storage_usage", "units": [ { "store_type": "casts", "used": 250, "capacity": 5000 }, { "store_type": "reactions", "used": 800, "capacity": 2500 }, { "store_type": "links", "used": 300, "capacity": 2500 }, { "store_type": "verifications", "used": 1, "capacity": 25 } ] } ```
Also reachable as `GET /v2/farcaster/storage/usage` and `GET /v2/farcaster/storage/allocations` — identical behavior, different paths. --- ## GET /v2/farcaster/user/fid List registered FIDs on the network, paginated. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Hex-encoded pagination cursor. | **Response** ```json { "fids": [1, 2, 3], "next": { "cursor": "..." } } ```
--- ## GET /v2/farcaster/user/channels Channels a user has recently been active in. Alias of `GET /v2/farcaster/channel/user-active`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no | **Response** — `ChannelsResponse`.
--- ## GET /v2/farcaster/user/memberships/list Alias of `GET /v2/farcaster/user/channels` — returns the same active-channels list in a membership-oriented response shape.
--- ## GET /v2/farcaster/user/best_friends Users that both follow and are followed by `fid` — the intersection. Sorted by most recent mutual follow. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no | **Response** — `FollowersResponse`.
--- ## GET /v2/farcaster/user/interactions Summarize the interaction history between two FIDs: mention counts, reaction counts, mutual-follow state. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The "from" user. | | `target_fid` | u64 | no | The "to" user. If omitted, an empty summary is returned. | **Response** ```json { "interactions": { "fid": 3, "target_fid": 5, "mentions": 7, "reactions": 42, "mutual_follow": true } } ``` Mentions are computed from the `CastsByMention` index (casts by `fid` that mention `target_fid`). Reactions are computed from the reactor's reaction set. Mutual-follow is a single index lookup.
--- ## Endpoints with no protocol data The following endpoints are registered for SDK compatibility but return empty responses because the Farcaster protocol does not track the underlying data: | Path | Returns | Why | |---|---|---| | `GET /v2/farcaster/user/power_users` | `{ "users": [] }` | Power-user curation is a proprietary scoring signal, not protocol data. | | `GET /v2/farcaster/user/balance` | `{ "balances": [], "next": { "cursor": null } }` | Token balances are on-chain state outside the Farcaster protocol. | | `GET /v2/farcaster/user/subscribed_to` | `{ "subscriptions": [], "next": { "cursor": null } }` | User-to-user subscriptions are not in the protocol. | | `GET /v2/farcaster/user/subscribers` | `{ "subscriptions": [], "next": { "cursor": null } }` | Same as above. | | `GET /v2/farcaster/user/subscriptions_created` | `{ "subscriptions": [], "next": { "cursor": null } }` | Same as above. | ## Write endpoints Endpoints that mutate user state (`POST /v2/farcaster/user/register`, `POST /v2/farcaster/user/follow`, `DELETE /v2/farcaster/user/follow`, `POST /v2/farcaster/user/verification`, `DELETE /v2/farcaster/user/verification`, `PATCH /v2/farcaster/user`) are registered but return `501 Not Implemented`. Write operations require submitting a signed Farcaster protocol message via the gRPC `SubmitMessage` endpoint instead. --- # Casts Casts are Farcaster messages — the core unit of user-generated content. Hypersnap serves them directly from its local RocksDB indexes over the message store. The common `Cast` response shape includes `hash`, `author` (full `User`), `text`, `timestamp`, `parent_hash`, `parent_url`, `root_parent_url`, `embeds`, `mentioned_profiles`, `reactions` (aggregate counts), and `replies.count`. See `src/api/types.rs` for the complete type. --- ## GET /v2/farcaster/cast Look up a single cast by hash or URL. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `identifier` | string | yes | Either a `0x`-prefixed cast hash or a Warpcast-style URL. | | `type` | `"hash"` \| `"url"` | no | Defaults to `"hash"`. | | `fid` | u64 | no | When `type="hash"` and the hash is ambiguous, narrows to a specific author. | **Response** ```json { "cast": { "hash": "0x...", "author": { ... }, "text": "...", "..." : "..." } } ```
--- ## GET /v2/farcaster/cast/bulk Batch cast lookup by hash list. Also reachable as `GET /v2/farcaster/casts` (spec-style plural), which accepts the same list under the `casts` query parameter instead of `hashes`. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `hashes` | string | yes (for `/cast/bulk`) | Comma-separated `0x`-prefixed cast hashes. | | `casts` | string | yes (for `/casts`) | Same shape — accepted as an alternate parameter name. | **Response** ```json { "casts": [ { "hash": "0x...", "..." : "..." } ] } ``` Each lookup is O(1) via the `cast_hash` index — no shard scan.
--- ## GET /v2/farcaster/cast/search Full-text search over cast content. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `q` | string | yes | Search query. | | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Pagination cursor. | **Response** ```json { "casts": [ ... ], "next": { "cursor": "..." } } ```
--- ## GET /v2/farcaster/cast/conversation Fetch a cast plus its reply tree up to a given depth. Ideal for rendering a thread view. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `identifier` | string | yes | Root cast hash or URL. | | `type` | `"hash"` \| `"url"` | yes | Disambiguate what `identifier` is. | | `reply_depth` | u32 | no | `0`–`5`, default `2`. How many levels of replies to include. | **Response** ```json { "cast": { "hash": "0x...", "..." : "..." }, "replies": [ { "cast": { "hash": "0x...", "..." : "..." }, "replies": [ /* same recursive shape, up to reply_depth */ ] } ] } ``` The top-level `cast` is the thread root. Each reply node has its own `cast` plus a nested `replies` array. Depth is capped at 5 to keep responses bounded.
--- ## GET /v2/farcaster/cast/quotes Casts that quote (embed the `CastId` of) a given cast. Backed by the `CastQuotesIndexer` — a reverse index populated on backfill and kept live. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `identifier` | string | yes | `0x`-prefixed hash of the quoted cast. | | `type` | `"hash"` | no | Only `hash` is supported. | | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Pagination cursor. | **Response** ```json { "casts": [ { "hash": "0x...", "..." : "..." } ], "next": { "cursor": null } } ```
--- ## GET /v2/farcaster/cast/metrics Aggregate cast search volume over a time interval. Currently returns an empty metrics array — per-cast metrics are available via the feed endpoints (which attach likes/recasts/replies counts), but aggregate time-series analytics over arbitrary search queries are not computed on-node. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `q` | string | yes | Search query. | | `interval` | string | no | `1d`, `7d`, `30d`, `90d`, `180d`. | | `author_fid` | u64 | no | Narrow to a specific author. | | `channel_id` | string | no | Narrow to a specific channel. | **Response** ```json { "metrics": [], "next": { "cursor": null } } ```
--- ## GET /v2/farcaster/cast/conversation/summary LLM-generated conversation summary. This node does not run an LLM — the endpoint is registered for SDK compatibility and returns a short placeholder string. **Response** ```json { "summary": "Conversation summaries require LLM integration which is not available on this node." } ``` --- ## GET /v2/farcaster/cast/embed/crawl Crawl and extract metadata from an embed URL. URL crawling requires an external HTTP service — not performed on-node. Returns `{ "metadata": null }`. ## Write endpoints `POST /v2/farcaster/cast` and `DELETE /v2/farcaster/cast` are registered but return `501 Not Implemented`. Submit casts via signed protocol messages through the gRPC `SubmitMessage` endpoint. --- # Feeds Feed endpoints return ordered lists of casts. Shape is always: ```json { "casts": [ { /* Cast */ } ], "next": { "cursor": "..." } } ``` See [Pagination & cursors](../../concepts/pagination.md) for how to walk large result sets. --- ## GET /v2/farcaster/feed Generic feed endpoint. Behavior depends on `feed_type`. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `feed_type` | string | no | `"following"` (default), `"trending"`, or a future feed type. | | `fid` | u64 | conditional | Required when `feed_type="following"` — whose feed to render. | | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Pagination cursor. |
--- ## GET /v2/farcaster/feed/following Explicit alias of `/feed?feed_type=following`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no | **Semantics** — casts authored by users `fid` follows, ordered newest-first.
--- ## GET /v2/farcaster/feed/trending Network-wide trending casts, ranked by an engagement heuristic over a rolling window. **Query parameters** | Name | Type | Required | |---|---|---| | `limit` | usize | no | | `cursor` | string | no |
--- ## GET /v2/farcaster/feed/channels Feed of casts parented to one or more channels (either by channel id or by parent URL). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `channel_ids` | string | yes | Comma-separated channel ids or channel parent URLs. | **Example** ```bash curl -s "https://haatz.quilibrium.com/v2/farcaster/feed/channels?channel_ids=memes,base,dev" ```
--- ## GET /v2/farcaster/feed/parent_urls Feed of casts with a matching `parent_url`. Useful when you have raw parent URLs (not yet resolved to a channel id). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `parent_urls` | string | yes | Comma-separated parent URLs. | | `limit` | usize | no | | | `cursor` | string | no | |
--- ## GET /v2/farcaster/feed/for_you Personalized "For You" feed. Personalization requires model inference which isn't available on-node, so this endpoint currently returns the trending feed. Registered for SDK compatibility. **Query parameters** | Name | Type | Required | |---|---|---| | `limit` | usize | no | | `cursor` | string | no |
--- ## GET /v2/farcaster/feed/topic Feed filtered by topic slug. No on-node hashtag/topic indexing exists, so this endpoint currently returns the trending feed. Registered for SDK compatibility.
--- ## GET /v2/farcaster/feed/user/casts User's casts in reverse chronological order. Each cast is enriched with engagement metrics (likes, recasts, replies counts) from the `MetricsIndexer`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no |
--- ## GET /v2/farcaster/feed/user/popular A user's top 10 casts by engagement score (likes + recasts + replies), computed from the local `MetricsIndexer`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | Default `10`, capped at `10`. |
--- ## GET /v2/farcaster/feed/user/replies_and_recasts A user's casts filtered to only replies (casts with a `parent` field). **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no |
--- # Channels Farcaster channels are parent-URL-scoped subcommunities. Hypersnap maintains a local channel registry and membership index. Common `Channel` fields: `id`, `parent_url`, `name`, `image_url`, `description`, `lead` (the channel host's `User`), `moderator_fids`, `follower_count`, `created_at`. --- ## GET /v2/farcaster/channel Look up a single channel. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `id` | string | yes | Either the channel id (e.g. `memes`) or the parent URL. | | `type` | `"id"` \| `"parent_url"` | no | Default `"id"`. | **Response** ```json { "channel": { "id": "memes", "..." : "..." } } ```
--- ## GET /v2/farcaster/channel/all List every channel the node knows about. Also reachable as `GET /v2/farcaster/channel/list` (spec-compat alias). **Query parameters** | Name | Type | Required | |---|---|---| | `limit` | usize | no | | `cursor` | string | no |
--- ## GET /v2/farcaster/channel/bulk Batch channel lookup. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `ids` | string | yes | Comma-separated channel ids. |
--- ## GET /v2/farcaster/channel/search Prefix-style search against the channel name index. **Query parameters** | Name | Type | Required | |---|---|---| | `q` | string | yes | | `limit` | usize | no |
--- ## GET /v2/farcaster/channel/trending Channels with the most engagement over a rolling window. **Query parameters** | Name | Type | Required | |---|---|---| | `limit` | usize | no |
--- ## GET /v2/farcaster/channel/members Members of a specific channel. Also reachable as `/v2/farcaster/channel/member/list`, `/v2/farcaster/channel/followers`, and `/v2/farcaster/channel/followers/relevant` — all three forward to the same member-list handler. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `channel_id` | string | yes | The channel id. | | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Pagination cursor. | **Response** ```json { "users": [ { /* User */ } ], "next": { "cursor": "..." } } ```
--- ## GET /v2/farcaster/channel/member/invite/list List open channel-member invites. The Farcaster protocol does not include an on-chain invite system — this endpoint is registered for SDK compatibility and returns an empty list. **Response** ```json { "members": [], "next": { "cursor": null } } ``` --- ## GET /v2/farcaster/channel/user-active Channels where a specific user has recently been active. Also reachable as `GET /v2/farcaster/channel/user` (spec-compat alias). **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no |
## Write endpoints `POST /v2/farcaster/channel/follow`, `DELETE /v2/farcaster/channel/follow`, `POST /v2/farcaster/channel/member/invite`, `PUT /v2/farcaster/channel/member/invite`, and `DELETE /v2/farcaster/channel/member` are registered but return `501 Not Implemented`. Follow/unfollow is expressed through the protocol via signed `LinkAdd`/`LinkRemove` messages. --- # Reactions Reactions are likes and recasts. Hypersnap indexes both directions — "who reacted to this cast" and "what has this user reacted to". Common response shape: ```json { "reactions": [ { "reaction_type": "like", "user": { /* User */ }, "cast": { /* Cast */ }, "timestamp": 1712345678 } ], "next": { "cursor": "..." } } ``` --- ## GET /v2/farcaster/reaction Generic reaction lookup. If `hash` is provided, acts like `/reaction/cast` (reactions targeting the cast). Otherwise acts like `/reaction/user` (reactions made by `fid`). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `hash` | string | conditional | If provided, switches to cast mode. | | `types` | `"likes"` \| `"recasts"` | no | For cast mode. Default `"likes"`. | | `type` | `"likes"` \| `"recasts"` | no | For user mode. Default `"likes"`. | | `fid` | u64 | conditional | Required in user mode. | | `limit` | usize | no | Default `10`. |
--- ## GET /v2/farcaster/reaction/cast Who has reacted to a specific cast. Also reachable as `GET /v2/farcaster/reactions/cast` (spec-compat plural). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `hash` | string | yes | `0x`-prefixed cast hash. | | `types` | `"likes"` \| `"recasts"` | no | Default `"likes"`. | | `fid` | u64 | no | If the cast hash is ambiguous, narrows to a specific author. | | `limit` | usize | no | Default `10`. |
--- ## GET /v2/farcaster/reaction/user What a specific user has liked or recasted. Also reachable as `GET /v2/farcaster/reactions/user` (spec-compat plural). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The reactor. | | `type` | `"likes"` \| `"recasts"` | no | Default `"likes"`. | | `limit` | usize | no | |
## Write endpoints `POST /v2/farcaster/reaction` and `DELETE /v2/farcaster/reaction` are registered but return `501 Not Implemented`. Submit signed `ReactionAdd`/`ReactionRemove` messages via the gRPC `SubmitMessage` endpoint. --- # Follows Who follows whom, with cursor pagination. Response shape: ```json { "users": [ { /* User */ } ], "next": { "cursor": "..." } } ``` --- ## GET /v2/farcaster/user/followers Users who follow `fid`. Also reachable as the alias `GET /v2/farcaster/followers`. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | Whose followers to list. | | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Pagination cursor. |
--- ## GET /v2/farcaster/user/following Users that `fid` follows. Also reachable as `GET /v2/farcaster/following` and `GET /v2/farcaster/follows` (spec-compat aliases). **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The follower. | | `limit` | usize | no | | | `cursor` | string | no | |
--- ## GET /v2/farcaster/followers/relevant Users who follow `fid` with relevance-style context. Relevance scoring is not implemented on-node, so this endpoint returns the same data as `/user/followers` (for SDK compatibility). **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no |
--- ## GET /v2/farcaster/followers/reciprocal Users where `fid` both follows and is followed by them (mutual follows). Computed on-demand from the social graph — iterates the followers list and filters each entry through `are_mutual_follows()`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | | `cursor` | string | no |
--- ## GET /v2/farcaster/following/suggested Suggested follows computed via friends-of-friends: who the people `fid` follows are also following, ranked by overlap frequency, excluding already-followed users. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | Default `10`. | **Response** — `FollowersResponse`.
## Write endpoints `POST /v2/farcaster/follow`, `DELETE /v2/farcaster/follow`, `POST /v2/farcaster/user/follow`, and `DELETE /v2/farcaster/user/follow` are registered but return `501 Not Implemented`. Submit signed `LinkAdd`/`LinkRemove` messages via the gRPC `SubmitMessage` endpoint. --- # User notifications Not to be confused with [mini-app push notifications](../miniapps/index.md) — this endpoint is the in-app "notifications tab" for a single user: replies to their casts, mentions, likes, recasts, new followers. --- ## GET /v2/farcaster/notifications **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The viewer. | | `limit` | usize | no | Default `10`. | | `cursor` | string | no | Pagination cursor. | **Response** ```json { "notifications": [ { "type": "cast-reply", "most_recent_timestamp": 1712345678, "cast": { /* the triggering cast */ }, "reactions": [ /* optional aggregate */ ], "follows": [ /* optional aggregate */ ] } ], "next": { "cursor": "..." } } ``` The `type` field discriminates what the notification represents: - `"cast-mention"` — your FID was `@`-mentioned. - `"cast-reply"` — a reply to one of your casts. - `"reaction"` — a like or recast of your cast (aggregated when multiple users do it within a window). - `"follow"` — someone followed you. Notifications are aggregated over a short window so that "15 people liked your cast" comes back as one entry with a count, not 15 separate rows.
--- ## GET /v2/farcaster/notifications/channel Notifications for `fid` filtered to casts in the specified channel(s). Mentions/replies whose parent cast is in one of the provided channels are returned; everything else is dropped. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The viewer. | | `channel_ids` | string | yes | Comma-separated channel ids or channel parent URLs. | | `limit` | usize | no | | | `cursor` | string | no | | Channel ids are resolved to parent URLs via the channels index; raw URLs (starting with `http` or `chain://`) are used verbatim.
--- ## GET /v2/farcaster/notifications/parent_url Notifications for `fid` filtered to casts whose `parent_url` matches one of the provided URLs. Same semantics as `/notifications/channel` but accepts raw parent URLs directly. **Query parameters** | Name | Type | Required | Notes | |---|---|---|---| | `fid` | u64 | yes | The viewer. | | `parent_urls` | string | yes | Comma-separated parent URLs. | | `limit` | usize | no | | | `cursor` | string | no | |
## Write endpoints `POST /v2/farcaster/notifications/seen` and `POST /v2/farcaster/notifications/mark_seen` are registered but return `501 Not Implemented`. Seen-state is not part of the Farcaster protocol. --- # Usernames & proofs Farcaster usernames are backed by off-chain "fnames" and on-chain ENS/Basenames. These endpoints let you check availability and look up the proof record attached to a name. --- ## GET /v2/farcaster/fname/availability Is an fname available to register? **Query parameters** | Name | Type | Required | |---|---|---| | `fname` | string | yes | **Response** ```json { "available": true, "username": null } ``` If the name is taken, `available` is `false` and `username` contains the normalized name string.
--- ## GET /v2/farcaster/username-proof Fetch the raw username proof record for a given name. Useful when you want to verify on-chain provenance yourself instead of trusting the resolved `User.username` field. **Query parameters** | Name | Type | Required | |---|---|---| | `username` | string | yes | **Response** ```json { "username_proof": { "timestamp": 1712345678, "name": "alice", "owner": "0x...", "signature": "0x...", "fid": 12345, "type": "USERNAME_TYPE_FNAME" } } ``` If the name has no proof, `username_proof` is `null`.
--- # Signers Signer endpoints expose on-chain `SignerEventBody` records from the `KeyRegistry` contract — the ed25519 keys a user has registered to sign Farcaster protocol messages. All responses share the on-chain event shape: ```json { "events": [ { "object": "signer", "fid": 3, "event_type": "signer", "block_number": 12345678, "block_timestamp": 1712345678, "signer_key": "0x", "key_type": 1, "metadata_type": 1 } ], "next": { "cursor": null } } ``` --- ## GET /v2/farcaster/signer Signers registered by `fid`. Also reachable as `GET /v2/farcaster/signers` (plural) and `GET /v2/farcaster/signer/list`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes |
--- ## GET /v2/farcaster/onchain/signers Identical to `/v2/farcaster/signer` — returns signer events for the given FID. Registered under the `/onchain/` namespace for spec compatibility.
--- ## GET /v2/farcaster/onchain/id_registry_event `IdRegistry` events for `fid` — `Register`, `Transfer`, `ChangeRecovery`. Each event exposes `block_number`, `block_timestamp`, and `event_type`. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes |
--- ## Registered-for-compatibility endpoints These endpoints exist for SDK compatibility but return empty responses. They depend on managed signer infrastructure that a self-hosted node does not operate: | Path | Method | |---|---| | `/v2/farcaster/signer/signed_key` | GET | | `/v2/farcaster/signer/developer_managed` | GET | | `/v2/farcaster/signer/developer_managed/signed_key` | GET | ## Write endpoints Signer creation and signed-key registration (`POST /v2/farcaster/signer`, `POST /v2/farcaster/signer/signed_key`, `POST /v2/farcaster/signer/developer_managed`, `POST /v2/farcaster/signer/developer_managed/signed_key`) return `501 Not Implemented`. Register signers directly against the on-chain `KeyRegistry` contract instead — the node will pick up the new signer on the next block. --- # Blocks, Mutes, Bans Block and mute state is expressed in the Farcaster protocol as `LinkAdd` messages with a non-follow `link_type` (`"block"` or `"mute"`). Hypersnap surfaces the current block/mute list for a given FID by querying the `LinkStore` directly. Ban lists are **app-level**, not protocol-level — they do not exist in the Farcaster protocol and the corresponding endpoint returns an empty list. Response shape for all list endpoints: ```json { "users": [ { /* User */ } ], "next": { "cursor": null } } ``` --- ## GET /v2/farcaster/block/list FIDs that `fid` has blocked. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | Backed by `LinkStore::get_link_adds_by_fid(fid, "block", ...)` — a direct index lookup, no scan.
--- ## GET /v2/farcaster/mute/list FIDs that `fid` has muted. **Query parameters** | Name | Type | Required | |---|---|---| | `fid` | u64 | yes | | `limit` | usize | no | Backed by `LinkStore::get_link_adds_by_fid(fid, "mute", ...)`.
--- ## GET /v2/farcaster/ban/list Registered for SDK compatibility — bans are an app-level concept and not part of the Farcaster protocol. **Response** ```json { "bans": [], "next": { "cursor": null } } ``` ## Write endpoints `POST /v2/farcaster/block`, `DELETE /v2/farcaster/block`, `POST /v2/farcaster/mute`, `DELETE /v2/farcaster/mute`, `POST /v2/farcaster/ban`, and `DELETE /v2/farcaster/ban` are registered but return `501 Not Implemented`. Submit signed `LinkAdd`/`LinkRemove` messages with `link_type = "block"` or `"mute"` via gRPC `SubmitMessage`. --- # Batch reads POST-body hydration endpoints for when you need to enrich a list of FIDs with additional state in one call. All of these are unauthenticated and take the same basic request shape: ```json { "fids": [12345, 67890] } ``` Responses are objects keyed by stringified FID. --- ## POST /v2/farcaster/batch/following For each FID in the request, return the list of FIDs they follow (truncated to a cap). **Request** ```json { "fids": [3, 5] } ``` **Response** ```json { "3": [ { "fid": 5, "followed_at": "2024-01-15T09:12:00Z" }, { "fid": 191, "followed_at": "2024-02-02T13:40:00Z" } ], "5": [ { "fid": 3, "followed_at": "2023-11-02T18:01:00Z" } ] } ```
--- ## POST /v2/farcaster/batch/reactions For each FID, return their recent reactions (likes + recasts). **Response** ```json { "3": [ { "target_fid": 5, "timestamp": 1712345678 } ] } ```
--- ## POST /v2/farcaster/batch/cast-interactions For each FID, return a list of cast hashes and whether the FID liked, recasted, or replied to each. **Response** ```json { "3": [ { "hash": "0xabc...", "liked": true, "recasted": false, "replied": true } ] } ``` Useful for rendering "you liked this" state across a feed in one call instead of one lookup per cast.
--- ## POST /v2/farcaster/batch/signers For each FID, return its registered signer set from the on-chain `KeyRegistry`. **Response** ```json { "3": [ { "signer": "0x", "created_at": 1712345678, "key_type": 1, "metadata_type": 1 } ] } ```
--- ## POST /v2/farcaster/batch/id-registrations For each FID, return its `IdRegistry` history — registration, transfers, and the current owner. **Response** ```json { "3": [ { "from": "0x0000...0000", "to": "0xabcd...abcd", "timestamp": 1700000000, "event_type": "register" } ] } ```
# Webhooks --- # Webhooks Webhooks let your server receive filtered Farcaster events over HTTP, in realtime. Hypersnap dispatches an event to you whenever it matches your subscription — you don't have to poll or maintain a long-lived connection. ## Who should use this - **Agents and bots** that need to react to casts, reactions, or follows as they happen. - **Analytics pipelines** that index a filtered slice of the firehose. - **Mini apps** that want to trigger server-side work when a user mentions them. If you want to push content to *users* (not your own servers), you probably want [mini-app notifications](../miniapps/index.md) instead. ## Flow at a glance 1. **Register** a webhook via `POST /v2/farcaster/webhook/`, signed by your FID's custody key. 2. **Receive** HMAC-signed HTTP POSTs at your `target_url` whenever a matching event fires. 3. **Verify** the HMAC signature on your receiver using the secret the register response gave you. 4. **Respond** with a `2xx` for success, or a `4xx`/`5xx`/timeout for failure. Hypersnap retries transient failures with exponential backoff. ## The pieces - **[Managing webhooks](./managing.md)** — create, read, update, delete, and rotate-secret over the signed management API. - **[Subscription filters](./filters.md)** — the shape of the filter DSL. Per-event arrays (author_fids, mentioned_fids, …), text/embed regex, size caps. - **[Delivery contract](./delivery.md)** — headers, HMAC computation, retry semantics, success/failure response codes. - **[Event schemas](./events.md)** — the JSON shape of each event type you can subscribe to. ## Ownership Webhooks are per-FID. To manage a webhook you sign a request with the FID's current custody key (see [Signed operations](../../concepts/authentication.md)). Only the owner can read, update, delete, or rotate. A default operator cap limits how many webhooks one FID can register — by default **25**. --- # Managing webhooks All endpoints under `/v2/farcaster/webhook/*` require an EIP-712 signature in headers as described in [Signed operations](../../concepts/authentication.md). The request body (if any) is included verbatim in the hash that gets signed. Max body: **256 KB**. --- ## POST /v2/farcaster/webhook/ — create `X-Hypersnap-Op`: `webhook.create` **Request body** (`CreateWebhookRequest`): ```json { "name": "my webhook", "url": "https://receiver.example.com/hook", "description": "optional free-form", "subscription": { "cast_created": { "author_fids": [3, 5], "mentioned_fids": [], "text": "optional regex", "embeds": "optional regex", "exclude_author_fids": [] } } } ``` The subscription must contain at least one event type. See [Subscription filters](./filters.md) for every available field and the size/regex constraints. The `url` is SSRF-checked at create time. By default, loopback and RFC1918 addresses are rejected so you can't register `http://127.0.0.1` or internal-network targets unless your operator explicitly allows it. **Response** (`WebhookResponse`): ```json { "webhook": { "webhook_id": "550e8400-e29b-41d4-a716-446655440000", "owner_fid": 3, "target_url": "https://receiver.example.com/hook", "title": "my webhook", "description": "optional free-form", "active": true, "secrets": [ { "uid": "...", "value": "<64-char hex signing secret>", "expires_at": null, "created_at": 1712345678 } ], "subscription": { ... }, "http_timeout": 10, "rate_limit": 1000, "rate_limit_duration": 60, "created_at": 1712345678, "updated_at": 1712345678 } } ``` **Save `secrets[0].value` now.** This is the HMAC signing secret you'll use to verify deliveries. You can list the webhook again later, but the secret value in subsequent responses is the same — rotating it via [secret rotation](#post-v2farcasterwebhooksecretrotate--rotate-secret) invalidates the old one on a grace window. **Errors** - `400` — invalid JSON, empty subscription, filter too large, invalid regex, SSRF-blocked URL. - `401` — signature / clock / nonce / custody mismatch. - `429` — per-FID cap reached.
--- ## GET /v2/farcaster/webhook/ — lookup `X-Hypersnap-Op`: `webhook.read` **Query** | Name | Type | Required | |---|---|---| | `webhook_id` | UUID | yes | **Response** — same `WebhookResponse` shape as create. `403` if the webhook belongs to a different FID; `404` if not found.
--- ## GET /v2/farcaster/webhook/list — list `X-Hypersnap-Op`: `webhook.read` **Query** — none. **Response** ```json { "webhooks": [ { /* webhook record */ } ] } ``` Returns up to the per-owner cap. Only webhooks owned by the signing FID are included.
--- ## PUT /v2/farcaster/webhook/ — update `X-Hypersnap-Op`: `webhook.update` **Request body** (`UpdateWebhookRequest` — all fields except `webhook_id` are optional, only supplied fields change): ```json { "webhook_id": "550e8400-e29b-41d4-a716-446655440000", "name": "optional new name", "url": "https://new-receiver.example.com/hook", "description": "optional new description", "subscription": { /* full replacement if supplied */ }, "active": true } ``` If you set `active: false`, the webhook stays registered but Hypersnap stops dispatching events to it — useful for pausing a receiver for maintenance without losing the filter config. **Response** — updated `WebhookResponse`. `403` if not owner.
--- ## DELETE /v2/farcaster/webhook/ — delete `X-Hypersnap-Op`: `webhook.delete` **Query** | Name | Type | Required | |---|---|---| | `webhook_id` | UUID | yes | **Response** ```json { "deleted": true } ``` Soft-deletes the record. Retries queued for in-flight events stop dispatching. `403` if not owner.
--- ## POST /v2/farcaster/webhook/secret/rotate — rotate secret `X-Hypersnap-Op`: `webhook.rotate_secret` **Query** | Name | Type | Required | |---|---|---| | `webhook_id` | UUID | yes | **Response** — the full `WebhookResponse`. The `secrets` array now has one additional entry (the newest) and the previously-active secrets have `expires_at` set to `now + secret_grace_period_secs` (default 24h). **How receivers should handle rotation** 1. Call `secret/rotate`. 2. Read the new secret from `secrets[-1].value` — this is what new deliveries will sign with. 3. Your receiver should accept *any* currently-valid secret when verifying. For the duration of the grace window, deliveries might be signed with either the old or the new key depending on timing. Maintain a set of accepted secrets and drop the old one when its `expires_at` passes.
--- # Subscription filters A `WebhookSubscription` object selects which Farcaster events you want delivered. The shape mirrors common Farcaster v2 contracts so existing filters port verbatim. ## Top-level shape ```json { "cast_created": { /* CastFilter */ }, "cast_deleted": { /* CastFilter */ }, "user_created": { }, "user_updated": { "fids": [12345] }, "follow_created": { "fids": [], "target_fids": [] }, "follow_deleted": { "fids": [], "target_fids": [] }, "reaction_created":{ "fids": [], "target_fids": [], "target_cast_hashes": [] }, "reaction_deleted":{ "fids": [], "target_fids": [], "target_cast_hashes": [] } } ``` **At least one event must be present.** Creating a webhook with an empty subscription returns `400`. Any filter object can be omitted or included as `{}` to subscribe to everything of that event type. ## CastFilter Used for both `cast_created` and `cast_deleted`. | Field | Type | Meaning | |---|---|---| | `author_fids` | `Vec` | Cast author FID is in this set. | | `exclude_author_fids` | `Vec` | Cast author FID is **not** in this set. | | `mentioned_fids` | `Vec` | Cast mentions any FID in this set. | | `parent_urls` | `Vec` | Cast is a reply under one of these parent URLs (channel scoping). | | `root_parent_urls` | `Vec` | Root-of-thread parent URL matches. (Accepted at create time; not enforced at dispatch.) | | `parent_hashes` | `Vec` | Cast is a reply to a cast with one of these hashes. | | `parent_author_fids` | `Vec` | Cast is a reply to a cast authored by one of these FIDs. | | `text` | `Option` | Regex. If set, cast `text` must match. Compiled at create time with the linear-time `regex` crate; lookaround is rejected. | | `embeds` | `Option` | Regex. Applied against embed URLs. (Accepted at create time; not enforced at dispatch.) | | `embedded_cast_author_fids` | `Vec` | (Accepted; not enforced at dispatch.) | | `embedded_cast_hashes` | `Vec` | (Accepted; not enforced at dispatch.) | **All supplied fields AND together.** If you set both `author_fids` and `mentioned_fids`, the cast must match both. Arrays OR within themselves — `author_fids: [3, 5]` matches a cast by FID 3 *or* 5. An empty array means "no restriction on this field", not "nothing matches". ## FollowFilter Used for both `follow_created` and `follow_deleted`. | Field | Type | Meaning | |---|---|---| | `fids` | `Vec` | The follower's FID is in this set. | | `target_fids` | `Vec` | The target (the one being followed) is in this set. | ## ReactionFilter Used for both `reaction_created` and `reaction_deleted`. | Field | Type | Meaning | |---|---|---| | `fids` | `Vec` | The reactor's FID is in this set. | | `target_fids` | `Vec` | The author of the cast being reacted to is in this set. | | `target_cast_hashes` | `Vec` | The specific cast hash(es) being reacted to. | ## UserUpdatedFilter | Field | Type | Meaning | |---|---|---| | `fids` | `Vec` | Only match updates to these FIDs. | ## UserCreatedFilter No filter fields — fires for every new FID registration seen on-chain. ## Size caps - Every array field is capped at **1024** entries. Exceeding the cap rejects the create/update with `400`. - Regex patterns (`text`, `embeds`) must compile under Rust's `regex` crate (linear time, no backtracking). Lookaround is rejected. Alternation, character classes, and quantifiers all work. ## Fields accepted but not enforced at dispatch A handful of filter fields (`root_parent_urls`, `embeds` regex, `embedded_cast_*`) are parsed and validated at create time but not used to filter events when they're dispatched. They require cross-message lookups that aren't on the dispatcher's hot path today. Setting them is harmless — events still fire — but they won't narrow your stream. If you need a tighter filter than the enforced fields can express, filter in your receiver and ignore the overdelivered events. ## Examples ### Every new cast network-wide ```json { "cast_created": {} } ``` ### Casts authored by a specific FID ```json { "cast_created": { "author_fids": [3] } } ``` ### Casts that mention your FID OR are replies to you ```json { "cast_created": { "mentioned_fids": [12345], "parent_author_fids": [12345] } } ``` **Caveat:** that filter uses AND semantics across fields, so it matches casts that both mention *and* reply to you. To OR the two conditions, register two webhooks (or two subscriptions on the same webhook) and dedupe on the receiver side. ### Likes on your casts ```json { "reaction_created": { "target_fids": [12345] } } ``` ### New follows where you are the target ```json { "follow_created": { "target_fids": [12345] } } ``` ### Keyword filter ```json { "cast_created": { "text": "(?i)\\b(hypersnap|farcaster)\\b" } } ``` `(?i)` is supported; `(?=...)` / `(?!...)` lookaround is not. --- # Delivery contract When an event matches a webhook's subscription, Hypersnap POSTs a JSON envelope to the webhook's `target_url`. This page describes exactly what your receiver should expect and how to verify it. ## Request shape **Method:** `POST` **Headers** | Header | Value | |---|---| | `Content-Type` | `application/json` | | `X-Hypersnap-Signature` | `` (header name is operator-configurable; this is the default) | **Body** ```json { "created_at": 1712345678, "type": "cast.created", "data": { /* event payload — see Event schemas */ } } ``` The `type` field tells you which event schema applies. The `data` field carries the event payload — see [Event schemas](./events.md) for the per-type shapes. ## Verifying the signature The signature is `hmac_sha512(secret, raw_body)`, hex-encoded. `raw_body` is the *literal bytes* of the HTTP body, not a re-serialized representation — verify before JSON-parsing. ### Node.js ```javascript import crypto from "crypto"; function verify(req, secret) { const received = req.headers["x-hypersnap-signature"]; const expected = crypto .createHmac("sha512", secret) .update(req.rawBody) // the literal bytes, NOT JSON.stringify(req.body) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(received, "hex"), Buffer.from(expected, "hex"), ); } ``` ### Python ```python import hmac, hashlib def verify(raw_body: bytes, received_hex: str, secret: str) -> bool: expected = hmac.new( secret.encode(), raw_body, hashlib.sha512 ).hexdigest() return hmac.compare_digest(expected, received_hex) ``` ### Rust ```rust,ignore use hmac::{Hmac, Mac}; use sha2::Sha512; fn verify(raw_body: &[u8], received_hex: &str, secret: &str) -> bool { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(raw_body); let expected = hex::encode(mac.finalize().into_bytes()); // subtle::ConstantTimeEq in production. expected == received_hex } ``` ## Which secret signs a delivery The secret used for a given delivery is the **most recent non-expired entry** in the webhook's `secrets` array. During a rotation grace window you may see two valid secrets in the list at once. Your receiver should: 1. Keep a set of accepted secrets, not just one. 2. Try each in turn until one verifies. 3. Drop a secret from the accepted set after its `expires_at` passes. This way a secret rotation never loses a delivery. ## Response codes Hypersnap expects | Your response | Hypersnap's interpretation | |---|---| | `2xx` | Delivered. Counted as `webhooks.delivery.succeeded`. | | `4xx` | Terminal failure. No retries. Counted as `webhooks.delivery.failed_4xx`. Your webhook is responsible for fixing whatever's wrong — a 4xx usually means a bug in the config (wrong URL path, auth) or a permanent rejection. | | `5xx` | Transient failure. Retried with exponential backoff up to `retry_max_attempts`. Counted as `webhooks.delivery.failed_5xx`. | | Timeout / network error | Transient failure. Retried. Counted as `webhooks.delivery.failed_network`. | **Default timeout** on a single attempt: **10 seconds** (`delivery_timeout_secs`). **Default retry schedule**: 5 attempts total, starting at 500ms and doubling (`retry_initial_backoff_ms × 2^attempt`). Failures in between live on a persistent RocksDB-backed retry queue so they survive restarts. ## Rate limiting Each webhook has a rate limit enforced by the dispatcher: - Default: **1000 events per 60 seconds**, per webhook. - Events above the limit are **dropped**, not queued. Dropped events are counted in the `webhooks.delivery.rate_limited` metric. If you expect high-volume streams, narrow your subscription filters so fewer events match, or ask your operator to raise the per-webhook limit. ## Delivery guarantees - **At-least-once.** Transient failures + retries can replay the same `(event, attempt)` pair. Dedupe on the `data` field's natural key (cast hash, reaction unique tuple, etc.). - **Not strictly ordered.** Two events for the same cast can arrive in either order if one is retried while the other succeeds first pass. If ordering matters, attach server timestamps from the event payload. - **Transient failure ≠ lost.** 5xx/network failures go on the retry queue. You get them eventually. - **4xx = lost.** If you reply 4xx, the event is permanently dropped. Reserve 4xx for cases where retrying would be pointless. ## Receiver checklist - [ ] Verify every delivery's HMAC before trusting the body. - [ ] Accept multiple active secrets during rotation grace windows. - [ ] Return `2xx` as soon as you've durably enqueued the event; don't hold the connection open for downstream work. - [ ] Dedupe on a natural key (cast hash, reaction tuple). - [ ] Return `5xx` on transient failures so Hypersnap can retry. - [ ] Reject with `4xx` only when there's nothing to be done. - [ ] Keep your receiver's p95 latency under `delivery_timeout_secs`. --- # Event schemas The `data` field of a delivered webhook envelope carries a type-specific payload. The `type` field in the envelope tells you which schema applies: ```json { "created_at": 1712345678, "type": "cast.created", "data": { /* see below */ } } ``` Hypersnap fires the following event types. Field shapes mirror common Farcaster v2 contracts so existing client models deserialize directly. --- ## cast.created Emitted when a new cast is ingested by the node. ```json { "type": "cast.created", "data": { "cast": { "hash": "0x...", "author": { /* User */ }, "text": "hello world", "timestamp": 1712345678, "parent_hash": null, "parent_url": null, "root_parent_url": null, "embeds": [], "mentioned_profiles": [], "reactions": { "likes_count": 0, "recasts_count": 0 }, "replies": { "count": 0 } } } } ``` ## cast.deleted Emitted when a cast delete message is applied. ```json { "type": "cast.deleted", "data": { "cast": { /* Same cast shape as cast.created */ } } } ``` ## user.created Emitted on an `IdRegistry` register event seen on-chain. No filter fields. ```json { "type": "user.created", "data": { "user": { "fid": 12345, "username": null, "..." : "..." } } } ``` ## user.updated Emitted when user-data (pfp, bio, display name, url, username) changes. ```json { "type": "user.updated", "data": { "user": { /* User (updated fields filled) */ } } } ``` ## follow.created Emitted when a `link_add` message with type `follow` is applied. ```json { "type": "follow.created", "data": { "follower": { /* User */ }, "target": { /* User */ } } } ``` ## follow.deleted Emitted on `link_remove` for a follow link. ```json { "type": "follow.deleted", "data": { "follower": { /* User */ }, "target": { /* User */ } } } ``` ## reaction.created Emitted when a like or recast is applied. ```json { "type": "reaction.created", "data": { "reaction_type": "like", "user": { /* User */ }, "cast": { /* Cast */ } } } ``` `reaction_type` is either `"like"` or `"recast"`. ## reaction.deleted Same shape as `reaction.created`, emitted when a reaction is removed. --- ## Dedupe keys For at-least-once delivery, dedupe on the natural key of each event type: | Event | Dedupe key | |---|---| | `cast.created` / `cast.deleted` | `data.cast.hash` | | `user.created` | `data.user.fid` | | `user.updated` | `(data.user.fid, received_at)` or compare fields against last-known state | | `follow.created` / `follow.deleted` | `(data.follower.fid, data.target.fid)` | | `reaction.created` / `reaction.deleted` | `(data.user.fid, data.cast.hash, data.reaction_type)` | ## Forward compatibility - New optional fields can appear on existing event payloads. Don't error on unknown fields. - New event types can be added. Subscribe only to the types you know; ignore unfamiliar `type` values your code doesn't recognize. - Event field semantics won't change for an existing `type`. # Mini-app notifications --- # Mini-app notifications Hypersnap is a multi-tenant proxy for [Farcaster Mini App notifications](https://miniapps.farcaster.xyz/docs/specification). Any Farcaster mini app can register with a Hypersnap node, receive the upstream token events, and send push notifications back to its users — using the exact wire contract the Farcaster Mini App spec defines, so clients (Warpcast, etc.) work against Hypersnap with no changes. ## Who should use this - Mini-app developers who want push notifications on new content, social activity, or in-app events. - Anyone who has implemented the upstream Farcaster Mini App notification flow against a different proxy and wants to switch. ## Three moving parts 1. **Register your app.** Sign a management request with your FID's custody key to create a mini-app record. Hypersnap assigns a random 16-character base58 `app_id` and returns a send secret. → [Registering a mini app](./registering.md) 2. **Receive token events.** Farcaster clients POST JFS-signed events to `/v2/farcaster/frame/webhook/` when users add your app or toggle notifications. Hypersnap verifies + stores them. You don't need to implement this endpoint yourself — point the client at Hypersnap and you're done. → [Client token webhook](./token-webhook.md) 3. **Send notifications.** POST a payload to `/v2/farcaster/frame/notifications/` with your send secret. Hypersnap looks up the enabled tokens for your app, groups them by notification URL, and POSTs to each client in batches. → [Sending notifications](./sending.md) ## Relationship to the rest of the API This is its own pipeline, entirely separate from: - **[Read API](../reads/index.md)** — unauthenticated GETs. - **[Webhooks](../webhooks/index.md)** — outbound event streams to your servers. - **[User notifications](../reads/notifications.md)** — the per-user in-app "mentions/replies" feed. ## Authentication summary | Surface | Auth | |---|---| | `/v2/farcaster/frame/app/*` | [EIP-712 custody signature](../../concepts/authentication.md) | | `/v2/farcaster/frame/webhook/` | [JFS from a Farcaster client](../../concepts/jfs.md) | | `/v2/farcaster/frame/notifications/` | Per-app `x-api-key: ` | The send secret is returned at app-creation time and can be rotated via the signed management API. Treat it as an API key and keep it server-side. --- # Registering a mini app All endpoints in this section are under `/v2/farcaster/frame/app/` and require an [EIP-712 signature](../../concepts/authentication.md) from your FID's custody key. Max body: **32 KB**. The server assigns a random 16-character base58 `app_id` (~93 bits of entropy) and a fresh send secret at create time. These are what you use in the URL paths for the token webhook and send endpoints. --- ## POST /v2/farcaster/frame/app/ — create `X-Hypersnap-Op`: `app.create` **Request body** (`CreateAppRequest`): ```json { "name": "my mini app", "app_url": "https://miniapp.example.com", "description": "optional", "signer_fid_allowlist": [12345, 67890] } ``` | Field | Type | Notes | |---|---|---| | `name` | string | 1–128 chars. Human-readable, shown in management responses. | | `app_url` | string | Canonical mini-app URL. SSRF-checked at create time. Hypersnap never POSTs to this URL itself — it's informational. | | `description` | string (optional) | Free-form. | | `signer_fid_allowlist` | `Vec` (optional) | FIDs whose Farcaster signing keys are permitted to submit JFS token events for this app. Empty = any active signer is accepted. Capped at 1024 entries. | **Response** (`AppResponse`): ```json { "app": { "app_id": "3Hq9ZgK2p4vNfWxR", "owner_fid": 12345, "name": "my mini app", "app_url": "https://miniapp.example.com", "description": "optional", "signer_fid_allowlist": [], "send_secrets": [ { "uid": "uuid", "value": "<64-char hex send secret>", "expires_at": null, "created_at": 1712345678 } ], "created_at": 1712345678, "updated_at": 1712345678 } } ``` **Save `app.app_id` and `app.send_secrets[0].value`.** You'll need: - `app_id` in the URL path for the token webhook (`/v2/farcaster/frame/webhook/`) and the send endpoint (`/v2/farcaster/frame/notifications/`). - `send_secrets[0].value` as the `x-api-key` header when you call the send endpoint. **Errors** - `400` — invalid name / app_url / SSRF-blocked URL / allowlist too big. - `401` — signature / auth failure. - `429` — per-FID app cap hit.
--- ## GET /v2/farcaster/frame/app/ — lookup `X-Hypersnap-Op`: `app.read` **Query** | Name | Type | Required | |---|---|---| | `app_id` | string | yes | **Response** — same `AppResponse` shape. `403` if the app belongs to a different FID; `404` if not found.
--- ## GET /v2/farcaster/frame/app/list — list `X-Hypersnap-Op`: `app.read` **Query** — none. **Response** ```json { "apps": [ { /* RegisteredApp */ } ] } ``` Returns every mini app owned by the signing FID.
--- ## PUT /v2/farcaster/frame/app/ — update `X-Hypersnap-Op`: `app.update` **Request body** (`UpdateAppRequest`): ```json { "app_id": "3Hq9ZgK2p4vNfWxR", "name": "optional new name", "app_url": "https://new.example.com", "description": "optional", "signer_fid_allowlist": [12345] } ``` Only supplied fields change. Passing `signer_fid_allowlist` replaces the whole list.
--- ## DELETE /v2/farcaster/frame/app/ — delete `X-Hypersnap-Op`: `app.delete` **Query** | Name | Type | Required | |---|---|---| | `app_id` | string | yes | **Response** ```json { "deleted": true } ``` After deletion, both the send endpoint and the client token webhook return `404` for this `app_id`. Previously registered notification tokens become unreachable (the token store still holds them but nothing reads them).
--- ## POST /v2/farcaster/frame/app/secret/rotate — rotate send secret `X-Hypersnap-Op`: `app.rotate_secret` **Query** | Name | Type | Required | |---|---|---| | `app_id` | string | yes | **Response** — the full `AppResponse`. `send_secrets` has one new entry appended (the newest) and previously-active secrets have `expires_at` set to `now + secret_grace_period_secs` (default 24h). **How to use rotation:** 1. Call `/secret/rotate`. 2. Read the new secret from `send_secrets[-1].value`. 3. Deploy the new secret to your backend. 4. The old secret keeps working until the grace window passes, so you don't have a downtime window during the rollout.
--- # Client token webhook You almost never call this endpoint yourself — **Farcaster clients** (Warpcast, etc.) POST to it when a user adds, removes, enables, or disables your mini app. This page is a reference for: - Understanding what Hypersnap does with those events. - Debugging cases where notifications aren't reaching users (did the event arrive?). ## URL ``` POST /v2/farcaster/frame/webhook/ ``` `` is the 16-character base58 string Hypersnap gave you at app-creation time. You embed this URL in your mini app's manifest so Farcaster clients know where to POST. ## Request body — JFS envelope ```json { "header": "", "payload": "", "signature": "" } ``` This is a standard [JSON Farcaster Signature envelope](../../concepts/jfs.md). The decoded `payload` is one of: ```json { "event": "miniapp_added", "notificationDetails": { "url": "https://...", "token": "..." } } { "event": "miniapp_removed" } { "event": "notifications_enabled", "notificationDetails": { "url": "https://...", "token": "..." } } { "event": "notifications_disabled" } ``` The four events map to state transitions on the user's relationship with your mini app: | Event | What Hypersnap does | |---|---| | `miniapp_added` (with `notificationDetails`) | Upserts an enabled token for `(fid, app_id)`. | | `miniapp_added` (without `notificationDetails`) | Records the add without a token (notifications opt-out). | | `miniapp_removed` | Deletes all tokens for `(fid, app_id)`. | | `notifications_enabled` | Upserts / re-enables a token. | | `notifications_disabled` | Marks the token disabled but does not delete it. | ## Verification rules Hypersnap applies these checks before touching the token store: 1. **Base64url decode** the three envelope fields. 2. **Ed25519 verify** the signature against the `key` in the JFS header. 3. **Active signer check** — look up the signer set for the claimed FID from the on-chain `KeyRegistry`. The signing key must be currently active. 4. **Signer allowlist** — if the mini-app record has a non-empty `signer_fid_allowlist`, the signer FID must be in it. If any step fails, Hypersnap returns `401` and does not apply the event. ## Response shape Successful ack: ```json { "success": true } ``` Failure: ```json { "success": false, "message": "..." } ``` Clients retry `5xx` and give up on `4xx` — same contract your own receiver would get, except here Hypersnap is the receiver and you're the beneficiary. ## What you need to implement Nothing, as long as you're proxying through Hypersnap. Put the `/v2/farcaster/frame/webhook/` URL in your mini app's manifest: ```json { "name": "my mini app", "icon_url": "https://...", "home_url": "https://miniapp.example.com", "webhook_url": "https://haatz.quilibrium.com/v2/farcaster/frame/webhook/3Hq9ZgK2p4vNfWxR" } ``` When users add your app, Farcaster clients POST to that URL, Hypersnap stores the token, and a subsequent call to your send endpoint will reach them. ## Debugging If a user added your app but isn't receiving notifications: 1. **Check that the event arrived.** Hypersnap logs ingest failures at the `hypersnap::notifications` tracing target. A failed verification logs why (bad signature, inactive signer, not in allowlist). 2. **Check the app exists.** Call `GET /v2/farcaster/frame/app/?app_id=` (signed) and confirm it returns `200`. 3. **Check the signer allowlist.** If you set `signer_fid_allowlist`, the user's Farcaster signer FID needs to be in it. An overly narrow allowlist is the most common mistake. 4. **Check you're pointed at the right app_id.** The `` in the webhook URL and the `` in your send endpoint calls must match. --- # Sending notifications ``` POST /v2/farcaster/frame/notifications/ ``` This is the endpoint you call from your backend when you want to push a notification to your mini app's users. **Auth:** `x-api-key: `. The value must match the most recently created unexpired entry in your app's `send_secrets` array. See [Registering a mini app](./registering.md) for how to obtain and rotate the secret. ## Request body ```json { "notification": { "title": "string (≤32 chars)", "body": "string (≤128 chars)", "target_url": "https://miniapp.example.com/path/in/app", "uuid": "optional UUID string" }, "target_fids": [12345, 67890], "exclude_fids": [], "following_fid": null, "minimum_user_score": null, "near_location": null } ``` ### notification | Field | Type | Required | Notes | |---|---|---|---| | `title` | string | yes | ≤ 32 characters (spec limit). | | `body` | string | yes | ≤ 128 characters (spec limit). | | `target_url` | string | yes | ≤ 256 characters. Must be the same domain as your mini-app's `app_url`. | | `uuid` | string | no | A UUID you supply. Becomes the `notificationId` on the per-client fan-out. Enables `(fid, notificationId)` dedupe within the 24h window. Generate a random UUID per logical notification. | ### Targeting - `target_fids` — explicit list of FIDs to deliver to. Empty = all enabled FIDs for this app. - `exclude_fids` — FIDs to drop from the computed recipient set after filtering. - `following_fid` — if set, only deliver to FIDs that follow this FID (useful for "notify your followers when you post"). - `minimum_user_score` — accepted for forward compatibility. No local user-score signal exists yet; Hypersnap parses but does not enforce this field. - `near_location` — accepted for forward compatibility. Not enforced. ## Response ```json { "campaign_id": "uuid", "success_count": 42, "failure_count": 1, "not_attempted_count": 0, "retryable_fids": [12345] } ``` | Field | Meaning | |---|---| | `campaign_id` | Server-assigned UUID for this send. Log it alongside your internal request id so you can trace deliveries. | | `success_count` | Tokens the downstream Farcaster client confirmed success on. | | `failure_count` | Tokens that returned failure (invalid / rate-limited). | | `not_attempted_count` | Tokens that were deduped out by `(fid, notificationId)` dedupe or filtered by `exclude_fids` / `following_fid`. | | `retryable_fids` | FIDs whose tokens came back as `rateLimitedTokens` from the client. Safe to retry later. | ## What Hypersnap does on the wire 1. Looks up the mini app by ``. 2. Resolves the recipient FIDs (explicit `target_fids` or all enabled tokens) and applies `exclude_fids` / `following_fid` filters. 3. Dedupes `(fid, notificationId)` pairs against the 24-hour LRU. 4. Groups remaining tokens by their `notification_url` (each Farcaster client has its own URL). 5. POSTs to each URL in batches of ≤ 100 tokens, in parallel up to `send_concurrency`. 6. Aggregates the per-client responses into the `SendNotificationResult` above. The per-client POST body is the Mini App spec contract: ```json { "notificationId": "string (≤128)", "title": "string (≤32)", "body": "string (≤128)", "targetUrl": "string (≤1024, same domain)", "tokens": ["token-a", "token-b"] } ``` And the client response is interpreted as: - `successfulTokens` → counted in `success_count`. - `invalidTokens` → permanently deleted from Hypersnap's token store. - `rateLimitedTokens` → the owning FIDs are returned in `retryable_fids`. ## Rate limits (per spec) - **Per token:** 1 notification per 30 seconds, 100 per day. Enforced by the Farcaster client, surfaced via `rate_limited_tokens`. - **`(fid, notificationId)` dedupe:** 24 hours. Enforced by Hypersnap. ## Try it
## Examples ### Send to one user ```bash curl -X POST \ -H "x-api-key: $SEND_SECRET" \ -H "Content-Type: application/json" \ -d '{ "notification": { "title": "You got a reply", "body": "alice: loved your take on this", "target_url": "https://miniapp.example.com/thread/0xabc", "uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8" }, "target_fids": [12345] }' \ https://haatz.quilibrium.com/v2/farcaster/frame/notifications/3Hq9ZgK2p4vNfWxR ``` ### Broadcast to everyone who has enabled notifications ```bash curl -X POST \ -H "x-api-key: $SEND_SECRET" \ -H "Content-Type: application/json" \ -d '{ "notification": { "title": "New season drop", "body": "Season 5 just opened — tap to play", "target_url": "https://miniapp.example.com/season/5", "uuid": "b2f63f8a-..." }, "target_fids": [] }' \ https://haatz.quilibrium.com/v2/farcaster/frame/notifications/3Hq9ZgK2p4vNfWxR ``` ### Handle retryable FIDs If the response comes back with `retryable_fids: [12345]`, schedule a retry 30+ seconds later with a fresh `uuid` (or keep the same one — the dedupe key `(fid, notificationId)` guarantees the same logical notification still won't arrive twice). # Guides --- # Build a Farcaster client A minimal Farcaster client needs to answer four questions about the current user and the network: 1. Who is this user? (profile, verifications, stats) 2. What's in their feed? (casts from people they follow, trending) 3. What's happening to them? (mentions, replies, reactions on their casts) 4. What do the things in their feed look like? (casts, authors, thread context) Every one of these is a single unauthenticated GET against a Hypersnap node. ## 1. Profile screen ```bash # Basic user info GET /v2/farcaster/user?fid=12345 # Or by username GET /v2/farcaster/user/by-username?username=alice ``` One of these, plus: ```bash # Follower / following counts are included on the User object. # If you want the actual lists: GET /v2/farcaster/user/followers?fid=12345&limit=50 GET /v2/farcaster/user/following?fid=12345&limit=50 ``` ## 2. Home feed Two options depending on what "home" means in your app: ```bash # Following feed — casts from people the user follows GET /v2/farcaster/feed/following?fid=12345&limit=20 # Trending feed — what the network is engaging with GET /v2/farcaster/feed/trending?limit=20 ``` Both return cursor-paginated `{ casts: [...], next: { cursor } }`. See [Pagination](../concepts/pagination.md). ## 3. Notifications tab ```bash GET /v2/farcaster/notifications?fid=12345&limit=20 ``` Returns aggregated notification rows — mentions, replies, reactions, follows. The `type` field discriminates the kind. ## 4. Rendering a single cast When the user taps a cast, fetch its thread: ```bash GET /v2/farcaster/cast/conversation?identifier=0xabc...&type=hash&reply_depth=3 ``` You get the root cast plus nested replies up to `reply_depth` (max 5). This is enough to render a thread view without additional roundtrips. ## 5. Who liked this cast? ```bash GET /v2/farcaster/reaction/cast?hash=0xabc...&types=likes GET /v2/farcaster/reaction/cast?hash=0xabc...&types=recasts ``` ## 6. Making "you liked this" work across a feed Rather than one lookup per cast, use the batch endpoint: ```bash POST /v2/farcaster/batch/cast-interactions Content-Type: application/json { "fids": [12345] } ``` Response: ```json { "12345": [ { "hash": "0xabc", "liked": true, "recasted": false, "replied": true } ] } ``` Then annotate your cached feed cards locally. ## 7. Search ```bash # Cast search GET /v2/farcaster/cast/search?q=hypersnap&limit=20 # User search (prefix match on username) GET /v2/farcaster/user/search?q=alice # Channel search GET /v2/farcaster/channel/search?q=base ``` ## 8. Channels ```bash # Channel profile GET /v2/farcaster/channel?id=memes # Channel feed (parent-scoped casts) GET /v2/farcaster/feed/channels?channel_ids=memes # Channel members GET /v2/farcaster/channel/members?channel_id=memes&limit=50 ``` ## That's the whole client Seriously — those are the endpoints. Everything else in the [Read API reference](../reference/reads/index.md) is either a nice-to-have (storage, username proofs, verifications, trending channels) or a batch-hydration shortcut for when you have a list of FIDs or cast hashes and want to enrich them in one call. ## What you *don't* do - **You don't submit casts through this API.** Cast/reaction/follow message submission uses the Farcaster protobuf wire format over gRPC. See the upstream [Farcaster protocol docs](https://github.com/farcasterxyz/protocol). - **You don't need auth.** All the endpoints above are public. If you find yourself needing an API key, double-check — you probably don't. - **You don't need to poll for new events.** If you want realtime, register a [webhook](../reference/webhooks/index.md). If you're building a read-only client, periodic refresh is fine. ## Recommended cache strategy Hypersnap serves reads from local indexes and is fast enough that you can go to the server for most things. Still, be nice: - Cache `User` objects for ~1 minute. Follower/following counts don't need to be perfectly fresh. - Cache `Cast` objects for longer — they're immutable after creation. Reactions on them are the thing that changes. - Invalidate on explicit user action (posting a cast, following someone, liking something). - Use `Cache-Control` / `ETag` at your edge if you have one. ## Next step When you want realtime updates instead of pulling, jump to [Build an agent](./build-an-agent.md). --- # Build an agent An "agent" for the purposes of this guide is anything that reacts to Farcaster activity in realtime — a bot that replies to mentions, a moderator that watches a channel for abuse, an analytics pipeline that indexes a slice of the firehose, a notification service that tells Discord when someone mentions your DAO. The core primitive is a **webhook subscription**. ## 1. Decide what you want to hear about Write out what events your agent needs, as plain English: > "I want to know about every cast that mentions FID 12345, and every reply to any cast authored by FID 12345." Translate to a `WebhookSubscription`: ```json { "cast_created": { "mentioned_fids": [12345], "parent_author_fids": [12345] } } ``` **Gotcha:** fields inside a filter AND together. The filter above matches casts that both mention *and* reply to you. To OR the two conditions, register two webhooks, or register one very broad webhook and filter in your receiver. See [Subscription filters](../reference/webhooks/filters.md) for every available field. ## 2. Stand up a receiver Your receiver is a public HTTPS endpoint that accepts `POST` with a JSON body. Minimal Node.js example: ```javascript import express from "express"; import crypto from "crypto"; const app = express(); // Preserve raw bytes for HMAC verification. app.use(express.raw({ type: "application/json", limit: "1mb" })); const SECRET = process.env.WEBHOOK_SECRET; app.post("/hook", (req, res) => { const received = req.header("x-hypersnap-signature"); const expected = crypto .createHmac("sha512", SECRET) .update(req.body) // raw bytes .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(received, "hex"), Buffer.from(expected, "hex"))) { return res.status(401).end(); } const event = JSON.parse(req.body.toString("utf8")); // Durably enqueue before ACKing. void enqueue(event); res.status(200).end(); }); app.listen(8080); ``` The crucial pieces: - **Raw bytes for HMAC.** Verify *before* `JSON.parse`. If you re-stringify the body, the hash won't match. - **Enqueue, then ACK.** Put the event on your own durable queue, then return `200`. Don't hold the connection open for downstream work. - **Return `5xx` on enqueue failure** so Hypersnap retries. Return `4xx` only if the message is malformed in a way that will never succeed. See [Receive webhooks](./receive-webhooks.md) for the deep version of this. ## 3. Register the webhook Sign an EIP-712 request as your FID's custody key: ```javascript import { ethers } from "ethers"; import { randomBytes } from "crypto"; const wallet = new ethers.Wallet(process.env.CUSTODY_KEY); const body = JSON.stringify({ name: "my agent", url: "https://receiver.example.com/hook", subscription: { cast_created: { mentioned_fids: [12345] }, }, }); const signedAt = Math.floor(Date.now() / 1000); const nonce = "0x" + randomBytes(32).toString("hex"); const reqHash = ethers.keccak256(ethers.toUtf8Bytes(body)); const sig = await wallet.signTypedData( { name: "Hypersnap", version: "1", chainId: 10 }, { HypersnapSignedOp: [ { name: "op", type: "string" }, { name: "fid", type: "uint64" }, { name: "signedAt", type: "uint256" }, { name: "nonce", type: "bytes32" }, { name: "requestHash", type: "bytes32" }, ]}, { op: "webhook.create", fid: 12345n, signedAt: BigInt(signedAt), nonce, requestHash: reqHash }, ); const resp = await fetch("https://haatz.quilibrium.com/v2/farcaster/webhook/", { method: "POST", headers: { "Content-Type": "application/json", "X-Hypersnap-Fid": "12345", "X-Hypersnap-Op": "webhook.create", "X-Hypersnap-Signed-At": String(signedAt), "X-Hypersnap-Nonce": nonce, "X-Hypersnap-Signature": sig, }, body, }); const { webhook } = await resp.json(); console.log("Save this signing secret:", webhook.secrets[0].value); ``` See [Sign an EIP-712 request](./sign-eip712.md) for Python and Rust. ## 4. Build your reaction logic Inside your enqueue path, process the event. Events have a typed schema — see [Event schemas](../reference/webhooks/events.md). ```javascript async function process(event) { if (event.type === "cast.created") { const cast = event.data.cast; if (cast.text.includes("@myagent")) { await reply(cast.hash, "hi, I'm a bot!"); } } } ``` ## 5. Dedupe Hypersnap delivers events **at-least-once**. Retries or network races can send the same logical event twice. Dedupe on the natural key: | Event | Dedupe key | |---|---| | `cast.created` / `cast.deleted` | `data.cast.hash` | | `follow.*` | `(follower.fid, target.fid)` | | `reaction.*` | `(user.fid, cast.hash, reaction_type)` | A cheap implementation: a Redis SET with a 1-hour TTL. ## 6. Rotate secrets If your signing secret leaks, rotate: ```javascript await fetch("https://haatz.quilibrium.com/v2/farcaster/webhook/secret/rotate?webhook_id=" + id, { method: "POST", headers: eip712Headers("webhook.rotate_secret", "" /* empty body */), }); ``` Response contains the new secrets array. During the grace window, deliveries might be signed with either the old or new key, so maintain a set of accepted secrets on your receiver and drop the old one when its `expires_at` passes. ## 7. When your agent needs to call reads too Realtime events tell you *that* something happened. If you need context (who is this author? what's the parent of this cast? does this user follow me?), use the [Read API](../reference/reads/index.md) from the same process. ## Operational notes - **Observability.** Log `(event.type, dedupe_key, attempt_count)` for every delivery. When things go wrong, you want to see whether an event was never delivered, delivered but failed locally, or delivered many times due to retries. - **Backpressure.** If your processing is slow, 1000 events/minute (the default per-webhook rate limit) will still build up a backlog. Put a queue between the HTTP handler and your processor. - **Cold starts.** When you first register a webhook, events start flowing within seconds. You do not get historical backfill — earlier events are not replayed. If you need history, do a one-time walk over the read API to seed state. --- # Build a mini app A Farcaster mini app is a hosted web app that clients (Warpcast, etc.) can launch inline, and that can push notifications back to users who have added it. The notification pipeline is what this guide covers. If you already have the mini-app frontend built and just want to send pushes, you're in the right place. ## Pieces you need 1. **A Farcaster mini app frontend.** Standard web app behind an `app_url`. Nothing Hypersnap-specific. 2. **A mini-app manifest** the Farcaster client can read, containing the `webhook_url` pointer. 3. **A registered app record on a Hypersnap node.** Gives you an `app_id` and a send secret. 4. **Your backend** that calls the send endpoint when you want to push to users. Steps 2 and 3 are the only Hypersnap-specific work. ## Step 1: Register the app From your backend, sign an EIP-712 request with your FID's custody key: ```bash # Pseudocode — see the sign-eip712 guide for a working example SIG="$(eip712_sign 'app.create' $FID "$BODY")" curl -X POST \ -H "Content-Type: application/json" \ -H "X-Hypersnap-Fid: $FID" \ -H "X-Hypersnap-Op: app.create" \ -H "X-Hypersnap-Signed-At: $SIGNED_AT" \ -H "X-Hypersnap-Nonce: $NONCE" \ -H "X-Hypersnap-Signature: $SIG" \ -d "$BODY" \ https://haatz.quilibrium.com/v2/farcaster/frame/app/ ``` Where `$BODY` is: ```json { "name": "my mini app", "app_url": "https://miniapp.example.com", "signer_fid_allowlist": [] } ``` Response: ```json { "app": { "app_id": "3Hq9ZgK2p4vNfWxR", "send_secrets": [{ "value": "", "..." : "..." }], "..." : "..." } } ``` **Save these two values:** - `app.app_id` — goes in the webhook URL and every send URL. - `app.send_secrets[0].value` — the `x-api-key` for your send calls. Store server-side. ## Step 2: Point your manifest at Hypersnap Your mini-app manifest includes a `webhook_url` that Farcaster clients will POST token events to. Use the Hypersnap receiver URL for this: ```json { "name": "my mini app", "icon_url": "https://miniapp.example.com/icon.png", "home_url": "https://miniapp.example.com", "webhook_url": "https://haatz.quilibrium.com/v2/farcaster/frame/webhook/3Hq9ZgK2p4vNfWxR" } ``` Substitute `node.example.com` for your Hypersnap hostname and `3Hq9ZgK2p4vNfWxR` for your `app_id`. Now when a user adds your mini app in a Farcaster client, the client POSTs a JFS-signed event to Hypersnap, which verifies and stores the notification token. You don't need to implement the receiver yourself — see [Client token webhook](../reference/miniapps/token-webhook.md) for the details of what Hypersnap is doing under the hood. ## Step 3: Send a notification From your backend: ```javascript const resp = await fetch( `https://haatz.quilibrium.com/v2/farcaster/frame/notifications/${APP_ID}`, { method: "POST", headers: { "x-api-key": SEND_SECRET, "Content-Type": "application/json", }, body: JSON.stringify({ notification: { title: "New reply", body: "alice replied to your post", target_url: `https://miniapp.example.com/thread/${threadId}`, uuid: crypto.randomUUID(), }, target_fids: [12345], }), }, ); const result = await resp.json(); console.log("Campaign", result.campaign_id, "success", result.success_count); ``` The response tells you how many tokens succeeded / failed / were rate-limited. See [Sending notifications](../reference/miniapps/sending.md) for the full request/response schema. ## Step 4: Handle `retryable_fids` When the Farcaster client rate-limits a token (spec: 1 / 30sec, 100 / day per token), the FID shows up in `retryable_fids`: ```json { "campaign_id": "uuid", "success_count": 10, "failure_count": 0, "retryable_fids": [12345, 67890] } ``` Schedule a retry 30+ seconds later. Either reuse the same `uuid` (the `(fid, notificationId)` dedupe prevents double-delivery if the original did go through) or generate a new one. ## Step 5: Rotate the send secret periodically Good operational hygiene: ```bash # Signed call curl -X POST ... https://haatz.quilibrium.com/v2/farcaster/frame/app/secret/rotate?app_id=$APP_ID ``` Response has a fresh secret in `send_secrets[-1].value` and grace-expires the old one for 24 hours. Deploy the new secret to your backend during the grace window. ## Broadcasting vs targeting - `target_fids: [12345]` — send to specific users. - `target_fids: []` — send to every FID that has enabled notifications for your app. - `target_fids: []` + `following_fid: 12345` — send to everyone who follows FID 12345. - `target_fids: [12345, 67890]` + `exclude_fids: [12345]` — send to 67890 only. ## What you should not build - **You shouldn't implement the JFS token webhook yourself** if you're proxying through Hypersnap. That's the whole point — let the node verify and store tokens, so your backend only needs the send endpoint. - **You shouldn't persist the send secret in client code.** It's a server-side credential. If it leaks, rotate. - **You shouldn't poll for token registrations.** They are transparently managed by Hypersnap; just send to `target_fids: []` to broadcast. --- # Receive webhooks A deep-dive on running a production webhook receiver for Hypersnap. Covers signature verification, rotation, dedupe, retries, and backpressure. ## Minimum viable receiver ```javascript import express from "express"; import crypto from "crypto"; const app = express(); app.use(express.raw({ type: "application/json", limit: "1mb" })); const SECRETS = new Set([process.env.WEBHOOK_SECRET]); app.post("/hook", (req, res) => { const receivedHex = req.header("x-hypersnap-signature"); if (!verifyAny(req.body, receivedHex, SECRETS)) { return res.status(401).end(); } const event = JSON.parse(req.body.toString("utf8")); void handle(event); res.status(200).end(); }); function verifyAny(body, receivedHex, secrets) { const recv = Buffer.from(receivedHex, "hex"); for (const s of secrets) { const exp = crypto.createHmac("sha512", s).update(body).digest(); if (recv.length === exp.length && crypto.timingSafeEqual(recv, exp)) { return true; } } return false; } app.listen(8080); ``` The rest of this guide is what you do on top of this scaffold to make it robust. ## Signature verification **Rule one: verify before parsing.** HMAC is computed over the raw bytes. If you parse JSON and re-serialize it, the bytes change (whitespace, key ordering) and your hash won't match the one in the header. Express: use `express.raw`. Koa/fastify: look up the raw-body option on your framework. Lambda: use the `isBase64Encoded` + `body` as-received. **Rule two: use constant-time comparison.** Never `==` on hex strings. Use `crypto.timingSafeEqual` / `hmac.compare_digest` / `subtle::ConstantTimeEq`. **Rule three: accept multiple secrets during rotation.** See below. ## Handling secret rotation When you rotate a webhook's secret, Hypersnap keeps the old one valid for a 24-hour grace window (configurable by your node operator). During that window, deliveries can be signed with either secret depending on timing. Your receiver needs to: 1. **Maintain an accepted-secrets set.** Start with the single active secret. 2. **When you rotate**, add the new one to the set. 3. **Try every secret** when verifying, return `401` only if none match. 4. **Drop the old secret** from the set once its `expires_at` has passed. In code: ```javascript const SECRETS = new Map(); // secret_value -> expires_at (null = active, never expires) async function refreshSecrets() { const resp = await signedFetch("GET", "/v2/farcaster/webhook/?webhook_id=" + ID); const webhook = (await resp.json()).webhook; const now = Math.floor(Date.now() / 1000); SECRETS.clear(); for (const s of webhook.secrets) { if (s.expires_at === null || s.expires_at > now) { SECRETS.set(s.value, s.expires_at); } } } // Refresh on boot and every few hours. await refreshSecrets(); setInterval(refreshSecrets, 60 * 60 * 1000); ``` ## Dedupe Deliveries are **at-least-once**. Retries, transient failures, and network races can replay the same logical event. You need an idempotency layer. Cheap version — Redis: ```javascript async function handle(event) { const key = dedupeKey(event); const first = await redis.set(`wh:${key}`, "1", "EX", 3600, "NX"); if (!first) return; // already processed await process(event); } function dedupeKey(event) { switch (event.type) { case "cast.created": case "cast.deleted": return `c:${event.data.cast.hash}`; case "follow.created": case "follow.deleted": return `f:${event.data.follower.fid}:${event.data.target.fid}`; case "reaction.created": case "reaction.deleted": return `r:${event.data.user.fid}:${event.data.cast.hash}:${event.data.reaction_type}`; default: return `x:${JSON.stringify(event.data)}`; } } ``` A 1-hour TTL is enough for retries (the retry queue times out well before that). ## Return codes | You return | Hypersnap's behavior | |---|---| | `2xx` | Delivered. | | `4xx` | Terminal failure. No retries. | | `5xx` | Retried up to `retry_max_attempts` (default 5) with exponential backoff. | | Timeout / connection error | Retried as above. | **Return `2xx` as fast as possible.** Enqueue the event to your local processing queue and ACK. Don't do downstream work on the HTTP handler's stack — you'll eat into the 10-second `delivery_timeout_secs` default and cause timeouts. ## Backpressure At the default `default_rate_limit = 1000 events / 60 seconds`, you can get a sustained load of ~17 events/sec per webhook. That's small, but peaky — a hot thread can burst higher than average. - Put a queue between the HTTP handler and your processor. - Monitor queue depth. - If the queue is backing up, your receiver should start returning `5xx` so Hypersnap requeues instead of dropping events. ## Observability Log every delivery with: - `event.type` - Dedupe key - Whether it was a first-sight or dedupe hit - Processing outcome - HTTP status returned With those fields you can answer every support question ("did this event ever arrive?", "how many times?", "what did we do with it?"). Hypersnap emits per-webhook metrics at `webhooks.delivery.*` — ask your operator for the dashboard link so you can compare delivery counts against your local receiver stats. ## Testing - **Signature verification:** unit-test with a known secret and a known body. - **Rotation:** seed your receiver with two secrets and confirm it accepts deliveries signed by either. - **Retries:** return `500` for a delivery and confirm Hypersnap retries with the expected backoff. - **Dedupe:** feed the same event twice; confirm your downstream processing ran exactly once. ## Checklist - [ ] Raw-body middleware preserves bytes for HMAC. - [ ] `timingSafeEqual` for signature compare. - [ ] Accept multiple secrets for rotation. - [ ] Dedupe on natural keys. - [ ] Return `2xx` after durable enqueue, not after processing. - [ ] Return `5xx` on transient enqueue failure. - [ ] Return `4xx` only when retry is pointless. - [ ] Log `(type, dedupe_key, first_sight, status)` on every request. --- # Sign an EIP-712 request This page has working examples in JavaScript, Python, and Rust for producing the EIP-712 signature + headers that Hypersnap's management endpoints require. See [Signed operations](../concepts/authentication.md) for the full spec and [Errors](../concepts/errors.md) if your signed request gets rejected. All three examples sign the same operation — `webhook.create` — against the same body. Adapt by changing the op string and body. ## Shared contract ``` Domain: { name: "Hypersnap", version: "1", chainId: 10 } Type: HypersnapSignedOp( string op, uint64 fid, uint256 signedAt, bytes32 nonce, bytes32 requestHash, // keccak256(raw body bytes) ) ``` Headers on the resulting request: - `X-Hypersnap-Fid: ` - `X-Hypersnap-Op: ` - `X-Hypersnap-Signed-At: ` - `X-Hypersnap-Nonce: 0x<32 bytes hex>` - `X-Hypersnap-Signature: 0x<65 bytes hex>` ## JavaScript (ethers v6) ```javascript import { ethers } from "ethers"; import { randomBytes } from "crypto"; const wallet = new ethers.Wallet(process.env.CUSTODY_PRIVATE_KEY); const FID = 12345n; const body = JSON.stringify({ name: "my webhook", url: "https://receiver.example.com/hook", subscription: { cast_created: { author_fids: [3] } }, }); const signedAt = Math.floor(Date.now() / 1000); const nonce = "0x" + Buffer.from(randomBytes(32)).toString("hex"); const reqHash = ethers.keccak256(ethers.toUtf8Bytes(body)); const domain = { name: "Hypersnap", version: "1", chainId: 10 }; const types = { HypersnapSignedOp: [ { name: "op", type: "string" }, { name: "fid", type: "uint64" }, { name: "signedAt", type: "uint256" }, { name: "nonce", type: "bytes32" }, { name: "requestHash", type: "bytes32" }, ], }; const value = { op: "webhook.create", fid: FID, signedAt: BigInt(signedAt), nonce, requestHash: reqHash, }; const signature = await wallet.signTypedData(domain, types, value); const resp = await fetch("https://haatz.quilibrium.com/v2/farcaster/webhook/", { method: "POST", headers: { "Content-Type": "application/json", "X-Hypersnap-Fid": String(FID), "X-Hypersnap-Op": "webhook.create", "X-Hypersnap-Signed-At": String(signedAt), "X-Hypersnap-Nonce": nonce, "X-Hypersnap-Signature": signature, }, body, }); console.log(resp.status, await resp.json()); ``` ## Python (eth_account) ```python import json, os, time from secrets import token_bytes from eth_account import Account from eth_account.messages import encode_typed_data from eth_utils import keccak import requests CUSTODY_PRIVATE_KEY = os.environ["CUSTODY_PRIVATE_KEY"] FID = 12345 body = json.dumps({ "name": "my webhook", "url": "https://receiver.example.com/hook", "subscription": {"cast_created": {"author_fids": [3]}}, }, separators=(",", ":")) signed_at = int(time.time()) nonce_bytes = token_bytes(32) nonce_hex = "0x" + nonce_bytes.hex() req_hash = keccak(body.encode()) typed_data = { "domain": {"name": "Hypersnap", "version": "1", "chainId": 10}, "primaryType": "HypersnapSignedOp", "types": { "EIP712Domain": [ {"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, ], "HypersnapSignedOp": [ {"name": "op", "type": "string"}, {"name": "fid", "type": "uint64"}, {"name": "signedAt", "type": "uint256"}, {"name": "nonce", "type": "bytes32"}, {"name": "requestHash", "type": "bytes32"}, ], }, "message": { "op": "webhook.create", "fid": FID, "signedAt": signed_at, "nonce": nonce_bytes, "requestHash": req_hash, }, } encoded = encode_typed_data(full_message=typed_data) signed = Account.from_key(CUSTODY_PRIVATE_KEY).sign_message(encoded) resp = requests.post( "https://haatz.quilibrium.com/v2/farcaster/webhook/", data=body, headers={ "Content-Type": "application/json", "X-Hypersnap-Fid": str(FID), "X-Hypersnap-Op": "webhook.create", "X-Hypersnap-Signed-At": str(signed_at), "X-Hypersnap-Nonce": nonce_hex, "X-Hypersnap-Signature": signed.signature.hex(), }, ) print(resp.status_code, resp.json()) ``` **Gotcha:** `json.dumps(..., separators=(",", ":"))` gives you deterministic bytes with no spaces. If you pass a pre-built dict straight to `requests.post(json=...)`, `requests` re-serializes it and the hash you signed may not match what's on the wire. Always serialize to a `str`/`bytes` value once and use the same bytes for both the hash and the request body. ## Rust (alloy) ```rust,ignore use alloy_dyn_abi::TypedData; use alloy_primitives::{keccak256, B256, U256}; use alloy_signer::{Signer, SignerSync}; use alloy_signer_local::PrivateKeySigner; use rand::{rngs::OsRng, RngCore}; use serde_json::json; use std::time::{SystemTime, UNIX_EPOCH}; #[tokio::main] async fn main() -> Result<(), Box> { let signer: PrivateKeySigner = std::env::var("CUSTODY_PRIVATE_KEY")?.parse()?; let fid: u64 = 12345; let body = serde_json::to_vec(&json!({ "name": "my webhook", "url": "https://receiver.example.com/hook", "subscription": { "cast_created": { "author_fids": [3] } } }))?; let signed_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let mut nonce_bytes = [0u8; 32]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = B256::from(nonce_bytes); let req_hash = keccak256(&body); // Build the typed-data JSON; alloy_dyn_abi::TypedData accepts this shape. let typed = json!({ "domain": { "name": "Hypersnap", "version": "1", "chainId": 10 }, "primaryType": "HypersnapSignedOp", "types": { "EIP712Domain": [ { "name": "name", "type": "string" }, { "name": "version", "type": "string" }, { "name": "chainId", "type": "uint256" } ], "HypersnapSignedOp": [ { "name": "op", "type": "string" }, { "name": "fid", "type": "uint64" }, { "name": "signedAt", "type": "uint256" }, { "name": "nonce", "type": "bytes32" }, { "name": "requestHash", "type": "bytes32" } ] }, "message": { "op": "webhook.create", "fid": fid, "signedAt": U256::from(signed_at), "nonce": format!("{nonce:#x}"), "requestHash": format!("{:#x}", req_hash) } }); let td: TypedData = serde_json::from_value(typed)?; let hash = td.eip712_signing_hash()?; let sig = signer.sign_hash_sync(&hash)?; let resp = reqwest::Client::new() .post("https://haatz.quilibrium.com/v2/farcaster/webhook/") .header("Content-Type", "application/json") .header("X-Hypersnap-Fid", fid.to_string()) .header("X-Hypersnap-Op", "webhook.create") .header("X-Hypersnap-Signed-At", signed_at.to_string()) .header("X-Hypersnap-Nonce", format!("{nonce:#x}")) .header("X-Hypersnap-Signature", format!("0x{}", hex::encode(sig.as_bytes()))) .body(body) .send() .await?; println!("{} {}", resp.status(), resp.text().await?); Ok(()) } ``` ## Adapting to other operations Change two things: 1. The `op` string in both the header and the signed typed data message. 2. The body. For `DELETE` / `GET` / `POST .../secret/rotate` which have no body, use an empty byte string (`""`) — the `requestHash` becomes `keccak256("")` which is `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`. The op ↔ method/path mapping is in [Signed operations](../concepts/authentication.md#operation-names). --- # Run the docs as a Farcaster mini app This docs site is static HTML + JS. Point a Farcaster mini-app manifest at the hosted URL and it becomes a usable mini app — wallet already connected (via the client's embedded wallet), FID already known, and the [API playground](../playground.md) can sign management requests without any extra UI. ## Manifest Create `manifest.json` at the root of wherever you're hosting the docs: ```json { "name": "Hypersnap docs", "icon_url": "https://hypersnap-docs.qstorage.quilibrium.com/favicon-8114d1fc.png", "home_url": "https://hypersnap-docs.qstorage.quilibrium.com/playground.html", "webhook_url": "https://haatz.quilibrium.com/v2/farcaster/frame/webhook/REPLACE-WITH-APP-ID" } ``` The manifest targets the playground page as the home — that's the part developers will want to use. The `webhook_url` is there so the Farcaster client can POST token events; it can point at any valid Hypersnap `app_id`, including one you registered for the docs itself (so the docs can push notifications back to devs who star it). ## Detection inside the playground The playground's Connect-wallet flow walks four detection paths in order and uses the first one that yields an EIP-1193 provider: 1. **Farcaster mini-app SDK, async API** — `window.sdk.wallet.getEthereumProvider()` if the global exposes the new async getter. 2. **Farcaster mini-app SDK, legacy sync property** — `window.sdk.wallet.ethProvider` or `window.farcasterMiniApp.ethProvider` for older clients. 3. **EIP-6963 multi-wallet discovery** — listens for `eip6963:announceProvider` events and picks the first announced provider. This is how modern extensions (MetaMask, Rabby, Coinbase Wallet) expose themselves without stomping on `window.ethereum`. 4. **Generic injected EIP-1193** — `window.ethereum` (or the first entry of `window.ethereum.providers` when multiple wallets have injected). All four paths converge on the same `request({ method: "eth_signTypedData_v4", … })` call. The `source` badge next to the connected address tells you which path was used ("Farcaster mini-app", "MetaMask", "window.ethereum", etc.). If none of the paths produce a provider, the Connect button surfaces a detailed error listing everything it tried — so you can tell whether your Farcaster client failed to inject the SDK, or the page is open in a plain browser with no wallet installed. ## What works, what doesn't - **Public reads** — work identically inside or outside a mini app. No wallet needed. - **Signed management calls** — work if the client's embedded wallet (or injected wallet) controls the custody address for the FID you set. If not, the signature will recover to a different address and Hypersnap returns `401`. - **Send-secret endpoint** — works. You still have to paste the per-app secret into the field; the mini-app container has no way to hand it to the docs. ## Hosting notes - Serve the `book/` directory from a CORS-friendly static host. GitHub Pages, Cloudflare Pages, Netlify, and direct S3 all work. - The built site is fully self-contained — no runtime fetches to an API other than the Hypersnap host the user configured. That means your CSP can be strict: `connect-src 'self' https://*.quilibrium.com` is enough unless you want to allow arbitrary hostnames in the Host field. - There is no build-time server-side rendering to worry about. ## Authoring new try-it panels If you want to extend the playground with an endpoint that isn't already covered, the markup is: ```html
``` Where: | Attribute | Meaning | |---|---| | `data-method` | HTTP method. | | `data-path` | Path template. Use `{param}` for path parameters — they get replaced from fields with `kind=path`. | | `data-title` | Human-readable label shown in the panel's summary row. | | `data-auth` | `none` (default for unsigned reads), `signed` (EIP-712 — implied when `data-op` is set), or `send-key` (per-app `x-api-key`). | | `data-op` | Op string for `signed` mode. One of `webhook.create` / `webhook.read` / etc. See [Signed operations](../concepts/authentication.md). | | `data-fields` | Semi-colon-separated field specs. Each field is `name|kind|type|placeholder|default|required` with `kind` in `{query, path, body, header}`. | | `data-body-template` | A starter JSON body shown in the body textarea. Users can edit before clicking Run. | The playground JS hydrates every `.try-it` block it finds, so you can drop panels anywhere in the docs — they'll work in-page alongside the reference prose. # For AI agents --- # Using these docs with an LLM This documentation is designed so that an AI coding agent — Claude, ChatGPT, Cursor, your own tool — can be handed the material in one shot and emerge able to write integration code for your project without guessing. ## The two files you care about When you run `./build.sh` (or `mdbook build && ./scripts/generate-llms.sh`), the build produces, alongside the normal HTML site: - **`book/llms.txt`** — a short, URL-annotated index in the format described at . Point an agent at this file first. It will know what exists and how to fetch any individual page. - **`book/llms-full.txt`** — every page in the docs concatenated into a single plain-text file. Drop this into an agent's context window directly. It is self-contained: one file, no external fetches required. Both files are plain text. You can serve them from the same static host as the HTML site — they live at `/llms.txt` and `/llms-full.txt`. ## Typical flow ### When the docs are online Give your agent the URL of `llms.txt`: > Here is the full index of the Hypersnap API documentation: > Fetch whichever pages you need and use them to write the integration. The agent fetches the index, picks the pages it needs, and pulls them over HTTP. ### When you want one-shot context Copy-paste `llms-full.txt` into the agent's context: > The full Hypersnap API docs are below. Using only this material, write me a Node.js agent that subscribes to `cast.created` events mentioning FID 12345 and posts them to a Slack channel. > > ``` > > ``` Because the file is complete, the agent never has to ask "what does the `/v2/farcaster/webhook/` response look like?" — everything is in context. ### Inside Claude Code / Cursor Point the agent at the repo root (or the `hypersnap-docs-web/src/` folder) and tell it to read the markdown directly. The source is organized by topic, so "read reference/webhooks first" works fine. ## What the agent should produce A typical Hypersnap integration spans three shapes of code: 1. **Read calls** — unauthenticated GETs. Trivial; any HTTP client works. 2. **Signed management calls** — need EIP-712 signing with the caller's custody key. See the [Sign an EIP-712 request](../guides/sign-eip712.md) page; it has working JS, Python, and Rust snippets you can hand over verbatim. 3. **Webhook receivers** — a long-running HTTP endpoint that verifies HMAC-SHA512. See the [Receive webhooks](../guides/receive-webhooks.md) page. If the agent asks "which one do I need?", the answer depends on direction: - Reading data = (1). - Sending events to users (mini-app push) or managing a subscription = (2). - Receiving events from Hypersnap = (3). - Agents/bots typically need (1) + (2) + (3). ## Per-agent pages - **[Prompting Claude](./claude.md)** — a ready-to-paste system prompt plus context-window strategy. - **[Prompting ChatGPT](./chatgpt.md)** — equivalent for GPT-family models. - **[Integration checklist](./integration-checklist.md)** — a one-page list of everything an integration needs, suitable to hand to the agent as acceptance criteria. - **[Full spec (single page)](./full-spec.md)** — the entire docs folded into one rendered page. Easier to copy-paste than walking the sidebar. ## A note on drift These docs are versioned with the hypersnap source they describe. When in doubt about whether a field exists or an endpoint behaves a specific way, trust the source — point your agent at `src/api/` in the hypersnap repo. For production integrations, pin to a specific git commit of both the hypersnap source and this docs site, and re-verify before bumping. --- # Prompting Claude Claude is well-suited to Hypersnap integrations because the API surface is small, the auth model is well-documented, and the long-form nature of these docs fits Claude's context window comfortably (`llms-full.txt` is a few thousand lines). ## System prompt to start from ```text You are helping me build an integration against the Hypersnap Farcaster API. Complete Hypersnap API documentation is provided below in plain text. Rules: 1. Only use endpoints, headers, fields, and behaviors that are explicitly described in the documentation. Do not invent endpoints or fields. 2. When the documentation says a field is required, treat it as required. When it says optional, you may omit it. When it gives a default, don't re-specify it unless I ask. 3. If the documentation contradicts your prior training about other Farcaster-compatible APIs, the documentation wins. 4. For signed management requests, always: - Include all five X-Hypersnap-* headers. - Hash the literal request body bytes (do not re-serialize JSON). - Use a fresh nonce per request. - Cross-check the op string against the HTTP method+path. 5. For webhook receivers, always: - Verify the HMAC-SHA512 signature using the raw request body bytes before JSON-parsing. - Use constant-time comparison. - Accept multiple valid secrets during rotation grace windows. - Dedupe on the event's natural key. - Return 2xx only after durably enqueuing the event. 6. Write idiomatic code for the target language/framework. Prefer standard library where possible. Avoid heavy wrapper SDKs unless I ask. When writing code, flag any assumption that isn't explicit in the docs. ``` ## How to provide the docs ### Option A — paste `llms-full.txt` into the conversation Copy the full file. The context window in Claude Sonnet/Opus 4.x is large enough to hold it alongside your question and Claude's response. This is the most reliable option — zero tool-use, everything Claude needs is in-band. ### Option B — Claude with web access Provide the URL of `llms.txt` and let Claude fetch pages via its web/file tools: > The Hypersnap docs index is at https://hypersnap-docs.qstorage.quilibrium.com/llms.txt — fetch the pages you need. Works well in Claude Code (where Claude already has fetch tools). Less reliable in plain Claude.ai unless the docs are on a host Claude can read. ### Option C — Claude Code with the repo checked out Most efficient for ongoing work. Check the docs repo out alongside the hypersnap repo: ``` /code ├── hypersnap/ └── hypersnap-docs-web/ └── src/ ``` Then invoke Claude Code from `/code/hypersnap-docs-web/` or `/code/hypersnap/` and say: > Read the markdown in ../hypersnap-docs-web/src/ first, then help me write an integration that does X. Claude Code will open the relevant files on demand. The directory layout is semantic (`concepts/`, `reference/webhooks/`, etc.) so "read reference/webhooks and reference/miniapps" is a perfectly clear instruction. ## Example prompts ### Build an agent that watches for mentions > Using only the Hypersnap docs, write a Node.js service that: > 1. Registers a webhook subscribed to `cast_created` events mentioning FID 12345. > 2. Verifies deliveries via HMAC-SHA512. > 3. For each matching cast, posts a summary to Slack via an incoming webhook. > > Use ethers v6 for EIP-712 signing. Include a rotation-safe secret set. ### Build a mini-app send backend > I have a registered mini app with `app_id=3Hq9ZgK2p4vNfWxR`. Using only the Hypersnap docs, write a Python Flask endpoint that accepts `POST /internal/notify` with JSON `{ fid, title, body }` and forwards it to the Hypersnap send endpoint. Use the spec's recommended dedupe UUID per notification. ### Build a read client > Using only the Hypersnap docs, write a TypeScript function `getThread(hash: string)` that returns the cast plus the first three levels of replies. Use `fetch`, no SDK. Handle 404. ## Tips - **Name the op string explicitly in your prompt.** Claude's training data includes other Farcaster-compatible APIs where op strings look different. Spelling it out ("use `webhook.create`") avoids a small but real hallucination risk. - **Constrain to "only use fields in the docs".** This pre-empts field inventions. - **Ask for a test in the same prompt.** Claude is happy to write a unit test for signature verification alongside the main code; you want that test. - **Review the signed bytes.** For EIP-712 code specifically, have Claude print the body bytes it hashed and verify they match what actually gets sent over the wire. Body hash mismatches are the #1 failure mode on first run. --- # Prompting ChatGPT GPT-4o / GPT-4.1 / GPT-5-series all handle Hypersnap integrations well. The patterns are the same as for [Claude](./claude.md) — the differences below are small, but worth knowing. ## System prompt ```text You are writing code against the Hypersnap Farcaster API. The full documentation for Hypersnap is attached / pasted below. Strict rules: 1. Only use endpoints, headers, fields, and behaviors explicitly listed in the documentation. If you are unsure whether something exists, say so and stop — do not guess. 2. Signed management requests must produce all five X-Hypersnap-* headers exactly as specified, and must hash the literal request body bytes using keccak256. 3. Webhook receivers must verify HMAC-SHA512 over the raw request body before parsing JSON, use a timing-safe comparison, and support multiple valid secrets for rotation windows. 4. When in doubt, produce working code for the simplest case and flag what you chose not to implement. 5. Do not fabricate client SDK package names. Use the HTTP client that ships with the target language unless I ask otherwise. Target language: . Target framework (if any): . ``` ## How to provide the docs ### Option A — attach `llms-full.txt` as a file In ChatGPT's web UI, drag-and-drop the file into the conversation. In the API, use a file upload + the file search tool. GPT will chunk-read it as needed. This is the preferred option: ChatGPT's file-handling path is more efficient than pasting the full text inline when the file is large. ### Option B — paste inline Works for smaller excerpts. If your integration only touches one section (e.g. just the webhooks pages), paste those specific pages into the prompt rather than the whole `llms-full.txt`. ### Option C — ChatGPT with tools / browsing Provide the `llms.txt` URL. ChatGPT's browsing path is slower and less reliable than Claude's file-reading for this kind of content, but it works. ## ChatGPT-specific gotchas - **Schema hallucination.** GPT is more prone than Claude to inventing field names from other Farcaster-compatible APIs (e.g. `object_type`, `viewer_context`). Use a strict prompt ("only use fields present in the documentation") and skim the output for familiar-but-wrong field names before running it. - **EIP-712 type naming.** GPT sometimes emits `verifyingContract` in the domain even though Hypersnap's domain only has `name`/`version`/`chainId`. Spell out the exact domain in your prompt. - **"Here's a helpful wrapper I just imagined."** GPT likes to propose fictitious npm packages (e.g. `@hypersnap/client`). Instruct it to use only the standard library or well-known HTTP clients (axios, requests, reqwest). ## Example prompt ```text You are writing code against the Hypersnap Farcaster API. The full documentation is attached as llms-full.txt. Rules: - Only use endpoints, headers, fields, and behaviors from the docs. - No fictitious SDK packages — use node's built-in `fetch` and `crypto`. - EIP-712 signing: use `ethers@6`. Task: Write a Node.js script `rotate-secret.js` that, given env vars CUSTODY_PRIVATE_KEY, FID, and WEBHOOK_ID, calls the webhook secret-rotation endpoint and prints the new secret value. Include error handling for 401 and 404. ``` ## Validating output Ask a follow-up: > Print the exact bytes of the request body you'll be hashing, and the exact value of each X-Hypersnap-* header. Do not run the code; just show me the values. If the body bytes don't match what the `fetch` call sends (e.g. `JSON.stringify(body)` vs a pre-serialized string variable), the signature will fail. Catching this statically saves a debugging loop. --- # Integration checklist Hand this page to your agent as acceptance criteria. Every item is small, testable, and answerable from the [Read API reference](../reference/reads/index.md), [Webhooks](../reference/webhooks/index.md), or [Mini-app notifications](../reference/miniapps/index.md) sections. ## For a read client - [ ] Base URL is configurable (not hardcoded to one node). - [ ] All calls are GET (or POST for batch reads); no auth headers required. - [ ] Query parameters are URL-encoded correctly (commas in `fids=3,5` are fine). - [ ] Cursor pagination: loop until `next.cursor` is null or response is shorter than `limit`. - [ ] Parses `{ "message": "..." }` error bodies and surfaces status codes. - [ ] Respects operator rate limits if/when they're enforced upstream. - [ ] Handles `404` explicitly (not-found vs fetch error). - [ ] Caches `User` and `Cast` objects sensibly. ## For a signed management caller (webhooks or mini-app registration) - [ ] Domain struct is exactly `{ name: "Hypersnap", version: "1", chainId: 10 }`. No `verifyingContract`. - [ ] `HypersnapSignedOp` type has these five fields in order: `op:string`, `fid:uint64`, `signedAt:uint256`, `nonce:bytes32`, `requestHash:bytes32`. - [ ] `requestHash` is `keccak256` of the **literal** request body bytes. - [ ] The body used for hashing is byte-for-byte the same as the body sent over the wire (no re-serialization between them). - [ ] `X-Hypersnap-Fid` is a decimal string. - [ ] `X-Hypersnap-Op` uses one of the documented op strings — `webhook.create`, `webhook.update`, `webhook.delete`, `webhook.read`, `webhook.rotate_secret`, `app.create`, `app.update`, `app.delete`, `app.read`, `app.rotate_secret`. - [ ] `X-Hypersnap-Signed-At` is current unix seconds; clock skew ≤ 5 minutes from the server. - [ ] `X-Hypersnap-Nonce` is a **fresh** random 32-byte value per request. - [ ] `X-Hypersnap-Signature` is the 65-byte signature as `0x`-prefixed hex. - [ ] The signed `op` matches the actual HTTP method + path (a signed `webhook.create` on a `PUT` request returns 400). - [ ] Handles `401`, `403`, `404`, `429` with distinct error paths. - [ ] Secret rotation flow deploys the new secret *after* reading the grace window. ## For a webhook receiver - [ ] Endpoint is reachable from the public internet over HTTPS. - [ ] Raw body bytes are preserved before any JSON parsing. - [ ] HMAC-SHA512 verification uses a timing-safe comparison function. - [ ] Rejects with `401` when no valid secret matches. - [ ] Accepts deliveries signed with any secret in the currently-active set (supports rotation). - [ ] Fetches the webhook's secrets on boot + periodically to refresh the accepted set. - [ ] Returns `2xx` as soon as the event is durably enqueued for processing. - [ ] Returns `5xx` on transient enqueue failure so Hypersnap retries. - [ ] Returns `4xx` only when retry is pointless. - [ ] Dedupes on the event's natural key (cast hash, reaction tuple, etc.). - [ ] p95 response latency < 10 seconds (the default `delivery_timeout_secs`). - [ ] Logs `(type, dedupe_key, first_sight, status)` on every request. ## For a mini-app notification sender - [ ] `app_id` is stored configurably, not hardcoded. - [ ] Send secret is stored server-side only; never shipped to the client. - [ ] `POST` to `/v2/farcaster/frame/notifications/` with `x-api-key: `. - [ ] `notification.title` ≤ 32 chars; `notification.body` ≤ 128 chars; `target_url` ≤ 256 chars. - [ ] Generates a per-notification `uuid` for dedupe (or reuses one deliberately on retry). - [ ] Handles `retryable_fids` in the response by scheduling a retry 30+ seconds later. - [ ] Handles `failure_count > 0` by logging, since the per-token reason is surfaced server-side. - [ ] Rotates the send secret on a schedule, with zero downtime using the grace window. ## For a mini-app webhook-URL pointer - [ ] Manifest `webhook_url` points at `https:///v2/farcaster/frame/webhook/`. - [ ] `app_id` matches the one you registered. - [ ] You don't need to implement the endpoint yourself. ## Cross-cutting - [ ] Pinned to a known-good hypersnap git commit / release for production. - [ ] Monitoring covers: HTTP success rate, signature verification failures, dedupe hit rate, retry queue depth. - [ ] Runbook includes "what to do if the custody key rotates" and "what to do if the send secret leaks". Hand this list to your agent as acceptance criteria alongside `llms-full.txt`. Every item is individually answerable from the docs, and every item prevents a real failure mode somebody has hit in integrating against the upstream Farcaster v2 contracts. --- # Full spec (single page) This page is a placeholder for the build process. During `./build.sh`, the `scripts/generate-llms.sh` helper concatenates every page in the book (excluding this file and the SUMMARY) into `book/llms-full.txt`. If you want the full single-file spec for an agent, fetch: ``` /llms-full.txt ``` or, if you're browsing locally after a build: ``` hypersnap-docs-web/book/llms-full.txt ``` There is also an index version (`/llms.txt`) for agents that prefer to fetch pages on-demand instead of consuming the whole spec up front. ## Why not render the full spec inline here? mdBook renders one HTML page per source markdown file, and the search index assigns teaser snippets based on sub-sections within a page. A 10,000-line "everything" page would swallow every search result with the same title, making the search UX worse. The concatenated spec lives outside the rendered site, as a plain text file, for LLM consumption only. ## Pointing at the text files Both `llms.txt` and `llms-full.txt` are static files emitted into `book/` alongside the HTML. Any static host (Cloudflare Pages, S3, nginx, GitHub Pages) will serve them without configuration. For a local quick-check: ```bash ./build.sh python3 -m http.server --directory book 8000 & curl http://localhost:8000/llms.txt curl http://localhost:8000/llms-full.txt | head ``` # Appendix --- # Glossary **App ID** : A 16-character base58 identifier assigned by Hypersnap when you register a mini app. Appears in URLs for the client token webhook and the send endpoint. **Cast** : The core Farcaster message type — a short post, optionally with parent context (a cast being replied to, or a channel parent URL). **Channel** : A parent-URL-scoped subcommunity. Casts can be posted into a channel by setting their `parent_url`. Channels have their own feeds, members, and lead moderators. **Custody address** : The Ethereum address currently in charge of an FID, per the on-chain `IdRegistry`. Signing a Hypersnap management request means producing an EIP-712 signature with this key. **Dedupe window** : The 24-hour time window over which `(fid, notificationId)` pairs are deduplicated on mini-app sends. Re-sending the same `uuid` to the same FID inside the window is a no-op. **EIP-712** : The Ethereum typed-data signing standard Hypersnap uses for management-request auth. Produces a structured signature distinct from arbitrary message signing. **FID** : Farcaster ID. A `u64` assigned by the on-chain `IdRegistry`. Permanent and non-transferable; the custody address that controls it can change. **Grace period** : The window during which a previously-active signing or send secret keeps working after a rotation. Default 24 hours. Exists so receivers and senders can switch over without downtime. **HMAC-SHA512** : The signing scheme used on outbound webhook deliveries. The per-webhook signing secret is the HMAC key; the raw HTTP body is the message. **JFS** : JSON Farcaster Signature. An Ed25519 signing envelope Farcaster clients use when POSTing mini-app token events. Defined in the Farcaster Mini App spec. **Mini app** : A hosted web app that Farcaster clients can launch inline and push notifications to. Registered with Hypersnap via the signed `/v2/farcaster/frame/app/` endpoints. **Nonce** : A 32-byte random value in every signed management request. Deduped in-memory for the duration of the signed_at window to prevent replay attacks. **Notification token** : The opaque per-(fid, app, client) identifier that a Farcaster client hands to a mini app when the user opts in. Used by the client to route push notifications. **Op string** : The string naming the operation a signed request is performing, e.g. `webhook.create`, `app.rotate_secret`. Both the `X-Hypersnap-Op` header and the `op` field inside the EIP-712 typed data carry this. **Retry queue** : A RocksDB-backed persistent queue holding webhook deliveries that hit transient failures. Re-injected onto the delivery pool with exponential backoff. **Send secret** : The per-mini-app bearer token used as the `x-api-key` header when calling the notification send endpoint. Rotated via `POST /v2/farcaster/frame/app/secret/rotate`. **Signed operation** : Any HTTP request that Hypersnap requires an EIP-712 signature for. The webhook management and mini-app registration endpoints are the signed surfaces today. **Signer** : An Ed25519 key registered to an FID via the on-chain `KeyRegistry`. Signs Farcaster messages (casts, reactions, follows) and JFS envelopes. Distinct from the custody address. **Subscription** : A filter spec attached to a webhook that picks which events get delivered. See [Subscription filters](../reference/webhooks/filters.md). **Webhook** : A registered HTTP endpoint that receives filtered Farcaster events as HMAC-signed POSTs. Created and managed via `/v2/farcaster/webhook/`. --- # Data retention Hypersnap stores data in three distinct tiers, each with its own retention characteristics: ## 1. Live message state Casts, reactions, follows, verifications, user data — everything that has a Farcaster CRDT behind it — is subject to the per-FID storage limits defined by the network. See `/v2/farcaster/user/storage-allocations` and `/v2/farcaster/user/storage-usage` for the live state of a given FID. When an FID's storage expires or is exceeded, older messages are pruned according to the store-specific CRDT rules. Hypersnap doesn't keep a local archive beyond what the protocol preserves. ## 2. Webhook retry queue Failed deliveries are held on a durable RocksDB-backed retry queue for the duration of the retry schedule: - Default `retry_max_attempts = 5` - Default `retry_initial_backoff_ms = 500` (doubles each attempt) - Total retry window ≈ a few minutes for a 5-retry sequence If all retries fail, the delivery is dropped. It is not held indefinitely. ## 3. Mini-app notification tokens Notification tokens are stored per `(fid, app_id)` indefinitely until: - The Farcaster client sends a `notifications_disabled` or `miniapp_removed` JFS event (token is deleted / disabled). - The send endpoint receives an `invalidTokens` response from the client for that token (Hypersnap deletes it). - The mini app itself is deleted. There is no background TTL sweeping the token store. ## 4. Nonce LRU EIP-712 nonces are held in-memory for `2 × signed_at_window_secs` (default 10 minutes). This is long enough to prevent replay inside the signed-at window and short enough to bound memory. Replay attempts outside the window are rejected by clock skew regardless. ## What's *not* stored - No session state, API keys, or user-facing credentials. - No audit log of read traffic. - No mirror of the operator's reverse-proxy logs. Hypersnap is a Farcaster node first and a developer API second. Long-horizon retention beyond what the Farcaster protocol preserves is your responsibility if you need it — wire up an agent to a webhook, mirror the events you care about, and own that data in your own system.