From 3197d612438f0089a4ae624632caf6472506ea78 Mon Sep 17 00:00:00 2001 From: felirami <6752178+felirami@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:38:04 +0000 Subject: [PATCH] chore: sync farcasterorg source snapshot --- src/data/farcasterorg-sources.json | 62 +++++++++++++++--------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/data/farcasterorg-sources.json b/src/data/farcasterorg-sources.json index bd6d405..97799b8 100644 --- a/src/data/farcasterorg-sources.json +++ b/src/data/farcasterorg-sources.json @@ -8,7 +8,7 @@ "publicRepoCount": 4, "createdAt": "2026-02-15T23:02:53Z" }, - "sourceUpdatedAt": "2026-06-04T21:59:38Z", + "sourceUpdatedAt": "2026-08-09T14:25:25Z", "publicNode": { "baseUrl": "https://haatz.quilibrium.com", "infoEndpoint": "/v1/info", @@ -58,11 +58,11 @@ "isEmpty": false, "primaryLanguage": "Rust", "license": "GPL-3.0", - "stars": 60, - "forks": 22, + "stars": 61, + "forks": 23, "openIssues": 13, - "pushedAt": "2026-06-04T21:59:38Z", - "updatedAt": "2026-06-04T21:59:43Z", + "pushedAt": "2026-08-09T14:25:25Z", + "updatedAt": "2026-08-09T14:26:20Z", "latestRelease": { "tagName": "v0.11.8", "name": "v0.11.8", @@ -449,7 +449,7 @@ "sourcePath": "src/playground.md", "githubUrl": "https://github.com/farcasterorg/hypersnap-docs-web/blob/master/src/playground.md", "slug": "playground", - "contentHtml": "
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.
\nhttps://haatz.quilibrium.com, the public node. Change the Host field on any panel and it's remembered for the rest of your session.eth_signTypedData_v4 — the library never sees your private key. Nothing is sent anywhere except directly to the node you configured.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.\n\nEverything happens in your browser. The docs are a static site. \"Connect wallet\" opens your wallet extension directly. \"Run\" issues a
\nfetchfrom your browser to the configured Hypersnap host. The docs site is never in the middle and never sees your keys, signatures, or responses.
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.
\nThis 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.
\n\n\nBe careful pasting production secrets into a web page. For development + testing it's fine; for production sends, call the endpoint from your own backend.
\n
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 for the one-line manifest that makes this work.
\n401 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.haatz.quilibrium.com is CORS-open for all documented routes.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.
\nhttps://haatz.quilibrium.com, the public node. Change the Host field on any panel and it's remembered for the rest of your session.eth_signTypedData_v4 — the library never sees your private key. Nothing is sent anywhere except directly to the node you configured.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.\n\nEverything happens in your browser. The docs are a static site. \"Connect wallet\" opens your wallet extension directly. \"Run\" issues a
\nfetchfrom your browser to the configured Hypersnap host. The docs site is never in the middle and never sees your keys, signatures, or responses.
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.
\nThis 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.
\n\n\nBe careful pasting production secrets into a web page. For development + testing it's fine; for production sends, call the endpoint from your own backend.
\n
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 for the one-line manifest that makes this work.
\n401 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.haatz.quilibrium.com is CORS-open for all documented routes.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.
All signed endpoints use the same typed-data shape:
\nDomain:\n {\n name: \"Hypersnap\",\n version: \"1\",\n chainId: 10\n }\n\nType:\n HypersnapSignedOp(\n string op, // operation name (see list below)\n uint64 fid,\n uint256 signedAt, // unix seconds\n bytes32 nonce,\n bytes32 requestHash // keccak256(raw HTTP body bytes)\n )\n\nThe 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.
| Header | \nValue | \n
|---|---|
X-Hypersnap-Fid | \nYour FID (decimal string). | \n
X-Hypersnap-Op | \nThe operation name — see the table below. | \n
X-Hypersnap-Signed-At | \nUnix seconds at time of signing. Must be within signed_at_window_secs (default 5 min) of the server clock. | \n
X-Hypersnap-Nonce | \n0x-prefixed 32-byte random nonce. Deduped in-memory for the duration of the signed_at window so a replay is impossible. | \n
X-Hypersnap-Signature | \n0x-prefixed 65-byte EIP-712 signature over the typed data above. | \n
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 | \nOp string | \n
|---|---|
POST /v2/farcaster/webhook/ | \nwebhook.create | \n
PUT /v2/farcaster/webhook/ | \nwebhook.update | \n
DELETE /v2/farcaster/webhook/ | \nwebhook.delete | \n
GET /v2/farcaster/webhook/ (lookup) | \nwebhook.read | \n
GET /v2/farcaster/webhook/list | \nwebhook.read | \n
POST /v2/farcaster/webhook/secret/rotate | \nwebhook.rotate_secret | \n
POST /v2/farcaster/frame/app/ | \napp.create | \n
PUT /v2/farcaster/frame/app/ | \napp.update | \n
DELETE /v2/farcaster/frame/app/ | \napp.delete | \n
GET /v2/farcaster/frame/app/ (lookup) | \napp.read | \n
GET /v2/farcaster/frame/app/list | \napp.read | \n
POST /v2/farcaster/frame/app/secret/rotate | \napp.rotate_secret | \n
The server performs these checks, in order, before routing a request to its handler:
\n|now − signed_at| > signed_at_window_secs.(fid, nonce) was used within the signed_at window.ecrecover.custodyOf(fid) on-chain and reject if the recovered address doesn't match.op doesn't match the actual HTTP method/path.If any step fails you get 401 Unauthorized with a short message body.
import { ethers } from \"ethers\";\nimport { randomBytes } from \"crypto\";\n\nconst wallet = new ethers.Wallet(privateKey);\n\nconst body = JSON.stringify({\n name: \"my webhook\",\n url: \"https://receiver.example.com/hook\",\n subscription: { cast_created: { author_fids: [3] } },\n});\n\nconst signedAt = Math.floor(Date.now() / 1000);\nconst nonce = \"0x\" + randomBytes(32).toString(\"hex\");\nconst requestHash = ethers.keccak256(ethers.toUtf8Bytes(body));\n\nconst domain = { name: \"Hypersnap\", version: \"1\", chainId: 10 };\nconst types = {\n HypersnapSignedOp: [\n { name: \"op\", type: \"string\" },\n { name: \"fid\", type: \"uint64\" },\n { name: \"signedAt\", type: \"uint256\" },\n { name: \"nonce\", type: \"bytes32\" },\n { name: \"requestHash\", type: \"bytes32\" },\n ],\n};\nconst value = {\n op: \"webhook.create\",\n fid: 3n,\n signedAt: BigInt(signedAt),\n nonce,\n requestHash,\n};\n\nconst signature = await wallet.signTypedData(domain, types, value);\n\nconst resp = await fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Hypersnap-Fid\": \"3\",\n \"X-Hypersnap-Op\": \"webhook.create\",\n \"X-Hypersnap-Signed-At\": String(signedAt),\n \"X-Hypersnap-Nonce\": nonce,\n \"X-Hypersnap-Signature\": signature,\n },\n body,\n});\n\nSee the Sign an EIP-712 request guide for Python and Rust equivalents.
", + "contentHtml": "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.
All signed endpoints use the same typed-data shape:
\nDomain:\n {\n name: \"Hypersnap\",\n version: \"1\",\n chainId: 10\n }\n\nType:\n HypersnapSignedOp(\n string op, // operation name (see list below)\n uint64 fid,\n uint256 signedAt, // unix seconds\n bytes32 nonce,\n bytes32 requestHash // keccak256(raw HTTP body bytes)\n )\n\nThe 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.
| 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. |
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 |
The server performs these checks, in order, before routing a request to its handler:
\n|now − signed_at| > signed_at_window_secs.(fid, nonce) was used within the signed_at window.ecrecover.custodyOf(fid) on-chain and reject if the recovered address doesn't match.op doesn't match the actual HTTP method/path.If any step fails you get 401 Unauthorized with a short message body.
import { ethers } from \"ethers\";\nimport { randomBytes } from \"crypto\";\n\nconst wallet = new ethers.Wallet(privateKey);\n\nconst body = JSON.stringify({\n name: \"my webhook\",\n url: \"https://receiver.example.com/hook\",\n subscription: { cast_created: { author_fids: [3] } },\n});\n\nconst signedAt = Math.floor(Date.now() / 1000);\nconst nonce = \"0x\" + randomBytes(32).toString(\"hex\");\nconst requestHash = ethers.keccak256(ethers.toUtf8Bytes(body));\n\nconst domain = { name: \"Hypersnap\", version: \"1\", chainId: 10 };\nconst types = {\n HypersnapSignedOp: [\n { name: \"op\", type: \"string\" },\n { name: \"fid\", type: \"uint64\" },\n { name: \"signedAt\", type: \"uint256\" },\n { name: \"nonce\", type: \"bytes32\" },\n { name: \"requestHash\", type: \"bytes32\" },\n ],\n};\nconst value = {\n op: \"webhook.create\",\n fid: 3n,\n signedAt: BigInt(signedAt),\n nonce,\n requestHash,\n};\n\nconst signature = await wallet.signTypedData(domain, types, value);\n\nconst resp = await fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Hypersnap-Fid\": \"3\",\n \"X-Hypersnap-Op\": \"webhook.create\",\n \"X-Hypersnap-Signed-At\": String(signedAt),\n \"X-Hypersnap-Nonce\": nonce,\n \"X-Hypersnap-Signature\": signature,\n },\n body,\n});\n\nSee the Sign an EIP-712 request guide for Python and Rust equivalents.
", "contentMarkdown": "# Signed operations (EIP-712)\n\nManagement 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.\n\n## What gets signed\n\nAll signed endpoints use the same typed-data shape:\n\n```text\nDomain:\n {\n name: \"Hypersnap\",\n version: \"1\",\n chainId: 10\n }\n\nType:\n HypersnapSignedOp(\n string op, // operation name (see list below)\n uint64 fid,\n uint256 signedAt, // unix seconds\n bytes32 nonce,\n bytes32 requestHash // keccak256(raw HTTP body bytes)\n )\n```\n\nThe `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.\n\n## Required HTTP headers\n\n| Header | Value |\n|---|---|\n| `X-Hypersnap-Fid` | Your FID (decimal string). |\n| `X-Hypersnap-Op` | The operation name — see the table below. |\n| `X-Hypersnap-Signed-At` | Unix seconds at time of signing. Must be within `signed_at_window_secs` (default 5 min) of the server clock. |\n| `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. |\n| `X-Hypersnap-Signature` | `0x`-prefixed 65-byte EIP-712 signature over the typed data above. |\n\n## Operation names\n\nThe `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.\n\n| Endpoint | Op string |\n|---|---|\n| `POST /v2/farcaster/webhook/` | `webhook.create` |\n| `PUT /v2/farcaster/webhook/` | `webhook.update` |\n| `DELETE /v2/farcaster/webhook/` | `webhook.delete` |\n| `GET /v2/farcaster/webhook/` (lookup) | `webhook.read` |\n| `GET /v2/farcaster/webhook/list` | `webhook.read` |\n| `POST /v2/farcaster/webhook/secret/rotate` | `webhook.rotate_secret` |\n| `POST /v2/farcaster/frame/app/` | `app.create` |\n| `PUT /v2/farcaster/frame/app/` | `app.update` |\n| `DELETE /v2/farcaster/frame/app/` | `app.delete` |\n| `GET /v2/farcaster/frame/app/` (lookup) | `app.read` |\n| `GET /v2/farcaster/frame/app/list` | `app.read` |\n| `POST /v2/farcaster/frame/app/secret/rotate` | `app.rotate_secret` |\n\n## Server-side verification\n\nThe server performs these checks, in order, before routing a request to its handler:\n\n1. **Clock skew** — reject if `|now − signed_at| > signed_at_window_secs`.\n2. **Nonce replay** — reject if `(fid, nonce)` was used within the signed_at window.\n3. **Typed data recovery** — compute the EIP-712 hash, recover the signer address via `ecrecover`.\n4. **Custody match** — look up `custodyOf(fid)` on-chain and reject if the recovered address doesn't match.\n5. **Op ↔ route match** — reject if the signed `op` doesn't match the actual HTTP method/path.\n\nIf any step fails you get `401 Unauthorized` with a short message body.\n\n## Why this shape\n\n- **Body bytes are signed directly**, so JSON canonicalization is a non-issue. You hash what you send.\n- **Typed data is EIP-712-native**, so every Ethereum wallet library can produce the signature with a small adapter.\n- **No session tokens.** Each request is individually signed; there is no login state the server has to track.\n- **The nonce prevents replay** inside the signed_at window. Outside the window, clock skew rejects the request anyway.\n\n## Minimal JavaScript example\n\n```javascript\nimport { ethers } from \"ethers\";\nimport { randomBytes } from \"crypto\";\n\nconst wallet = new ethers.Wallet(privateKey);\n\nconst body = JSON.stringify({\n name: \"my webhook\",\n url: \"https://receiver.example.com/hook\",\n subscription: { cast_created: { author_fids: [3] } },\n});\n\nconst signedAt = Math.floor(Date.now() / 1000);\nconst nonce = \"0x\" + randomBytes(32).toString(\"hex\");\nconst requestHash = ethers.keccak256(ethers.toUtf8Bytes(body));\n\nconst domain = { name: \"Hypersnap\", version: \"1\", chainId: 10 };\nconst types = {\n HypersnapSignedOp: [\n { name: \"op\", type: \"string\" },\n { name: \"fid\", type: \"uint64\" },\n { name: \"signedAt\", type: \"uint256\" },\n { name: \"nonce\", type: \"bytes32\" },\n { name: \"requestHash\", type: \"bytes32\" },\n ],\n};\nconst value = {\n op: \"webhook.create\",\n fid: 3n,\n signedAt: BigInt(signedAt),\n nonce,\n requestHash,\n};\n\nconst signature = await wallet.signTypedData(domain, types, value);\n\nconst resp = await fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Hypersnap-Fid\": \"3\",\n \"X-Hypersnap-Op\": \"webhook.create\",\n \"X-Hypersnap-Signed-At\": String(signedAt),\n \"X-Hypersnap-Nonce\": nonce,\n \"X-Hypersnap-Signature\": signature,\n },\n body,\n});\n```\n\nSee the [Sign an EIP-712 request](../guides/sign-eip712.md) guide for Python and Rust equivalents.\n", "toc": [ { @@ -689,7 +689,7 @@ "sourcePath": "src/concepts/rate-limits.md", "githubUrl": "https://github.com/farcasterorg/hypersnap-docs-web/blob/master/src/concepts/rate-limits.md", "slug": "concepts/rate-limits", - "contentHtml": "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:
\nThere 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.
\nIn 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.
\nEach webhook has a per-webhook rate limit enforced by the dispatcher:
\ndefault_rate_limit / default_rate_limit_duration_secs.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.
\nThe send endpoint has three applicable limits:
\n(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.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.When signing management requests:
\nmax_webhooks_per_owner (default 25). Creating a 26th returns 429 Too Many Requests.max_apps_per_owner (default 25). Same behavior.Both caps are operator-configurable.
\n| Surface | \nLimit | \nEnforced where | \n
|---|---|---|
| Public reads | \nNone by default | \nYour operator's reverse proxy | \n
| Webhook delivery | \n1000 events / 60 sec (default) | \nHypersnap dispatcher | \n
| Send → client batch | \n100 tokens per POST | \nHypersnap sender (spec-mandated) | \n
| Send → per token | \n1 / 30 sec, 100 / day | \nFarcaster client (spec-mandated), surfaced as retryable_fids | \n
| Send → dedupe | \n(fid, notificationId) for 24h | \nHypersnap (spec-mandated) | \n
| Webhooks per FID | \n25 (default) | \nHypersnap store | \n
| Mini apps per FID | \n25 (default) | \nHypersnap store | \n
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:
\nThere 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.
\nIn 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.
\nEach webhook has a per-webhook rate limit enforced by the dispatcher:
\ndefault_rate_limit / default_rate_limit_duration_secs.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.
\nThe send endpoint has three applicable limits:
\n(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.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.When signing management requests:
\nmax_webhooks_per_owner (default 25). Creating a 26th returns 429 Too Many Requests.max_apps_per_owner (default 25). Same behavior.Both caps are operator-configurable.
\n| 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 |
All error responses have the same shape:
\n{ \"message\": \"human-readable explanation\" }\n\n| Code | \nMeaning | \nExample causes | \n
|---|---|---|
200 OK | \nSuccess | \nNormal read, successful management write. | \n
400 Bad Request | \nMalformed input | \nMissing required query param, invalid JSON body, invalid regex in a subscription filter, URL fails SSRF check, missing ?owner_fid= when creating a webhook. | \n
401 Unauthorized | \nBad signature / auth headers | \nEIP-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. | \n
403 Forbidden | \nNot the owner | \nYou signed a valid EIP-712 request but the resource (webhook/app) belongs to a different FID. | \n
404 Not Found | \nResource doesn't exist | \nwebhook_id / app_id / FID not found, unknown route. | \n
429 Too Many Requests | \nPer-owner cap hit | \nCreating webhook #26 when max_webhooks_per_owner=25, or mini app #26. | \n
500 Internal Server Error | \nUnexpected server fault | \nRocksDB error, panic in a handler, transient storage failure. Please file a bug. | \n
If you get a 401 on a signed request, work down this list:
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.|now − signed_at| must be ≤ signed_at_window_secs (default 5 min). date -u on the client should match the server.(fid, nonce) pair rejects for up to 2 × signed_at_window_secs.webhook.create not webhook_create or createWebhook. See Signed operations for the full list.webhook.create on DELETE /.../webhook/ returns 400 \"signed op does not match the HTTP method/path\".IdRegistry state. If you recently transferred the FID, give the indexer a few seconds to catch up.Per-owner caps are opt-in and operator-controlled. If you're hitting a cap, either:
\nDELETE /v2/farcaster/webhook/ or DELETE /v2/farcaster/frame/app/ to free up slots, ormax_webhooks_per_owner / max_apps_per_owner.Webhook delivery errors don't come back on your management-API call — they come back on the webhook delivery. See Delivery contract for how successes and failures propagate.
", + "contentHtml": "All error responses have the same shape:
\n{ \"message\": \"human-readable explanation\" }\n\n| 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. |
If you get a 401 on a signed request, work down this list:
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.|now − signed_at| must be ≤ signed_at_window_secs (default 5 min). date -u on the client should match the server.(fid, nonce) pair rejects for up to 2 × signed_at_window_secs.webhook.create not webhook_create or createWebhook. See Signed operations for the full list.webhook.create on DELETE /.../webhook/ returns 400 \"signed op does not match the HTTP method/path\".IdRegistry state. If you recently transferred the FID, give the indexer a few seconds to catch up.Per-owner caps are opt-in and operator-controlled. If you're hitting a cap, either:
\nDELETE /v2/farcaster/webhook/ or DELETE /v2/farcaster/frame/app/ to free up slots, ormax_webhooks_per_owner / max_apps_per_owner.Webhook delivery errors don't come back on your management-API call — they come back on the webhook delivery. See Delivery contract for how successes and failures propagate.
", "contentMarkdown": "# Errors\n\nAll error responses have the same shape:\n\n```json\n{ \"message\": \"human-readable explanation\" }\n```\n\n## HTTP status codes\n\n| Code | Meaning | Example causes |\n|---|---|---|\n| `200 OK` | Success | Normal read, successful management write. |\n| `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. |\n| `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. |\n| `403 Forbidden` | Not the owner | You signed a valid EIP-712 request but the resource (webhook/app) belongs to a different FID. |\n| `404 Not Found` | Resource doesn't exist | `webhook_id` / `app_id` / FID not found, unknown route. |\n| `429 Too Many Requests` | Per-owner cap hit | Creating webhook #26 when `max_webhooks_per_owner=25`, or mini app #26. |\n| `500 Internal Server Error` | Unexpected server fault | RocksDB error, panic in a handler, transient storage failure. Please file a bug. |\n\n## Debugging auth failures\n\nIf you get a `401` on a signed request, work down this list:\n\n1. **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.\n2. **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.\n3. **Is the nonce fresh?** A replayed `(fid, nonce)` pair rejects for up to `2 × signed_at_window_secs`.\n4. **Is your op string exactly right?** `webhook.create` not `webhook_create` or `createWebhook`. See [Signed operations](./authentication.md) for the full list.\n5. **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\"`.\n6. **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.\n\n## Debugging 429\n\nPer-owner caps are opt-in and operator-controlled. If you're hitting a cap, either:\n\n- Delete unused webhooks/apps via `DELETE /v2/farcaster/webhook/` or `DELETE /v2/farcaster/frame/app/` to free up slots, or\n- Ask your operator to raise `max_webhooks_per_owner` / `max_apps_per_owner`.\n\n## Debugging webhook delivery failures\n\nWebhook 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.\n", "toc": [ { @@ -777,7 +777,7 @@ "sourcePath": "src/reference/reads/users.md", "githubUrl": "https://github.com/farcasterorg/hypersnap-docs-web/blob/master/src/reference/reads/users.md", "slug": "reference/reads/users", - "contentHtml": "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.
Look up a single user by FID.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe FID to fetch. | \n
Response
\n{ \"user\": { \"fid\": 3, \"username\": \"dwr.eth\", \"...\" : \"...\" } }\n\nExample
\ncurl -s \"https://haatz.quilibrium.com/v2/farcaster/user?fid=3\"\n\n\nBatch lookup by FID list.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fids | \nstring | \nyes | \nComma-separated list of FIDs, e.g. 3,5,191 | \n
Response
\n{ \"users\": [ { \"fid\": 3, \"...\" : \"...\" }, { \"fid\": 5, \"...\" : \"...\" } ] }\n\nMissing 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.
\n\nBatch lookup by verified Ethereum address.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
addresses | \nstring | \nyes | \nComma-separated 0x-addresses. Matches against User.verified_addresses.eth_addresses. | \n
Response — same BulkUsersResponse shape.
Addresses that aren't verified against any FID are silently omitted.
\n\nLook 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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
username | \nstring | \nyes | \nCase-sensitive. Without a leading @. | \n
Response — single UserResponse. 404 if the name isn't registered.
Reverse-lookup: find the user whose custody Ethereum address matches.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
custody_address | \nstring | \nyes | \n0x-prefixed Ethereum address. | \n
Response — UserResponse. 404 if no FID is registered to that address. If multiple FIDs share the address (legacy), the first is returned.
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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
username | \nstring | \nyes | \nCase-insensitive match against the stored X username. | \n
Response — UserResponse. 404 if no user has that X username registered.
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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
location | \nstring | \nyes | \nPrefix-match against the stored location string (case-insensitive). | \n
limit | \nusize | \nno | \nDefault 10. | \n
Response — BulkUsersResponse.
Prefix-style search against the username index.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
q | \nstring | \nyes | \nSearch query. | \n
limit | \nusize | \nno | \nDefault 10. | \n
Response — BulkUsersResponse.
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
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
How much Farcaster storage an FID currently has allocated to it.
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
Response
\n{\n \"total_active_units\": 2,\n \"allocations\": [\n {\n \"object\": \"storage_allocation\",\n \"fid\": 3,\n \"units\": 2,\n \"expiry\": 1760000000,\n \"timestamp\": 1700000000\n }\n ]\n}\n\n\nHow much of the allocated storage the FID has actually consumed, broken down by message type.
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
Response
\n{\n \"object\": \"storage_usage\",\n \"units\": [\n { \"store_type\": \"casts\", \"used\": 250, \"capacity\": 5000 },\n { \"store_type\": \"reactions\", \"used\": 800, \"capacity\": 2500 },\n { \"store_type\": \"links\", \"used\": 300, \"capacity\": 2500 },\n { \"store_type\": \"verifications\", \"used\": 1, \"capacity\": 25 }\n ]\n}\n\n\nAlso reachable as GET /v2/farcaster/storage/usage and GET /v2/farcaster/storage/allocations — identical behavior, different paths.
List registered FIDs on the network, paginated.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nHex-encoded pagination cursor. | \n
Response
\n{ \"fids\": [1, 2, 3], \"next\": { \"cursor\": \"...\" } }\n\n\nChannels a user has recently been active in. Alias of GET /v2/farcaster/channel/user-active.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Response — ChannelsResponse.
Alias of GET /v2/farcaster/user/channels — returns the same active-channels list in a membership-oriented response shape.
Users that both follow and are followed by fid — the intersection. Sorted by most recent mutual follow.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Response — FollowersResponse.
Summarize the interaction history between two FIDs: mention counts, reaction counts, mutual-follow state.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe \"from\" user. | \n
target_fid | \nu64 | \nno | \nThe \"to\" user. If omitted, an empty summary is returned. | \n
Response
\n{\n \"interactions\": {\n \"fid\": 3,\n \"target_fid\": 5,\n \"mentions\": 7,\n \"reactions\": 42,\n \"mutual_follow\": true\n }\n}\n\nMentions 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.
The following endpoints are registered for SDK compatibility but return empty responses because the Farcaster protocol does not track the underlying data:
\n| Path | \nReturns | \nWhy | \n
|---|---|---|
GET /v2/farcaster/user/power_users | \n{ \"users\": [] } | \nPower-user curation is a proprietary scoring signal, not protocol data. | \n
GET /v2/farcaster/user/balance | \n{ \"balances\": [], \"next\": { \"cursor\": null } } | \nToken balances are on-chain state outside the Farcaster protocol. | \n
GET /v2/farcaster/user/subscribed_to | \n{ \"subscriptions\": [], \"next\": { \"cursor\": null } } | \nUser-to-user subscriptions are not in the protocol. | \n
GET /v2/farcaster/user/subscribers | \n{ \"subscriptions\": [], \"next\": { \"cursor\": null } } | \nSame as above. | \n
GET /v2/farcaster/user/subscriptions_created | \n{ \"subscriptions\": [], \"next\": { \"cursor\": null } } | \nSame as above. | \n
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.
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.
Look up a single user by FID.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fid | u64 | yes | The FID to fetch. |
Response
\n{ \"user\": { \"fid\": 3, \"username\": \"dwr.eth\", \"...\" : \"...\" } }\n\nExample
\ncurl -s \"https://haatz.quilibrium.com/v2/farcaster/user?fid=3\"\n\n\nBatch lookup by FID list.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fids | string | yes | Comma-separated list of FIDs, e.g. 3,5,191 |
Response
\n{ \"users\": [ { \"fid\": 3, \"...\" : \"...\" }, { \"fid\": 5, \"...\" : \"...\" } ] }\n\nMissing 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.
\n\nBatch lookup by verified Ethereum address.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
\n\nLook 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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
username | string | yes | Case-sensitive. Without a leading @. |
Response — single UserResponse. 404 if the name isn't registered.
Reverse-lookup: find the user whose custody Ethereum address matches.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
location | string | yes | Prefix-match against the stored location string (case-insensitive). |
limit | usize | no | Default 10. |
Response — BulkUsersResponse.
Prefix-style search against the username index.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
q | string | yes | Search query. |
limit | usize | no | Default 10. |
Response — BulkUsersResponse.
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
How much Farcaster storage an FID currently has allocated to it.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
Response
\n{\n \"total_active_units\": 2,\n \"allocations\": [\n {\n \"object\": \"storage_allocation\",\n \"fid\": 3,\n \"units\": 2,\n \"expiry\": 1760000000,\n \"timestamp\": 1700000000\n }\n ]\n}\n\n\nHow much of the allocated storage the FID has actually consumed, broken down by message type.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
Response
\n{\n \"object\": \"storage_usage\",\n \"units\": [\n { \"store_type\": \"casts\", \"used\": 250, \"capacity\": 5000 },\n { \"store_type\": \"reactions\", \"used\": 800, \"capacity\": 2500 },\n { \"store_type\": \"links\", \"used\": 300, \"capacity\": 2500 },\n { \"store_type\": \"verifications\", \"used\": 1, \"capacity\": 25 }\n ]\n}\n\n\nAlso reachable as GET /v2/farcaster/storage/usage and GET /v2/farcaster/storage/allocations — identical behavior, different paths.
List registered FIDs on the network, paginated.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
limit | usize | no | Default 10. |
cursor | string | no | Hex-encoded pagination cursor. |
Response
\n{ \"fids\": [1, 2, 3], \"next\": { \"cursor\": \"...\" } }\n\n\nChannels a user has recently been active in. Alias of GET /v2/farcaster/channel/user-active.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
Response — ChannelsResponse.
Alias of GET /v2/farcaster/user/channels — returns the same active-channels list in a membership-oriented response shape.
Users that both follow and are followed by fid — the intersection. Sorted by most recent mutual follow.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
Response — FollowersResponse.
Summarize the interaction history between two FIDs: mention counts, reaction counts, mutual-follow state.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{\n \"interactions\": {\n \"fid\": 3,\n \"target_fid\": 5,\n \"mentions\": 7,\n \"reactions\": 42,\n \"mutual_follow\": true\n }\n}\n\nMentions 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.
The following endpoints are registered for SDK compatibility but return empty responses because the Farcaster protocol does not track the underlying data:
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
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 are Farcaster messages — the core unit of user-generated content. Hypersnap serves them directly from its local RocksDB indexes over the message store.
\nThe 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.
Look up a single cast by hash or URL.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
identifier | \nstring | \nyes | \nEither a 0x-prefixed cast hash or a Warpcast-style URL. | \n
type | \n\"hash\" | \"url\" | \nno | \nDefaults to \"hash\". | \n
fid | \nu64 | \nno | \nWhen type=\"hash\" and the hash is ambiguous, narrows to a specific author. | \n
Response
\n{ \"cast\": { \"hash\": \"0x...\", \"author\": { ... }, \"text\": \"...\", \"...\" : \"...\" } }\n\n\nBatch 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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
hashes | \nstring | \nyes (for /cast/bulk) | \nComma-separated 0x-prefixed cast hashes. | \n
casts | \nstring | \nyes (for /casts) | \nSame shape — accepted as an alternate parameter name. | \n
Response
\n{ \"casts\": [ { \"hash\": \"0x...\", \"...\" : \"...\" } ] }\n\nEach lookup is O(1) via the cast_hash index — no shard scan.
Full-text search over cast content.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
q | \nstring | \nyes | \nSearch query. | \n
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nPagination cursor. | \n
Response
\n{ \"casts\": [ ... ], \"next\": { \"cursor\": \"...\" } }\n\n\nFetch a cast plus its reply tree up to a given depth. Ideal for rendering a thread view.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
identifier | \nstring | \nyes | \nRoot cast hash or URL. | \n
type | \n\"hash\" | \"url\" | \nyes | \nDisambiguate what identifier is. | \n
reply_depth | \nu32 | \nno | \n0–5, default 2. How many levels of replies to include. | \n
Response
\n{\n \"cast\": { \"hash\": \"0x...\", \"...\" : \"...\" },\n \"replies\": [\n {\n \"cast\": { \"hash\": \"0x...\", \"...\" : \"...\" },\n \"replies\": [ /* same recursive shape, up to reply_depth */ ]\n }\n ]\n}\n\nThe 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.
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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
identifier | \nstring | \nyes | \n0x-prefixed hash of the quoted cast. | \n
type | \n\"hash\" | \nno | \nOnly hash is supported. | \n
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nPagination cursor. | \n
Response
\n{ \"casts\": [ { \"hash\": \"0x...\", \"...\" : \"...\" } ], \"next\": { \"cursor\": null } }\n\n\nAggregate 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.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
q | \nstring | \nyes | \nSearch query. | \n
interval | \nstring | \nno | \n1d, 7d, 30d, 90d, 180d. | \n
author_fid | \nu64 | \nno | \nNarrow to a specific author. | \n
channel_id | \nstring | \nno | \nNarrow to a specific channel. | \n
Response
\n{ \"metrics\": [], \"next\": { \"cursor\": null } }\n\n\nLLM-generated conversation summary. This node does not run an LLM — the endpoint is registered for SDK compatibility and returns a short placeholder string.
\nResponse
\n{ \"summary\": \"Conversation summaries require LLM integration which is not available on this node.\" }\n\nCrawl and extract metadata from an embed URL. URL crawling requires an external HTTP service — not performed on-node. Returns { \"metadata\": null }.
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.
Casts are Farcaster messages — the core unit of user-generated content. Hypersnap serves them directly from its local RocksDB indexes over the message store.
\nThe 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.
Look up a single cast by hash or URL.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{ \"cast\": { \"hash\": \"0x...\", \"author\": { ... }, \"text\": \"...\", \"...\" : \"...\" } }\n\n\nBatch 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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{ \"casts\": [ { \"hash\": \"0x...\", \"...\" : \"...\" } ] }\n\nEach lookup is O(1) via the cast_hash index — no shard scan.
Full-text search over cast content.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
q | string | yes | Search query. |
limit | usize | no | Default 10. |
cursor | string | no | Pagination cursor. |
Response
\n{ \"casts\": [ ... ], \"next\": { \"cursor\": \"...\" } }\n\n\nFetch a cast plus its reply tree up to a given depth. Ideal for rendering a thread view.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{\n \"cast\": { \"hash\": \"0x...\", \"...\" : \"...\" },\n \"replies\": [\n {\n \"cast\": { \"hash\": \"0x...\", \"...\" : \"...\" },\n \"replies\": [ /* same recursive shape, up to reply_depth */ ]\n }\n ]\n}\n\nThe 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.
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{ \"casts\": [ { \"hash\": \"0x...\", \"...\" : \"...\" } ], \"next\": { \"cursor\": null } }\n\n\nAggregate 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.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{ \"metrics\": [], \"next\": { \"cursor\": null } }\n\n\nLLM-generated conversation summary. This node does not run an LLM — the endpoint is registered for SDK compatibility and returns a short placeholder string.
\nResponse
\n{ \"summary\": \"Conversation summaries require LLM integration which is not available on this node.\" }\n\nCrawl and extract metadata from an embed URL. URL crawling requires an external HTTP service — not performed on-node. Returns { \"metadata\": null }.
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.
Feed endpoints return ordered lists of casts. Shape is always:
\n{ \"casts\": [ { /* Cast */ } ], \"next\": { \"cursor\": \"...\" } }\n\nSee Pagination & cursors for how to walk large result sets.
\nGeneric feed endpoint. Behavior depends on feed_type.
Query parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
feed_type | \nstring | \nno | \n\"following\" (default), \"trending\", or a future feed type. | \n
fid | \nu64 | \nconditional | \nRequired when feed_type=\"following\" — whose feed to render. | \n
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nPagination cursor. | \n
Explicit alias of /feed?feed_type=following.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Semantics — casts authored by users fid follows, ordered newest-first.
Network-wide trending casts, ranked by an engagement heuristic over a rolling window.
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Feed of casts parented to one or more channels (either by channel id or by parent URL).
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
channel_ids | \nstring | \nyes | \nComma-separated channel ids or channel parent URLs. | \n
Example
\ncurl -s \"https://haatz.quilibrium.com/v2/farcaster/feed/channels?channel_ids=memes,base,dev\"\n\n\nFeed of casts with a matching parent_url. Useful when you have raw parent URLs (not yet resolved to a channel id).
Query parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
parent_urls | \nstring | \nyes | \nComma-separated parent URLs. | \n
limit | \nusize | \nno | \n\n |
cursor | \nstring | \nno | \n\n |
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.
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Feed filtered by topic slug. No on-node hashtag/topic indexing exists, so this endpoint currently returns the trending feed. Registered for SDK compatibility.
\n\nUser's casts in reverse chronological order. Each cast is enriched with engagement metrics (likes, recasts, replies counts) from the MetricsIndexer.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
A user's top 10 casts by engagement score (likes + recasts + replies), computed from the local MetricsIndexer.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
A user's casts filtered to only replies (casts with a parent field).
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Feed endpoints return ordered lists of casts. Shape is always:
\n{ \"casts\": [ { /* Cast */ } ], \"next\": { \"cursor\": \"...\" } }\n\nSee Pagination & cursors for how to walk large result sets.
\nGeneric feed endpoint. Behavior depends on feed_type.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
Explicit alias of /feed?feed_type=following.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
Semantics — casts authored by users fid follows, ordered newest-first.
Network-wide trending casts, ranked by an engagement heuristic over a rolling window.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
limit | usize | no |
cursor | string | no |
Feed of casts parented to one or more channels (either by channel id or by parent URL).
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
channel_ids | string | yes | Comma-separated channel ids or channel parent URLs. |
Example
\ncurl -s \"https://haatz.quilibrium.com/v2/farcaster/feed/channels?channel_ids=memes,base,dev\"\n\n\nFeed of casts with a matching parent_url. Useful when you have raw parent URLs (not yet resolved to a channel id).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
parent_urls | string | yes | Comma-separated parent URLs. |
limit | usize | no | |
cursor | string | no |
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.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
limit | usize | no |
cursor | string | no |
Feed filtered by topic slug. No on-node hashtag/topic indexing exists, so this endpoint currently returns the trending feed. Registered for SDK compatibility.
\n\nUser's casts in reverse chronological order. Each cast is enriched with engagement metrics (likes, recasts, replies counts) from the MetricsIndexer.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
A user's top 10 casts by engagement score (likes + recasts + replies), computed from the local MetricsIndexer.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
A user's casts filtered to only replies (casts with a parent field).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
Farcaster channels are parent-URL-scoped subcommunities. Hypersnap maintains a local channel registry and membership index.
\nCommon Channel fields: id, parent_url, name, image_url, description, lead (the channel host's User), moderator_fids, follower_count, created_at.
Look up a single channel.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
id | \nstring | \nyes | \nEither the channel id (e.g. memes) or the parent URL. | \n
type | \n\"id\" | \"parent_url\" | \nno | \nDefault \"id\". | \n
Response
\n{ \"channel\": { \"id\": \"memes\", \"...\" : \"...\" } }\n\n\nList every channel the node knows about. Also reachable as GET /v2/farcaster/channel/list (spec-compat alias).
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
Batch channel lookup.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
ids | \nstring | \nyes | \nComma-separated channel ids. | \n
Prefix-style search against the channel name index.
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
q | \nstring | \nyes | \n
limit | \nusize | \nno | \n
Channels with the most engagement over a rolling window.
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
limit | \nusize | \nno | \n
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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
channel_id | \nstring | \nyes | \nThe channel id. | \n
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nPagination cursor. | \n
Response
\n{ \"users\": [ { /* User */ } ], \"next\": { \"cursor\": \"...\" } }\n\n\nList 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.
\nResponse
\n{ \"members\": [], \"next\": { \"cursor\": null } }\n\nChannels where a specific user has recently been active. Also reachable as GET /v2/farcaster/channel/user (spec-compat alias).
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
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.
Farcaster channels are parent-URL-scoped subcommunities. Hypersnap maintains a local channel registry and membership index.
\nCommon Channel fields: id, parent_url, name, image_url, description, lead (the channel host's User), moderator_fids, follower_count, created_at.
Look up a single channel.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\n{ \"channel\": { \"id\": \"memes\", \"...\" : \"...\" } }\n\n\nList every channel the node knows about. Also reachable as GET /v2/farcaster/channel/list (spec-compat alias).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
limit | usize | no |
cursor | string | no |
Batch channel lookup.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
ids | string | yes | Comma-separated channel ids. |
Prefix-style search against the channel name index.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
q | string | yes |
limit | usize | no |
Channels with the most engagement over a rolling window.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
limit | usize | no |
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
channel_id | string | yes | The channel id. |
limit | usize | no | Default 10. |
cursor | string | no | Pagination cursor. |
Response
\n{ \"users\": [ { /* User */ } ], \"next\": { \"cursor\": \"...\" } }\n\n\nList 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.
\nResponse
\n{ \"members\": [], \"next\": { \"cursor\": null } }\n\nChannels where a specific user has recently been active. Also reachable as GET /v2/farcaster/channel/user (spec-compat alias).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
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 are likes and recasts. Hypersnap indexes both directions — \"who reacted to this cast\" and \"what has this user reacted to\".
\nCommon response shape:
\n{\n \"reactions\": [\n {\n \"reaction_type\": \"like\",\n \"user\": { /* User */ },\n \"cast\": { /* Cast */ },\n \"timestamp\": 1712345678\n }\n ],\n \"next\": { \"cursor\": \"...\" }\n}\n\nGeneric 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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
hash | \nstring | \nconditional | \nIf provided, switches to cast mode. | \n
types | \n\"likes\" | \"recasts\" | \nno | \nFor cast mode. Default \"likes\". | \n
type | \n\"likes\" | \"recasts\" | \nno | \nFor user mode. Default \"likes\". | \n
fid | \nu64 | \nconditional | \nRequired in user mode. | \n
limit | \nusize | \nno | \nDefault 10. | \n
Who has reacted to a specific cast. Also reachable as GET /v2/farcaster/reactions/cast (spec-compat plural).
Query parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
hash | \nstring | \nyes | \n0x-prefixed cast hash. | \n
types | \n\"likes\" | \"recasts\" | \nno | \nDefault \"likes\". | \n
fid | \nu64 | \nno | \nIf the cast hash is ambiguous, narrows to a specific author. | \n
limit | \nusize | \nno | \nDefault 10. | \n
What a specific user has liked or recasted. Also reachable as GET /v2/farcaster/reactions/user (spec-compat plural).
Query parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe reactor. | \n
type | \n\"likes\" | \"recasts\" | \nno | \nDefault \"likes\". | \n
limit | \nusize | \nno | \n\n |
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.
Reactions are likes and recasts. Hypersnap indexes both directions — \"who reacted to this cast\" and \"what has this user reacted to\".
\nCommon response shape:
\n{\n \"reactions\": [\n {\n \"reaction_type\": \"like\",\n \"user\": { /* User */ },\n \"cast\": { /* Cast */ },\n \"timestamp\": 1712345678\n }\n ],\n \"next\": { \"cursor\": \"...\" }\n}\n\nGeneric 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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
Who has reacted to a specific cast. Also reachable as GET /v2/farcaster/reactions/cast (spec-compat plural).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
What a specific user has liked or recasted. Also reachable as GET /v2/farcaster/reactions/user (spec-compat plural).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fid | u64 | yes | The reactor. |
type | \"likes\" | \"recasts\" | no | Default \"likes\". |
limit | usize | no |
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.
Who follows whom, with cursor pagination.
\nResponse shape:
\n{\n \"users\": [ { /* User */ } ],\n \"next\": { \"cursor\": \"...\" }\n}\n\nUsers who follow fid. Also reachable as the alias GET /v2/farcaster/followers.
Query parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nWhose followers to list. | \n
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nPagination cursor. | \n
Users that fid follows. Also reachable as GET /v2/farcaster/following and GET /v2/farcaster/follows (spec-compat aliases).
Query parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe follower. | \n
limit | \nusize | \nno | \n\n |
cursor | \nstring | \nno | \n\n |
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
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
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
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
cursor | \nstring | \nno | \n
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
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
Response — FollowersResponse.
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.
Who follows whom, with cursor pagination.
\nResponse shape:
\n{\n \"users\": [ { /* User */ } ],\n \"next\": { \"cursor\": \"...\" }\n}\n\nUsers who follow fid. Also reachable as the alias GET /v2/farcaster/followers.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fid | u64 | yes | Whose followers to list. |
limit | usize | no | Default 10. |
cursor | string | no | Pagination cursor. |
Users that fid follows. Also reachable as GET /v2/farcaster/following and GET /v2/farcaster/follows (spec-compat aliases).
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fid | u64 | yes | The follower. |
limit | usize | no | |
cursor | string | no |
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
cursor | string | no |
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
Response — FollowersResponse.
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.
Not to be confused with mini-app push notifications — this endpoint is the in-app \"notifications tab\" for a single user: replies to their casts, mentions, likes, recasts, new followers.
\nQuery parameters
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe viewer. | \n
limit | \nusize | \nno | \nDefault 10. | \n
cursor | \nstring | \nno | \nPagination cursor. | \n
Response
\n{\n \"notifications\": [\n {\n \"type\": \"cast-reply\",\n \"most_recent_timestamp\": 1712345678,\n \"cast\": { /* the triggering cast */ },\n \"reactions\": [ /* optional aggregate */ ],\n \"follows\": [ /* optional aggregate */ ]\n }\n ],\n \"next\": { \"cursor\": \"...\" }\n}\n\nThe 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.
\n\nNotifications 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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe viewer. | \n
channel_ids | \nstring | \nyes | \nComma-separated channel ids or channel parent URLs. | \n
limit | \nusize | \nno | \n\n |
cursor | \nstring | \nno | \n\n |
Channel ids are resolved to parent URLs via the channels index; raw URLs (starting with http or chain://) are used verbatim.
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
\n| Name | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
fid | \nu64 | \nyes | \nThe viewer. | \n
parent_urls | \nstring | \nyes | \nComma-separated parent URLs. | \n
limit | \nusize | \nno | \n\n |
cursor | \nstring | \nno | \n\n |
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.
Not to be confused with mini-app push notifications — this endpoint is the in-app \"notifications tab\" for a single user: replies to their casts, mentions, likes, recasts, new followers.
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fid | u64 | yes | The viewer. |
limit | usize | no | Default 10. |
cursor | string | no | Pagination cursor. |
Response
\n{\n \"notifications\": [\n {\n \"type\": \"cast-reply\",\n \"most_recent_timestamp\": 1712345678,\n \"cast\": { /* the triggering cast */ },\n \"reactions\": [ /* optional aggregate */ ],\n \"follows\": [ /* optional aggregate */ ]\n }\n ],\n \"next\": { \"cursor\": \"...\" }\n}\n\nThe 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.
\n\nNotifications 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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required | Notes |
|---|---|---|---|
fid | u64 | yes | The viewer. |
parent_urls | string | yes | Comma-separated parent URLs. |
limit | usize | no | |
cursor | string | no |
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.
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.
\nIs an fname available to register?
\nQuery parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fname | \nstring | \nyes | \n
Response
\n{\n \"available\": true,\n \"username\": null\n}\n\nIf the name is taken, available is false and username contains the normalized name string.
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
\n| Name | \nType | \nRequired | \n
|---|---|---|
username | \nstring | \nyes | \n
Response
\n{\n \"username_proof\": {\n \"timestamp\": 1712345678,\n \"name\": \"alice\",\n \"owner\": \"0x...\",\n \"signature\": \"0x...\",\n \"fid\": 12345,\n \"type\": \"USERNAME_TYPE_FNAME\"\n }\n}\n\nIf the name has no proof, username_proof is null.
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.
\nIs an fname available to register?
\nQuery parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fname | string | yes |
Response
\n{\n \"available\": true,\n \"username\": null\n}\n\nIf the name is taken, available is false and username contains the normalized name string.
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
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
username | string | yes |
Response
\n{\n \"username_proof\": {\n \"timestamp\": 1712345678,\n \"name\": \"alice\",\n \"owner\": \"0x...\",\n \"signature\": \"0x...\",\n \"fid\": 12345,\n \"type\": \"USERNAME_TYPE_FNAME\"\n }\n}\n\nIf the name has no proof, username_proof is null.
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:
\n{\n \"events\": [\n {\n \"object\": \"signer\",\n \"fid\": 3,\n \"event_type\": \"signer\",\n \"block_number\": 12345678,\n \"block_timestamp\": 1712345678,\n \"signer_key\": \"0x<ed25519-pubkey>\",\n \"key_type\": 1,\n \"metadata_type\": 1\n }\n ],\n \"next\": { \"cursor\": null }\n}\n\nSigners registered by fid. Also reachable as GET /v2/farcaster/signers (plural) and GET /v2/farcaster/signer/list.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
Identical to /v2/farcaster/signer — returns signer events for the given FID. Registered under the /onchain/ namespace for spec compatibility.
IdRegistry events for fid — Register, Transfer, ChangeRecovery. Each event exposes block_number, block_timestamp, and event_type.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
These endpoints exist for SDK compatibility but return empty responses. They depend on managed signer infrastructure that a self-hosted node does not operate:
\n| Path | \nMethod | \n
|---|---|
/v2/farcaster/signer/signed_key | \nGET | \n
/v2/farcaster/signer/developer_managed | \nGET | \n
/v2/farcaster/signer/developer_managed/signed_key | \nGET | \n
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.
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:
\n{\n \"events\": [\n {\n \"object\": \"signer\",\n \"fid\": 3,\n \"event_type\": \"signer\",\n \"block_number\": 12345678,\n \"block_timestamp\": 1712345678,\n \"signer_key\": \"0x<ed25519-pubkey>\",\n \"key_type\": 1,\n \"metadata_type\": 1\n }\n ],\n \"next\": { \"cursor\": null }\n}\n\nSigners registered by fid. Also reachable as GET /v2/farcaster/signers (plural) and GET /v2/farcaster/signer/list.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
Identical to /v2/farcaster/signer — returns signer events for the given FID. Registered under the /onchain/ namespace for spec compatibility.
IdRegistry events for fid — Register, Transfer, ChangeRecovery. Each event exposes block_number, block_timestamp, and event_type.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
These endpoints exist for SDK compatibility but return empty responses. They depend on managed signer infrastructure that a self-hosted node does not operate:
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Path | Method |
|---|---|
/v2/farcaster/signer/signed_key | GET |
/v2/farcaster/signer/developer_managed | GET |
/v2/farcaster/signer/developer_managed/signed_key | GET |
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.
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.
\nResponse shape for all list endpoints:
\n{\n \"users\": [ { /* User */ } ],\n \"next\": { \"cursor\": null }\n}\n\nFIDs that fid has blocked.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
Backed by LinkStore::get_link_adds_by_fid(fid, \"block\", ...) — a direct index lookup, no scan.
FIDs that fid has muted.
Query parameters
\n| Name | \nType | \nRequired | \n
|---|---|---|
fid | \nu64 | \nyes | \n
limit | \nusize | \nno | \n
Backed by LinkStore::get_link_adds_by_fid(fid, \"mute\", ...).
Registered for SDK compatibility — bans are an app-level concept and not part of the Farcaster protocol.
\nResponse
\n{ \"bans\": [], \"next\": { \"cursor\": null } }\n\nPOST /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.
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.
\nResponse shape for all list endpoints:
\n{\n \"users\": [ { /* User */ } ],\n \"next\": { \"cursor\": null }\n}\n\nFIDs that fid has blocked.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
FIDs that fid has muted.
Query parameters
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
fid | u64 | yes |
limit | usize | no |
Backed by LinkStore::get_link_adds_by_fid(fid, \"mute\", ...).
Registered for SDK compatibility — bans are an app-level concept and not part of the Farcaster protocol.
\nResponse
\n{ \"bans\": [], \"next\": { \"cursor\": null } }\n\nPOST /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.
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:
\n{ \"fids\": [12345, 67890] }\n\nResponses are objects keyed by stringified FID.
\nFor each FID in the request, return the list of FIDs they follow (truncated to a cap).
\nRequest
\n{ \"fids\": [3, 5] }\n\nResponse
\n{\n \"3\": [\n { \"fid\": 5, \"followed_at\": \"2024-01-15T09:12:00Z\" },\n { \"fid\": 191, \"followed_at\": \"2024-02-02T13:40:00Z\" }\n ],\n \"5\": [\n { \"fid\": 3, \"followed_at\": \"2023-11-02T18:01:00Z\" }\n ]\n}\n\n\nFor each FID, return their recent reactions (likes + recasts).
\nResponse
\n{\n \"3\": [\n { \"target_fid\": 5, \"timestamp\": 1712345678 }\n ]\n}\n\n\nFor each FID, return a list of cast hashes and whether the FID liked, recasted, or replied to each.
\nResponse
\n{\n \"3\": [\n { \"hash\": \"0xabc...\", \"liked\": true, \"recasted\": false, \"replied\": true }\n ]\n}\n\nUseful for rendering \"you liked this\" state across a feed in one call instead of one lookup per cast.
\n\nFor each FID, return its registered signer set from the on-chain KeyRegistry.
Response
\n{\n \"3\": [\n {\n \"signer\": \"0x<ed25519-pubkey>\",\n \"created_at\": 1712345678,\n \"key_type\": 1,\n \"metadata_type\": 1\n }\n ]\n}\n\n\nFor each FID, return its IdRegistry history — registration, transfers, and the current owner.
Response
\n{\n \"3\": [\n {\n \"from\": \"0x0000...0000\",\n \"to\": \"0xabcd...abcd\",\n \"timestamp\": 1700000000,\n \"event_type\": \"register\"\n }\n ]\n}\n\n",
+ "contentHtml": "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:
\n{ \"fids\": [12345, 67890] }\n\nResponses are objects keyed by stringified FID.
\nFor each FID in the request, return the list of FIDs they follow (truncated to a cap).
\nRequest
\n{ \"fids\": [3, 5] }\n\nResponse
\n{\n \"3\": [\n { \"fid\": 5, \"followed_at\": \"2024-01-15T09:12:00Z\" },\n { \"fid\": 191, \"followed_at\": \"2024-02-02T13:40:00Z\" }\n ],\n \"5\": [\n { \"fid\": 3, \"followed_at\": \"2023-11-02T18:01:00Z\" }\n ]\n}\n\n\nFor each FID, return their recent reactions (likes + recasts).
\nResponse
\n{\n \"3\": [\n { \"target_fid\": 5, \"timestamp\": 1712345678 }\n ]\n}\n\n\nFor each FID, return a list of cast hashes and whether the FID liked, recasted, or replied to each.
\nResponse
\n{\n \"3\": [\n { \"hash\": \"0xabc...\", \"liked\": true, \"recasted\": false, \"replied\": true }\n ]\n}\n\nUseful for rendering \"you liked this\" state across a feed in one call instead of one lookup per cast.
\n\nFor each FID, return its registered signer set from the on-chain KeyRegistry.
Response
\n{\n \"3\": [\n {\n \"signer\": \"0x<ed25519-pubkey>\",\n \"created_at\": 1712345678,\n \"key_type\": 1,\n \"metadata_type\": 1\n }\n ]\n}\n\n\nFor each FID, return its IdRegistry history — registration, transfers, and the current owner.
Response
\n{\n \"3\": [\n {\n \"from\": \"0x0000...0000\",\n \"to\": \"0xabcd...abcd\",\n \"timestamp\": 1700000000,\n \"event_type\": \"register\"\n }\n ]\n}\n\n",
"contentMarkdown": "# Batch reads\n\nPOST-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:\n\n```json\n{ \"fids\": [12345, 67890] }\n```\n\nResponses are objects keyed by stringified FID.\n\n---\n\n## POST /v2/farcaster/batch/following\n\nFor each FID in the request, return the list of FIDs they follow (truncated to a cap).\n\n**Request**\n\n```json\n{ \"fids\": [3, 5] }\n```\n\n**Response**\n\n```json\n{\n \"3\": [\n { \"fid\": 5, \"followed_at\": \"2024-01-15T09:12:00Z\" },\n { \"fid\": 191, \"followed_at\": \"2024-02-02T13:40:00Z\" }\n ],\n \"5\": [\n { \"fid\": 3, \"followed_at\": \"2023-11-02T18:01:00Z\" }\n ]\n}\n```\n\n\n\n---\n\n## POST /v2/farcaster/batch/reactions\n\nFor each FID, return their recent reactions (likes + recasts).\n\n**Response**\n\n```json\n{\n \"3\": [\n { \"target_fid\": 5, \"timestamp\": 1712345678 }\n ]\n}\n```\n\n\n\n---\n\n## POST /v2/farcaster/batch/cast-interactions\n\nFor each FID, return a list of cast hashes and whether the FID liked, recasted, or replied to each.\n\n**Response**\n\n```json\n{\n \"3\": [\n { \"hash\": \"0xabc...\", \"liked\": true, \"recasted\": false, \"replied\": true }\n ]\n}\n```\n\nUseful for rendering \"you liked this\" state across a feed in one call instead of one lookup per cast.\n\n\n\n---\n\n## POST /v2/farcaster/batch/signers\n\nFor each FID, return its registered signer set from the on-chain `KeyRegistry`.\n\n**Response**\n\n```json\n{\n \"3\": [\n {\n \"signer\": \"0xAll endpoints under /v2/farcaster/webhook/* require an EIP-712 signature in headers as described in Signed operations. The request body (if any) is included verbatim in the hash that gets signed.
Max body: 256 KB.
\nX-Hypersnap-Op: webhook.create
Request body (CreateWebhookRequest):
{\n \"name\": \"my webhook\",\n \"url\": \"https://receiver.example.com/hook\",\n \"description\": \"optional free-form\",\n \"subscription\": {\n \"cast_created\": {\n \"author_fids\": [3, 5],\n \"mentioned_fids\": [],\n \"text\": \"optional regex\",\n \"embeds\": \"optional regex\",\n \"exclude_author_fids\": []\n }\n }\n}\n\nThe subscription must contain at least one event type. See Subscription filters for every available field and the size/regex constraints.
\nThe 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):
{\n \"webhook\": {\n \"webhook_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"owner_fid\": 3,\n \"target_url\": \"https://receiver.example.com/hook\",\n \"title\": \"my webhook\",\n \"description\": \"optional free-form\",\n \"active\": true,\n \"secrets\": [\n {\n \"uid\": \"...\",\n \"value\": \"<64-char hex signing secret>\",\n \"expires_at\": null,\n \"created_at\": 1712345678\n }\n ],\n \"subscription\": { ... },\n \"http_timeout\": 10,\n \"rate_limit\": 1000,\n \"rate_limit_duration\": 60,\n \"created_at\": 1712345678,\n \"updated_at\": 1712345678\n }\n}\n\nSave 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 invalidates the old one on a grace window.
Errors
\n400 — invalid JSON, empty subscription, filter too large, invalid regex, SSRF-blocked URL.401 — signature / clock / nonce / custody mismatch.429 — per-FID cap reached.X-Hypersnap-Op: webhook.read
Query
\n| Name | \nType | \nRequired | \n
|---|---|---|
webhook_id | \nUUID | \nyes | \n
Response — same WebhookResponse shape as create.
403 if the webhook belongs to a different FID; 404 if not found.
X-Hypersnap-Op: webhook.read
Query — none.
\nResponse
\n{ \"webhooks\": [ { /* webhook record */ } ] }\n\nReturns up to the per-owner cap. Only webhooks owned by the signing FID are included.
\n\nX-Hypersnap-Op: webhook.update
Request body (UpdateWebhookRequest — all fields except webhook_id are optional, only supplied fields change):
{\n \"webhook_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"optional new name\",\n \"url\": \"https://new-receiver.example.com/hook\",\n \"description\": \"optional new description\",\n \"subscription\": { /* full replacement if supplied */ },\n \"active\": true\n}\n\nIf 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.
X-Hypersnap-Op: webhook.delete
Query
\n| Name | \nType | \nRequired | \n
|---|---|---|
webhook_id | \nUUID | \nyes | \n
Response
\n{ \"deleted\": true }\n\nSoft-deletes the record. Retries queued for in-flight events stop dispatching. 403 if not owner.
X-Hypersnap-Op: webhook.rotate_secret
Query
\n| Name | \nType | \nRequired | \n
|---|---|---|
webhook_id | \nUUID | \nyes | \n
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
\nsecret/rotate.secrets[-1].value — this is what new deliveries will sign with.expires_at passes.All endpoints under /v2/farcaster/webhook/* require an EIP-712 signature in headers as described in Signed operations. The request body (if any) is included verbatim in the hash that gets signed.
Max body: 256 KB.
\nX-Hypersnap-Op: webhook.create
Request body (CreateWebhookRequest):
{\n \"name\": \"my webhook\",\n \"url\": \"https://receiver.example.com/hook\",\n \"description\": \"optional free-form\",\n \"subscription\": {\n \"cast_created\": {\n \"author_fids\": [3, 5],\n \"mentioned_fids\": [],\n \"text\": \"optional regex\",\n \"embeds\": \"optional regex\",\n \"exclude_author_fids\": []\n }\n }\n}\n\nThe subscription must contain at least one event type. See Subscription filters for every available field and the size/regex constraints.
\nThe 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):
{\n \"webhook\": {\n \"webhook_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"owner_fid\": 3,\n \"target_url\": \"https://receiver.example.com/hook\",\n \"title\": \"my webhook\",\n \"description\": \"optional free-form\",\n \"active\": true,\n \"secrets\": [\n {\n \"uid\": \"...\",\n \"value\": \"<64-char hex signing secret>\",\n \"expires_at\": null,\n \"created_at\": 1712345678\n }\n ],\n \"subscription\": { ... },\n \"http_timeout\": 10,\n \"rate_limit\": 1000,\n \"rate_limit_duration\": 60,\n \"created_at\": 1712345678,\n \"updated_at\": 1712345678\n }\n}\n\nSave 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 invalidates the old one on a grace window.
Errors
\n400 — invalid JSON, empty subscription, filter too large, invalid regex, SSRF-blocked URL.401 — signature / clock / nonce / custody mismatch.429 — per-FID cap reached.X-Hypersnap-Op: webhook.read
Query
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
X-Hypersnap-Op: webhook.read
Query — none.
\nResponse
\n{ \"webhooks\": [ { /* webhook record */ } ] }\n\nReturns up to the per-owner cap. Only webhooks owned by the signing FID are included.
\n\nX-Hypersnap-Op: webhook.update
Request body (UpdateWebhookRequest — all fields except webhook_id are optional, only supplied fields change):
{\n \"webhook_id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"optional new name\",\n \"url\": \"https://new-receiver.example.com/hook\",\n \"description\": \"optional new description\",\n \"subscription\": { /* full replacement if supplied */ },\n \"active\": true\n}\n\nIf 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.
X-Hypersnap-Op: webhook.delete
Query
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
webhook_id | UUID | yes |
Response
\n{ \"deleted\": true }\n\nSoft-deletes the record. Retries queued for in-flight events stop dispatching. 403 if not owner.
X-Hypersnap-Op: webhook.rotate_secret
Query
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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
\nsecret/rotate.secrets[-1].value — this is what new deliveries will sign with.expires_at passes.A WebhookSubscription object selects which Farcaster events you want delivered. The shape mirrors common Farcaster v2 contracts so existing filters port verbatim.
{\n \"cast_created\": { /* CastFilter */ },\n \"cast_deleted\": { /* CastFilter */ },\n \"user_created\": { },\n \"user_updated\": { \"fids\": [12345] },\n \"follow_created\": { \"fids\": [], \"target_fids\": [] },\n \"follow_deleted\": { \"fids\": [], \"target_fids\": [] },\n \"reaction_created\":{ \"fids\": [], \"target_fids\": [], \"target_cast_hashes\": [] },\n \"reaction_deleted\":{ \"fids\": [], \"target_fids\": [], \"target_cast_hashes\": [] }\n}\n\nAt 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.
Used for both cast_created and cast_deleted.
| Field | \nType | \nMeaning | \n
|---|---|---|
author_fids | \nVec<u64> | \nCast author FID is in this set. | \n
exclude_author_fids | \nVec<u64> | \nCast author FID is not in this set. | \n
mentioned_fids | \nVec<u64> | \nCast mentions any FID in this set. | \n
parent_urls | \nVec<String> | \nCast is a reply under one of these parent URLs (channel scoping). | \n
root_parent_urls | \nVec<String> | \nRoot-of-thread parent URL matches. (Accepted at create time; not enforced at dispatch.) | \n
parent_hashes | \nVec<String> | \nCast is a reply to a cast with one of these hashes. | \n
parent_author_fids | \nVec<u64> | \nCast is a reply to a cast authored by one of these FIDs. | \n
text | \nOption<String> | \nRegex. If set, cast text must match. Compiled at create time with the linear-time regex crate; lookaround is rejected. | \n
embeds | \nOption<String> | \nRegex. Applied against embed URLs. (Accepted at create time; not enforced at dispatch.) | \n
embedded_cast_author_fids | \nVec<u64> | \n(Accepted; not enforced at dispatch.) | \n
embedded_cast_hashes | \nVec<String> | \n(Accepted; not enforced at dispatch.) | \n
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\".
\nUsed for both follow_created and follow_deleted.
| Field | \nType | \nMeaning | \n
|---|---|---|
fids | \nVec<u64> | \nThe follower's FID is in this set. | \n
target_fids | \nVec<u64> | \nThe target (the one being followed) is in this set. | \n
Used for both reaction_created and reaction_deleted.
| Field | \nType | \nMeaning | \n
|---|---|---|
fids | \nVec<u64> | \nThe reactor's FID is in this set. | \n
target_fids | \nVec<u64> | \nThe author of the cast being reacted to is in this set. | \n
target_cast_hashes | \nVec<String> | \nThe specific cast hash(es) being reacted to. | \n
| Field | \nType | \nMeaning | \n
|---|---|---|
fids | \nVec<u64> | \nOnly match updates to these FIDs. | \n
No filter fields — fires for every new FID registration seen on-chain.
\n400.text, embeds) must compile under Rust's regex crate (linear time, no backtracking). Lookaround is rejected. Alternation, character classes, and quantifiers all work.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.
{ \"cast_created\": {} }\n\n{ \"cast_created\": { \"author_fids\": [3] } }\n\n{\n \"cast_created\": {\n \"mentioned_fids\": [12345],\n \"parent_author_fids\": [12345]\n }\n}\n\nCaveat: 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.
\n{ \"reaction_created\": { \"target_fids\": [12345] } }\n\n{ \"follow_created\": { \"target_fids\": [12345] } }\n\n{ \"cast_created\": { \"text\": \"(?i)\\\\b(hypersnap|farcaster)\\\\b\" } }\n\n(?i) is supported; (?=...) / (?!...) lookaround is not.
A WebhookSubscription object selects which Farcaster events you want delivered. The shape mirrors common Farcaster v2 contracts so existing filters port verbatim.
{\n \"cast_created\": { /* CastFilter */ },\n \"cast_deleted\": { /* CastFilter */ },\n \"user_created\": { },\n \"user_updated\": { \"fids\": [12345] },\n \"follow_created\": { \"fids\": [], \"target_fids\": [] },\n \"follow_deleted\": { \"fids\": [], \"target_fids\": [] },\n \"reaction_created\":{ \"fids\": [], \"target_fids\": [], \"target_cast_hashes\": [] },\n \"reaction_deleted\":{ \"fids\": [], \"target_fids\": [], \"target_cast_hashes\": [] }\n}\n\nAt 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.
Used for both cast_created and cast_deleted.
| Field | Type | Meaning |
|---|---|---|
author_fids | Vec<u64> | Cast author FID is in this set. |
exclude_author_fids | Vec<u64> | Cast author FID is not in this set. |
mentioned_fids | Vec<u64> | Cast mentions any FID in this set. |
parent_urls | Vec<String> | Cast is a reply under one of these parent URLs (channel scoping). |
root_parent_urls | Vec<String> | Root-of-thread parent URL matches. (Accepted at create time; not enforced at dispatch.) |
parent_hashes | Vec<String> | Cast is a reply to a cast with one of these hashes. |
parent_author_fids | Vec<u64> | Cast is a reply to a cast authored by one of these FIDs. |
text | Option<String> | Regex. If set, cast text must match. Compiled at create time with the linear-time regex crate; lookaround is rejected. |
embeds | Option<String> | Regex. Applied against embed URLs. (Accepted at create time; not enforced at dispatch.) |
embedded_cast_author_fids | Vec<u64> | (Accepted; not enforced at dispatch.) |
embedded_cast_hashes | Vec<String> | (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\".
\nUsed for both follow_created and follow_deleted.
| Field | Type | Meaning |
|---|---|---|
fids | Vec<u64> | The follower's FID is in this set. |
target_fids | Vec<u64> | The target (the one being followed) is in this set. |
Used for both reaction_created and reaction_deleted.
| Field | Type | Meaning |
|---|---|---|
fids | Vec<u64> | The reactor's FID is in this set. |
target_fids | Vec<u64> | The author of the cast being reacted to is in this set. |
target_cast_hashes | Vec<String> | The specific cast hash(es) being reacted to. |
| Field | Type | Meaning |
|---|---|---|
fids | Vec<u64> | Only match updates to these FIDs. |
No filter fields — fires for every new FID registration seen on-chain.
\n400.text, embeds) must compile under Rust's regex crate (linear time, no backtracking). Lookaround is rejected. Alternation, character classes, and quantifiers all work.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.
{ \"cast_created\": {} }\n\n{ \"cast_created\": { \"author_fids\": [3] } }\n\n{\n \"cast_created\": {\n \"mentioned_fids\": [12345],\n \"parent_author_fids\": [12345]\n }\n}\n\nCaveat: 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.
\n{ \"reaction_created\": { \"target_fids\": [12345] } }\n\n{ \"follow_created\": { \"target_fids\": [12345] } }\n\n{ \"cast_created\": { \"text\": \"(?i)\\\\b(hypersnap|farcaster)\\\\b\" } }\n\n(?i) is supported; (?=...) / (?!...) lookaround is not.
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.
Method: POST
Headers
\n| Header | \nValue | \n
|---|---|
Content-Type | \napplication/json | \n
X-Hypersnap-Signature | \n<hex(hmac_sha512(secret, raw_body))> (header name is operator-configurable; this is the default) | \n
Body
\n{\n \"created_at\": 1712345678,\n \"type\": \"cast.created\",\n \"data\": { /* event payload — see Event schemas */ }\n}\n\nThe type field tells you which event schema applies. The data field carries the event payload — see Event schemas for the per-type shapes.
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.
import crypto from \"crypto\";\n\nfunction verify(req, secret) {\n const received = req.headers[\"x-hypersnap-signature\"];\n const expected = crypto\n .createHmac(\"sha512\", secret)\n .update(req.rawBody) // the literal bytes, NOT JSON.stringify(req.body)\n .digest(\"hex\");\n return crypto.timingSafeEqual(\n Buffer.from(received, \"hex\"),\n Buffer.from(expected, \"hex\"),\n );\n}\n\nimport hmac, hashlib\n\ndef verify(raw_body: bytes, received_hex: str, secret: str) -> bool:\n expected = hmac.new(\n secret.encode(), raw_body, hashlib.sha512\n ).hexdigest()\n return hmac.compare_digest(expected, received_hex)\n\nuse hmac::{Hmac, Mac};\nuse sha2::Sha512;\n\nfn verify(raw_body: &[u8], received_hex: &str, secret: &str) -> bool {\n let mut mac = Hmac::<Sha512>::new_from_slice(secret.as_bytes()).unwrap();\n mac.update(raw_body);\n let expected = hex::encode(mac.finalize().into_bytes());\n // subtle::ConstantTimeEq in production.\n expected == received_hex\n}\n\nThe 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:
expires_at passes.This way a secret rotation never loses a delivery.
\n| Your response | \nHypersnap's interpretation | \n
|---|---|
2xx | \nDelivered. Counted as webhooks.delivery.succeeded. | \n
4xx | \nTerminal 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. | \n
5xx | \nTransient failure. Retried with exponential backoff up to retry_max_attempts. Counted as webhooks.delivery.failed_5xx. | \n
| Timeout / network error | \nTransient failure. Retried. Counted as webhooks.delivery.failed_network. | \n
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.
Each webhook has a rate limit enforced by the dispatcher:
\nwebhooks.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.
\n(event, attempt) pair. Dedupe on the data field's natural key (cast hash, reaction unique tuple, etc.).2xx as soon as you've durably enqueued the event; don't hold the connection open for downstream work.5xx on transient failures so Hypersnap can retry.4xx only when there's nothing to be done.delivery_timeout_secs.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.
Method: POST
Headers
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Header | Value |
|---|---|
Content-Type | application/json |
X-Hypersnap-Signature | <hex(hmac_sha512(secret, raw_body))> (header name is operator-configurable; this is the default) |
Body
\n{\n \"created_at\": 1712345678,\n \"type\": \"cast.created\",\n \"data\": { /* event payload — see Event schemas */ }\n}\n\nThe type field tells you which event schema applies. The data field carries the event payload — see Event schemas for the per-type shapes.
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.
import crypto from \"crypto\";\n\nfunction verify(req, secret) {\n const received = req.headers[\"x-hypersnap-signature\"];\n const expected = crypto\n .createHmac(\"sha512\", secret)\n .update(req.rawBody) // the literal bytes, NOT JSON.stringify(req.body)\n .digest(\"hex\");\n return crypto.timingSafeEqual(\n Buffer.from(received, \"hex\"),\n Buffer.from(expected, \"hex\"),\n );\n}\n\nimport hmac, hashlib\n\ndef verify(raw_body: bytes, received_hex: str, secret: str) -> bool:\n expected = hmac.new(\n secret.encode(), raw_body, hashlib.sha512\n ).hexdigest()\n return hmac.compare_digest(expected, received_hex)\n\nuse hmac::{Hmac, Mac};\nuse sha2::Sha512;\n\nfn verify(raw_body: &[u8], received_hex: &str, secret: &str) -> bool {\n let mut mac = Hmac::<Sha512>::new_from_slice(secret.as_bytes()).unwrap();\n mac.update(raw_body);\n let expected = hex::encode(mac.finalize().into_bytes());\n // subtle::ConstantTimeEq in production.\n expected == received_hex\n}\n\nThe 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:
expires_at passes.This way a secret rotation never loses a delivery.
\n| 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.
Each webhook has a rate limit enforced by the dispatcher:
\nwebhooks.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.
\n(event, attempt) pair. Dedupe on the data field's natural key (cast hash, reaction unique tuple, etc.).2xx as soon as you've durably enqueued the event; don't hold the connection open for downstream work.5xx on transient failures so Hypersnap can retry.4xx only when there's nothing to be done.delivery_timeout_secs.The data field of a delivered webhook envelope carries a type-specific payload. The type field in the envelope tells you which schema applies:
{\n \"created_at\": 1712345678,\n \"type\": \"cast.created\",\n \"data\": { /* see below */ }\n}\n\nHypersnap fires the following event types. Field shapes mirror common Farcaster v2 contracts so existing client models deserialize directly.
\nEmitted when a new cast is ingested by the node.
\n{\n \"type\": \"cast.created\",\n \"data\": {\n \"cast\": {\n \"hash\": \"0x...\",\n \"author\": { /* User */ },\n \"text\": \"hello world\",\n \"timestamp\": 1712345678,\n \"parent_hash\": null,\n \"parent_url\": null,\n \"root_parent_url\": null,\n \"embeds\": [],\n \"mentioned_profiles\": [],\n \"reactions\": { \"likes_count\": 0, \"recasts_count\": 0 },\n \"replies\": { \"count\": 0 }\n }\n }\n}\n\nEmitted when a cast delete message is applied.
\n{\n \"type\": \"cast.deleted\",\n \"data\": {\n \"cast\": { /* Same cast shape as cast.created */ }\n }\n}\n\nEmitted on an IdRegistry register event seen on-chain. No filter fields.
{\n \"type\": \"user.created\",\n \"data\": {\n \"user\": { \"fid\": 12345, \"username\": null, \"...\" : \"...\" }\n }\n}\n\nEmitted when user-data (pfp, bio, display name, url, username) changes.
\n{\n \"type\": \"user.updated\",\n \"data\": {\n \"user\": { /* User (updated fields filled) */ }\n }\n}\n\nEmitted when a link_add message with type follow is applied.
{\n \"type\": \"follow.created\",\n \"data\": {\n \"follower\": { /* User */ },\n \"target\": { /* User */ }\n }\n}\n\nEmitted on link_remove for a follow link.
{\n \"type\": \"follow.deleted\",\n \"data\": {\n \"follower\": { /* User */ },\n \"target\": { /* User */ }\n }\n}\n\nEmitted when a like or recast is applied.
\n{\n \"type\": \"reaction.created\",\n \"data\": {\n \"reaction_type\": \"like\",\n \"user\": { /* User */ },\n \"cast\": { /* Cast */ }\n }\n}\n\nreaction_type is either \"like\" or \"recast\".
Same shape as reaction.created, emitted when a reaction is removed.
For at-least-once delivery, dedupe on the natural key of each event type:
\n| Event | \nDedupe key | \n
|---|---|
cast.created / cast.deleted | \ndata.cast.hash | \n
user.created | \ndata.user.fid | \n
user.updated | \n(data.user.fid, received_at) or compare fields against last-known state | \n
follow.created / follow.deleted | \n(data.follower.fid, data.target.fid) | \n
reaction.created / reaction.deleted | \n(data.user.fid, data.cast.hash, data.reaction_type) | \n
type values your code doesn't recognize.type.The data field of a delivered webhook envelope carries a type-specific payload. The type field in the envelope tells you which schema applies:
{\n \"created_at\": 1712345678,\n \"type\": \"cast.created\",\n \"data\": { /* see below */ }\n}\n\nHypersnap fires the following event types. Field shapes mirror common Farcaster v2 contracts so existing client models deserialize directly.
\nEmitted when a new cast is ingested by the node.
\n{\n \"type\": \"cast.created\",\n \"data\": {\n \"cast\": {\n \"hash\": \"0x...\",\n \"author\": { /* User */ },\n \"text\": \"hello world\",\n \"timestamp\": 1712345678,\n \"parent_hash\": null,\n \"parent_url\": null,\n \"root_parent_url\": null,\n \"embeds\": [],\n \"mentioned_profiles\": [],\n \"reactions\": { \"likes_count\": 0, \"recasts_count\": 0 },\n \"replies\": { \"count\": 0 }\n }\n }\n}\n\nEmitted when a cast delete message is applied.
\n{\n \"type\": \"cast.deleted\",\n \"data\": {\n \"cast\": { /* Same cast shape as cast.created */ }\n }\n}\n\nEmitted on an IdRegistry register event seen on-chain. No filter fields.
{\n \"type\": \"user.created\",\n \"data\": {\n \"user\": { \"fid\": 12345, \"username\": null, \"...\" : \"...\" }\n }\n}\n\nEmitted when user-data (pfp, bio, display name, url, username) changes.
\n{\n \"type\": \"user.updated\",\n \"data\": {\n \"user\": { /* User (updated fields filled) */ }\n }\n}\n\nEmitted when a link_add message with type follow is applied.
{\n \"type\": \"follow.created\",\n \"data\": {\n \"follower\": { /* User */ },\n \"target\": { /* User */ }\n }\n}\n\nEmitted on link_remove for a follow link.
{\n \"type\": \"follow.deleted\",\n \"data\": {\n \"follower\": { /* User */ },\n \"target\": { /* User */ }\n }\n}\n\nEmitted when a like or recast is applied.
\n{\n \"type\": \"reaction.created\",\n \"data\": {\n \"reaction_type\": \"like\",\n \"user\": { /* User */ },\n \"cast\": { /* Cast */ }\n }\n}\n\nreaction_type is either \"like\" or \"recast\".
Same shape as reaction.created, emitted when a reaction is removed.
For at-least-once delivery, dedupe on the natural key of each event type:
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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) |
type values your code doesn't recognize.type.Hypersnap is a multi-tenant proxy for Farcaster Mini App notifications. 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.
\napp_id and returns a send secret. → Registering a mini app/v2/farcaster/frame/webhook/<app_id> 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/v2/farcaster/frame/notifications/<app_id> 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 notificationsThis is its own pipeline, entirely separate from:
\n| Surface | \nAuth | \n
|---|---|
/v2/farcaster/frame/app/* | \nEIP-712 custody signature | \n
/v2/farcaster/frame/webhook/<app_id> | \nJFS from a Farcaster client | \n
/v2/farcaster/frame/notifications/<app_id> | \nPer-app x-api-key: <send_secret> | \n
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.
", + "contentHtml": "Hypersnap is a multi-tenant proxy for Farcaster Mini App notifications. 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.
\napp_id and returns a send secret. → Registering a mini app/v2/farcaster/frame/webhook/<app_id> 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/v2/farcaster/frame/notifications/<app_id> 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 notificationsThis is its own pipeline, entirely separate from:
\n| Surface | Auth |
|---|---|
/v2/farcaster/frame/app/* | EIP-712 custody signature |
/v2/farcaster/frame/webhook/<app_id> | JFS from a Farcaster client |
/v2/farcaster/frame/notifications/<app_id> | Per-app x-api-key: <send_secret> |
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.
", "contentMarkdown": "# Mini-app notifications\n\nHypersnap 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.\n\n## Who should use this\n\n- Mini-app developers who want push notifications on new content, social activity, or in-app events.\n- Anyone who has implemented the upstream Farcaster Mini App notification flow against a different proxy and wants to switch.\n\n## Three moving parts\n\n1. **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)\n2. **Receive token events.** Farcaster clients POST JFS-signed events to `/v2/farcaster/frame/webhook/All endpoints in this section are under /v2/farcaster/frame/app/ and require an EIP-712 signature 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.
X-Hypersnap-Op: app.create
Request body (CreateAppRequest):
{\n \"name\": \"my mini app\",\n \"app_url\": \"https://miniapp.example.com\",\n \"description\": \"optional\",\n \"signer_fid_allowlist\": [12345, 67890]\n}\n\n| Field | \nType | \nNotes | \n
|---|---|---|
name | \nstring | \n1–128 chars. Human-readable, shown in management responses. | \n
app_url | \nstring | \nCanonical mini-app URL. SSRF-checked at create time. Hypersnap never POSTs to this URL itself — it's informational. | \n
description | \nstring (optional) | \nFree-form. | \n
signer_fid_allowlist | \nVec<u64> (optional) | \nFIDs whose Farcaster signing keys are permitted to submit JFS token events for this app. Empty = any active signer is accepted. Capped at 1024 entries. | \n
Response (AppResponse):
{\n \"app\": {\n \"app_id\": \"3Hq9ZgK2p4vNfWxR\",\n \"owner_fid\": 12345,\n \"name\": \"my mini app\",\n \"app_url\": \"https://miniapp.example.com\",\n \"description\": \"optional\",\n \"signer_fid_allowlist\": [],\n \"send_secrets\": [\n {\n \"uid\": \"uuid\",\n \"value\": \"<64-char hex send secret>\",\n \"expires_at\": null,\n \"created_at\": 1712345678\n }\n ],\n \"created_at\": 1712345678,\n \"updated_at\": 1712345678\n }\n}\n\nSave 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/<app_id>) and the send endpoint (/v2/farcaster/frame/notifications/<app_id>).send_secrets[0].value as the x-api-key header when you call the send endpoint.Errors
\n400 — invalid name / app_url / SSRF-blocked URL / allowlist too big.401 — signature / auth failure.429 — per-FID app cap hit.X-Hypersnap-Op: app.read
Query
\n| Name | \nType | \nRequired | \n
|---|---|---|
app_id | \nstring | \nyes | \n
Response — same AppResponse shape. 403 if the app belongs to a different FID; 404 if not found.
X-Hypersnap-Op: app.read
Query — none.
\nResponse
\n{ \"apps\": [ { /* RegisteredApp */ } ] }\n\nReturns every mini app owned by the signing FID.
\n\nX-Hypersnap-Op: app.update
Request body (UpdateAppRequest):
{\n \"app_id\": \"3Hq9ZgK2p4vNfWxR\",\n \"name\": \"optional new name\",\n \"app_url\": \"https://new.example.com\",\n \"description\": \"optional\",\n \"signer_fid_allowlist\": [12345]\n}\n\nOnly supplied fields change. Passing signer_fid_allowlist replaces the whole list.
X-Hypersnap-Op: app.delete
Query
\n| Name | \nType | \nRequired | \n
|---|---|---|
app_id | \nstring | \nyes | \n
Response
\n{ \"deleted\": true }\n\nAfter 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).
X-Hypersnap-Op: app.rotate_secret
Query
\n| Name | \nType | \nRequired | \n
|---|---|---|
app_id | \nstring | \nyes | \n
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:
\n/secret/rotate.send_secrets[-1].value.All endpoints in this section are under /v2/farcaster/frame/app/ and require an EIP-712 signature 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.
X-Hypersnap-Op: app.create
Request body (CreateAppRequest):
{\n \"name\": \"my mini app\",\n \"app_url\": \"https://miniapp.example.com\",\n \"description\": \"optional\",\n \"signer_fid_allowlist\": [12345, 67890]\n}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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<u64> (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):
{\n \"app\": {\n \"app_id\": \"3Hq9ZgK2p4vNfWxR\",\n \"owner_fid\": 12345,\n \"name\": \"my mini app\",\n \"app_url\": \"https://miniapp.example.com\",\n \"description\": \"optional\",\n \"signer_fid_allowlist\": [],\n \"send_secrets\": [\n {\n \"uid\": \"uuid\",\n \"value\": \"<64-char hex send secret>\",\n \"expires_at\": null,\n \"created_at\": 1712345678\n }\n ],\n \"created_at\": 1712345678,\n \"updated_at\": 1712345678\n }\n}\n\nSave 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/<app_id>) and the send endpoint (/v2/farcaster/frame/notifications/<app_id>).send_secrets[0].value as the x-api-key header when you call the send endpoint.Errors
\n400 — invalid name / app_url / SSRF-blocked URL / allowlist too big.401 — signature / auth failure.429 — per-FID app cap hit.X-Hypersnap-Op: app.read
Query
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
app_id | string | yes |
Response — same AppResponse shape. 403 if the app belongs to a different FID; 404 if not found.
X-Hypersnap-Op: app.read
Query — none.
\nResponse
\n{ \"apps\": [ { /* RegisteredApp */ } ] }\n\nReturns every mini app owned by the signing FID.
\n\nX-Hypersnap-Op: app.update
Request body (UpdateAppRequest):
{\n \"app_id\": \"3Hq9ZgK2p4vNfWxR\",\n \"name\": \"optional new name\",\n \"app_url\": \"https://new.example.com\",\n \"description\": \"optional\",\n \"signer_fid_allowlist\": [12345]\n}\n\nOnly supplied fields change. Passing signer_fid_allowlist replaces the whole list.
X-Hypersnap-Op: app.delete
Query
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| Name | Type | Required |
|---|---|---|
app_id | string | yes |
Response
\n{ \"deleted\": true }\n\nAfter 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).
X-Hypersnap-Op: app.rotate_secret
Query
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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:
\n/secret/rotate.send_secrets[-1].value.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.
\nThis page is a reference for:
\nPOST /v2/farcaster/frame/webhook/<app_id>\n\n<app_id> 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.
{\n \"header\": \"<base64url>\",\n \"payload\": \"<base64url JSON>\",\n \"signature\": \"<base64url>\"\n}\n\nThis is a standard JSON Farcaster Signature envelope. The decoded payload is one of:
{ \"event\": \"miniapp_added\", \"notificationDetails\": { \"url\": \"https://...\", \"token\": \"...\" } }\n{ \"event\": \"miniapp_removed\" }\n{ \"event\": \"notifications_enabled\", \"notificationDetails\": { \"url\": \"https://...\", \"token\": \"...\" } }\n{ \"event\": \"notifications_disabled\" }\n\nThe four events map to state transitions on the user's relationship with your mini app:
\n| Event | \nWhat Hypersnap does | \n
|---|---|
miniapp_added (with notificationDetails) | \nUpserts an enabled token for (fid, app_id). | \n
miniapp_added (without notificationDetails) | \nRecords the add without a token (notifications opt-out). | \n
miniapp_removed | \nDeletes all tokens for (fid, app_id). | \n
notifications_enabled | \nUpserts / re-enables a token. | \n
notifications_disabled | \nMarks the token disabled but does not delete it. | \n
Hypersnap applies these checks before touching the token store:
\nkey in the JFS header.KeyRegistry. The signing key must be currently active.signer_fid_allowlist, the signer FID must be in it.If any step fails, Hypersnap returns 401 and does not apply the event.
Successful ack:
\n{ \"success\": true }\n\nFailure:
\n{ \"success\": false, \"message\": \"...\" }\n\nClients 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.
Nothing, as long as you're proxying through Hypersnap. Put the /v2/farcaster/frame/webhook/<app_id> URL in your mini app's manifest:
{\n \"name\": \"my mini app\",\n \"icon_url\": \"https://...\",\n \"home_url\": \"https://miniapp.example.com\",\n \"webhook_url\": \"https://haatz.quilibrium.com/v2/farcaster/frame/webhook/3Hq9ZgK2p4vNfWxR\"\n}\n\nWhen 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.
\nIf a user added your app but isn't receiving notifications:
\nhypersnap::notifications tracing target. A failed verification logs why (bad signature, inactive signer, not in allowlist).GET /v2/farcaster/frame/app/?app_id=<id> (signed) and confirm it returns 200.signer_fid_allowlist, the user's Farcaster signer FID needs to be in it. An overly narrow allowlist is the most common mistake.<app_id> in the webhook URL and the <app_id> in your send endpoint calls must match.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.
\nThis page is a reference for:
\nPOST /v2/farcaster/frame/webhook/<app_id>\n\n<app_id> 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.
{\n \"header\": \"<base64url>\",\n \"payload\": \"<base64url JSON>\",\n \"signature\": \"<base64url>\"\n}\n\nThis is a standard JSON Farcaster Signature envelope. The decoded payload is one of:
{ \"event\": \"miniapp_added\", \"notificationDetails\": { \"url\": \"https://...\", \"token\": \"...\" } }\n{ \"event\": \"miniapp_removed\" }\n{ \"event\": \"notifications_enabled\", \"notificationDetails\": { \"url\": \"https://...\", \"token\": \"...\" } }\n{ \"event\": \"notifications_disabled\" }\n\nThe four events map to state transitions on the user's relationship with your mini app:
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
Hypersnap applies these checks before touching the token store:
\nkey in the JFS header.KeyRegistry. The signing key must be currently active.signer_fid_allowlist, the signer FID must be in it.If any step fails, Hypersnap returns 401 and does not apply the event.
Successful ack:
\n{ \"success\": true }\n\nFailure:
\n{ \"success\": false, \"message\": \"...\" }\n\nClients 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.
Nothing, as long as you're proxying through Hypersnap. Put the /v2/farcaster/frame/webhook/<app_id> URL in your mini app's manifest:
{\n \"name\": \"my mini app\",\n \"icon_url\": \"https://...\",\n \"home_url\": \"https://miniapp.example.com\",\n \"webhook_url\": \"https://haatz.quilibrium.com/v2/farcaster/frame/webhook/3Hq9ZgK2p4vNfWxR\"\n}\n\nWhen 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.
\nIf a user added your app but isn't receiving notifications:
\nhypersnap::notifications tracing target. A failed verification logs why (bad signature, inactive signer, not in allowlist).GET /v2/farcaster/frame/app/?app_id=<id> (signed) and confirm it returns 200.signer_fid_allowlist, the user's Farcaster signer FID needs to be in it. An overly narrow allowlist is the most common mistake.<app_id> in the webhook URL and the <app_id> in your send endpoint calls must match.POST /v2/farcaster/frame/notifications/<app_id>\n\nThis is the endpoint you call from your backend when you want to push a notification to your mini app's users.
\nAuth: x-api-key: <send_secret>. The value must match the most recently created unexpired entry in your app's send_secrets array. See Registering a mini app for how to obtain and rotate the secret.
{\n \"notification\": {\n \"title\": \"string (≤32 chars)\",\n \"body\": \"string (≤128 chars)\",\n \"target_url\": \"https://miniapp.example.com/path/in/app\",\n \"uuid\": \"optional UUID string\"\n },\n \"target_fids\": [12345, 67890],\n \"exclude_fids\": [],\n \"following_fid\": null,\n \"minimum_user_score\": null,\n \"near_location\": null\n}\n\n| Field | \nType | \nRequired | \nNotes | \n
|---|---|---|---|
title | \nstring | \nyes | \n≤ 32 characters (spec limit). | \n
body | \nstring | \nyes | \n≤ 128 characters (spec limit). | \n
target_url | \nstring | \nyes | \n≤ 256 characters. Must be the same domain as your mini-app's app_url. | \n
uuid | \nstring | \nno | \nA 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. | \n
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.{\n \"campaign_id\": \"uuid\",\n \"success_count\": 42,\n \"failure_count\": 1,\n \"not_attempted_count\": 0,\n \"retryable_fids\": [12345]\n}\n\n| Field | \nMeaning | \n
|---|---|
campaign_id | \nServer-assigned UUID for this send. Log it alongside your internal request id so you can trace deliveries. | \n
success_count | \nTokens the downstream Farcaster client confirmed success on. | \n
failure_count | \nTokens that returned failure (invalid / rate-limited). | \n
not_attempted_count | \nTokens that were deduped out by (fid, notificationId) dedupe or filtered by exclude_fids / following_fid. | \n
retryable_fids | \nFIDs whose tokens came back as rateLimitedTokens from the client. Safe to retry later. | \n
<app_id>.target_fids or all enabled tokens) and applies exclude_fids / following_fid filters.(fid, notificationId) pairs against the 24-hour LRU.notification_url (each Farcaster client has its own URL).send_concurrency.SendNotificationResult above.The per-client POST body is the Mini App spec contract:
\n{\n \"notificationId\": \"string (≤128)\",\n \"title\": \"string (≤32)\",\n \"body\": \"string (≤128)\",\n \"targetUrl\": \"string (≤1024, same domain)\",\n \"tokens\": [\"token-a\", \"token-b\"]\n}\n\nAnd the client response is interpreted as:
\nsuccessfulTokens → counted in success_count.invalidTokens → permanently deleted from Hypersnap's token store.rateLimitedTokens → the owning FIDs are returned in retryable_fids.rate_limited_tokens.(fid, notificationId) dedupe: 24 hours. Enforced by Hypersnap.curl -X POST \\\n -H \"x-api-key: $SEND_SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"notification\": {\n \"title\": \"You got a reply\",\n \"body\": \"alice: loved your take on this\",\n \"target_url\": \"https://miniapp.example.com/thread/0xabc\",\n \"uuid\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\"\n },\n \"target_fids\": [12345]\n }' \\\n https://haatz.quilibrium.com/v2/farcaster/frame/notifications/3Hq9ZgK2p4vNfWxR\n\ncurl -X POST \\\n -H \"x-api-key: $SEND_SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"notification\": {\n \"title\": \"New season drop\",\n \"body\": \"Season 5 just opened — tap to play\",\n \"target_url\": \"https://miniapp.example.com/season/5\",\n \"uuid\": \"b2f63f8a-...\"\n },\n \"target_fids\": []\n }' \\\n https://haatz.quilibrium.com/v2/farcaster/frame/notifications/3Hq9ZgK2p4vNfWxR\n\nIf 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).
POST /v2/farcaster/frame/notifications/<app_id>\n\nThis is the endpoint you call from your backend when you want to push a notification to your mini app's users.
\nAuth: x-api-key: <send_secret>. The value must match the most recently created unexpired entry in your app's send_secrets array. See Registering a mini app for how to obtain and rotate the secret.
{\n \"notification\": {\n \"title\": \"string (≤32 chars)\",\n \"body\": \"string (≤128 chars)\",\n \"target_url\": \"https://miniapp.example.com/path/in/app\",\n \"uuid\": \"optional UUID string\"\n },\n \"target_fids\": [12345, 67890],\n \"exclude_fids\": [],\n \"following_fid\": null,\n \"minimum_user_score\": null,\n \"near_location\": null\n}\n\n| 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. |
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.{\n \"campaign_id\": \"uuid\",\n \"success_count\": 42,\n \"failure_count\": 1,\n \"not_attempted_count\": 0,\n \"retryable_fids\": [12345]\n}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
<app_id>.target_fids or all enabled tokens) and applies exclude_fids / following_fid filters.(fid, notificationId) pairs against the 24-hour LRU.notification_url (each Farcaster client has its own URL).send_concurrency.SendNotificationResult above.The per-client POST body is the Mini App spec contract:
\n{\n \"notificationId\": \"string (≤128)\",\n \"title\": \"string (≤32)\",\n \"body\": \"string (≤128)\",\n \"targetUrl\": \"string (≤1024, same domain)\",\n \"tokens\": [\"token-a\", \"token-b\"]\n}\n\nAnd the client response is interpreted as:
\nsuccessfulTokens → counted in success_count.invalidTokens → permanently deleted from Hypersnap's token store.rateLimitedTokens → the owning FIDs are returned in retryable_fids.rate_limited_tokens.(fid, notificationId) dedupe: 24 hours. Enforced by Hypersnap.curl -X POST \\\n -H \"x-api-key: $SEND_SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"notification\": {\n \"title\": \"You got a reply\",\n \"body\": \"alice: loved your take on this\",\n \"target_url\": \"https://miniapp.example.com/thread/0xabc\",\n \"uuid\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\"\n },\n \"target_fids\": [12345]\n }' \\\n https://haatz.quilibrium.com/v2/farcaster/frame/notifications/3Hq9ZgK2p4vNfWxR\n\ncurl -X POST \\\n -H \"x-api-key: $SEND_SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"notification\": {\n \"title\": \"New season drop\",\n \"body\": \"Season 5 just opened — tap to play\",\n \"target_url\": \"https://miniapp.example.com/season/5\",\n \"uuid\": \"b2f63f8a-...\"\n },\n \"target_fids\": []\n }' \\\n https://haatz.quilibrium.com/v2/farcaster/frame/notifications/3Hq9ZgK2p4vNfWxR\n\nIf 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).
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.
\nThe core primitive is a webhook subscription.
\nWrite out what events your agent needs, as plain English:
\n\n\n\"I want to know about every cast that mentions FID 12345, and every reply to any cast authored by FID 12345.\"
\n
Translate to a WebhookSubscription:
{\n \"cast_created\": {\n \"mentioned_fids\": [12345],\n \"parent_author_fids\": [12345]\n }\n}\n\nGotcha: 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.
\nSee Subscription filters for every available field.
\nYour receiver is a public HTTPS endpoint that accepts POST with a JSON body. Minimal Node.js example:
import express from \"express\";\nimport crypto from \"crypto\";\n\nconst app = express();\n\n// Preserve raw bytes for HMAC verification.\napp.use(express.raw({ type: \"application/json\", limit: \"1mb\" }));\n\nconst SECRET = process.env.WEBHOOK_SECRET;\n\napp.post(\"/hook\", (req, res) => {\n const received = req.header(\"x-hypersnap-signature\");\n const expected = crypto\n .createHmac(\"sha512\", SECRET)\n .update(req.body) // raw bytes\n .digest(\"hex\");\n\n if (!crypto.timingSafeEqual(Buffer.from(received, \"hex\"), Buffer.from(expected, \"hex\"))) {\n return res.status(401).end();\n }\n\n const event = JSON.parse(req.body.toString(\"utf8\"));\n // Durably enqueue before ACKing.\n void enqueue(event);\n res.status(200).end();\n});\n\napp.listen(8080);\n\nThe crucial pieces:
\nJSON.parse. If you re-stringify the body, the hash won't match.200. Don't hold the connection open for downstream work.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 for the deep version of this.
\nSign an EIP-712 request as your FID's custody key:
\nimport { ethers } from \"ethers\";\nimport { randomBytes } from \"crypto\";\n\nconst wallet = new ethers.Wallet(process.env.CUSTODY_KEY);\n\nconst body = JSON.stringify({\n name: \"my agent\",\n url: \"https://receiver.example.com/hook\",\n subscription: {\n cast_created: { mentioned_fids: [12345] },\n },\n});\n\nconst signedAt = Math.floor(Date.now() / 1000);\nconst nonce = \"0x\" + randomBytes(32).toString(\"hex\");\nconst reqHash = ethers.keccak256(ethers.toUtf8Bytes(body));\n\nconst sig = await wallet.signTypedData(\n { name: \"Hypersnap\", version: \"1\", chainId: 10 },\n { HypersnapSignedOp: [\n { name: \"op\", type: \"string\" },\n { name: \"fid\", type: \"uint64\" },\n { name: \"signedAt\", type: \"uint256\" },\n { name: \"nonce\", type: \"bytes32\" },\n { name: \"requestHash\", type: \"bytes32\" },\n ]},\n { op: \"webhook.create\", fid: 12345n, signedAt: BigInt(signedAt), nonce, requestHash: reqHash },\n);\n\nconst resp = await fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Hypersnap-Fid\": \"12345\",\n \"X-Hypersnap-Op\": \"webhook.create\",\n \"X-Hypersnap-Signed-At\": String(signedAt),\n \"X-Hypersnap-Nonce\": nonce,\n \"X-Hypersnap-Signature\": sig,\n },\n body,\n});\n\nconst { webhook } = await resp.json();\nconsole.log(\"Save this signing secret:\", webhook.secrets[0].value);\n\nSee Sign an EIP-712 request for Python and Rust.
\nInside your enqueue path, process the event. Events have a typed schema — see Event schemas.
\nasync function process(event) {\n if (event.type === \"cast.created\") {\n const cast = event.data.cast;\n if (cast.text.includes(\"@myagent\")) {\n await reply(cast.hash, \"hi, I'm a bot!\");\n }\n }\n}\n\nHypersnap delivers events at-least-once. Retries or network races can send the same logical event twice. Dedupe on the natural key:
\n| Event | \nDedupe key | \n
|---|---|
cast.created / cast.deleted | \ndata.cast.hash | \n
follow.* | \n(follower.fid, target.fid) | \n
reaction.* | \n(user.fid, cast.hash, reaction_type) | \n
A cheap implementation: a Redis SET with a 1-hour TTL.
\nIf your signing secret leaks, rotate:
\nawait fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/secret/rotate?webhook_id=\" + id, {\n method: \"POST\",\n headers: eip712Headers(\"webhook.rotate_secret\", \"\" /* empty body */),\n});\n\nResponse 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.
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 from the same process.
\n(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.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.
\nThe core primitive is a webhook subscription.
\nWrite out what events your agent needs, as plain English:
\n\n\n\"I want to know about every cast that mentions FID 12345, and every reply to any cast authored by FID 12345.\"
\n
Translate to a WebhookSubscription:
{\n \"cast_created\": {\n \"mentioned_fids\": [12345],\n \"parent_author_fids\": [12345]\n }\n}\n\nGotcha: 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.
\nSee Subscription filters for every available field.
\nYour receiver is a public HTTPS endpoint that accepts POST with a JSON body. Minimal Node.js example:
import express from \"express\";\nimport crypto from \"crypto\";\n\nconst app = express();\n\n// Preserve raw bytes for HMAC verification.\napp.use(express.raw({ type: \"application/json\", limit: \"1mb\" }));\n\nconst SECRET = process.env.WEBHOOK_SECRET;\n\napp.post(\"/hook\", (req, res) => {\n const received = req.header(\"x-hypersnap-signature\");\n const expected = crypto\n .createHmac(\"sha512\", SECRET)\n .update(req.body) // raw bytes\n .digest(\"hex\");\n\n if (!crypto.timingSafeEqual(Buffer.from(received, \"hex\"), Buffer.from(expected, \"hex\"))) {\n return res.status(401).end();\n }\n\n const event = JSON.parse(req.body.toString(\"utf8\"));\n // Durably enqueue before ACKing.\n void enqueue(event);\n res.status(200).end();\n});\n\napp.listen(8080);\n\nThe crucial pieces:
\nJSON.parse. If you re-stringify the body, the hash won't match.200. Don't hold the connection open for downstream work.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 for the deep version of this.
\nSign an EIP-712 request as your FID's custody key:
\nimport { ethers } from \"ethers\";\nimport { randomBytes } from \"crypto\";\n\nconst wallet = new ethers.Wallet(process.env.CUSTODY_KEY);\n\nconst body = JSON.stringify({\n name: \"my agent\",\n url: \"https://receiver.example.com/hook\",\n subscription: {\n cast_created: { mentioned_fids: [12345] },\n },\n});\n\nconst signedAt = Math.floor(Date.now() / 1000);\nconst nonce = \"0x\" + randomBytes(32).toString(\"hex\");\nconst reqHash = ethers.keccak256(ethers.toUtf8Bytes(body));\n\nconst sig = await wallet.signTypedData(\n { name: \"Hypersnap\", version: \"1\", chainId: 10 },\n { HypersnapSignedOp: [\n { name: \"op\", type: \"string\" },\n { name: \"fid\", type: \"uint64\" },\n { name: \"signedAt\", type: \"uint256\" },\n { name: \"nonce\", type: \"bytes32\" },\n { name: \"requestHash\", type: \"bytes32\" },\n ]},\n { op: \"webhook.create\", fid: 12345n, signedAt: BigInt(signedAt), nonce, requestHash: reqHash },\n);\n\nconst resp = await fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Hypersnap-Fid\": \"12345\",\n \"X-Hypersnap-Op\": \"webhook.create\",\n \"X-Hypersnap-Signed-At\": String(signedAt),\n \"X-Hypersnap-Nonce\": nonce,\n \"X-Hypersnap-Signature\": sig,\n },\n body,\n});\n\nconst { webhook } = await resp.json();\nconsole.log(\"Save this signing secret:\", webhook.secrets[0].value);\n\nSee Sign an EIP-712 request for Python and Rust.
\nInside your enqueue path, process the event. Events have a typed schema — see Event schemas.
\nasync function process(event) {\n if (event.type === \"cast.created\") {\n const cast = event.data.cast;\n if (cast.text.includes(\"@myagent\")) {\n await reply(cast.hash, \"hi, I'm a bot!\");\n }\n }\n}\n\nHypersnap delivers events at-least-once. Retries or network races can send the same logical event twice. Dedupe on the natural key:
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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.
\nIf your signing secret leaks, rotate:
\nawait fetch(\"https://haatz.quilibrium.com/v2/farcaster/webhook/secret/rotate?webhook_id=\" + id, {\n method: \"POST\",\n headers: eip712Headers(\"webhook.rotate_secret\", \"\" /* empty body */),\n});\n\nResponse 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.
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 from the same process.
\n(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.A deep-dive on running a production webhook receiver for Hypersnap. Covers signature verification, rotation, dedupe, retries, and backpressure.
\nimport express from \"express\";\nimport crypto from \"crypto\";\n\nconst app = express();\napp.use(express.raw({ type: \"application/json\", limit: \"1mb\" }));\n\nconst SECRETS = new Set([process.env.WEBHOOK_SECRET]);\n\napp.post(\"/hook\", (req, res) => {\n const receivedHex = req.header(\"x-hypersnap-signature\");\n if (!verifyAny(req.body, receivedHex, SECRETS)) {\n return res.status(401).end();\n }\n const event = JSON.parse(req.body.toString(\"utf8\"));\n void handle(event);\n res.status(200).end();\n});\n\nfunction verifyAny(body, receivedHex, secrets) {\n const recv = Buffer.from(receivedHex, \"hex\");\n for (const s of secrets) {\n const exp = crypto.createHmac(\"sha512\", s).update(body).digest();\n if (recv.length === exp.length && crypto.timingSafeEqual(recv, exp)) {\n return true;\n }\n }\n return false;\n}\n\napp.listen(8080);\n\nThe rest of this guide is what you do on top of this scaffold to make it robust.
\nRule 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.
\nExpress: 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.
\nWhen 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:
\n401 only if none match.expires_at has passed.In code:
\nconst SECRETS = new Map(); // secret_value -> expires_at (null = active, never expires)\n\nasync function refreshSecrets() {\n const resp = await signedFetch(\"GET\", \"/v2/farcaster/webhook/?webhook_id=\" + ID);\n const webhook = (await resp.json()).webhook;\n const now = Math.floor(Date.now() / 1000);\n SECRETS.clear();\n for (const s of webhook.secrets) {\n if (s.expires_at === null || s.expires_at > now) {\n SECRETS.set(s.value, s.expires_at);\n }\n }\n}\n\n// Refresh on boot and every few hours.\nawait refreshSecrets();\nsetInterval(refreshSecrets, 60 * 60 * 1000);\n\nDeliveries are at-least-once. Retries, transient failures, and network races can replay the same logical event. You need an idempotency layer.
\nCheap version — Redis:
\nasync function handle(event) {\n const key = dedupeKey(event);\n const first = await redis.set(`wh:${key}`, \"1\", \"EX\", 3600, \"NX\");\n if (!first) return; // already processed\n await process(event);\n}\n\nfunction dedupeKey(event) {\n switch (event.type) {\n case \"cast.created\":\n case \"cast.deleted\":\n return `c:${event.data.cast.hash}`;\n case \"follow.created\":\n case \"follow.deleted\":\n return `f:${event.data.follower.fid}:${event.data.target.fid}`;\n case \"reaction.created\":\n case \"reaction.deleted\":\n return `r:${event.data.user.fid}:${event.data.cast.hash}:${event.data.reaction_type}`;\n default:\n return `x:${JSON.stringify(event.data)}`;\n }\n}\n\nA 1-hour TTL is enough for retries (the retry queue times out well before that).
\n| You return | \nHypersnap's behavior | \n
|---|---|
2xx | \nDelivered. | \n
4xx | \nTerminal failure. No retries. | \n
5xx | \nRetried up to retry_max_attempts (default 5) with exponential backoff. | \n
| Timeout / connection error | \nRetried as above. | \n
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.
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.
5xx so Hypersnap requeues instead of dropping events.Log every delivery with:
\nevent.typeWith those fields you can answer every support question (\"did this event ever arrive?\", \"how many times?\", \"what did we do with it?\").
\nHypersnap 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.
500 for a delivery and confirm Hypersnap retries with the expected backoff.timingSafeEqual for signature compare.2xx after durable enqueue, not after processing.5xx on transient enqueue failure.4xx only when retry is pointless.(type, dedupe_key, first_sight, status) on every request.A deep-dive on running a production webhook receiver for Hypersnap. Covers signature verification, rotation, dedupe, retries, and backpressure.
\nimport express from \"express\";\nimport crypto from \"crypto\";\n\nconst app = express();\napp.use(express.raw({ type: \"application/json\", limit: \"1mb\" }));\n\nconst SECRETS = new Set([process.env.WEBHOOK_SECRET]);\n\napp.post(\"/hook\", (req, res) => {\n const receivedHex = req.header(\"x-hypersnap-signature\");\n if (!verifyAny(req.body, receivedHex, SECRETS)) {\n return res.status(401).end();\n }\n const event = JSON.parse(req.body.toString(\"utf8\"));\n void handle(event);\n res.status(200).end();\n});\n\nfunction verifyAny(body, receivedHex, secrets) {\n const recv = Buffer.from(receivedHex, \"hex\");\n for (const s of secrets) {\n const exp = crypto.createHmac(\"sha512\", s).update(body).digest();\n if (recv.length === exp.length && crypto.timingSafeEqual(recv, exp)) {\n return true;\n }\n }\n return false;\n}\n\napp.listen(8080);\n\nThe rest of this guide is what you do on top of this scaffold to make it robust.
\nRule 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.
\nExpress: 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.
\nWhen 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:
\n401 only if none match.expires_at has passed.In code:
\nconst SECRETS = new Map(); // secret_value -> expires_at (null = active, never expires)\n\nasync function refreshSecrets() {\n const resp = await signedFetch(\"GET\", \"/v2/farcaster/webhook/?webhook_id=\" + ID);\n const webhook = (await resp.json()).webhook;\n const now = Math.floor(Date.now() / 1000);\n SECRETS.clear();\n for (const s of webhook.secrets) {\n if (s.expires_at === null || s.expires_at > now) {\n SECRETS.set(s.value, s.expires_at);\n }\n }\n}\n\n// Refresh on boot and every few hours.\nawait refreshSecrets();\nsetInterval(refreshSecrets, 60 * 60 * 1000);\n\nDeliveries are at-least-once. Retries, transient failures, and network races can replay the same logical event. You need an idempotency layer.
\nCheap version — Redis:
\nasync function handle(event) {\n const key = dedupeKey(event);\n const first = await redis.set(`wh:${key}`, \"1\", \"EX\", 3600, \"NX\");\n if (!first) return; // already processed\n await process(event);\n}\n\nfunction dedupeKey(event) {\n switch (event.type) {\n case \"cast.created\":\n case \"cast.deleted\":\n return `c:${event.data.cast.hash}`;\n case \"follow.created\":\n case \"follow.deleted\":\n return `f:${event.data.follower.fid}:${event.data.target.fid}`;\n case \"reaction.created\":\n case \"reaction.deleted\":\n return `r:${event.data.user.fid}:${event.data.cast.hash}:${event.data.reaction_type}`;\n default:\n return `x:${JSON.stringify(event.data)}`;\n }\n}\n\nA 1-hour TTL is enough for retries (the retry queue times out well before that).
\n| 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.
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.
5xx so Hypersnap requeues instead of dropping events.Log every delivery with:
\nevent.typeWith those fields you can answer every support question (\"did this event ever arrive?\", \"how many times?\", \"what did we do with it?\").
\nHypersnap 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.
500 for a delivery and confirm Hypersnap retries with the expected backoff.timingSafeEqual for signature compare.2xx after durable enqueue, not after processing.5xx on transient enqueue failure.4xx only when retry is pointless.(type, dedupe_key, first_sight, status) on every request.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 can sign management requests without any extra UI.
\nCreate manifest.json at the root of wherever you're hosting the docs:
{\n \"name\": \"Hypersnap docs\",\n \"icon_url\": \"https://hypersnap-docs.qstorage.quilibrium.com/favicon-8114d1fc.png\",\n \"home_url\": \"https://hypersnap-docs.qstorage.quilibrium.com/playground.html\",\n \"webhook_url\": \"https://haatz.quilibrium.com/v2/farcaster/frame/webhook/REPLACE-WITH-APP-ID\"\n}\n\nThe 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).
The playground's Connect-wallet flow walks four detection paths in order and uses the first one that yields an EIP-1193 provider:
\nwindow.sdk.wallet.getEthereumProvider() if the global exposes the new async getter.window.sdk.wallet.ethProvider or window.farcasterMiniApp.ethProvider for older clients.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.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.
\n401.book/ directory from a CORS-friendly static host. GitHub Pages, Cloudflare Pages, Netlify, and direct S3 all work.connect-src 'self' https://*.quilibrium.com is enough unless you want to allow arbitrary hostnames in the Host field.If you want to extend the playground with an endpoint that isn't already covered, the markup is:
\n<div class=\"try-it\"\n data-method=\"GET\"\n data-path=\"/v2/farcaster/...\"\n data-title=\"optional label\"\n data-auth=\"none|signed|send-key\"\n data-op=\"webhook.read\"\n data-fields=\"name|kind|type|placeholder|default|required; ...\"\n data-body-template='{\"optional\": \"pre-filled body\"}'></div>\n\nWhere:
\n| Attribute | \nMeaning | \n
|---|---|
data-method | \nHTTP method. | \n
data-path | \nPath template. Use {param} for path parameters — they get replaced from fields with kind=path. | \n
data-title | \nHuman-readable label shown in the panel's summary row. | \n
data-auth | \nnone (default for unsigned reads), signed (EIP-712 — implied when data-op is set), or send-key (per-app x-api-key). | \n
data-op | \nOp string for signed mode. One of webhook.create / webhook.read / etc. See Signed operations. | \n
data-fields | \nSemi-colon-separated field specs. Each field is `name | \n
data-body-template | \nA starter JSON body shown in the body textarea. Users can edit before clicking Run. | \n
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.
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 can sign management requests without any extra UI.
\nCreate manifest.json at the root of wherever you're hosting the docs:
{\n \"name\": \"Hypersnap docs\",\n \"icon_url\": \"https://hypersnap-docs.qstorage.quilibrium.com/favicon-8114d1fc.png\",\n \"home_url\": \"https://hypersnap-docs.qstorage.quilibrium.com/playground.html\",\n \"webhook_url\": \"https://haatz.quilibrium.com/v2/farcaster/frame/webhook/REPLACE-WITH-APP-ID\"\n}\n\nThe 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).
The playground's Connect-wallet flow walks four detection paths in order and uses the first one that yields an EIP-1193 provider:
\nwindow.sdk.wallet.getEthereumProvider() if the global exposes the new async getter.window.sdk.wallet.ethProvider or window.farcasterMiniApp.ethProvider for older clients.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.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.
\n401.book/ directory from a CORS-friendly static host. GitHub Pages, Cloudflare Pages, Netlify, and direct S3 all work.connect-src 'self' https://*.quilibrium.com is enough unless you want to allow arbitrary hostnames in the Host field.If you want to extend the playground with an endpoint that isn't already covered, the markup is:
\n<div class=\"try-it\"\n data-method=\"GET\"\n data-path=\"/v2/farcaster/...\"\n data-title=\"optional label\"\n data-auth=\"none|signed|send-key\"\n data-op=\"webhook.read\"\n data-fields=\"name|kind|type|placeholder|default|required; ...\"\n data-body-template='{\"optional\": \"pre-filled body\"}'></div>\n\nWhere:
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n| 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. |
data-fields | Semi-colon-separated field specs. Each field is `name |
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.