Skip to content

Copy curl and JavaScript for each task, with a real response from a mainnet node.

Set NODE first, as in Build on your node. Each recipe does one job in curl and JavaScript, with a trimmed response from a synced mainnet node. The examples use two public accounts: balajis (FID 37) and dwr (FID 3).

Every endpoint takes an FID, the account number, but people know each other by username. Your node keeps a signed proof for every fname and ENS name, so it can answer the lookup itself.

Terminal window
curl -s "$NODE/v1/userNameProofByName?name=balajis" | jq '{name, fid, type}'
Response (trimmed)
{ "name": "balajis", "fid": 37, "type": "USERNAME_TYPE_FNAME" }
Output
37 3

What this returns: the FID that owns the name today, and whether it is an fname or an ENS name (USERNAME_TYPE_ENS_L1). An unknown name gets HTTP 400 with Username proof not found.

Official reference: Username proofs

A profile is a set of USER_DATA_ADD messages, one per field: display name, username, bio, picture, links and primary addresses.

Terminal window
curl -s "$NODE/v1/userDataByFid?fid=37" | jq '[.messages[].data.userDataBody | {(.type): .value}] | add'
Response, folded into one object (trimmed)
{
"USER_DATA_TYPE_DISPLAY": "balajis",
"USER_DATA_TYPE_USERNAME": "balajis.eth",
"USER_DATA_TYPE_URL": "https://ns.com",
"USER_DATA_TYPE_PFP": "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/16e9ecaa-0837-4243-4316-ceeb9db19600/original",
"USER_DATA_TYPE_BIO": "Author of the Network State.\nFounder of the Network School.\nJoin us at ns.com.",
"USER_DATA_PRIMARY_ADDRESS_ETHEREUM": "0x1a21C1f29FabEbce8cc160BD48f9802A30d9608e"
}

What this returns: one message per field the account has set. The picture is a URL on someone else’s server; your app decides whether to load it.

Official reference: User data. The reference lists a user_data_type filter; this node ignored it and returned every field, so filter in your code.

castsByFid lists one account’s casts. With reverse=true the newest come first.

Terminal window
curl -s "$NODE/v1/castsByFid?fid=37&pageSize=3&reverse=true" \
| jq '.messages[] | select(.data.type == "MESSAGE_TYPE_CAST_ADD") | {hash, timestamp: .data.timestamp, text: .data.castAddBody.text[0:60], reply: (.data.castAddBody.parentCastId != null)}'
Response, one cast (trimmed)
{
"hash": "0xc87cfa68811e39167f5e38264a16c67b99dd6ff1",
"timestamp": 182827068,
"text": "One mechanism for proof of human is vouching. Spoke to you t",
"reply": true
}
Output (trimmed)
2026-10-18T01:17:48.000Z (reply) One mechanism for proof of human is vouching. Spoke to you t
2026-10-17T18:14:10.000Z (reply) Three quick clarifications: …

What this returns: casts and replies together; a reply has parentCastId. Deleted casts show up as MESSAGE_TYPE_CAST_REMOVE messages in the same list, which is why both examples check the type.

Official reference: Casts

A node has no home timeline. You build one: list the accounts someone follows, fetch each one’s newest casts, and merge them newest first. The RunFarcaster app does the same in client/src/feed.ts.

Terminal window
# 1. Who does FID 37 follow?
curl -s "$NODE/v1/linksByFid?fid=37&link_type=follow&pageSize=100&reverse=true" \
| jq '[.messages[].data.linkBody.targetFid] | length'
# 2. Then, for each FID, the newest casts:
curl -s "$NODE/v1/castsByFid?fid=3&pageSize=5&reverse=true" | jq '[.messages[].data.timestamp]'
Output (trimmed)
57
[182745002, 182744608, 182651500, 182651424, 182651058]
260 [
"347: oh so we're time traveling now?",
"3854: CROPS = censorship resistant, open sourc",
"3854: Super fair. For as long as you want to d"
]

What this returns: FID 37 follows 57 accounts; with 5 casts each, plus FID 37’s own, the merged feed held 260 casts. For an account that follows more than 100, page through linksByFid with pageToken.

Official reference: Links, Casts

