# Farcaster Snaps Documentation > This file aggregates all Farcaster Snaps documentation for LLM consumption. > Source: https://snap.farcaster.xyz/docs --- ## Introduction # Farcaster Snaps Snaps are interactive embeds inside Farcaster casts. They render as cards in the feed and can be multi-page, stateful, and dynamic. > **Using Claude Code?** Install the snap generator skill and run `/create-farcaster-snap` to go from prompt to deployed snap. See the [Building a Snap](/docs/building) guide for install instructions and all other approaches. ## Overview A Farcaster Snap is defined by a JSON schema served by an external server. The Farcaster client renders the JSON -- it never executes arbitrary code. Snaps are the evolution of Frames: richer elements, multi-page flows, dynamic content, and the same server-driven model. ## How It Works 1. A cast embed points to a URL that implements the Snap protocol 2. The client GETs that URL, signaling snap support. The server responds with a JSON `SnapResponse` 3. The client renders the response as an embed 4. The user interacts (taps a button, moves a slider, types text) 5. The client POSTs the interaction data to the server and receives a new JSON `SnapResponse` 6. The client replaces the previous content with the new content 7. Repeat ## Signaling Snap Support The snap media type is `application/vnd.farcaster.snap+json`. - A client MAY include `application/vnd.farcaster.snap+json` in `Accept` to indicate snap support - If `application/vnd.farcaster.snap+json` is the highest-priority acceptable type, the server MAY return a snap response - If the server returns a snap response, it MUST set `Content-Type: application/vnd.farcaster.snap+json` - If the request does not indicate snap support, the server SHOULD return a normal HTTP response (for example, a fallback or guidance) - Even when snap is requested, the server MAY return a different content type If the response `Content-Type` is `application/vnd.farcaster.snap+json`, the client MUST render it as a snap. Clients MAY cache GET responses from snap servers to avoid extraneous re-fetching. ## SnapResponse Shape Every valid response is a page payload with this shape: ```json { "version": "1.0", "page": { "theme": { "accent": "purple" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Hello world" } ] }, "buttons": [ { "label": "Submit", "action": "post", "target": "https://example.com/submit" } ] } } ``` ### Top-Level Fields | Field | Required | Description | | --- | --- | --- | | `version` | Yes | Spec version. Must be `"1.0"` | | `page.theme` | No | Accent color for the snap. Client derives all visual styling from this + the app's current light/dark mode. Defaults to `"purple"` | | `page.elements` | Yes | Page body tree root. MUST be `{ "type": "stack", "children": [...] }`. Min 1, max 5 children. Max 1 media element | | `page.buttons` | No | Array of action buttons at the bottom. Min 0, max 4 | | `page.button_layout` | No | Layout for action buttons: `"stack"` (default), `"row"`, `"grid"` | | `page.effects` | No | Array of effect names to trigger on page load | ## Feed Card Dimensions - **Width**: 100% of the feed column. The client determines the actual pixel width - **Height**: Determined by content, designed so valid pages fit within a ~500px feed card All snap pages render in the feed card -- including pages returned by `post` button taps. The card replaces its content in place. ## Navigation There is no client-managed back button. Navigation is server-driven. If a snap wants "go back" functionality, it includes a button on the page that POSTs to the server, and the server returns the appropriate previous page. The server is responsible for maintaining navigation state. ## Linking Between Snaps A button with `action: "post"` can target any snap server URL, including a different snap's URL. This allows one snap to link to another. When the user taps such a button, the client fetches the target URL, receives a page JSON, and renders it. The user is now "inside" the new snap. ## Broken Snaps If the snap URL is unreachable, returns invalid JSON, or fails schema validation: - The embed does **not** render in the feed - The cast displays normally with the snap URL shown as plain text - The client may cache the last valid first page and show it with a "stale" indicator If a `post` button request fails (timeout, server error, or invalid JSON response): - The client stays on the current page -- it is never replaced with a blank screen - An inline error is shown: "Something went wrong. Tap to retry." - The user can retry the same button tap, or close/navigate away ## Versioning The `version` field is required on every page response. Clients must check this field. - If the version is supported, render normally - If the version is newer than the client supports, show a fallback: "Update Farcaster to view this snap" - Snaps should target the lowest version that supports their element types --- ## Elements # Elements Every snap page body is a tree whose root is a `stack`. The client renders child elements in order, top to bottom. The client controls sizing, spacing, fonts, and padding -- snaps do not specify pixel dimensions, margins, or CSS. > **Sample:** Open the [Element Showcase](https://farcaster.xyz/~/developers/snaps?url=https://snap-element-showcase.host.neynar.app/) in the emulator to interact with every element type. ## Page Root: `stack` `page.elements` must be a `stack` node: a vertical list of child elements. - `page.elements.type` MUST be `"stack"` - `page.elements.children` is an array of elements (min 1, max 5) - Max 1 media element (`image` or `grid`) per page ```json { "type": "stack", "children": [{ "type": "text", "style": "title", "content": "Hello" }] } ``` ## `text` Renders text content in a predefined style.
Title style
Body style — for main descriptive text
Caption style — secondary info
Label style
```json { "type": "text", "style": "title", "content": "Rate these movies" } ``` | Property | Required | Values | | --------- | -------- | --------------------------------------------------------------------------------------------------------- | | `style` | Yes | `"title"` (max 80 chars), `"body"` (max 160 chars), `"caption"` (max 100 chars), `"label"` (max 40 chars) | | `content` | Yes | The text string | | `align` | No | `"left"` (default), `"center"`, `"right"` | ## `image` Renders an image from a URL.
Sample landscape
```json { "type": "image", "url": "https://example.com/photo.jpg", "aspect": "16:9" } ``` | Property | Required | Values | | -------- | -------- | --------------------------------------------------------------------- | | `url` | Yes | HTTPS image URL. Supports jpg, png, gif, webp. GIFs autoplay and loop | | `aspect` | Yes | `"1:1"`, `"16:9"`, `"4:3"`, `"3:4"`, `"9:16"` | | `alt` | No | Alt text for accessibility | ## `divider` A horizontal line to visually separate content sections.
Poll Results
42 votes