A cast is identified by its author’s FID and its hash. castsByParent returns the direct replies; call it again on a reply to go one level deeper. The example uses the bounty cast this guide was written for.

Terminal window
curl -s "$NODE/v1/castsByParent?fid=37&hash=0x50184b62b4c52fd76628713562e1eda3dccaf63d&pageSize=2" \
| jq '{n: (.messages | length), replies: [.messages[] | {fid: .data.fid, text: .data.castAddBody.text[0:50]}]}'
Response (trimmed)
{
"n": 4,
"replies": [
{ "fid": 4167, "text": " curate news" },
{ "fid": 217261, "text": "Farcaster clients are a great thing, but do you ha" }
]
}
Output
37 direct replies; first from FID 875356

What this returns: direct replies only, from every shard. pageSize=2 returned 4 because each of the two account shards sent 2. The JavaScript stops on the last-page token described in Paging; without that check the loop never ends.

Official reference: Casts: castsByParent. It also takes url= instead of fid and hash, for casts posted under a URL such as a channel.

Reactions are messages too. reactionsByCast lists them for one cast and one reaction type.

Terminal window
curl -s "$NODE/v1/reactionsByCast?target_fid=37&target_hash=0x50184b62b4c52fd76628713562e1eda3dccaf63d&reaction_type=Like&pageSize=2" \
| jq '.messages[0].data | {fid, type: .reactionBody.type, target: .reactionBody.targetCastId}'
Response, first reaction (trimmed)
{
"fid": 37,
"type": "REACTION_TYPE_LIKE",
"target": { "fid": 37, "hash": "0x50184b62b4c52fd76628713562e1eda3dccaf63d" }
}
Output
likes 197 recasts 49

What this returns: one message per account that liked or recast the cast, with its FID. Pass reaction_type=Like or reaction_type=Recast; the full enum name REACTION_TYPE_LIKE gets HTTP 400 here, though the response uses it.

Official reference: Reactions

A bot usually starts here: who mentioned me? castsByMention answers from every shard.

Terminal window
curl -s "$NODE/v1/castsByMention?fid=37&pageSize=1&reverse=true" \
| jq '.messages[0].data | {fid, text: .castAddBody.text[0:50], mentions: .castAddBody.mentions, mentionsPositions: .castAddBody.mentionsPositions}'
Response (trimmed)
{
"fid": 1001428,
"text": " we allowed to do honest takes or its a felony?",
"mentions": [37],
"mentionsPositions": [0]
}
Output (trimmed)
1001428 @balajis we allowed to do honest takes or its a felony?
846887 @fid:4877 has angrily blocked @balajis

What this returns: casts that mention the FID. The @name is not in text: mentions holds the FIDs and mentionsPositions the byte offsets where each name goes, which withMentions puts back.

Official reference: Casts: castsByMention

Each cast names the app key that signed it (signer). When an account approves a key, the requesting app goes into the key’s metadata as an FID, so your node can tell you which app a cast came from. The app’s Posted with filter works this way, in client/src/apps.ts.

Terminal window
# The key that signed a cast is in its "signer" field
curl -s "$NODE/v1/castsByFid?fid=37&pageSize=1&reverse=true" | jq -r '.messages[0].signer'
# The account's keys, each with ABI-encoded metadata that starts with the app's FID
curl -s "$NODE/v1/signersByFid?fid=37" \
| jq '.signers[] | {key: .key[0:18], source, metadata: (.onChainEvent.signerEventBody.metadata // .metadata)[0:24]}'
Response (trimmed)
"0x0766980c52e24fda5fb1784d430d94f3555ca3f71f63f0c0411849401c63ee5d"
{ "key": "0x0766980c52e24fda", "source": "SIGNER_SOURCE_ONCHAIN", "metadata": "AAAAAAAAAAAAAAAAAAAAAAAA" }
Output
app FID 9152 (@warpcast): 20 of the last 20 casts

What this returns: every active key of the account, onchain and gasless, with the metadata that names the requesting app’s FID. Casts signed with a key that was since removed match no entry; show them as an unknown app.

Official reference: signersByFid is missing from the official HTTP reference (Docs issues #6). How Farcaster works explains keys and their metadata.

The HTTP API has no push. Poll /v1/events instead: remember the last event ID and ask for everything after it. Accounts are split between shard 1 and shard 2, so watch both; shard 0 carries gasless key changes.

Terminal window
# Newest event on shard 1 (accounts are split between shard 1 and shard 2)
curl -s "$NODE/v1/events?shard_index=1&pageSize=1&reverse=true" | jq '.events[0] | {id, type, blockNumber}'
# Everything after an event id you already have
curl -s "$NODE/v1/events?shard_index=1&from_event_id=<id>&pageSize=100" \
| jq '[.events[] | select(.type == "HUB_EVENT_TYPE_MERGE_MESSAGE") | .mergeMessageBody.message.data | {type, fid}]'
Response (trimmed)
{ "id": 802851454976, "type": "HUB_EVENT_TYPE_BLOCK_CONFIRMED", "blockNumber": 49002164 }
[
{ "type": "MESSAGE_TYPE_REACTION_ADD", "fid": 1079922 },
{ "type": "MESSAGE_TYPE_REACTION_ADD", "fid": 8636 }
]
Output
MESSAGE_TYPE_CAST_ADD from FID 1455330
MESSAGE_TYPE_REACTION_ADD from FID 777135
MESSAGE_TYPE_REACTION_ADD from FID 1076895
5 new messages on shard 1 in about 15 seconds

What this returns: events in order: HUB_EVENT_TYPE_MERGE_MESSAGE for each new message, HUB_EVENT_TYPE_PRUNE_MESSAGE when storage limits push an old one out, and HUB_EVENT_TYPE_BLOCK_CONFIRMED once per block. The official reference says nodes keep about 3 days of events; store the last ID you processed and resume from it.

Official reference: Events. The gRPC API on port 3383 has a streaming Subscribe call; this guide keeps that port closed and has not tested it.

Posting means building a signed protobuf message and sending its bytes to POST /v1/submitMessage. You need an app key that the account has approved; Post with an app key adds one. @farcaster/core builds and signs the message.

post.mjs
// npm install @farcaster/core
import { CastType, FarcasterNetwork, Message, NobleEd25519Signer, makeCastAdd } from "@farcaster/core";
const NODE = "https://<your node>";
const FID = Number(process.env.FID); // the account that approved the app key
const appKey = Uint8Array.from(Buffer.from(process.env.APP_KEY_HEX, "hex")); // 32-byte ed25519 private key
const DRY_RUN = process.env.DRY_RUN !== "0"; // true: validate only, nothing is posted
const signer = new NobleEd25519Signer(appKey);
const built = await makeCastAdd(
{ text: "Hello from my own node", embeds: [], embedsDeprecated: [], mentions: [], mentionsPositions: [], type: CastType.CAST },
{ fid: FID, network: FarcasterNetwork.MAINNET },
signer,
);
if (built.isErr()) throw built.error;
const bytes = Message.encode(built.value).finish();
const path = DRY_RUN ? "/v1/validateMessage" : "/v1/submitMessage";
const res = await fetch(`${NODE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: bytes,
});
console.log(res.status, DRY_RUN ? (await res.json()).valid : await res.text());
Run it
FID=<your fid> APP_KEY_HEX=<app key, 64 hex characters> node post.mjs # validate only
FID=<your fid> APP_KEY_HEX=<app key, 64 hex characters> DRY_RUN=0 node post.mjs # post
Output (validate only)
200 true

What this returns: with the default dry run, /v1/validateMessage checks the message’s format and signature and posts nothing. It does not check that the key is approved, so a random key also passes. With DRY_RUN=0, submitMessage returns the message as JSON once your node has checked it and passed it on to the validators. A 200 there does not mean the cast is in a block yet: fetch it back with castById until it appears. Where a post goes from a read node explains why, and client/src/pending.ts does the retries.

The app key is a delegated signer, the preferred way to post. Keep it out of your code and your logs. The dry run above ran against a synced node with a random key; the real post path ran once on mainnet (Proof).

submitMessage must come from the same origin as the node. It needs Content-Type: application/octet-stream, and a browser on another origin first sends a CORS preflight (OPTIONS) that the node does not answer. A script on a server, or a page served by the Caddy setup from step 3, has no such problem.

Official reference: Message API. The app’s own builders: client/src/messages.ts.