Closes in 2 hours
```json { "type": "divider" } ``` No additional properties. ## `spacer` Vertical breathing room. The client determines actual height. ```json { "type": "spacer", "size": "medium" } ``` | Property | Required | Values | | -------- | -------- | ------------------------------------------ | | `size` | No | `"small"`, `"medium"` (default), `"large"` | ## `progress` A horizontal progress bar.
72% Yes
```json { "type": "progress", "value": 72, "max": 100, "label": "72% Yes" } ``` | Property | Required | Values | | -------- | -------- | ------------------------------------------------------------- | | `value` | Yes | Number, current value | | `max` | Yes | Number, maximum value | | `label` | No | Text label displayed alongside. Max 60 chars | | `color` | No | `"accent"` (default), `"green"`, `"red"`, `"amber"`, `"gray"` | ## `list` An ordered or unordered list of items.
  1. @dwr.eth 8/10 (80%)
  2. @jessepollak 7/10 (70%)
```json { "type": "list", "style": "ordered", "items": [ { "content": "@dwr.eth", "trailing": "8/10 (80%)" }, { "content": "@jessepollak", "trailing": "7/10 (70%)" } ] } ``` | Property | Required | Values | | ------------------ | -------- | ----------------------------------------------- | | `style` | No | `"ordered"` (default), `"unordered"`, `"plain"` | | `items` | Yes | Array of list items. Max 4 items | | `items[].content` | Yes | Item text. Max 100 chars | | `items[].trailing` | No | Right-aligned text. Max 40 chars | ## `grid` A rows-by-columns grid of cells. Each cell has a background color and optional text content. For game boards, pixel canvases, and tile-based UIs.
```json { "type": "grid", "cols": 5, "rows": 6, "cells": [ { "row": 0, "col": 0, "color": "#22C55E", "content": "C" }, { "row": 0, "col": 1, "color": "#6B7280", "content": "R" } ] } ``` | Property | Required | Values | | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `cols` | Yes | Number of columns. Min 2, max 64 | | `rows` | Yes | Number of rows. Min 2, max 8 | | `cells` | Yes | Array of cell definitions. Only non-empty cells need to be specified | | `cells[].row` | Yes | Row index (0-based) | | `cells[].col` | Yes | Column index (0-based) | | `cells[].color` | No | 6-digit hex color (`#RRGGBB`) for background. Omit for transparent | | `cells[].content` | No | Text content to display in the cell | | `cellSize` | No | `"auto"` (default, fills available width), `"square"` (cells are square) | | `gap` | No | `"none"`, `"small"` (default), `"medium"` | | `interactive` | No | If `true`, cells with no entry in `cells` array are tappable. Tap coordinates are included in the next POST | ## `text_input` A single-line text input field.
```json { "type": "text_input", "name": "guess", "placeholder": "Type 5-letter word...", "maxLength": 5 } ``` | Property | Required | Values | | ------------- | -------- | --------------------------------------- | | `name` | Yes | Field identifier, included in POST data | | `placeholder` | No | Placeholder text. Max 60 chars | | `maxLength` | No | Max input length. Max 280 chars | ## `slider` A horizontal slider for numeric input.
Your estimate
```json { "type": "slider", "name": "estimate", "min": 0, "max": 100, "step": 1, "label": "Your estimate" } ``` | Property | Required | Values | | ---------- | -------- | --------------------------------------- | | `name` | Yes | Field identifier, included in POST data | | `min` | Yes | Minimum value (number) | | `max` | Yes | Maximum value (number) | | `step` | No | Step increment. Default: 1 | | `value` | No | Initial value. Default: midpoint | | `label` | No | Label text. Max 60 chars | | `minLabel` | No | Label at left end. Max 20 chars | | `maxLabel` | No | Label at right end. Max 20 chars | ## `button_group` A set of tappable options. User selects one.
```json { "type": "button_group", "name": "vote", "options": ["Tabs", "Spaces"], "style": "row" } ``` | Property | Required | Values | | --------- | -------- | ---------------------------------------------------------------------------------------------------- | | `name` | Yes | Field identifier, included in POST data | | `options` | Yes | Array of option strings. Min 2, max 4. Each max 40 chars | | `style` | No | `"row"` (side by side, default for 2-3), `"stack"` (vertical, default for 4+), `"grid"` (2-col grid) | ## `toggle` A single on/off toggle.
Enable reminders
```json { "type": "toggle", "name": "notifications", "label": "Enable reminders", "value": false } ``` | Property | Required | Values | | -------- | -------- | --------------------------------------- | | `name` | Yes | Field identifier, included in POST data | | `label` | Yes | Label text. Max 60 chars | | `value` | No | Initial state. Default: `false` | ## `bar_chart` A vertical bar chart for displaying labeled values. For poll results, rankings, and distributions.
21
18
10
Anthropic
Databricks
OpenAI
```json { "type": "bar_chart", "bars": [ { "label": "Anthropic", "value": 21 }, { "label": "Databricks", "value": 18 }, { "label": "OpenAI", "value": 10, "color": "teal" } ], "max": 100 } ``` | Property | Required | Values | | -------------- | -------- | ------------------------------------------------------------------------------------------ | | `bars` | Yes | Array of bar objects. Min 1, max 6 | | `bars[].label` | Yes | Bar label text. Max 40 chars | | `bars[].value` | Yes | Numeric value (>= 0) | | `bars[].color` | No | Palette name (e.g. `"green"`, `"red"`). Overrides chart `color` | | `max` | No | Scale maximum. If omitted, derived from largest bar value | | `color` | No | Default bar color: `"accent"` (default), `"green"`, `"red"`, `"amber"`, `"blue"`, `"gray"` | ## `group` Arranges child elements horizontally in a row. For displaying related stats, inputs, or content side by side.
42
score
7
streak
92%
win rate
```json { "type": "group", "layout": "row", "children": [ { "type": "text", "style": "title", "content": "42" }, { "type": "text", "style": "caption", "content": "score" } ] } ``` | Property | Required | Values | | ---------- | -------- | ------------------------------------- | | `layout` | Yes | `"row"` (horizontal arrangement) | | `children` | Yes | Array of child elements. Min 2, max 3 | ### Group Rules - A group counts as 1 element toward the page max of 5 - No media elements inside groups (`image`, `grid` are not allowed) - No nesting: groups cannot contain other groups - Children are rendered with equal width, side by side - Any non-media element is valid as a child: `text`, `progress`, `list`, `slider`, `button_group`, `toggle`, `text_input`, `divider`, `spacer`, `bar_chart` ## First Page Requirements The first page returned from the snap URL is rendered as the feed card. In addition to normal page rules, the first page MUST include: - At least one `text` element with `style: "title"` or `style: "body"` - At least one interactive element (`button_group`, `slider`, `text_input`, `toggle`) OR at least one media element (`image` or `grid`) This ensures the feed card always has readable content plus engagement or visual context. ### Example: Valid First Page
Best sci-fi movies
Pick your favorite, then tap Vote
Vote
```json { "version": "1.0", "page": { "theme": { "accent": "purple" }, "button_layout": "stack", "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Best sci-fi movies" }, { "type": "button_group", "name": "pick", "options": ["Arrival", "Dune", "Interstellar"] }, { "type": "text", "style": "caption", "content": "Pick your favorite, then tap Vote" } ] }, "buttons": [ { "label": "Vote", "action": "post", "target": "https://example.com/vote" } ] } } ``` This is valid because it has a `title` text element and an interactive element (`button_group`). ## Common Validation Errors These examples show snap responses that **fail validation** and why. ### No title or body text
```json { "version": "1.0", "page": { "elements": { "type": "stack", "children": [ { "type": "image", "url": "https://example.com/photo.jpg", "aspect": "16:9" } ] } } } ``` **Fails:** First page must have at least one `text` element with style `"title"` or `"body"`. An image alone isn't enough. ### No interactive or media element
Hello
Welcome to my snap
```json { "version": "1.0", "page": { "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Hello" }, { "type": "text", "style": "body", "content": "Welcome to my snap" } ] } } } ``` **Fails:** First page must have at least one interactive element (`button_group`, `slider`, `text_input`, `toggle`) or media element (`image`, `grid`). Text-only pages are invalid. ### Too many elements
Title
Body
1. Item 1
Too many!
6 elements — max is 5
```json { "version": "1.0", "page": { "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Title" }, { "type": "text", "style": "body", "content": "Body" }, { "type": "progress", "value": 50, "max": 100 }, { "type": "list", "items": [{ "content": "Item 1" }] }, { "type": "slider", "name": "val", "min": 0, "max": 100 }, { "type": "text", "style": "caption", "content": "Too many!" } ] } } } ``` **Fails:** Max 5 elements per page. This has 6. ### Two media elements
Gallery
2 images — max is 1 media element
```json { "version": "1.0", "page": { "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Gallery" }, { "type": "image", "url": "https://example.com/a.jpg", "aspect": "16:9" }, { "type": "image", "url": "https://example.com/b.jpg", "aspect": "16:9" } ] } } } ``` **Fails:** Max 1 media element (`image` or `grid`) per page. ### Hex accent (must be palette name)
Hello
Agree
accent: "#8B5CF6" — must be "purple"
```json { "version": "1.0", "page": { "theme": { "accent": "#8B5CF6" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Hello" }, { "type": "toggle", "name": "ok", "label": "Agree" } ] } } } ``` **Fails:** `accent` must be a palette name (`gray`, `blue`, `red`, `amber`, `green`, `teal`, `purple`, `pink`), not a hex value. Use `"purple"` instead of `"#8B5CF6"`. --- ## Buttons # Buttons Buttons appear at the bottom of the page, below all elements. Each button performs one action. ```json { "buttons": [ { "label": "Submit", "action": "post", "target": "https://example.com/submit" }, { "label": "Learn more", "action": "link", "target": "https://example.com" } ] } ``` ## Button Properties | Property | Required | Description | | --- | --- | --- | | `label` | Yes | Button text. Max 30 chars | | `action` | Yes | One of the four action types (see below) | | `target` | Yes | URL or SDK action identifier | | `style` | No | `"primary"` (filled, default for first button), `"secondary"` (outlined, default for rest) | ## Button Layout The `page.button_layout` field controls how buttons are arranged: - `"stack"` -- vertical, one button per row (default) - `"row"` -- horizontal, side by side - `"grid"` -- 2-column grid ## Target URLs For `post`, `link`, and `mini_app`, `target` is a normal URL and must use HTTPS in production. For local development, `http://` is valid only when the host is loopback: `localhost`, `127.0.0.1`, or IPv6 loopback (`[::1]` / `::1`). Any other `http://` target is invalid. For `sdk`, `target` is an SDK action identifier, not an HTTP(S) URL. ## Action Types ### `post` Makes a POST request to the target URL. The request body is a **JFS compact string** (JSON Farcaster Signatures) whose decoded payload includes all input element values from the current page, the user's FID, and a timestamp. Decoded JFS payload shape: ```json { "fid": 12345, "inputs": { "guess": "CLASS", "vote": "Tabs" }, "button_index": 0, "timestamp": 1710864000 } ``` The response must be a valid page JSON; the client renders it as the next page. **Timeout**: The client waits up to 5 seconds. If the server doesn't respond, the client shows an error state on the current page. The user can retry. ### `link` Opens the target URL in the device's external browser. No request is made to the server. The snap stays in its current state. ### `mini_app` Opens the target URL as a Farcaster mini app (slides up from bottom, rendered inside the Farcaster app). The target must be a valid Farcaster mini app URL. ### `sdk` Triggers a Farcaster SDK action. The target is an SDK action identifier with parameters. ```json { "label": "View cast", "action": "sdk", "target": "cast:view:0x1234abcd" } ``` ```json { "label": "Follow", "action": "sdk", "target": "user:follow:12345" } ``` ```json { "label": "Send tip", "action": "sdk", "target": "wallet:send:0x1234:0.01:ETH" } ``` ## Input Data in POST Requests When a button with `action: "post"` is tapped, the client collects values from all input elements on the current page and includes them in the POST body. | Element Type | Data Included | | --- | --- | | `text_input` | `{ "name": "string value" }` | | `slider` | `{ "name": numeric_value }` | | `button_group` | `{ "name": "selected option string" }` | | `toggle` | `{ "name": true/false }` | | `grid` (interactive) | `{ "grid_tap": { "row": N, "col": N } }` | Input elements without a user interaction are included with their default/initial values. --- ## Color Palette # Color Palette All colors in snaps are specified as **named palette colors**, not hex values. The client maps each name to a hex value appropriate for its current light/dark mode. This ensures visual consistency across the feed and guarantees readability in both modes. ## The Palette Snaps have 8 colors to choose from: [See color palette table on docs site]
blue Light: #006BFF Dark: #006FFE
red Light: #FC0036 Dark: #F13342
amber Light: #FFAE00 Dark: #FFAE00
green Light: #28A948 Dark: #00AC3A
teal Light: #00AC96 Dark: #00AA96
purple Light: #8B5CF6 Dark: #A78BFA
pink Light: #F32782 Dark: #F12B82
The snap specifies a name (e.g. `"blue"`). The client resolves it to the correct hex for the current mode. ## Where Palette Colors Are Used **`page.theme.accent`** -- one of the 8 palette names. Default: `"purple"`. ```json { "theme": { "accent": "blue" } } ``` **`progress.color`** -- `"accent"` (uses theme accent) or any palette name. ```json { "type": "progress", "value": 72, "max": 100, "color": "green" } ``` **`bar_chart.color`** -- default bar fill. `"accent"` or any palette name. **`bar_chart.bars[].color`** -- per-bar color override. Any palette name. ```json { "type": "bar_chart", "bars": [ { "label": "Yes", "value": 62, "color": "green" }, { "label": "No", "value": 38, "color": "red" } ] } ``` ## Accent Surfaces The accent color is applied to: - **Primary button fill** -- the main call-to-action button - **Progress bar fill** -- unless overridden by the `color` property - **Slider active track and thumb** -- the filled portion of the slider - **Button group selected option** -- highlights the selected choice - **Toggle active state** -- the toggle switch when enabled - **Interactive grid tap highlight** -- feedback when tapping grid cells ## Grid Cell Colors (Exception) Grid cells are the only exception. Each cell can specify an arbitrary hex color via `cells[].color`. This is necessary for game boards, pixel art, and other visual applications where the color IS the content. ```json { "type": "grid", "cols": 5, "rows": 6, "cells": [ { "row": 0, "col": 0, "color": "#22C55E", "content": "C" }, { "row": 0, "col": 1, "color": "#6B7280", "content": "R" }, { "row": 0, "col": 2, "color": "#CA8A04", "content": "A" } ] } ``` ## What Snaps Cannot Specify - Background colors on individual elements (except grid cells) - Font colors or text colors - Light/dark mode (determined by app settings) - Any CSS custom properties or variables --- ## Theme & Styling # Theme & Styling Snaps specify only an accent color. The client handles all other styling, including light/dark mode from app settings. ## How Theming Works The snap provides a single `theme.accent` color. The Farcaster client uses this accent to style interactive elements, then derives everything else -- backgrounds, text colors, borders, spacing -- from its own design system and the user's current light/dark mode preference. ```json { "version": "1.0", "page": { "theme": { "accent": "purple" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "My Snap" } ] } } } ``` ### Theme Properties | Property | Required | Values | | --- | --- | --- | | `page.theme` | No | Theme object. If omitted, defaults apply | | `page.theme.accent` | No | Palette name (e.g. `"purple"`, `"blue"`). Default: `"purple"` | ## Accent Surfaces The accent color is used for: - Primary button fill - Progress bar fill (unless overridden by `color`) - Slider active track and thumb - Button group selected option highlight - Toggle active state fill - Interactive grid tap highlight ## What Snaps Cannot Control Snaps intentionally have no control over visual details. This keeps snaps consistent within the Farcaster feed and prevents visual clutter. Snaps **cannot** specify: - Font family, font size, or font weight - Padding, margins, or spacing - Border radius, shadows, or decorative styling - Custom CSS or inline styles - Background colors on individual elements (except grid cells) - Element pixel dimensions - Light/dark mode The client is responsible for all layout decisions -- spacing between elements, card padding, font rendering, and responsive behavior. This means snaps look native in every Farcaster client, regardless of platform. --- ## Effects # Effects Effects are page-level overlays that fire when a page is rendered. They trigger on both the initial load (GET) and after button taps (POST responses). They do not count toward the 5-element limit. ## Available Effects | Effect | Behavior | | --- | --- | | `confetti` | One-time burst of confetti particles when the page is rendered | Effects fire once per page render. If a POST button returns the same page with `"effects": ["confetti"]`, the confetti fires again. They do not repeat on client-side re-renders of the same page. ## Preview ## Usage Add the `effects` array to the page object: ```json { "version": "1.0", "page": { "effects": ["confetti"], "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "You won!", "align": "center" }, { "type": "text", "style": "body", "content": "Congratulations on completing the challenge!", "align": "center" } ] } } } ``` ## When to Use Effects Effects are best for: - **Celebrations** -- completing a challenge, winning a game - **Milestones** -- reaching a streak, hitting a follower count - **Completion states** -- finishing a multi-page flow Use effects sparingly. They are most impactful when they are unexpected and earned, not when they appear on every page transition. --- ## Constraints # Constraints Snaps enforce strict limits to ensure every valid page fits within a ~500px feed card and renders consistently across clients. ## Response Constraints Summary | Constraint | Limit | | --- | --- | | Elements per page | Max 5 | | Media elements per page | Max 1 (image or grid) | | Buttons per page | Max 4 | | Title text | Max 80 chars | | Body text | Max 160 chars | | Caption text | Max 100 chars | | Label text | Max 40 chars | | Button label | Max 30 chars | | Text input max length | Max 280 chars | | List items | Max 4 | | Button group options | Min 2, max 4 | | Group children | Min 2, max 3. No media, no nested groups | | Bar chart bars | Min 1, max 6 | | Grid dimensions | Min 2x2, max 64 cols x 8 rows | | POST response timeout | 5s | ## Height-Enforced Element Constraints These constraints apply to **every page**, not just the first. They are designed so that any valid combination of elements fits within the 500px feed card height (including padding and buttons). | Constraint | Limit | Rationale | | --- | --- | --- | | Elements per page | Max 5 | Prevents vertical overflow | | Media elements per page | Max 1 (image or grid) | Media elements are tall; only one per page | | Body text | Max 160 chars | ~4 lines, ~80px | | Caption text | Max 100 chars | ~2 lines, ~36px | | List items | Max 4 | ~120px total | | Button group options | Max 4 | ~100px stacked | | Grid rows | Max 8 | Keeps the page within the feed card height | Snaps that need longer text, bigger grids, or more list items can spread content across multiple pages via `post` buttons, or link to a Farcaster mini app for the full experience. ## Validation A valid snap page must pass these checks: ### Schema Validation (at render time) - JSON conforms to the page schema - All required fields present - All field values within allowed ranges and types - Element count within limits (max 5, max 1 media element) - Button count within limits (max 4) - Text lengths within limits per element type - Grid dimensions within limits (max 64 cols x 8 rows) - Version field matches a supported version ### URL Validation (at publish time) - For `post`, `link`, and `mini_app` buttons, `target` must use **HTTPS**. Exception for local development: `http://` is allowed when the host is loopback only (`localhost`, `127.0.0.1`, or `[::1]`) - Snap URL responds with valid page JSON - No `javascript:` URIs If schema validation fails at render time, the snap does not render (falls back to plain link in cast text). No LLM evaluation is required. --- ## Building a Snap # Building a Snap There are several ways to create a Farcaster Snap, from AI-assisted generation to manual implementation. ## Claude Code Skill If you use [Claude Code](https://claude.ai/code), you can install a custom slash command that generates snaps from natural language: ```bash # Install the skill into your project mkdir -p .claude/commands curl -sS -o .claude/commands/create-farcaster-snap.md \ https://raw.githubusercontent.com/farcasterxyz/snap/main/agent-skills/create-farcaster-snap/SKILL.md ``` You can also [view the skill source on GitHub](https://github.com/farcasterxyz/snap/blob/main/agent-skills/create-farcaster-snap/SKILL.md). Then run it: ``` /create-farcaster-snap a poll asking users to pick their favorite L2 ``` The skill will: 1. Read the full snap spec 2. Generate valid snap JSON and server logic 3. Validate the output against the schema 4. Optionally deploy to a live URL This is the fastest way to go from idea to working snap. > **Note:** The skill works best when run from within the [snap](https://github.com/farcasterxyz/snap) repo, since it references the spec docs and template locally. ## Template (Hono) The `snap-template/` directory is a starter project using [Hono](https://hono.dev) with the `@farcaster/snap-hono` package: ```bash # From the repo root cp -r snap-template my-snap cd my-snap pnpm install ``` Edit `src/index.ts` to implement your snap logic: ```typescript import { Hono } from "hono"; import { registerSnapHandler } from "@farcaster/snap-hono"; const app = new Hono(); registerSnapHandler( app, async (ctx) => { if (ctx.action.type === "get") { return { version: "1.0", page: { theme: { accent: "purple" }, elements: { type: "stack", children: [ { type: "text", style: "title", content: "My Snap" }, { type: "text", style: "body", content: "Hello world" }, ], }, }, }; } // Handle POST interactions const { fid, inputs, button_index } = ctx.action; // ... your logic here }, { skipJFSVerification: process.env.SKIP_JFS_VERIFICATION === "1", }, ); ``` Run locally: ```bash SKIP_JFS_VERIFICATION=1 pnpm dev # http://localhost:3003 ``` ## Testing Use the [Emulator](https://farcaster.xyz/~/developers/snaps) to test your snap. Enter your snap's URL and interact with it -- the emulator signs messages automatically, so no signature bypass is needed. ## Deploying Snaps can be deployed anywhere that serves HTTP. Common options: - **Vercel** -- works with the Hono template out of the box - **Any Node.js host** -- the Hono template includes a standalone server Set `SNAP_PUBLIC_BASE_URL` to your deployment origin (no trailing slash) so button target URLs resolve correctly. After deploying, verify your snap works: ```bash curl -sS -H 'Accept: application/vnd.farcaster.snap+json' https://your-snap-url.com/ ``` You should get valid JSON with content type `application/vnd.farcaster.snap+json`. --- ## Adding a Snap to an Existing Website # Adding a Snap to an Existing Website If you already have a website and want it to render as a snap when shared on Farcaster, you need **content negotiation** -- serving different responses based on the `Accept` header. When a Farcaster client loads your URL, it sends `Accept: application/vnd.farcaster.snap+json`. Your server checks for this header and returns snap JSON instead of HTML. Browsers don't send this header, so they continue to see your normal webpage. ## How it works ``` Browser → GET example.com → Accept: text/html → Your normal webpage Farcaster → GET example.com → Accept: application/vnd.farcaster.snap+json → Snap JSON ``` The server must include `Vary: Accept` on these responses so caches key correctly. ## Dynamic sites (Node.js, Hono, Express) If your site has a server, add middleware that checks the `Accept` header before your existing routes. ### With Hono ```typescript import { Hono } from "hono"; import { MEDIA_TYPE } from "@farcaster/snap"; const app = new Hono(); // Snap handler -- runs before your existing routes app.get("/", async (c, next) => { const accept = c.req.header("Accept") || ""; if (!accept.includes("application/vnd.farcaster.snap+json")) { return next(); // Not a snap request, continue to normal site } // Return snap JSON return c.json( { version: "1.0", page: { theme: { accent: "purple" }, elements: { type: "stack", children: [ { type: "text", style: "title", content: "My Site" }, { type: "text", style: "body", content: "Welcome to my site on Farcaster.", }, ], }, buttons: [ { label: "Visit site", action: "link", target: "https://example.com", }, ], }, }, 200, { "Content-Type": "application/vnd.farcaster.snap+json", Vary: "Accept", }, ); }); // Your existing routes continue to work app.get("/", (c) => c.html("

My normal website

")); ``` ### With Express ```typescript app.get("/", (req, res, next) => { const accept = req.headers.accept || ""; if (!accept.includes("application/vnd.farcaster.snap+json")) { return next(); } res.set("Content-Type", "application/vnd.farcaster.snap+json"); res.set("Vary", "Accept"); res.json({ version: "1.0", page: { theme: { accent: "blue" }, elements: { type: "stack", children: [ { type: "text", style: "title", content: "My Site" }, { type: "text", style: "body", content: "Check us out on Farcaster.", }, ], }, }, }); }); ``` ## Static sites (GitHub Pages, Netlify, S3) Static hosts can't do server-side content negotiation. Use one of these approaches: ### Option 1: Cloudflare Worker proxy Put a Cloudflare Worker in front of your static site. It checks the `Accept` header and routes snap requests to a separate snap server. ```typescript export default { async fetch(request: Request): Promise { const accept = request.headers.get("Accept") || ""; const url = new URL(request.url); if (accept.includes("application/vnd.farcaster.snap+json")) { // Forward to your snap server (e.g. deployed on host.neynar.app) const snapUrl = "https://my-snap.host.neynar.app" + url.pathname; return fetch(snapUrl, { method: request.method, headers: request.headers, body: request.body, }); } // Forward to your static site return fetch(request); }, }; ``` Deploy the snap server separately (e.g. using the [snap template](https://github.com/farcasterxyz/snap/tree/main/template) on [host.neynar.app](https://host.neynar.app)) and point the worker at it. ### Option 2: Vercel Edge Middleware If your static site is on Vercel, add a `middleware.ts` at the project root: ```typescript import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { const accept = request.headers.get("Accept") || ""; if (accept.includes("application/vnd.farcaster.snap+json")) { // Rewrite to your snap API route or external snap server return NextResponse.rewrite( new URL("https://my-snap.host.neynar.app" + request.nextUrl.pathname), ); } return NextResponse.next(); } ``` ### Option 3: Separate snap URL The simplest approach -- deploy your snap to a different URL entirely and share that URL in casts. Your website stays untouched. - Website: `https://example.com` - Snap: `https://example-snap.host.neynar.app` Users who click the link in a browser go to the snap's fallback page (which previews the snap and links to your site). Farcaster clients render the snap inline. ## Handling POST interactions If your snap has `post` buttons, the Farcaster client sends signed POST requests to the button's `target` URL. For static site setups (Options 1-3 above), these POSTs go to your snap server, not the static site. Make sure your snap's button targets point to the snap server URL, not the static site: ```json { "buttons": [ { "label": "Vote", "action": "post", "target": "https://my-snap.host.neynar.app/vote" } ] } ``` ## Testing Test content negotiation with curl: ```bash # Should return your normal HTML curl -sS https://example.com/ # Should return snap JSON curl -sS -H 'Accept: application/vnd.farcaster.snap+json' https://example.com/ ``` Then test interactively at [farcaster.xyz/~/developers/snaps](https://farcaster.xyz/~/developers/snaps) -- enter your URL and click Load snap. The emulator sends real signed POST requests, so buttons work exactly like in the feed. --- ## Authentication # Authentication Every POST request from the client to a snap server MUST be authenticated with **JSON Farcaster Signatures (JFS)**. ## How It Works When a user taps a `post` button, the Farcaster client: 1. Collects all input values from the current page 2. Builds a payload with the user's FID, inputs, button index, and timestamp 3. Signs the payload using the user's Farcaster signer key 4. Sends the signed JFS compact string as the POST body The snap server then: 1. Verifies the JFS signature cryptographically 2. Checks the signing key against hub state for the claimed FID 3. Validates the timestamp for replay protection 4. Processes the request and returns a new page ## JFS Payload Shape The decoded JFS payload (signed inside JFS, not sent as bare JSON): ```json { "fid": 12345, "inputs": { "guess": "CLASS", "vote": "Tabs" }, "button_index": 0, "timestamp": 1710864000 } ``` ## JSON Farcaster Signatures (JFS) Format JFS is a standardized way for Farcaster identities to sign arbitrary payloads. It consists of three components: 1. **Header** -- metadata (FID, key type, key) 2. **Payload** -- the content being signed 3. **Signature** -- the cryptographic signature ### Compact Serialization JFS uses a dot-separated format similar to JWT: ``` BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature) ``` The signing input is constructed as: ``` ASCII(BASE64URL(UTF8(Header)) || '.' || BASE64URL(Payload)) ``` ### Key Types JFS supports three key types: | Type | Signature Method | Description | | --------- | ---------------- | ---------------------------------------- | | `custody` | ERC-191 | Signature from the FID's custody address | | `auth` | ERC-191 | Signature from a registered auth address | | `app_key` | EdDSA | Signature from a registered App Key | For snaps, the client typically uses `app_key` (EdDSA signature from the user's registered signer key). ### Verification To verify a JFS: 1. Decode the header and extract the `fid`, `type`, and `key` 2. Verify the FID is registered and the key is active for that FID 3. Verify the signature matches the signing input using the declared key 4. Query a Farcaster Hub to confirm the key is currently associated with the FID ### Reference Implementation The official Node.js package is [`@farcaster/jfs`](https://github.com/farcasterxyz/auth-monorepo). ## Replay Protection The request payload MUST contain a `timestamp` field (Unix seconds). Servers SHOULD reject requests with timestamps outside an allowed skew (for example, 5 minutes) to limit replay attacks. ## Requirements - The client MUST send a valid JFS for every authenticated POST - The server MUST verify the JFS cryptographically and MUST verify the signing key against hub (or equivalent) state for the FID - The server SHOULD enforce replay protection using `timestamp` (and any other policy you require) ## Server-Side Verification with @farcaster/snap-hono The `@farcaster/snap-hono` package handles JFS verification automatically: ```typescript import { registerSnapHandler } from "@farcaster/snap-hono"; registerSnapHandler( app, async (ctx) => { // ctx.action.fid is verified — the JFS signature was checked // ctx.action.inputs contains the user's input values // ctx.action.button_index is which button was tapped }, { skipJFSVerification: false, // true for local dev }, ); ``` Set `SKIP_JFS_VERIFICATION=1` in your environment for local development to skip JFS verification. --- ## Examples # Examples Real-world examples of `SnapResponse` payloads showing common patterns. ## Collaborative Wordle A word game with a grid board, text input, and submit button. ### First page (feed card) ```json { "version": "1.0", "page": { "theme": { "accent": "green" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Daily Wordle · Day 12" }, { "type": "grid", "cols": 5, "rows": 6, "cellSize": "square", "gap": "small", "cells": [ { "row": 0, "col": 0, "color": "#CA8A04", "content": "C" }, { "row": 0, "col": 1, "color": "#6B7280", "content": "R" }, { "row": 0, "col": 2, "color": "#22C55E", "content": "A" }, { "row": 0, "col": 3, "color": "#6B7280", "content": "N" }, { "row": 0, "col": 4, "color": "#6B7280", "content": "E" } ] }, { "type": "text_input", "name": "guess", "placeholder": "Type 5-letter word...", "maxLength": 5 }, { "type": "text", "style": "caption", "content": "1,247 guesses today · Attempt 4/6" } ] }, "buttons": [ { "label": "Submit guess", "action": "post", "target": "https://wordle.example.com/guess" } ] } } ``` ### Response after submitting a guess ```json { "version": "1.0", "page": { "theme": { "accent": "green" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Daily Wordle · Day 12" }, { "type": "grid", "cols": 5, "rows": 6, "cellSize": "square", "gap": "small", "cells": [ { "row": 0, "col": 0, "color": "#CA8A04", "content": "C" }, { "row": 0, "col": 1, "color": "#6B7280", "content": "R" }, { "row": 0, "col": 2, "color": "#22C55E", "content": "A" }, { "row": 0, "col": 3, "color": "#6B7280", "content": "N" }, { "row": 0, "col": 4, "color": "#6B7280", "content": "E" }, { "row": 3, "col": 0, "color": "#22C55E", "content": "C" }, { "row": 3, "col": 1, "color": "#22C55E", "content": "L" }, { "row": 3, "col": 2, "color": "#22C55E", "content": "A" }, { "row": 3, "col": 3, "color": "#22C55E", "content": "S" }, { "row": 3, "col": 4, "color": "#22C55E", "content": "S" } ] }, { "type": "text", "style": "body", "content": "Your guess has been submitted!", "align": "center" }, { "type": "text", "style": "caption", "content": "The crowd's most popular guess will be locked in at 6pm" } ] }, "buttons": [ { "label": "Open full game", "action": "mini_app", "target": "https://wordle.example.com/app" } ] } } ``` ## This or That A voting snap with button groups and progress bars for results. ### First page (feed card) ```json { "version": "1.0", "page": { "theme": { "accent": "blue" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Startup dilemmas" }, { "type": "text", "style": "caption", "content": "by @dwr.eth · 3.1k voted" }, { "type": "button_group", "name": "vote", "options": [ "Move fast, break things", "Move deliberately, build trust" ], "style": "stack" } ] }, "buttons": [ { "label": "Vote", "action": "post", "target": "https://example.com/thisorthat/vote" } ] } } ``` ### Response after voting ```json { "version": "1.0", "page": { "theme": { "accent": "blue" }, "elements": { "type": "stack", "children": [ { "type": "text", "style": "title", "content": "Startup dilemmas" }, { "type": "text", "style": "label", "content": "Move fast, break things" }, { "type": "progress", "value": 38, "max": 100, "label": "38%", "color": "accent" }, { "type": "text", "style": "label", "content": "Move deliberately, build trust" }, { "type": "progress", "value": 62, "max": 100, "label": "62%", "color": "green" } ] }, "buttons": [ { "label": "Next question", "action": "post", "target": "https://example.com/thisorthat/next" }, { "label": "Share results", "action": "link", "target": "https://example.com/thisorthat/share/abc123" } ] } } ```