# createStartaleAccountSDK Source: https://docs.startale.com/api-reference/create-startale-account-sdk Initialize the Startale App SDK and obtain an EIP-1193 provider. Creates an SDK instance and returns helpers for working with it. ```ts theme={null} import { createStartaleAccountSDK } from '@startale/app-sdk' const sdk = createStartaleAccountSDK({ appName: 'My App', appLogoUrl: 'https://my-app.com/logo.png', appChainIds: [1868], }) ``` ## Signature ```ts theme={null} function createStartaleAccountSDK(options: CreateProviderOptions): { getProvider(): ProviderInterface } ``` ## Options The display name shown in the connection popup. Defaults to `'App'` if omitted; recommended to set. Maximum length is unconstrained but keep it under 32 characters for readability. HTTPS URL to a square logo. Shown in the connection popup. Must be reachable. Chain IDs your app supports. Defaults to `[]` if omitted. Pass `1868` for Soneium Mainnet. See [Networks](/concepts/networks). Optional. Filters auth methods, requires linked EOA, sets the wallet popup URL. Override the wallet popup URL. Defaults to `https://app.startale.com`. Useful for staging. Limit which sign-in methods are offered. Omit to allow all. Force the user to link a MetaMask wallet during connection. Default `false`. Enable or disable SDK telemetry. Default `true`. Optional. The ERC-7677 paymaster service that sponsors gas for a standalone application, keyed by chain id. Only `url` is transmitted, and only on `wallet_sendCalls`. Ignored inside the Startale App, where the host sponsors. See [Gas in a standalone application](/app-sdk/gasless). ## Returns Returns the EIP-1193 provider. Use it directly or wrap it in a wagmi connector. ## Related * [`startaleConnector`](/api-reference/startale-connector), the wagmi connector that wraps this SDK. * [Initialization](/app-sdk/initialization), guidance on which mode to use. # Errors Source: https://docs.startale.com/api-reference/errors Error codes thrown by the SDK provider. All errors are instances of `ProviderRpcError`: ```ts theme={null} interface ProviderRpcError extends Error { code: number message: string data?: unknown } ``` ## Provider error codes | Code | Meaning | | - | - | | `4001` | User rejected the request | | `4100` | Method requires a connected account | | `4200` | Unsupported method | | `4900` | Provider disconnected | | `4901` | Chain disconnected | | `4902` | Unsupported chain | ## RPC error codes | Code | Meaning | | - | - | | `-32700` | Parse error, malformed JSON | | `-32600` | Invalid request shape | | `-32601` | Method not found | | `-32602` | Invalid params | | `-32603` | Internal error | | `-32000` | Invalid input | | `-32001` | Resource not found | | `-32002` | Resource unavailable | | `-32003` | Transaction rejected | | `-32004` | Method not supported | | `-32005` | Limit exceeded | ## Handling ```ts theme={null} try { await provider.request({ method: 'eth_sendTransaction', params: [tx] }) } catch (error) { if (error.code === 4001) return // User cancelled if (error.code === 4100) return promptConnect() throw error } ``` See the [SDK errors guide](/app-sdk/errors) for handling patterns and a wagmi-flavored example. # Method support Source: https://docs.startale.com/api-reference/method-support Which EIP-1193 methods the Startale App SDK actually serves, in a standalone application and inside a Mini App. The provider accepts any EIP-1193 method, but not every method is served. Some are answered by the SDK locally, some open the Startale App popup, and some are accepted and then fail. This page is the map, so you find out here rather than at runtime. ## Standalone applications Until the user connects, only `eth_requestAccounts`, `wallet_connect`, `wallet_sendCalls`, `eth_accounts`, `eth_chainId`, and `net_version` are served. The other methods on this page, apart from `wallet_getCallsStatus`, throw `4100`. Connect first. **Answered locally by the SDK.** No popup, no user interaction. | Method | Notes | | - | - | | `eth_accounts`, `eth_requestAccounts` | Returns the connected accounts. `eth_requestAccounts` opens the popup only if not yet connected, and `eth_accounts` returns `[]` until then. | | `eth_chainId`, `net_version` | The active chain. Before the user connects, these report chain `1`. | | `eth_coinbase` | The first connected account. | | `wallet_getCapabilities` | Served from the capabilities the Startale App returned when the user connected. | | `wallet_getUserInfo` | Throws `4100` if the user is not connected. | | `wallet_switchEthereumChain` | Resolved locally for a network the Startale App serves. See [`wallet_switchEthereumChain`](/api-reference/rpc-methods/wallet-switch-ethereum-chain). | **Served by the Startale App popup.** These prompt the user. | Method | Notes | | - | - | | `wallet_connect` | Connection and capability negotiation. | | `personal_sign` | | | `eth_signTypedData_v4` | Use this version, not `_v1` or `_v3`. | | `eth_sendTransaction` | Cannot carry a custom paymaster. See [Gas](/app-sdk/gasless). | | `wallet_sendCalls` | The only method that carries `paymasterOptions`. | **Not served.** These requests fail. `eth_signTypedData_v1`, `eth_signTypedData_v3`, `eth_signTypedData` (unversioned), `eth_signTransaction`, `eth_ecRecover`, `personal_ecRecover`, `wallet_addEthereumChain`, `wallet_watchAsset`, `wallet_grantPermissions`, `wallet_showCallsStatus` open the Startale App popup, which shows the method as not supported. Closing it rejects the request with `4001`, not `4200`, so do not treat every `4001` as the user declining a supported action. `wallet_getCallsStatus` never reaches the Startale App and is not supported. Do not call it. Do not build against the unsupported list. Several of these are standard methods a wallet is often assumed to implement, and a library that calls one on your behalf will fail at the point of use rather than at setup. `eth_signTypedData_v4` and `wallet_sendCalls` are the supported equivalents for typed-data signing and batching. ## Inside a Mini App The surface is deliberately narrower. Anything not listed throws `4200 Unsupported method`. | Method | Behaviour | | - | - | | `eth_chainId`, `eth_accounts`, `eth_requestAccounts` | Read-only. | | `personal_sign` | `from` forced to the smart account. | | `eth_signTypedData_v4` | `from` forced to the smart account. | | `eth_sendTransaction` | `from` forced to the smart account. Sponsored. | | `wallet_sendCalls` | `from` forced to the smart account. Sponsored. | | `wallet_switchEthereumChain`, `wallet_addEthereumChain` | Switch to a chain the Mini App is enabled for, Soneium only by default. Other chains throw `4200`. See [Chains](/miniapps/wallet-integration#chains). | See [Mini App wallet integration](/miniapps/wallet-integration) for the full constraint surface, including why signing from a linked EOA is not possible. ## Batch status is not pollable Neither `wallet_getCallsStatus` nor `wallet_showCallsStatus` is implemented in either context. `wallet_sendCalls` resolves with `transactionHash` and `userOpHash` once the batch is submitted. If you need a receipt, look it up with your own RPC client. ## Related The error codes these methods throw. The narrowed surface, and why. # eth_requestAccounts Source: https://docs.startale.com/api-reference/rpc-methods/eth-request-accounts Request the connected wallet address. Opens the connection popup if not yet connected. Initiates the connection flow. The first call opens the popup; subsequent calls return the cached address. ## Request ```ts theme={null} const accounts = await provider.request({ method: 'eth_requestAccounts', }) ``` ## Parameters None. ## Returns `string[]`, array of addresses. The first element is the user's smart-account address. Currently always a single address. ## Errors | Code | Cause | | - | - | | `4001` | User rejected the connection in the popup | ## Notes * Must be called from within a user gesture (e.g. a click handler), or browsers will block the popup. * After this call resolves, all other RPC methods become available. ## Related * [`wallet_connect`](/api-reference/rpc-methods/wallet-connect), variant that also returns capabilities. * [Connect a wallet](/app-sdk/connect) # eth_sendTransaction Source: https://docs.startale.com/api-reference/rpc-methods/eth-send-transaction Send a single transaction from the user's smart account. Sends a single transaction. The user's smart account is the sender. Inside the Startale App this call is sponsored and the user pays no gas. In a standalone application it is not sponsored, and it cannot carry a custom paymaster. See the note under [Notes](#notes). ## Request ```ts theme={null} const hash = await provider.request<`0x${string}`>({ method: 'eth_sendTransaction', params: [ { to: '0x...', value: '0x38d7ea4c68000', // 0.001 ETH data: '0x...', // optional }, ], }) ``` ## Parameters Recipient address. Hex-encoded value in wei. Defaults to `0x0`. Hex-encoded calldata. Defaults to `0x`. The connected smart-account address. Handling differs by context: * **Inside a Mini App**, the host overwrites `from` with the smart account, so you can omit it. * **In a standalone application**, `from` is required and is **not** rewritten. A missing value, or one that does not match the connected account, is rejected with `-32602`. ## Returns `0x${string}`, the transaction hash. ## Errors | Code | Cause | | - | - | | `4001` | User rejected the transaction | | `4100` | Wallet not connected | | `-32603` | The network fee could not be estimated, or the transaction failed to send | ## Notes * In a standalone application, pass the connected smart-account address as `from`. Only a Mini App has it rewritten for you. * Inside the Startale App, gas is sponsored by Startale. * In a standalone application, this method **cannot** use `paymasterOptions`. The paymaster travels as an EIP-5792 capability and this method carries no capabilities, so the user pays. Send through [`wallet_sendCalls`](/api-reference/rpc-methods/wallet-send-calls) to route through your own paymaster, even for a single call. ## Related * [`wallet_sendCalls`](/api-reference/rpc-methods/wallet-send-calls), atomic batched calls, and the only path that carries a custom paymaster. * [Who pays for gas](/concepts/gasless). * [Send transactions](/app-sdk/send-transactions) # eth_signTypedData_v4 Source: https://docs.startale.com/api-reference/rpc-methods/eth-sign-typed-data-v4 Sign EIP-712 structured data with the user's smart account. Signs typed data per EIP-712. Use for orders, permits, and any signed payload that needs domain separation. ## Request ```ts theme={null} const signature = await provider.request<`0x${string}`>({ method: 'eth_signTypedData_v4', params: [ address, JSON.stringify({ domain: { name: 'My App', version: '1', chainId: 1868, verifyingContract: '0x...', }, types: { EIP712Domain: [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], Order: [ { name: 'item', type: 'string' }, { name: 'amount', type: 'uint256' }, ], }, primaryType: 'Order', message: { item: 'Pass', amount: '1' }, }), ], }) ``` ## Parameters Smart-account address. EIP-712 typed data. Accepts a JSON-stringified string or the object directly; the SDK forwards the value verbatim. ## Returns `0x${string}`, the signature. ## Errors | Code | Cause | | - | - | | `4001` | User rejected | | `4100` | Wallet not connected | | `-32602` | Malformed typed data | ## Verification ```ts theme={null} import { publicClient } from './viem' const isValid = await publicClient.verifyTypedData({ address, domain, types, primaryType: 'Order', message, signature, }) ``` ## Related * [`personal_sign`](/api-reference/rpc-methods/personal-sign), for plain strings. * [Sign messages](/app-sdk/sign-messages) # personal_sign Source: https://docs.startale.com/api-reference/rpc-methods/personal-sign Sign a plain string with the user's smart account. Produces an ERC-1271-compatible signature over a plain message. ## Request ```ts theme={null} import { stringToHex } from 'viem' const signature = await provider.request<`0x${string}`>({ method: 'personal_sign', params: [stringToHex('I acknowledge the terms.'), address], }) ``` ## Parameters Hex-encoded message bytes. Smart-account address. Must match the connected account. ## Returns `0x${string}`, the signature, packed for ERC-1271 verification. ## Errors | Code | Cause | | - | - | | `4001` | User rejected the signature | | `4100` | Wallet not connected | ## Verification ```ts theme={null} import { publicClient } from './viem' const isValid = await publicClient.verifyMessage({ address, message: 'I acknowledge the terms.', signature, }) ``` viem handles ERC-1271 (and ERC-6492 for undeployed accounts) automatically. ## Related * [`eth_signTypedData_v4`](/api-reference/rpc-methods/eth-sign-typed-data-v4), for structured data. * [Sign messages](/app-sdk/sign-messages) # wallet_connect Source: https://docs.startale.com/api-reference/rpc-methods/wallet-connect Connect with explicit capability negotiation. Returns accounts and the capabilities the wallet supports. A richer connection method than `eth_requestAccounts`. Returns the connected accounts plus a capabilities map describing which features (spend permissions, batched calls) the wallet supports. ## Request ```ts theme={null} const result = await provider.request({ method: 'wallet_connect', params: [ { version: '1', capabilities: { // optional capability requests }, }, ], }) ``` ## Parameters Connection protocol version. Use `'1'`. Optional capability requests. Reserved for spend-permission flows. ## Returns ```ts theme={null} { accounts: [ { address: '0x...', capabilities?: { /* per-account caps */ }, }, ], userInfo?: { email?: string name?: string }, } ``` `userInfo` is present when the user authenticated with a social provider (Google, LINE). It is absent for EOA-only connections. ## Errors | Code | Cause | | - | - | | `4001` | User rejected | ## Related * [`eth_requestAccounts`](/api-reference/rpc-methods/eth-request-accounts), simpler variant returning addresses only. # wallet_getCapabilities Source: https://docs.startale.com/api-reference/rpc-methods/wallet-get-capabilities Discover which capabilities (atomic batching, paymaster service) the wallet supports for a given account and chain. Returns the capability map for the connected account on one or more chains. Per EIP-5792. ## Request ```ts theme={null} const caps = await provider.request({ method: 'wallet_getCapabilities', params: [address, ['0x74c']], // optional chain filter }) ``` ## Parameters Account address. Optional. Restrict the response to specific chain IDs (hex-encoded). Omit to return capabilities for all chains available in the wallet store. ## Returns ```ts theme={null} { '0x74c': { atomic: { status: 'supported' }, paymasterService: { supported: true }, // ... }, } ``` A map keyed by hex chain ID. ## Errors | Code | Cause | | - | - | | `4100` | Wallet not connected | ## Related * [`wallet_sendCalls`](/api-reference/rpc-methods/wallet-send-calls), uses these capabilities. # wallet_getUserInfo Source: https://docs.startale.com/api-reference/rpc-methods/wallet-get-user-info Fetch profile information about the connected user, display name, auth method, user ID. Returns lightweight profile information about the connected user. Useful for display purposes (greeting, avatar, identifying the auth provider). ## Request ```ts theme={null} const info = await provider.request({ method: 'wallet_getUserInfo', }) ``` ## Parameters None. ## Returns ```ts theme={null} { email?: string name?: string authType: 'google' | 'line' | 'apple' | 'metamask' userId: string } ``` ## Errors | Code | Cause | | - | - | | `4100` | Wallet not connected | ## Privacy `email` and `name` are populated only for auth methods that share them (Google, LINE, Apple). MetaMask connections return only `authType` and `userId`. Treat `userId` as a stable identifier for the user across sessions and devices, but do not display it, it is opaque. # wallet_sendCalls Source: https://docs.startale.com/api-reference/rpc-methods/wallet-send-calls Send a batch of calls atomically (EIP-5792). Sends multiple calls in a single user operation. All calls succeed together or revert together. ## Request ```ts theme={null} const { transactionHash, userOpHash } = await provider.request<{ transactionHash: `0x${string}` userOpHash: `0x${string}` }>({ method: 'wallet_sendCalls', params: [ { version: '1', from: address, chainId: '0x74c', atomicRequired: true, calls: [ { to: '0x...', data: '0x...' }, { to: '0x...', data: '0x...', value: '0x0' }, ], }, ], }) ``` ## Parameters Use `'1'`. The connected smart-account address. Handling differs by context: * **Inside a Mini App**, the host overwrites `from` with the smart account before the request is processed, so any value you pass is replaced. * **In a standalone application**, `from` is **not** rewritten. A value that does not match the connected account is rejected with `-32602`. Pass the connected smart-account address in both cases. Hex-encoded chain ID. Has no effect. Startale accounts always execute a batch atomically, whatever you pass. Send `true` so the request stays correct against other EIP-5792 wallets. Array of calls. `to` and `data` are both **required**; `value` is optional and defaults to zero. `data` has no default. A call that omits it, or passes anything that is not a hex string, is rejected with `-32602` before the user sees a prompt. For a plain value transfer with no calldata, pass `data: '0x'` explicitly. ## Returns ```ts theme={null} { transactionHash: `0x${string}`, userOpHash: `0x${string}`, } ``` The request resolves directly once the user approves and the batch is submitted as a single UserOperation. There is no separate status to poll: `wallet_getCallsStatus` is not implemented in either context, so do not build a flow that depends on it. ## Errors | Code | Cause | | - | - | | `4001` | User rejected | | `4100` | Wallet not connected | | `-32602` | Malformed call array, including a missing or non-hex `data` | | `-32602` | `from` does not match the connected account (standalone applications only) | | `-32602` | `chainId` does not match the connected network | | `-32603` | The network fee could not be estimated, or the transaction failed to send | ## Related * [`eth_sendTransaction`](/api-reference/rpc-methods/eth-send-transaction), single transaction. * [Batched calls](/app-sdk/batched-calls) # wallet_switchEthereumChain Source: https://docs.startale.com/api-reference/rpc-methods/wallet-switch-ethereum-chain Switch the wallet's active chain. Switches the active chain. ## Request ```ts theme={null} await provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0x1' }], // Ethereum Mainnet }) ``` ## Parameters Hex-encoded chain ID of a network the Startale App serves: `0x74c` for Soneium Mainnet or `0x1` for Ethereum Mainnet. Inside a Mini App, it must be a chain your Mini App is enabled for. ## Returns `null` on success. The provider emits `chainChanged` with the new chain ID. ## Behavior In a standalone application, the switch happens in the SDK, without a popup, once the user is connected. A chain the Startale App does not serve opens the Startale App, which rejects it. Inside a Mini App, the host switches only to a chain your Mini App is enabled for, which is Soneium Mainnet only by default. See [Mini App chains](/miniapps/wallet-integration#chains). ## Errors | Code | Cause | | - | - | | `4100` | Wallet not connected (standalone applications) | | `4001` | The chain is not served by the Startale App (standalone applications). The Startale App shows the request as not supported, and closing it rejects the request. | | `4200` | The chain is not enabled for your Mini App (Mini Apps) | ## Related * [Networks](/concepts/networks) * [Wallet integration](/miniapps/wallet-integration), Mini App constraints. # startaleConnector Source: https://docs.startale.com/api-reference/startale-connector Wagmi connector for the Startale App SDK. A [wagmi](https://wagmi.sh) connector that integrates the Startale App SDK with wagmi's connection lifecycle, account events, and chain switching. ```ts theme={null} import { startaleConnector } from '@startale/app-sdk' import { createConfig, http } from 'wagmi' import { soneium } from 'wagmi/chains' export const config = createConfig({ chains: [soneium], connectors: [ startaleConnector({ appName: 'My App', appLogoUrl: 'https://my-app.com/logo.png', }), ], transports: { [soneium.id]: http(), }, }) ``` ## Signature ```ts theme={null} function startaleConnector(parameters?: BaseAccountParameters): Connector ``` ## Parameters Display name shown in the connection popup. Defaults to `document.title`. HTTPS URL to a square logo. Optional. Configures `authType`, `eoaRequired`, `walletUrl`. Chains are derived from the wagmi `createConfig({ chains })` array, not from `preference`. ## Returns A wagmi `Connector` instance. Use it inside `connectors: [...]` in `createConfig`. ## Related * [`createStartaleAccountSDK`](/api-reference/create-startale-account-sdk), the underlying factory. * [Quickstart](/quickstart), full wagmi-based integration. * [Connect a wallet](/app-sdk/connect), connection patterns. # Types Source: https://docs.startale.com/api-reference/types Type definitions exported from @startale/app-sdk. ## AppMetadata ```ts theme={null} type AppMetadata = { appName: string appLogoUrl: string | null appChainIds: number[] } ``` Identifies your app to the wallet popup. ## Preference ```ts theme={null} type Preference = { walletUrl?: string telemetry?: boolean authType?: 'google' | 'line' eoaRequired?: boolean attribution?: { auto: boolean } } ``` User-facing wallet behavior. ## ProviderInterface ```ts theme={null} interface ProviderInterface extends EventEmitter { request(args: RequestArguments): Promise disconnect(): Promise } ``` The EIP-1193 provider returned by `sdk.getProvider()`. See [Connect a wallet](/app-sdk/connect) and [Events](/app-sdk/events). ## RequestArguments ```ts theme={null} type RequestArguments = { method: string params?: unknown[] | object } ``` Standard EIP-1193 request shape. ## ProviderRpcError ```ts theme={null} interface ProviderRpcError extends Error { code: number data?: unknown } ``` The error type thrown by all provider methods. See [Errors](/api-reference/errors) for codes. ## CreateProviderOptions ```ts theme={null} type CreateProviderOptions = Partial & { preference?: Preference paymasterOptions?: Record } ``` Argument to [`createStartaleAccountSDK`](/api-reference/create-startale-account-sdk). ## PaymasterOptions ```ts theme={null} type PaymasterOptions = { url: string id: string } ``` Configuration for a custom paymaster on a specific chain. `url` is the ERC-7677 endpoint the Startale App forwards paymaster requests to. `id` is reserved and is not transmitted to the paymaster today, so a service must not depend on receiving it. See [Gas in a standalone application](/app-sdk/gasless) and the [paymaster wire format](/app-sdk/custom-paymaster). # Batched calls Source: https://docs.startale.com/app-sdk/batched-calls Bundle multiple calls into a single atomic transaction with wallet_sendCalls. `wallet_sendCalls` (EIP-5792) sends multiple calls in a single user operation. All calls succeed together or revert together. The user approves once. ## When to use it * ERC-20 approve + spend in one click. * Batch state changes that should atomically commit. * Multi-token settlement. ## Send a batch ```ts theme={null} const { transactionHash, userOpHash } = await provider.request({ method: 'wallet_sendCalls', params: [ { version: '1', from: address, chainId: '0x74c', // 1868 Soneium Mainnet atomicRequired: true, calls: [ { to: USDSC_ADDRESS, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [SPENDER, amount], }), }, { to: SPENDER, data: encodeFunctionData({ abi: spenderAbi, functionName: 'pull', args: [amount], }), }, ], }, ], }) ``` The `from` field is rewritten to the user's smart account. The popup shows a single approval covering both calls. The request resolves directly with `transactionHash` and `userOpHash` once the batch lands, there is no separate id to poll. ## With wagmi Wagmi exposes `useSendCalls` for the same flow: ```tsx theme={null} import { useSendCalls } from 'wagmi' const { sendCalls } = useSendCalls() sendCalls({ calls: [ { to: USDSC_ADDRESS, data: approveData }, { to: SPENDER, data: pullData }, ], }) ``` ## Status tracking `wallet_getCallsStatus` and `wallet_showCallsStatus` are not implemented, in either a Mini App or a standalone application. Do not build a flow around either method. Read the `transactionHash` returned directly from `wallet_sendCalls` and, if you need a receipt, look it up with your own RPC client or `waitForTransactionReceipt`. ## Atomicity is always guaranteed `atomicRequired` is part of EIP-5792, where it lets a wallet fall back to executing calls sequentially. **It has no effect here.** Startale accounts pack every batch into a single UserOperation, so all calls land together or none do, whatever you pass. Set `atomicRequired: true` anyway. It is the honest declaration of what your batch needs, and it keeps your code correct against any other EIP-5792 wallet. ## What happens underneath `wallet_sendCalls` is the EIP-5792 wrapper. Under it, every entry of `calls` is packed into a single ERC-4337 UserOperation and submitted through the bundler. Either the whole batch lands or none of it does. This is also the only path that carries a custom paymaster. Capabilities travel on `wallet_sendCalls` and not on `eth_sendTransaction`, so a standalone application that sponsors gas should send through this method even when the batch contains a single call. See [Gas in a standalone application](/app-sdk/gasless). # Connect a wallet Source: https://docs.startale.com/app-sdk/connect Open the Startale App popup, request accounts, and react to the connection state. Connecting a user opens a popup to `app.startale.com`. The user signs in with Google, LINE, or Apple, or links an existing wallet (MetaMask, Rabby, or WalletConnect), and approves the connection. After approval, your app receives the address of their smart account. ## With wagmi ```tsx theme={null} import { useAccount, useConnect, useDisconnect } from 'wagmi' export function ConnectButton() { const { address, status } = useAccount() const { connect, connectors } = useConnect() const { disconnect } = useDisconnect() const startale = connectors[0] if (status === 'connected') { return ( ) } return ( ) } ``` ## With the raw provider ```ts theme={null} const provider = sdk.getProvider() const [address] = await provider.request({ method: 'eth_requestAccounts' }) ``` The first call to `eth_requestAccounts` opens the popup. Subsequent calls return the cached address without re-prompting. ## Reading connection state ```ts theme={null} const accounts = await provider.request({ method: 'eth_accounts' }) ``` `eth_accounts` returns an array. It is empty when disconnected and contains a single element, the smart-account address, when connected. ## Listening to connection events ```ts theme={null} provider.on('connect', ({ chainId }) => { // Connected; chainId is hex }) provider.on('disconnect', () => { // User or app called disconnect }) provider.on('accountsChanged', (accounts: string[]) => { // Switched user or fully disconnected (accounts is []) }) provider.on('chainChanged', (chainId: string) => { // Active chain switched }) ``` See [Events](/app-sdk/events) for the complete list. ## Disconnecting ```ts theme={null} await provider.disconnect() ``` In wagmi, call `disconnect()` from `useDisconnect()`. Both clear local connection state. The user's Startale account is unchanged. ## Popup blockers The popup is opened in a direct response to a user gesture (a button click). Triggering connection from outside a click handler, for example, on page load, will be blocked by most browsers. Always tie connection to a user-initiated action. # Paymaster wire format Source: https://docs.startale.com/app-sdk/custom-paymaster The ERC-7677 methods a paymaster must implement to sponsor gas for an application built on @startale/app-sdk, and the constraints the Startale App applies when forwarding to it. The App SDK is provider-neutral. It does not host a paymaster and it does not prefer one. When you set [`paymasterOptions`](/app-sdk/gasless#configure-a-paymaster), the Startale App forwards standard [ERC-7677](https://eips.ethereum.org/EIPS/eip-7677) requests to the URL you name, and any service that answers them correctly will work. This page is the contract that service has to satisfy. ## How the request reaches your paymaster ```mermaid theme={null} sequenceDiagram participant App as Your application participant Host as Startale App participant PM as Your paymaster participant B as Bundler App->>Host: wallet_sendCalls (paymasterService.url) Host->>PM: pm_getPaymasterStubData PM-->>Host: stub paymasterData Host->>PM: pm_getPaymasterData PM-->>Host: signed paymasterData Host->>B: eth_sendUserOperation ``` The Startale App calls your paymaster from its own server rather than from the browser. Your endpoint therefore has to be reachable from the public internet over HTTPS, so it cannot be a localhost service or sit behind a private network address. It also never sees the browser's headers: nothing you set on the client reaches it, and no `Authorization` header is forwarded. ## Two required methods Both methods are JSON-RPC calls against the same endpoint. ### `pm_getPaymasterStubData` Called before gas estimation. Return a stub that is representative in size so the bundler can estimate gas, without committing a real signature. The stub request does not carry the user's operation. The Startale App requests it with a placeholder operation, with a zero-address `sender`, empty `callData`, and nonce and gas fields set to zero, then caches the result and reuses it across transactions. Do not validate stub requests or apply your sponsorship policy to them: a rule on `sender` or on the target contract rejects every stub, and sponsorship fails. **Request** ```json theme={null} { "jsonrpc": "2.0", "id": 1, "method": "pm_getPaymasterStubData", "params": [ { "sender": "0x0000000000000000000000000000000000000000", "nonce": "0x0", "callData": "0x", "callGasLimit": "0x0", "verificationGasLimit": "0x0", "preVerificationGas": "0x0", "maxFeePerGas": "0x0", "maxPriorityFeePerGas": "0x0" }, "0x0000000071727De22E5E9d8BAf0edAc6f37da032", "0x74c", { "calculateGasLimits": false } ] } ``` `params[1]` is the EntryPoint address, `params[2]` is the hex chain id, and `params[3]` is the context object described [below](#the-context-object). Startale accounts use EntryPoint v0.7 at the address shown. **Response (EntryPoint v0.7)** ```json theme={null} { "jsonrpc": "2.0", "id": 1, "result": { "paymaster": "0x...PaymasterAddress", "paymasterData": "0x...stubBytes", "paymasterVerificationGasLimit": "0x...", "paymasterPostOpGasLimit": "0x..." } } ``` ### `pm_getPaymasterData` Called after gas estimation, with the user's UserOperation. Return the real signature. The request shape is the same and the response is the same, with real `paymasterData` in place of the stub. Do not return `isFinal: true` from `pm_getPaymasterStubData`. ERC-7677 lets a stub declare itself final. The Startale App then skips `pm_getPaymasterData` and submits paymaster data that was produced for the placeholder operation, not for the user's. This is the call that commits you to paying. Every sponsorship decision your service makes belongs here, evaluated against the `sender` and `callData` of the operation in front of it. ### The context object ERC-7677 allows a fourth parameter carrying a service-defined context object. The Startale App sends one, and its contents belong to the App rather than to your configuration: ```json theme={null} { "calculateGasLimits": false } ``` The `id` field of `paymasterOptions` is **not** part of it and does not reach you. Treat the context as opaque, and ignore any field you do not recognise. If your service needs to distinguish a policy, a tenant, or an environment, put that distinction in the URL. A separate path or query parameter per policy works, and it is the only mechanism available through this integration. This also means a hosted provider that requires its own context, such as Alchemy's `policyId`, cannot be used directly. Put it behind a proxy that adds the context before forwarding. See [Secure your paymaster](/app-sdk/paymaster-security#keep-the-provider-credential-server-side). `calculateGasLimits` tells you which of two attempts a request belongs to. The Startale App always makes the `false` attempt first, and makes the `true` attempt only if the first one fails: | Value | When | What the Startale App does | | - | - | - | | `false` | Every transaction, first attempt | Uses the stub from `pm_getPaymasterStubData`, lets the bundler estimate gas, then calls `pm_getPaymasterData` to sign. | | `true` | Retry, only after the first attempt fails | Calls `pm_getPaymasterData` before the bundler estimates gas, with the gas limits set to zero, and expects the paymaster gas limits back with the signature. After estimation it calls `pm_getPaymasterData` again to sign the final operation. | Return `paymasterVerificationGasLimit` and `paymasterPostOpGasLimit` from both methods and you satisfy either strategy without having to branch on the flag. `pm_getPaymasterData` can therefore be called more than once for a single transaction, for example when the Startale App retries, so it has to be safe to call repeatedly. Do not decrement a quota, consume a nonce, or write a billing record on the assumption of one call per transaction. Count spend from onchain settlement instead. ## Deciding what to run | Option | When it fits | | - | - | | A hosted gas manager from an infrastructure provider | You want sponsorship in production quickly and your rules fit what the product offers. | | A service you write yourself | You need rules a hosted product cannot express, such as sponsoring only specific contract methods, resolving policy from your own user records, or settling against your own accounting. | | A thin proxy in front of a hosted gas manager | The common middle ground. The provider signs, and your proxy holds the credential and applies the rules the provider cannot. See [Secure your paymaster](/app-sdk/paymaster-security). | Whichever you choose, EntryPoint v0.7 is the version in use on Soneium, so return the v0.7 response fields shown above. ## Constraints applied when forwarding | Constraint | Value | | - | - | | Scheme | HTTPS only | | Host | Must resolve to a publicly routable address | | Redirects | Not followed. A `3xx` is treated as a failure. | | Request and response size | 256 KB each | | Upstream timeout | 10 seconds | | Headers forwarded | None | | Method | `POST`, with the JSON-RPC body forwarded unchanged | Keep responses small and fast. A paymaster that takes longer than 10 seconds to sign will fail the user's transaction, and the failure will look to your user like the application is broken. ## Before you go live Serve both methods from one HTTPS endpoint on a public host, returning EntryPoint v0.7 fields from each. Answer stub requests without validating them, and never mark a stub final. Make the sponsorship decision in `pm_getPaymasterData` from `sender` and `callData`, decline with a JSON-RPC error rather than a malformed result, and ignore any context field you do not recognise instead of rejecting the request outright. Log every signature against the resulting UserOperation hash so spend can be reconciled later, and keep responses well inside the 10 second timeout. ## Related The call site, and the batched-call requirement that makes it take effect. Your URL is public. Scope what you sponsor before you fund it. Mini App and standalone contexts, and the division of responsibility. How `wallet_sendCalls` builds the UserOperation your paymaster signs. # Errors Source: https://docs.startale.com/app-sdk/errors Error codes the SDK throws and how to handle them. The SDK uses standard EIP-1193 and JSON-RPC error codes. All errors are instances of `ProviderRpcError` with a `code`, `message`, and optional `data` field. ## Provider errors | Code | Name | Meaning | | - | - | - | | `4001` | User rejected | User dismissed the popup or rejected the request | | `4100` | Unauthorized | Method requires a connected account; call `eth_requestAccounts` first | | `4200` | Unsupported method | The method is not supported by the SDK or in the current context | | `4900` | Provider disconnected | The popup is unreachable | | `4901` | Chain disconnected | The active chain is not reachable | | `4902` | Unsupported chain | Chain not in `appChainIds` | ## RPC errors | Code | Name | Meaning | | - | - | - | | `-32700` | Parse error | Malformed JSON in request | | `-32600` | Invalid request | Request shape invalid | | `-32601` | Method not found | Unknown method name | | `-32602` | Invalid params | Parameters don't match the method's schema | | `-32603` | Internal error | Unexpected internal failure | ## Handling rejections ```ts theme={null} try { const hash = await sendTransactionAsync({ to, value }) } catch (error) { if (error.code === 4001) { // User cancelled return } if (error.code === 4100) { // Not connected, prompt connect return } throw error } ``` ## Wagmi errors Wagmi wraps provider errors in its own error types. The same `code` values are preserved on the `cause` field: ```ts theme={null} try { await sendTransactionAsync({ to, value }) } catch (error) { if (error.cause?.code === 4001) { // User cancelled } } ``` ## Popup-specific failures | Symptom | Likely cause | | - | - | | Popup never opens | Browser popup blocker; the call must be triggered from a user gesture | | Popup opens then immediately closes | Cross-Origin-Opener-Policy mismatch on the host page | | Connection completes but `accountsChanged` never fires | Listener registered after connection; subscribe before calling `connect` | # Events Source: https://docs.startale.com/app-sdk/events EIP-1193 events the SDK provider emits. The SDK provider extends an event emitter. Subscribe with `provider.on(event, handler)` and unsubscribe with `provider.removeListener` (or its alias `provider.off`). ## connect ```ts theme={null} provider.on('connect', ({ chainId }: { chainId: string }) => { // chainId is hex (e.g. "0x74c" for Soneium Mainnet) }) ``` Fired when the provider transitions to a connected state. With wagmi, `useAccount()` exposes the same signal through `status`. ## disconnect ```ts theme={null} provider.on('disconnect', (error: ProviderRpcError) => { // Provider lost connection (network drop, popup closed, user disconnected) }) ``` ## accountsChanged ```ts theme={null} provider.on('accountsChanged', (accounts: string[]) => { if (accounts.length === 0) { // Disconnected } else { // Connected as accounts[0] } }) ``` Fired when the connected account changes, including when the user disconnects (empty array) or switches accounts in the popup. ## chainChanged ```ts theme={null} provider.on('chainChanged', (chainId: string) => { // chainId is hex }) ``` Fired after a successful `wallet_switchEthereumChain` or after the user changes the active chain in the popup. ## Cleanup Always remove listeners when your component or scope unmounts: ```ts theme={null} const onAccountsChanged = (accounts: string[]) => { /* ... */ } provider.on('accountsChanged', onAccountsChanged) // Later: both forms work; removeListener is the primary SDK method provider.removeListener('accountsChanged', onAccountsChanged) // provider.off('accountsChanged', onAccountsChanged) // alias for removeListener ``` If you use wagmi, the connector handles event subscription internally. Just consume `useAccount()`, `useChainId()`, and friends. # Gas in a standalone application Source: https://docs.startale.com/app-sdk/gasless Configure paymasterOptions so your paymaster settles gas for your users, or design for the user paying gas from their smart account. Applications that use `@startale/app-sdk` from their own origin are not sponsored by Startale. This page covers the two supported outcomes at the call site: your paymaster pays, or the user pays. Building a Mini App instead? Gas is sponsored by the Startale App and there is nothing to configure. See [Gas in a Mini App](/miniapps/wallet-integration#gas). ## Configure a paymaster Pass `paymasterOptions` to either `createStartaleAccountSDK` or `startaleConnector`. The map is keyed by chain id. ```ts theme={null} import { createStartaleAccountSDK } from '@startale/app-sdk' const sdk = createStartaleAccountSDK({ appName: 'My App', appChainIds: [1868], paymasterOptions: { 1868: { url: 'https://paymaster.my-app.com/rpc', id: 'my-sponsorship-policy', }, }, }) ``` ```ts theme={null} import { startaleConnector } from '@startale/app-sdk' startaleConnector({ appName: 'My App', paymasterOptions: { 1868: { url: 'https://paymaster.my-app.com/rpc', id: 'my-sponsorship-policy', }, }, }) ``` The connector forwards every option to `createStartaleAccountSDK` internally. Configuration is identical. | Field | Type | Behaviour | | - | - | - | | `url` | `string` | The ERC-7677 paymaster endpoint that will be asked to sponsor the operation. Required. Must be HTTPS and must resolve to a publicly routable address. | | `id` | `string` | Reserved. Accepted by the SDK today but **not transmitted to your paymaster**. Do not build sponsorship logic that depends on receiving it. | `id` does not reach your paymaster. Only `url` is forwarded. The Startale App does send an ERC-7677 context object, but it carries the App's own gas-estimation flag rather than anything you configured. If your service needs to distinguish policies, tenants, or environments, encode that in the URL itself, for example as a distinct path or as a query parameter. See [the context object](/app-sdk/custom-paymaster#the-context-object). ## Only batched calls carry your paymaster `paymasterOptions` is attached as an [EIP-5792](https://eips.ethereum.org/EIPS/eip-5792) `paymasterService` capability, and that capability exists only on `wallet_sendCalls`. A plain `eth_sendTransaction` carries no capabilities, so it cannot carry your paymaster either. | Call | Carries `paymasterOptions` | Result in a standalone application | | - | - | - | | `wallet_sendCalls` | Yes | Your paymaster is asked to sponsor. | | `eth_sendTransaction` | No | The user pays, even with `paymasterOptions` configured. | This is the single most common integration mistake. A viem `walletClient.sendTransaction` call, or wagmi's `useSendTransaction` and `useWriteContract`, all issue `eth_sendTransaction`. Configuring `paymasterOptions` and then sending through those paths produces a working transaction that the **user** pays for, with no error to tell you the paymaster was skipped. To route through your paymaster, send the transaction as a batch of one: ```ts theme={null} const { transactionHash, userOpHash } = await provider.request({ method: 'wallet_sendCalls', params: [ { version: '1', from: smartAccountAddress, chainId: '0x74c', // 1868, Soneium Mainnet atomicRequired: true, calls: [{ to: contractAddress, data: encodedCall }], }, ], }) ``` The SDK injects the `paymasterService` capability for you, so there is no capability to write by hand. On wagmi, use the EIP-5792 `useSendCalls` hook rather than the single-transaction hooks. See [Batched calls](/app-sdk/batched-calls). ## Or let the user pay With no `paymasterOptions`, UserOperations are still built and submitted and gas is debited from the user's smart account. The user needs: * ETH at their **smart account** address, not at the EOA that signs for it. * A route to put ETH there, such as a bridge or a deposit flow inside your product. Make the funding requirement explicit in your interface before the first transaction. Without it, a new user reaches a failed transaction with no way to interpret or resolve it, which is a worse experience than not offering the flow at all. ## Configure per chain `paymasterOptions` is a map, so sponsorship is a per-chain decision. A chain with no entry falls back to the user paying. ```ts theme={null} paymasterOptions: { // Soneium Mainnet: your paymaster pays. 1868: { url: 'https://paymaster.my-app.com/rpc', id: 'soneium' }, // Any chain without an entry: the user pays. } ``` ## Constraints applied by the Startale App Paymaster requests are forwarded server-side by the Startale App rather than called from the browser, and that forwarding applies fixed limits. Check these first when a sponsorship fails for no obvious reason. | Constraint | Value | | - | - | | Scheme | HTTPS only | | Host | Must resolve to a public address. Private and internal addresses are rejected. | | Redirects | Not followed | | Request and response size | 256 KB each | | Upstream timeout | 10 seconds | | Headers forwarded | None. No `Authorization` header reaches your paymaster. | Because no `Authorization` header is forwarded, any credential your paymaster requires must be carried in the URL or terminated by a gateway you control. See [Secure your paymaster](/app-sdk/paymaster-security). ## Failure modes Your application does not see your paymaster's error text or the forwarding layer's. The Startale App reports a failed send to your `wallet_sendCalls` request as a JSON-RPC error with a short reason category. | What your application receives | Cause | Fix | | - | - | - | | `-32602`, message naming `capabilities.paymasterService.url` | The paymaster `url` is not HTTPS. The request is rejected before the user can confirm. | Serve the paymaster over HTTPS. | | `-32603` `Transaction sending failed ().` | The transaction could not be sent. `` is a category such as `paymaster_error`, `network_error`, or `insufficient_funds`. This covers your paymaster declining, failing, or breaking one of the constraints above. | Check your paymaster's logs first, then the constraints above. Startale has no visibility into your sponsorship decisions. | | `-32603` | The user pays, and their smart account does not hold enough ETH for gas. | Fund the smart account, or configure `paymasterOptions`. | | The transaction succeeds but your paymaster is never called | The call went through `eth_sendTransaction`. | Send through `wallet_sendCalls`. | ## Related The two ERC-7677 methods your service must implement, with request and response shapes. Your URL is public. Proxy your credentials and scope what you sponsor. The model across both contexts, and where Startale's responsibility ends. The `wallet_sendCalls` path that carries your paymaster. # Initialization Source: https://docs.startale.com/app-sdk/initialization Pick between the wagmi connector and the raw EIP-1193 provider. The Startale App SDK supports two integration modes. Pick the one that matches your codebase. ## With wagmi (recommended for React) ```ts theme={null} import { startaleConnector } from '@startale/app-sdk' import { http, createConfig } from 'wagmi' import { soneium } from 'wagmi/chains' export const config = createConfig({ chains: [soneium], connectors: [ startaleConnector({ appName: 'My App', appLogoUrl: 'https://my-app.com/logo.png', }), ], transports: { [soneium.id]: http(), }, }) declare module 'wagmi' { interface Register { config: typeof config } } ``` Wrap your app with `WagmiProvider` and `QueryClientProvider`. See the [Quickstart](/quickstart) for a complete example. ## With the raw provider For non-React apps, custom state management, or server-side wallet code: ```ts theme={null} import { createStartaleAccountSDK } from '@startale/app-sdk' const sdk = createStartaleAccountSDK({ appName: 'My App', appLogoUrl: 'https://my-app.com/logo.png', appChainIds: [1868], }) const provider = sdk.getProvider() const accounts = await provider.request({ method: 'eth_requestAccounts' }) ``` ## Options The display name shown in the connection popup. Defaults to `'App'` if omitted; recommended to set. An HTTPS URL to a square logo. Shown in the connection popup. Chain IDs your app supports. Defaults to `[]` if omitted. Pass `1868` for Soneium Mainnet. See [Networks](/concepts/networks). Optional preferences. See [`createStartaleAccountSDK`](/api-reference/create-startale-account-sdk) for the full type. Optional. Wire an ERC-7677 paymaster service per chain. A standalone application needs this to sponsor gas; without it, users pay from their smart account ETH balance. Mini Apps inside the Startale App ignore this field and are sponsored by the host. Only `url` is transmitted, and only on `wallet_sendCalls`. See [Gas in a standalone application](/app-sdk/gasless). # Installation Source: https://docs.startale.com/app-sdk/installation Install @startale/app-sdk and its peer dependencies. The Startale App SDK is published on npm as [`@startale/app-sdk`](https://www.npmjs.com/package/@startale/app-sdk). ## Install ```bash pnpm theme={null} pnpm add @startale/app-sdk wagmi viem @tanstack/react-query ``` ```bash npm theme={null} npm install @startale/app-sdk wagmi viem @tanstack/react-query ``` ```bash yarn theme={null} yarn add @startale/app-sdk wagmi viem @tanstack/react-query ``` ```bash bun theme={null} bun add @startale/app-sdk wagmi viem @tanstack/react-query ``` ## Peer dependencies The SDK has no required peer dependencies. Most React apps will also install: * `wagmi`, recommended for connection state, hooks, and chain management. * `viem`, required by wagmi; also useful for direct RPC and contract calls. * `@tanstack/react-query`, required by wagmi. If you are not using wagmi, omit the three above and use the SDK provider directly. See [Initialization](/app-sdk/initialization). ## Requirements | Requirement | Version | | - | - | | Node.js | 20 or later | | TypeScript | 5.0 or later (recommended; not required) | | React | 18 or later (only if you use the wagmi connector) | ## Verifying the install ```bash theme={null} pnpm view @startale/app-sdk versions ``` The latest version is shown at the bottom of the list. The SDK follows semantic versioning. # Pay with USDSC Source: https://docs.startale.com/app-sdk/pay-with-usdsc Charge users in Startale USD using a single batched call. USDSC (Startale USD) is the canonical stablecoin on Soneium. Charging a user is a standard ERC-20 `transfer`, so the user pays the USDSC amount plus whatever gas costs. Inside the Startale App that gas is sponsored and the user pays only the USDSC. In a standalone application, gas is covered by your paymaster or by the user. See [Who pays for gas](/concepts/gasless). The `useWriteContract` example below issues `eth_sendTransaction`, which cannot carry a custom paymaster. A standalone application that sponsors gas must charge through [batched calls](#approve-and-spend-in-one-click) instead, even for a single transfer. Inside a Mini App either path is sponsored. ## Contract addresses | Network | USDSC contract | | - | - | | Soneium Mainnet | `0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a` | USDSC follows the standard ERC-20 interface. Read decimals at runtime, do not hardcode them. ## Send a USDSC payment ```tsx theme={null} import { useWriteContract } from 'wagmi' import { erc20Abi, parseUnits } from 'viem' const USDSC = '0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a' as const export function PayButton({ to, amount }: { to: `0x${string}`; amount: string }) { const { writeContractAsync, isPending } = useWriteContract() const onClick = async () => { const hash = await writeContractAsync({ address: USDSC, abi: erc20Abi, functionName: 'transfer', args: [to, parseUnits(amount, 6)], }) console.log({ hash }) } return ( ) } ``` The `parseUnits(amount, 6)` call assumes USDSC uses 6 decimals. Read the actual value at runtime with `decimals()` if you support multiple stablecoins or want to be defensive against contract upgrades. ## Approve and spend in one click Use `wallet_sendCalls` (EIP-5792) when your flow needs an `approve` followed by a contract call. The user signs once and both calls land atomically. ```ts theme={null} import { encodeFunctionData, erc20Abi, parseUnits } from 'viem' const calls = [ { to: USDSC, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [SPENDER, parseUnits('10', 6)], }), }, { to: SPENDER, data: encodeFunctionData({ abi: spenderAbi, functionName: 'subscribe', args: [parseUnits('10', 6)], }), }, ] const { id } = await provider.request({ method: 'wallet_sendCalls', params: [ { version: '1', from: address, chainId: '0x74c', atomicRequired: true, calls }, ], }) ``` See [Batched calls](/app-sdk/batched-calls) for the full pattern. ## Pay in ETH For native ETH payments, use `useSendTransaction`: ```tsx theme={null} import { parseEther } from 'viem' await sendTransactionAsync({ to: '0xRecipient...', value: parseEther('0.05'), }) ``` Gas is paid the same way as any other transaction. Only the value moves from the user's smart account. ## Reading balances ```ts theme={null} import { erc20Abi, formatUnits } from 'viem' import { publicClient } from './viem' const balance = await publicClient.readContract({ address: USDSC, abi: erc20Abi, functionName: 'balanceOf', args: [address], }) const decimals = await publicClient.readContract({ address: USDSC, abi: erc20Abi, functionName: 'decimals', }) const display = formatUnits(balance, decimals) ``` # Secure your paymaster Source: https://docs.startale.com/app-sdk/paymaster-security A paymaster URL in client code is public. Proxy your credentials, scope what you sponsor, and cap what a single user can spend. The paymaster you configure through `paymasterOptions` lives in your application's client code. Anyone using your application can read it, and so can anyone who never intends to use your application at all. Every ERC-7677 integration shares this. The consequence is that an unscoped paymaster will pay for whatever it is asked to pay for, by whoever asks, until it is empty. Startale forwards paymaster requests and nothing more. It applies no policy on your behalf, has no visibility into your sponsorship decisions, and cannot recover funds a drained paymaster has spent. Do not fund a paymaster before it has a policy. The window between "it works" and "it has rules" is the window in which it gets drained. ## Keep the provider credential server-side Hosted gas managers authenticate with an API key, usually embedded in the URL. Putting that URL straight into `paymasterOptions` publishes the key, and a key that can sign sponsorships can spend your balance from outside your application entirely. Run a thin endpoint of your own and point `paymasterOptions` at that instead. Your endpoint holds the credential, applies your rules, and forwards to the provider. ```ts theme={null} // POST /api/paymaster // Your endpoint is what the Startale App calls. Your credential never leaves it. export async function POST(request: Request) { const rpc = await request.json() if (rpc.method !== 'pm_getPaymasterStubData' && rpc.method !== 'pm_getPaymasterData') { return jsonRpcError(rpc.id, -32601, 'Method not supported') } const [userOp, entryPoint, chainId, context] = rpc.params // Stub requests carry a placeholder operation, not the user's, so let them through. // Apply your policy to the operation you are asked to sign. if (rpc.method === 'pm_getPaymasterData' && !isSponsorable(userOp)) { return jsonRpcError(rpc.id, -32602, 'Operation not eligible for sponsorship') } // Add the context your provider needs (for example a policy id) to the // context the Startale App sent, without dropping its fields. const params = [userOp, entryPoint, chainId, { ...context, policyId: process.env.PAYMASTER_POLICY_ID }] const upstream = await fetch(process.env.PAYMASTER_URL!, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...rpc, params }), }) return new Response(await upstream.text(), { headers: { 'content-type': 'application/json' }, }) } ``` Hiding the key is the obvious benefit. The one that matters more during an incident is that you can express rules your provider cannot, and you can stop sponsoring by changing a single deployment instead of waiting on a dashboard or a support ticket. Note that the Startale App forwards no headers to your endpoint, so it cannot present a shared secret. Treat the endpoint as publicly reachable and let the policy, not the authentication, be what protects it. ## Scope what you sponsor `isSponsorable` in the sketch above is where the real protection lives. Decide it from the UserOperation itself, inside `pm_getPaymasterData`, before you sign anything. Do not apply it to `pm_getPaymasterStubData`: the Startale App requests the stub with a placeholder operation (zero-address `sender`, empty `callData`), so any sender or contract rule would reject it and the transaction would fail. See [the stub request](/app-sdk/custom-paymaster#pm_getpaymasterstubdata). | Scope on | Why it matters | | - | - | | Target contract | Sponsor calls to your own contracts only. An address allowlist is the highest-value rule and the cheapest to write. | | Method selector | Sponsor the specific functions your product calls. The first four bytes of `callData` are enough to check. | | Per-operation gas ceiling | Reject any single operation whose declared fees far exceed what your flows legitimately need. This bounds the damage of one abusive operation. | | Per-sender rate and spend | Cap operations and cumulative gas per account over a rolling window, above real usage and below the point where one account becomes expensive. | | Your own user records | If sponsorship is a benefit of holding an account, resolve `sender` against your database and decline addresses you do not recognise. | An allowlist on contract and method also rejects, by construction, operations that do nothing useful. An account can be made to submit an operation that performs no meaningful work while still burning real gas at a high fee, and a paymaster that sponsors anything will pay for every one of them. Scoping to your own methods removes that whole class of abuse without having to detect it. Check what your provider's policy layer can actually express before relying on it. Hosted controls usually cover spending limits and per-account allowlists, but scoping by contract address or method selector is frequently missing, and some providers offer it only through a webhook back into your own service. Where the provider cannot express a rule, implement it in your proxy. ## Cap the blast radius Set your numbers on the assumption that everything above will eventually be bypassed by something you did not anticipate. Fund incrementally. A paymaster holding a week of expected spend has a bounded worst case; one holding a year of spend does not. Alert on burn rate rather than balance, since a drain shows up as an unusual rate long before the balance runs out. Keep a kill switch you can reach in minutes, which with your own proxy is a flag that makes `isSponsorable` return false, and make sure someone other than you knows how to flip it. Reconcile as well. Log every signature you issue against the resulting UserOperation hash and compare that against the gas your paymaster actually paid. A gap between the two is how you find out something is being sponsored that you did not intend. ## Fail honestly When you decline to sponsor, return a JSON-RPC error rather than a malformed result, so the transaction fails cleanly. Your error message does not reach your application. The Startale App reports the failure to your `wallet_sendCalls` request as `-32603` with a short category such as `paymaster_error`, not your paymaster's own text. Your interface can show a generic "not sponsored" state, but not your specific reason. See [Failure modes](/app-sdk/gasless#failure-modes). Design that declined path as deliberately as the happy path. A user told "this action is not sponsored, and your account needs ETH to continue" can act on it. A user who sees an unexplained failure files a support ticket, or leaves. ## Related The methods your proxy has to implement, and the limits applied when forwarding to it. Where the URL goes, and why only batched calls carry it. The division of responsibility between Startale and your application. Surfacing a declined sponsorship to the user. # Send transactions Source: https://docs.startale.com/app-sdk/send-transactions Send a single transaction from the user's smart account. Send a transaction the same way you would with any wagmi-compatible wallet. The user's smart account is the sender. For details on who pays gas in each context, see [Who pays for gas](/concepts/gasless). ## Send native value ```tsx theme={null} import { useSendTransaction } from 'wagmi' import { parseEther } from 'viem' const { sendTransactionAsync } = useSendTransaction() const hash = await sendTransactionAsync({ to: '0x000000000000000000000000000000000000dEaD', value: parseEther('0.001'), }) ``` ## Call a contract ```tsx theme={null} import { useWriteContract } from 'wagmi' import { erc20Abi } from 'viem' const { writeContractAsync } = useWriteContract() const hash = await writeContractAsync({ address: USDSC_ADDRESS, abi: erc20Abi, functionName: 'transfer', args: [recipient, amount], }) ``` ## With the raw provider ```ts theme={null} const hash = await provider.request({ method: 'eth_sendTransaction', params: [ { to: '0x000000000000000000000000000000000000dEaD', value: '0x38d7ea4c68000', // 0.001 ETH in wei }, ], }) ``` The `from` field is set automatically to the user's smart account. If you supply a `from` value, it is ignored. ## How gas works in Mini Apps When your Mini App runs inside the Startale App, `eth_sendTransaction` is gasless for the user. What happens: 1. **Context detection.** When your page runs inside an iframe, the App SDK uses the Mini App wallet provider, so the request goes to the Startale App host instead of opening a popup. 2. **Conversion to a UserOperation.** The host converts the request into an ERC-4337 UserOperation sent from the user's smart account. 3. **Sponsorship.** The host attaches its own paymaster. Nothing in your code selects it and nothing in the request carries it. Sponsorship is decided by the host, not by the Mini App. 4. **Submission.** The host submits the UserOperation to a bundler, which calls `handleOps` on the EntryPoint contract. Gas is settled by Startale rather than by the user. **As a Mini App developer you configure nothing.** There is no paymaster URL and no `paymasterOptions` in your code. See [Gas](/miniapps/wallet-integration#gas) for the caps that apply and how to handle them. ## Gas in a standalone application Outside the Startale App, gas is not sponsored. You configure a paymaster or the user pays. See [Gas in a standalone application](/app-sdk/gasless). `eth_sendTransaction` cannot carry a custom paymaster. `paymasterOptions` is delivered as an EIP-5792 capability, and capabilities travel only on `wallet_sendCalls`. If you configure a paymaster and then send through `eth_sendTransaction`, the transaction succeeds and the **user** pays, with no error indicating the paymaster was skipped. Use [batched calls](/app-sdk/batched-calls) instead, even for a single call. For batched calls or atomic multi-step transactions, see [Batched calls](/app-sdk/batched-calls). ## Errors | Code | Meaning | | - | - | | `4001` | User rejected the transaction in the popup | | `4100` | Wallet not connected | | `4900` | Provider disconnected | See [Errors](/app-sdk/errors) for the full list. # Sign messages Source: https://docs.startale.com/app-sdk/sign-messages Sign plain strings with personal_sign or structured data with EIP-712. Two signing methods are supported. | Method | Use for | | - | - | | `personal_sign` | Plain-string messages, login challenges, terms acknowledgements | | `eth_signTypedData_v4` | Structured EIP-712 data, orders, permits, domain-bound consent | Signatures are produced by the user's smart account. They are ERC-1271 compatible and verifiable off-chain with viem's `verifyMessage` or `verifyTypedData`. ## Sign a string ```tsx theme={null} import { useAccount, useSignMessage } from 'wagmi' const { signMessageAsync } = useSignMessage() const signature = await signMessageAsync({ message: 'I acknowledge the airdrop terms.', }) ``` With the raw provider: ```ts theme={null} const signature = await provider.request({ method: 'personal_sign', params: [stringToHex('I acknowledge the airdrop terms.'), address], }) ``` ## Sign typed data ```tsx theme={null} import { useSignTypedData } from 'wagmi' const { signTypedDataAsync } = useSignTypedData() const signature = await signTypedDataAsync({ domain: { name: 'My App', version: '1', chainId: 1868, verifyingContract: '0x...', }, types: { Order: [ { name: 'item', type: 'string' }, { name: 'amount', type: 'uint256' }, ], }, primaryType: 'Order', message: { item: 'Pass', amount: 1n }, }) ``` ## Verify a signature offchain Use viem. Smart-account signatures are resolved automatically, no special handling required. ```ts theme={null} import { publicClient } from './viem' const isValid = await publicClient.verifyMessage({ address, message: 'I acknowledge the airdrop terms.', signature, }) ``` For typed data, use `verifyTypedData`. ## Counterfactual signatures Before a smart account is deployed on a given chain, signatures are wrapped per [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). The wrapping is transparent: verifiers that support 6492, including viem, accept both wrapped and unwrapped signatures. If you write your own verifier, look up "ERC-6492 magic byte". The suffix `0x6492649264926492…` indicates an undeployed account; unwrap the signature before validation. # Authentication Source: https://docs.startale.com/concepts/authentication Users sign in to Startale with Google, LINE, passkey, or an existing wallet. The smart-account address stays the same regardless of method. Users authenticate through the Startale App popup. Three sign-in options are available: * Google * LINE * Passkey A fourth path, **Connect a wallet**, lets users authenticate with MetaMask or any EIP-6963-compatible wallet. That wallet is linked as an EOA; the active signer for transactions is always the user's smart account. A user's smart-account address is determined by their account in the Startale system, not by the auth method they chose. A user who signs in with Google on one device and passkey on another lands on the same smart account. ## Restricting auth methods Use `Preference.authType` to limit which social providers are offered: ```ts theme={null} startaleConnector({ appName: 'My App', preference: { authType: 'google', }, }) ``` Accepted values: `'google'`, `'line'`. When omitted, the popup offers all available methods. Passkey and wallet connection are not filterable through `authType`. EOA linking is configured separately through `eoaRequired`. ## Requiring a linked EOA Set `Preference.eoaRequired: true` to force users to link a MetaMask wallet during connection. The smart account remains the signer for transactions. The linked EOA is exposed read-only via [the Mini App runtime context](/miniapps/runtime-context) for identity continuity use cases. ## Session lifetime and limits Sessions are managed by the Startale App. The Startale App SDK does not impose its own session timeout, idle limit, or concurrent-session cap on top of that. **What `disconnect()` does.** Calling `disconnect()` (or wagmi's `useDisconnect()`) clears the SDK's local connection state in browser `localStorage`, namely the smart-account address, active chain, and cached metadata. It does not sign the user out of the Startale App itself. Reconnecting from your application reuses the same smart-account address without re-authentication, as long as the user's Startale App session is still active. **Multi-device.** A user can be signed in on multiple devices and browsers concurrently. Disconnecting on one device has no effect on the others. **Token refresh.** Your application never sees the user's auth token. The Startale App owns the session lifecycle, and the SDK does not perform silent refresh. If a user's Startale session ends through manual sign-out or auth-provider expiry, the next signed request through your application reopens the popup for re-authentication. During that window, `useAccount()` flips to disconnected briefly until the user completes the popup flow. # Context Source: https://docs.startale.com/concepts/context A brief overview of the runtime context Startale provides to Mini Apps and how to use it. Context is the runtime data the host environment makes available to your app at run time. In Startale Mini Apps, context is how you read user and session-scoped information without building your own identity layer inside the Mini App. ## What Startale adds When your Mini App runs inside the Startale App, the host augments the Mini App SDK context with Startale-specific fields under `sdk.context.startale`, including: * **STAR Points**: The user’s current STAR Points balance. * **Linked EOAs**: Wallet addresses the user has verified as linked to their Startale account. * **Legacy address**: The user's smart account address from before an account upgrade, with a signature proving the legacy account approved the upgrade. Present only for users who have completed one. ## How you read it ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' const context = await sdk.context const starPoints = context.startale?.starPoints ?? 0 const eoaWallets = context.startale?.eoaWallets ?? [] ``` ## Read the full runtime context reference This page is intentionally brief. For the full field shape, constraints (including what works in the sandbox), and end-to-end examples, see [Runtime context](/miniapps/runtime-context). # Who pays for gas Source: https://docs.startale.com/concepts/gasless Gas payment for Startale smart accounts depends on where your application runs. Mini Apps are sponsored by the Startale App. Standalone applications supply their own paymaster or let the user pay. Every transaction from a Startale smart account is an ERC-4337 UserOperation. The gas for that operation is paid either by a **paymaster** that co-signs it, or by the smart account itself out of its own ETH balance. Which of the two applies is decided by one thing: where your application runs. ## The two contexts ```mermaid theme={null} flowchart LR subgraph Host["Inside the Startale App"] MA[Your Mini App] --> HostPM[Startale App
sponsors gas] end subgraph Own["On your own origin"] App["Your application
using @startale/app-sdk"] App -->|paymasterOptions set| Yours[Your paymaster
pays gas] App -->|no paymasterOptions| User[User pays gas from
their smart account] end ``` | | Mini App inside the Startale App | Standalone application | | - | - | - | | Where it runs | An iframe inside the Startale App | Your own website, webview, or desktop shell | | Who pays gas | The Startale App | Your paymaster, or the user | | What you configure | Nothing | `paymasterOptions`, or nothing if the user pays | ## Mini Apps are sponsored by the Startale App Transactions that originate from a Mini App running inside the Startale App are sponsored by Startale. Gas is invisible to your code and to the user, and there is nothing to configure. Do not set `paymasterOptions` in a Mini App; the host uses its own paymaster and ignores yours. Sponsorship carries two limits: * **Ethereum Mainnet is capped per user.** Mini Apps run on Soneium only unless enabled for another chain. For a Mini App enabled for Ethereum Mainnet, the first three Ethereum Mainnet transactions per user are sponsored, counted per user across the Startale App. After that the user pays from their smart account's ETH balance. Soneium transactions are not capped. * **Sponsorship is an operated service, not a guarantee.** Startale can withdraw sponsorship for a specific app, for example when its transactions generate abnormal gas cost. If that happens the user pays, and your flows should surface the resulting failure rather than retrying silently. See [Gas in a Mini App](/miniapps/wallet-integration#gas) for the Mini App call sites. ## Standalone applications supply their own paymaster When your application uses `@startale/app-sdk` from your own origin, Startale does not sponsor its gas. You choose between two outcomes: 1. **You pay.** Configure a paymaster through `paymasterOptions`. The Startale App forwards paymaster requests to the service you name and your paymaster settles the gas. 2. **The user pays.** Configure nothing. The UserOperation is still built and submitted, and gas comes out of the user's smart account ETH balance. There is no third option in which Startale absorbs the cost. Both outcomes are supported, and choosing the second is a legitimate decision for internal tooling or for applications whose users already hold ETH. It is not a legitimate default for a consumer flow, because a user with an empty smart account simply cannot transact. [Gas in a standalone application](/app-sdk/gasless) covers both paths in code. ## Where Startale's responsibility ends For a standalone application, the Startale App accepts a paymaster URL from your application and forwards ERC-7677 paymaster requests to it. That forwarding is the extent of the platform's involvement. Everything about the paymaster itself is operated by you. | Startale is responsible for | You are responsible for | | - | - | | Accepting `paymasterOptions` and forwarding paymaster requests to your URL | Running or procuring the paymaster service | | Building, signing, and submitting the UserOperation | Funding it, and keeping it funded | | Sponsoring Mini App transactions inside the Startale App | Every sponsorship rule it applies | | | Protecting the credentials it uses | Your paymaster URL travels in your application's client code, so treat it as public from the first day. A paymaster that accepts any request from any caller will be drained by someone other than your users. [Secure your paymaster](/app-sdk/paymaster-security) is not optional reading. ## Which paymaster services work Any service that implements [ERC-7677](https://eips.ethereum.org/EIPS/eip-7677) works. The integration is provider-neutral: the App SDK attaches your paymaster URL to the request as a `paymasterService` capability, and the Startale App forwards `pm_getPaymasterStubData` and `pm_getPaymasterData` to it, whoever operates it. Common choices are a hosted gas manager from an infrastructure provider, or a service you run yourself when you need sponsorship rules a hosted product cannot express. ## Pick your path Nothing to configure. Read the caps and the failure modes. Configure `paymasterOptions`, or design for the user paying gas. The ERC-7677 methods, request shape, and the constraints the host applies. Proxy your credentials and write a sponsorship policy that holds. # Networks Source: https://docs.startale.com/concepts/networks The networks the Startale App serves, with RPC endpoints, block explorers, and chain configuration for viem and wagmi. Signatures and transactions from the App SDK are handled by the Startale App, so the networks you can transact on are the ones the Startale App serves. | Network | Chain ID | Use for | | - | - | - | | Soneium Mainnet | 1868 | Production | | Ethereum Mainnet | 1 | Deposits and transfers. Available to a Mini App only when it is enabled for Ethereum Mainnet. | The Startale App does not serve a testnet today. ## Public RPC endpoints | Network | RPC URL | | - | - | | Soneium Mainnet | `https://rpc.soneium.org/` | These public endpoints are rate-limited and intended for development. For production traffic, run your own node or use an infrastructure partner. See the [Soneium builder docs](https://docs.soneium.org/docs/builders/overview) for the current options. ## Block explorers | Network | Explorer | | - | - | | Soneium Mainnet | [soneium.blockscout.com](https://soneium.blockscout.com/) | ## Chain definitions Soneium chains are exported by `viem/chains` and `wagmi/chains`: ```ts theme={null} import { soneium } from 'wagmi/chains' ``` ## Declaring supported chains For the wagmi connector, the chain list comes from wagmi's `createConfig({ chains })` array. `startaleConnector()` derives `appChainIds` from it automatically: ```ts theme={null} createConfig({ chains: [soneium], transports: { [soneium.id]: http(), }, connectors: [startaleConnector({ /* ... */ })], }) ``` ## Switching networks Use wagmi's `useSwitchChain()` or the raw provider's `wallet_switchEthereumChain` method. Once connected, a switch to a network the Startale App serves happens in the SDK without a popup and emits `chainChanged`. See [`wallet_switchEthereumChain`](/api-reference/rpc-methods/wallet-switch-ethereum-chain). ```tsx theme={null} import { useSwitchChain } from 'wagmi' import { soneium } from 'wagmi/chains' const { switchChain } = useSwitchChain() switchChain({ chainId: soneium.id }) ``` Inside a Mini App, the host only switches to a chain your Mini App is enabled for, which is Soneium Mainnet only by default. See [Mini App chains](/miniapps/wallet-integration#chains). ## Stablecoin USDSC is the canonical stablecoin on Soneium. | Network | USDSC contract | | - | - | | Soneium Mainnet | `0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a` | See [Pay with USDSC](/app-sdk/pay-with-usdsc) for full transfer examples. # Smart accounts Source: https://docs.startale.com/concepts/smart-accounts Every Startale user has an ERC-4337 smart account. The SDK exposes it through a standard EIP-1193 provider. Every user who signs in to a Startale-integrated application gets an ERC-4337 smart account. The SDK exposes this account through a standard EIP-1193 provider, so wagmi, viem, and ethers all work without modification. ## How accounts are created When a user signs in with Google, LINE, Apple, or MetaMask, an embedded wallet is provisioned for them. The smart account is deterministically derived from that embedded wallet, then deployed on first use. ## Signature compatibility Signatures returned by the SDK are ERC-1271 compatible. To verify them off-chain, use viem's `verifyMessage`. It resolves smart-account signatures automatically by calling `isValidSignature` on the deployed account. ## Counterfactual signatures Before the smart account is deployed, signatures are wrapped per [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). Verifiers that support 6492, including viem, handle both deployed and undeployed accounts transparently. ## Powered by Smart-account internals are implemented in [`scs-aa-sdk`](https://github.com/StartaleGroup/scs-aa-sdk), Startale's open-source account-abstraction primitive. You do not need to install it directly. `@startale/app-sdk` includes everything you need. # Startale Documentation Source: https://docs.startale.com/introduction Developer documentation for the Startale App SDK on Soneium: integrate a web application, or ship a Mini App inside the Startale App. This documentation covers the **App SDK** (`@startale/app-sdk`), the integration library for connecting applications to the Startale App on Soneium. It handles user authentication, smart account provisioning, and onchain transactions through a familiar EIP-1193 provider and wagmi connector. You can use it two ways: inside the Startale App as a Mini App, or from your own origin as a standalone application. Install the SDK, connect a Startale smart account, then sign and send your first transaction. A full integration walkthrough, covering installation, error & how to use the methods. Ship your project as a Mini App inside the Startale App. Manifest, context, submission. Per-method pages for every supported RPC method, with parameters, and error handling. ## What you get Use the SDK to provision an ERC-4337 smart account for every user. The account is exposed through a EIP-1193 provider. Mini Apps run gasless inside the Startale App. Standalone applications wire any ERC-7677 paymaster through `paymasterOptions`, or let the user pay. Let users sign in with Google, LINE, passkey, or an existing wallet. The same smart-account address resolves across every Startale-integrated app. Distribute as a Mini App inside the Startale App and reach users with active smart accounts on Soneium ecosystem. ## Pick a path Add the SDK to your existing project. Users connect through a popup to the Startale App. Build a Mini App that runs inside the Startale App and uses the host wallet directly. Understand who pays in each context, then wire an ERC-7677 paymaster through `paymasterOptions`. ## Resources The `@startale/app-sdk` source and examples. Working projects you can clone and run. Ask questions and share what you are building. Updates from the Startale Group and the App teams. # Categories Source: https://docs.startale.com/miniapps/categories Accepted values for the primaryCategory manifest field. The `frame.primaryCategory` field tells the Startale explorer where to surface your Mini App. Pick exactly one of the recommended values below. Other strings are accepted but render with default formatting and will not match any explorer category filter. | Value | Display | | - | - | | `games` | Game | | `social` | Social | | `finance` | Finance | | `utility` | Utility | | `productivity` | Productivity | | `health-fitness` | Health & Fitness | | `news-media` | News & Media | | `music` | Music | | `shopping` | Shopping | | `education` | Education | | `developer-tools` | Developer Tools | | `entertainment` | Entertainment | | `art-creativity` | Art & Creativity | | `rwa` | RWA | `rwa` (real-world assets) is a Startale-specific addition not present in the Farcaster standard. The remaining values come from the Farcaster category list. ## Pick the smallest accurate label Don't pick `games` for a financial product just because the UI is playful. Reviewers will reclassify Mini Apps that are submitted with a misleading category. ## Tags vs categories `primaryCategory` is the primary slot. Use `frame.tags` for additional searchable keywords: ```json theme={null} { "primaryCategory": "games", "tags": ["prediction-market", "soneium", "stablecoin"] } ``` Tags do not affect placement but are searchable. # Deep linking Source: https://docs.startale.com/miniapps/deep-linking Link directly to a specific screen inside your Mini App with /miniapps#, resolved same-origin against your home URL. A Mini App deep link is a URL on `app.startale.com` that opens your Mini App directly, no feed or preview card involved. Typed in a browser, tapped from a Missions CTA, or shared in a chat, it loads the Startale App straight to your Mini App. ``` https://app.startale.com/miniapps# ``` ## Format | Part | Required | Description | | - | - | - | | `appId` | Yes | Your Mini App's canonical id, assigned by the Startale team. See [Getting your app id](#getting-your-app-id). | | `relativeUrl` | No | A path (`/...`), a query (`?...`), or both, resolved against your Mini App's home URL. Anything else is rejected. | ``` #co-nft → opens CoNFT at its home screen #co-nft/collection/0x41be52... → opens CoNFT at that collection #elhexa/tokens/123?ref=x → opens ELHEXA at /tokens/123 with ?ref=x #elhexa?tab=1 → opens ELHEXA at its home path with ?tab=1 ``` `appId` is read up to the first `/` or `?` in the fragment; everything from that character on is `relativeUrl`, passed through unmodified (including its own nested `/` or `?` characters, e.g. `?path=/a/b`). ## Resolving the target `relativeUrl` is resolved against your Mini App's home URL, not treated as a full URL: * **A path (`/...`) resolves under your home's base path.** If your home URL is `https://your-app.com/x`, `/collections/123` resolves to `https://your-app.com/x/collections/123`. The base path is always kept, it is never replaced. * **A query (`?...`) leaves the path untouched** and only affects the query string. * **Your home URL's own query params are preserved.** If your home URL is `https://your-app.com/?lang=ja`, a deep link to `/tokens/1` resolves to `https://your-app.com/tokens/1?lang=ja`. * **A deep-link param wins on key collision.** `?lang=en` against a home URL of `https://your-app.com/?lang=ja` resolves to `?lang=en`, not both. * **Resolution uses the viewer's own edition of your home URL.** If your manifest declares a `jpUrl`, a Japan-edition viewer resolves `relativeUrl` against `jpUrl` (base path and query included); a Global-edition viewer resolves it against your default `homeUrl`. The same shared link produces the correct edition-specific URL for whoever opens it, you do not need separate deep links per edition. **Only `/...` or `?...` is accepted.** A bare `foo/bar` is rejected (it would resolve as a *sibling* of your home path under standard URL parent-directory rules, not a child of it) and the deep link opens your Mini App's home instead. Always start `relativeUrl` with `/` or `?`. ## Security: same-origin only A deep link can never point your Mini App's iframe at a different origin. The relative URL is rejected (falls back to home) if it would: * Resolve to a different origin than your home URL (`https://evil.com/...`, `//evil.com/...`, and other Startale App browser origin-confusion payloads such as `/\evil.com` all fail this check). * Escape your home's base path via `..` (e.g. `/../secret` against a home path of `/x`). * Fail to parse as a URL at all. This is enforced twice, independently: once when the deep link is resolved, and again as a backstop when the resolved URL is handed to the iframe. There is no path through the deep-link format that can navigate your Mini App off its own origin. ## Getting your app id You do not choose your Mini App's `appId`. It is assigned by the Startale team when your Mini App is registered, as part of onboarding, alongside your referral code. See [Submission](/miniapps/submission) for how a Mini App gets listed. **An unregistered or misspelled `appId` opens nothing.** The Startale App matches `appId` against its own catalog of listed Mini Apps. If there's no match, the deep link falls through with no app opening at all, not an error page. Double-check the id you were given if a deep link isn't working. If your project is later renamed, reach out to the Startale team rather than assuming your existing deep links keep working under a new id; legacy ids are kept working only where an alias has been explicitly set up for that rename. ## Deep links and notification tap destinations Notifications V1's `targetUrl` (see [Notifications](/miniapps/notifications#sending-a-notification)) also lands a tap on a specific screen in your Mini App, but it does not go through this page's format. The two only intersect in one narrow case: * **With a valid, same-origin `targetUrl`**: tapping **Open Mini App** opens your Mini App inline, right on the notifications screen, loaded directly at that absolute URL. This never touches `#appId` at all, it's a structurally separate code path. * **Without a `targetUrl`** (not set, or its origin doesn't match your registered home URL): tapping **Open Mini App** navigates to `#appId`, this page's format, but always in its plain, **home-only** form. There is no `relativeUrl` in this fallback, it cannot land the user on a specific screen, only your home. | | Deep link (`#appId`) | Notification `targetUrl` | | - | - | - | | Format | `appId` + an optional relative path/query, resolved against your home URL | A full, absolute URL you control end to end | | Where it's used | Shareable/external links, in-app CTAs, and the no-`targetUrl` notification fallback (home only, see above) | The tap destination of a push notification you send, when set and same-origin | | Origin check | Enforced against your registered home URL, on open | Enforced against your `senderId`, both at send time (NS) and on open | Building a `targetUrl` doesn't give you `relativeUrl` support and vice versa: these are two independent mechanisms that happen to both end up rendering inside the same `AppSheet` surface. ## Linking to another Mini App from inside yours There is no dedicated SDK action for "open a different Mini App," but you can get there today by combining two things that already exist: [`sdk.actions.openUrl`](/miniapps/sdk-actions#actions-openurl) and a deep link. Point `openUrl` at another Mini App's deep link instead of an external URL: ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' // From inside e.g. Morning Moon Pocket, link to the NFT the user just minted on CoNFT sdk.actions.openUrl('https://app.startale.com/miniapps#co-nft/collection/0x41be52c22C95D611624d7db2a9A8D277d7cc286d') ``` `openUrl` doesn't treat `app.startale.com` specially, it opens whatever `http(s)` URL you give it in a new browser tab. Because that tab loads the Startale App fresh, the deep link resolves normally on load and the user lands on that exact CoNFT screen. There is no registration or allowlist check on the target beyond what deep linking itself already enforces (the target `appId` must be a registered Mini App, see [Getting your app id](#getting-your-app-id)), so this works for linking to any Mini App, including your own. **This opens a new tab, it does not hand off in place.** The linking Mini App's own iframe is left running in the background tab; there is currently no "swap to app B, then back to app A where you left off" navigation. If you need that instead of a new tab, talk to the Startale team, it's a different, not-yet-built capability. # Embed meta tag Source: https://docs.startale.com/miniapps/embed The fc:miniapp meta tag turns any of your shareable pages into a launchable Mini App preview on social platforms. Add an `fc:miniapp` meta tag to the `` of every page you want to be launchable from a feed (X, Telegram, Discord, Farcaster). When the URL is shared, those platforms render a preview card with a CTA that opens your Mini App. The Startale App validates this tag using its Embed Tool. Use [app.startale.com/developers/embed](https://app.startale.com/developers/embed) to confirm your tag passes before submitting for listing review. ## Tag All fields are delivered as a single stringified JSON object: ```html theme={null} ``` ## Required fields String-length values below come from the Farcaster Mini App spec. They are not enforced by the Startale parser, but staying within them is recommended for consistent display across feeds and the launcher splash. | Field | Type | Constraints | | - | - | - | | `version` | string | Must be `"1"`. | | `imageUrl` | string | Preview image shown in the social feed. HTTPS, 3:2 aspect ratio. Recommended ≤1024 chars. | | `button.title` | string | Label on the CTA button. Plain text, recommended ≤32 chars. | | `button.action.type` | string | One of `"launch_frame"` or `"view_token"`. | | `button.action.name` | string | App name shown on the splash screen after the user taps the CTA. Plain text, recommended ≤32 chars. | | `button.action.url` | string | HTTPS launch URL when the button is tapped. Recommended ≤1024 chars. | ## Optional fields | Field | Type | Constraints | | - | - | - | | `button.action.splashImageUrl` | string | Centered splash icon. PNG, 200×200 px. | | `button.action.splashBackgroundColor` | string | Hex color for the splash background. | ## Example ```html theme={null} ``` ## Troubleshooting previews Test your embed tag at [app.startale.com/developers/embed](https://app.startale.com/developers/embed) before sharing anywhere. * The image must be reachable over HTTPS with a valid certificate. * Cache headers matter. Feeds reuse stale previews. Set `Cache-Control: public, max-age=60` on preview images while iterating. * Test your embed by sharing the URL into a private cast on Farcaster or a saved-messages chat on Telegram. # Manifest Source: https://docs.startale.com/miniapps/manifest Every Mini App publishes a manifest at /.well-known/farcaster.json. Required fields, Startale extensions, and validation rules. Every Mini App publishes a manifest at a fixed path. The Startale App fetches this file to register, display, and launch your Mini App. ## Location ``` GET https:///.well-known/farcaster.json ``` | Constraint | Value | | - | - | | Protocol | HTTPS required (HTTP allowed for `localhost` in development only) | | Timeout | 10 seconds | | Maximum size | 10 KB | | Maximum redirects | 3 | Private IP addresses and reserved ranges are blocked at fetch time. See [URL rules](#url-rules). Validate your manifest before submitting at [app.startale.com/developers/manifest](https://app.startale.com/developers/manifest). ## Top-level structure Use the `frame` key. It is the Farcaster standard, so a single `farcaster.json` file works on Startale, Farcaster, and Base without rename. Startale-specific fields (`featuredBannerImageUrl`, `projectWebsite`, `socialLinks`, `screenCompatibility`) live under a root-level `startale` key, sibling to `frame`. ```json theme={null} { "frame": { "version": "1", "name": "Your App Name", "tagline": "One-line pitch here", "iconUrl": "https://your-app.com/icon.webp", "homeUrl": "https://your-app.com", "splashImageUrl": "https://your-app.com/splash.png", "splashBackgroundColor": "#1a1a2e", "heroImageUrl": "https://your-app.com/hero.webp", "primaryCategory": "games", "tags": ["game", "web3", "soneium"], "webhookUrl": "https://your-app.com/api/notify" }, "startale": { "manifestVersion": "2.3", "screenCompatibility": { "desktop": true, "landscapeOnly": false }, "featuredBannerImageUrl": "https://your-app.com/banner.webp", "projectWebsite": "https://your-app.com", "socialLinks": { "twitter": "https://x.com/yourapp", "discord": "https://discord.gg/abc123", "telegram": "https://t.me/yourapp" } } } ``` ## Required fields ### Validated automatically The manifest parser rejects any manifest missing these three fields: | Field | Type | Constraints | | - | - | - | | `frame.name` | string | Display name. Recommended ≤32 chars. | | `frame.iconUrl` | string | App icon. See [Media specs](/miniapps/media-specs). | | `frame.homeUrl` | string | Canonical origin URL: scheme + host only, no path or query string. Recommended ≤1024 chars. | ### Required for submission review The following fields are not parser-enforced but are required for your Mini App to pass review, appear correctly in the explorer, and qualify for featured placement. A manifest missing any of these will be rejected during manual review. | Field | Type | Notes | | - | - | - | | `frame.version` | string | Must be `"1"` | | `frame.primaryCategory` | string | One value from [Categories](/miniapps/categories). | | `frame.heroImageUrl` | string | Image shown on the app grid card. | | `startale.featuredBannerImageUrl` | string | Featured rotation banner at the top of the explorer. | | `startale.screenCompatibility.desktop` | boolean | `true` = full browser on desktop; `false` = default 424×695 px portrait frame. Omitting it shows a Missing flag in the validator. | **One `homeUrl` per app, even across language editions.** A localized or JP-specific build must declare the same `homeUrl` as the global edition, not a variant with a language path or query string (`/jp`, `?lang=ja`). Notifications derive your `senderId` from this field trimmed to a bare origin, so a differing or non-bare `homeUrl` breaks manifest resolution and subscriptions for that build. See [Notifications](/miniapps/notifications#the-webhook-contract) for how `senderId` is used. ## Optional fields | Field | Type | Notes | | - | - | - | | `frame.tagline` | string | One-line pitch shown on the app card. Max 30 chars. | | `frame.splashImageUrl` | string | Centered icon on the loading screen. 200×200px, PNG/JPG/WebP, max 50KB. | | `frame.splashBackgroundColor` | string | Hex color for the splash background. | | `frame.tags` | string\[] | Up to 5 tags, lowercase alphanumeric + hyphens, ≤20 chars each. Used for explorer search and filtering. | | `frame.webhookUrl` | string | Notification endpoint. Required for push notifications. | | `startale.manifestVersion` | string | Startale manifest spec version. Use `"2.3"` for new manifests. | | `startale.projectWebsite` | string | HTTPS URL to your main project site. | | `startale.socialLinks` | object | Object with `twitter`, `discord`, `telegram`, `farcaster`, `website` keys. | | `startale.screenCompatibility.landscapeOnly` | boolean | When `true`, content rotates 90° when the viewport is portrait. Default `false`. | ## URL rules Every URL in the manifest is validated. Failures reject the manifest. | Rule | Detail | | - | - | | Protocol | HTTPS required. HTTP only for `localhost` and `127.0.0.1` in development. | | Private IPv4 ranges blocked | `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `198.18.0.0/15`, `240.0.0.0/4` | | Private IPv6 ranges blocked | Loopback (`::1`), link-local (`fe80::/10`), unique local (`fc00::/7`) | | Localhost in production | Blocked | The same rules apply to every image URL in the manifest. ## Banner fallback chain When the Startale App displays your banner, it tries fields in this priority order: 1. `startale.featuredBannerImageUrl` 2. `frame.imageUrl` 3. `frame.heroImageUrl` 4. `frame.splashImageUrl` 5. `frame.iconUrl` Provide `featuredBannerImageUrl` (2400×1260 px) for the best result. ## Reusing a Farcaster or Base manifest If your Mini App is already live on Farcaster or Base, your existing `farcaster.json` works on Startale unchanged. Add a root-level `startale` key (sibling to `frame`) with the Startale-specific fields (`featuredBannerImageUrl`, `projectWebsite`, `socialLinks`, `screenCompatibility`). A single file works on all three platforms. # Image and video specs Source: https://docs.startale.com/miniapps/media-specs Exact dimensions, file sizes, formats, and safe zones for every image and video field in the manifest. Every image and video in the manifest is validated. Submissions that fail validation are rejected. For best results, export marketing assets as high-quality WebP. Use PNG only where it is strictly necessary, for example sharp logos on flat backgrounds. ## Images | Field | Dimensions | Aspect | Max size | Formats | Notes | | - | - | - | - | - | - | | `iconUrl` | 1024 × 1024 px | 1:1 | 512 KB | WebP (recommended), PNG, JPG | No transparency. Submit as flat square, the corner radius is applied by the Startale App. | | `splashImageUrl` | 200 × 200 px | 1:1 | 50 KB | WebP (recommended), PNG, JPG | Centered icon shown while the iframe loads. | | `heroImageUrl` | 1200 × 630 px | 1.91:1 | 512 KB | WebP (recommended), PNG, JPG | Used for the app grid card. | | `imageUrl` | Any | 3:2 | 3 MB | PNG, JPG, JPEG, WebP | Embed preview image. Flexible resolution at 3:2. | | `ogImageUrl` | 1200 × 630 px | 1.91:1 | 256 KB | WebP (recommended), PNG, JPG | Social previews on X, Telegram, Discord. | | `featuredBannerImageUrl` | 2400 × 1260 px | 1.91:1 | 1 MB | WebP (recommended), PNG, JPG | Featured rotation banner at the top of the explorer. | | `screenshotUrls[]` | 1284 × 2778 px | Portrait | 750 KB each | WebP (recommended), PNG, JPG | Maximum 3 screenshots. | ## Videos | Field | Dimensions | Aspect | Max duration | Max size | Format | | - | - | - | - | - | - | | `heroImageUrlAnimated` | 1200 × 630 px | 1.91:1 | 4 seconds | 1 MB | MP4, H.264 baseline, max 30 fps, no audio | | `featuredBannerImageUrlAnimated` | 2400 × 1260 px | 1.91:1 | 4 seconds | 2 MB | MP4, H.264 baseline, max 30 fps, no audio | ## Icon safe zone iconUrl safe zone diagram. The icon canvas is 1024×1024 px with a centered safe zone circle at 83% (850×850 px). Logos must stay inside the safe zone circle. | | Value | | - | - | | Canvas size | 1024 × 1024 px | | Safe zone | 850 × 850 px (83%), centered | | Format | WebP recommended, PNG, or JPG | | Max file size | 512 KB | | Transparency | Not allowed | | Corner radius | Applied by the Startale App, submit as a flat square | Keep your logo within the safe zone circle. Do not pre-apply a corner radius. The Startale App rounds the corners for you. ## Hero image safe zone heroImageUrl safe zone diagram. The canvas is 1200×630 px (1.91:1) with a vertically centered safe zone of 1200×420 px. | | Value | | - | - | | Canvas size | 1200 × 630 px (1.91:1) | | Safe zone | 1200 × 420 px, vertically centered | | Format | WebP recommended, PNG, or JPG | | Max file size | 512 KB | Keep important content within the vertically centered safe area. The hero image is used for the app grid card. ## Featured banner safe zone featuredBannerImageUrl safe zone diagram. The canvas is 2400×1260 px (1.91:1) with a centered safe zone of 1600×560 px. | | Value | | - | - | | Canvas size | 2400 × 1260 px (1.91:1) | | Safe zone | 1600 × 560 px, centered | | Format | WebP recommended, PNG, or JPG | | Max file size | 1 MB | | Desktop overlay | App info panel covers the left \~33% of the canvas | | Mobile overlay | Info bar covers the bottom \~20% of the canvas | Place logos, characters, title text, and other focal content inside the centered 1600×560 safe zone. Background imagery can extend to the full canvas, but anything load-bearing should sit within the safe area. ## App viewport If `screenCompatibility.desktop` is omitted or `false`, your Mini App renders inside a 424×695 px portrait frame on desktop. When `desktop: true`, the iframe expands to fill the available browser viewport. When `landscapeOnly: true`, the effective canvas is 695×424 px, content is rotated 90° clockwise, and you should design at that effective size. ## Aspect ratio enforcement Aspect ratios above are enforced at validation with a 1% tolerance. A 1200×640 image, for example, fails the 1.91:1 hero ratio check. ## Hosting recommendations * Serve images over a CDN with HTTP/2 and proper `Cache-Control` headers. * WebP delivers smaller files than PNG or JPG at the same visual quality. Use it where possible. * Avoid serving images from third-party redirect chains. Only 3 redirects are followed before the URL is rejected. # NFT assets Source: https://docs.startale.com/miniapps/nft-assets How the Startale App displays NFTs a user holds, and the metadata structure your collection needs for that display to work correctly. Users can hold NFTs from your Mini App in their smart account, and the Startale App surfaces them automatically in its Collectibles UI. There is no API to register a collection, display is driven entirely by standard onchain metadata that your contracts expose. Getting that metadata right is what determines whether your collection renders correctly. ## Where NFTs appear | Surface | Behavior | | - | - | | Home screen (Collectibles tab) | Groups the wallet's NFTs by issuing contract and shows up to 8 collection tiles. A count badge appears when a collection holds more than one token. Once the wallet holds more than 8 collections, a "View all" link appears, leading to `/wallet`. | | `/wallet` screen (Collectibles tab) | Shows every collection the user holds, paginated via infinite scroll (25 collections per page). | The app does not sort NFTs or collections client-side, display order comes directly from the wallet's NFT API response. A newly received NFT can show a gray placeholder tile if its collection name hasn't finished backend enrichment yet. The app polls for updated data every 15 seconds for up to 60 seconds, then stops. If a tile is still incomplete after that window, treat it as a metadata issue rather than an enrichment delay. ## Metadata structure Two layers of metadata feed the Collectibles UI, and they serve different purposes. ### Token-level metadata (required) Every NFT needs standard token metadata: `tokenURI()` for ERC-721, `uri()` for ERC-1155. The response must be JSON with an `image` field pointing to an actual image resource (a URL or `ipfs://` CID that resolves to `image/*` content). This is what renders in the NFT detail view, and what the app falls back to as a collection cover when collection-level metadata is missing or invalid. ### Collection-level metadata (optional) `contractURI()` is optional. If you implement it, the Startale App uses its `image` field as the cover shown on collection tiles, instead of picking a token image. This gives you control over the tile's appearance, but only if the field is correct. If you implement `contractURI()`, its `image` field must resolve to an actual image resource (`image/*` Content-Type), not your project's website, a redirect page, or any other HTML page. A collection that returns its homepage URL in `image` does not fail outright: the URL passes the app's extension check, so it gets set as the tile's cover, fails to load, and falls back to a held token's image (or the placeholder, if none is available) rather than showing a visibly broken image. Collection-level metadata is indexed and cached on the backend rather than read live by the app, so a correction may take some time to propagate to the Collectibles UI. You have two valid options: 1. **Skip `contractURI()` entirely.** The app falls back to a held token's image as the collection cover. Simplest option if you don't need a distinct collection-level cover. 2. **Implement `contractURI()` correctly.** Point `image` at a real image file. ```json theme={null} // Collection-level metadata, contractURI() response { "name": "Your Collection Name", "image": "https://yourproject.xyz/assets/collection-cover.webp" } ``` ```json theme={null} // Token-level metadata, tokenURI()/uri() response { "name": "Token #1", "image": "https://yourproject.xyz/assets/token-1.jpg", "attributes": [ { "trait_type": "Rarity", "value": "Rare" } ] } ``` The Startale App falls back to a token image when collection-level metadata is missing or unusable, picking one token's image and keeping it stable unless that specific token leaves the wallet. This exists to limit damage from bad data, not as a substitute for correct metadata: relying on it means your collection never gets a distinct cover, only whatever token image the app happens to pick. # Notification Policy Source: https://docs.startale.com/miniapps/notification-policy Rules every Mini App partner must follow when sending notifications through the Startale Notification Server. Notification access is a privilege. Startale owns the user's notification inbox and enforces the rules below to protect its quality. Review this page before integrating; for the wiring itself, see the [Notifications integration guide](/miniapps/notifications). Violations can result in suspension or permanent revocation of your notification access. ## Permitted notification types There are two kinds of permitted notifications: * **User-triggered** (event-driven, status updates, time-sensitive alerts): tied to a specific action the user already took inside your Mini App, or an alert the user explicitly configured. The outcome must have resolved **after the user left the app**. If the user saw the result in your UI, no notification is needed. * **Promotional**: your Mini App's own announcements and offers. These can be broadcast to every user who has added your Mini App and enabled notifications, with no prior action required. | Type | Rules | Examples | | - | - | - | | **Event-driven** | Triggered by a specific user action where the outcome resolved after they left the Mini App. | Allowed: Tournament the user entered ended while away; reward credited hours after a quest. Not allowed: "Your reward was credited" when the user was still in the app and saw it happen. | | **Status updates** | Progress or completion of a user-initiated flow that resolved after they left. | Allowed: NFT minted after a multi-step process; order fulfilled hours later. Not allowed: "Step 2 of 5 complete" (micro-step within an active flow). | | **Time-sensitive alerts** | Only for actions the user explicitly opted into within the Mini App. | Allowed: Price alert the user set; reminder the user configured. Not allowed: an alert the user never set up, framed as if they had. | | **Promotional: first-party announcements** | Your Mini App's own offers, sales, events, limited-time campaigns, and product announcements. First-party only. May be broadcast to all users who have added your Mini App and enabled notifications. | Allowed: "20% off power-ups this weekend"; "Season pass is half price today"; "New 1,000 USDC prize pool, open until Sunday"; "The weekly tournament is now live"; "Squad battles just launched." Not allowed: third-party or cross-promo offers; fake deadlines or scarcity. | **Core principle:** User-triggered notifications are for events the user missed, not events the user witnessed. Use your in-app UI for real-time feedback, and notifications as the catch-up channel for when the user is away. Promotional notifications should announce something real and worth the user's attention. Broadcasts count against each recipient's daily cap and your fan-out limit (see [Rate limits](#rate-limits)). All other notification types are prohibited in v1. ## Prohibited content | # | Prohibited | Clarification | | - | - | - | | 1 | **Financial advice or recommendations** | Investment recommendations, yield projections, and price predictions. Factual status updates about the user's own position are allowed (e.g., "Your vault APY changed from 5% to 3%"). | | 2 | **Impersonation of Startale** | Mimicking Startale system notifications (e.g., fake "Funds received" or "Security alert"). | | 3 | **Offensive or illegal content** | Hateful, discriminatory, or illegal content of any kind. | | 4 | **Deceptive content** | False urgency, fake scarcity, misleading CTAs, misinformation. | | 5 | **Third-party promotions** | Affiliate offers or cross-promotions from entities other than your own Mini App. | ## Content format requirements Payloads that violate character limits or contain prohibited formatting are rejected at ingestion with HTTP 400. | Field | Limit | Rules | | - | - | - | | **Title** | 32 chars | Plain text only. Sentence case. Must clearly describe the event. Do not repeat your Mini App name; it is already shown as the sender. | | **Body** | 128 chars | Plain text only. No HTML or markdown. Include the key information the user needs to decide whether to tap through. | | **Target URL** | | The tap destination for **Open Mini App**, when its **origin** matches your `senderId` (your Mini App's domain: scheme + host). A mismatched or absent `targetUrl` falls back to opening your Mini App's home instead, and is not delivered to that recipient at all (its token comes back in `invalidTokens`). | | **Emoji** | Max 1 per field | One emoji allowed in the title and one in the body. No emoji-only content. Emoji must not replace words. | | **Exclamation marks** | Max 1 total | One across the entire notification (title + body combined). | | **ALL CAPS** | Prohibited | No fully-capitalized words, except a short allow-list of recognized abbreviations (currently ETH, USDC, NFT). | **Good:** * Title: "Tournament ended" / Body: "You placed 3rd in the weekly tournament. 50 USDC reward credited to your wallet." * Title: "NFT minted" / Body: "Your Genesis Pass NFT has been minted. Tap to view in your collection." * Title: "Price alert triggered" / Body: "ETH dropped below \$2,000, the threshold you set on March 15." **Bad:** * Title: "DON'T MISS THIS!!!" / Body: "CLAIM YOUR REWARD NOW BEFORE IT EXPIRES!!!" (all caps, multiple exclamation marks, false urgency) * Title: "MiniGame: Update" / Body: "Check out what is new in MiniGame!" (Mini App name in title, vague, no concrete offer or event) * Title: "" / Body: "\[emoji-only content]" (empty title, emoji-only body) ## Rate limits NS enforces the limits below. When a recipient is throttled, its token comes back in the `rateLimitedTokens` array of the [send response](/miniapps/notifications#sending-a-notification). There is **no** separate error code, `reason`, or `Retry-After` returned to the sender. Retry later with backoff; don't hammer. | Layer | Limit | | - | - | | Per-(user, Mini App) daily cap | 20 notifications per user per day | | Per-Mini-App fan-out | 500 unique recipients / minute | | API gateway (Kong) | 1,000 req/min and 50 req/sec per client IP | Counting is per user, not per message: one send to 100 recipients counts once against each of those 100 users' daily caps. The fan-out limit is keyed per Mini App (your registered `senderId`), not per company. Both limits are enforced inside NS and surface only through `rateLimitedTokens`. A Kong API gateway rate-limit layer also sits above NS, applied to the whole service (both the send and delete endpoints). It is keyed per client IP, with no per-sender-ID or per-Mini-App scoping at the gateway layer. It runs in local mode per Kong instance and returns HTTP 429 when exceeded. The gateway layer is a broad abuse guard; the per-user and fan-out limits above are the ones that affect normal senders. ## Frequency guidance * There is no recommended weekly cap. User-triggered notifications scale naturally with user activity. Promotional broadcasts don't, so send them sparingly: every broadcast reaches your whole notification audience at once. * **Quality over quantity.** Only notify for meaningful state changes the user would care about. Don't notify for micro-steps within a flow ("Step 2 of 5 complete") or for confirmations the user already saw in your UI. * The hard ceiling above (20 notifications per user per day, per Mini App) is the actual limit. * **Health signal:** if your Mini App's mute rate exceeds 20% of users within 7 days of their first notification, your notifications likely aren't relevant enough. In v2, mute rates above 30% will trigger automatic review. ## User opt-out * You must stop sending to a user immediately upon receiving a [`notifications_disabled` or `miniapp_removed`](/miniapps/notifications#the-webhook-contract) webhook event. * Attempting to re-subscribe a user who has opted out is a violation and grounds for immediate suspension. * NS does not expose per-user mute state to senders. You only see per-token outcomes in the send response (`successfulTokens` / `invalidTokens` / `rateLimitedTokens`). Treat the lifecycle webhooks as the source of truth and don't try to probe or work around delivery state. ## Language * Notifications should be in English. * If your Mini App has a Japanese version, we highly recommend sending its notifications in Japanese. ## Partner onboarding * You must review and sign off on this policy before sending notifications in production. * There is no separate notification allow-list to apply for. A Mini App that ships in Startale App is already registered with the backend, so notification access activates as soon as your backend implements the webhook and stores the per-user tokens NS delivers. Coordinate production rollout timing with your Startale contact. * There is no API key. Notification access works through the per-user tokens NS delivers to your backend via the webhook. Treat those tokens as secrets: store them server-side and never share or reuse them across Mini Apps. ## Payload validation * Title: 32 characters max. Body: 128 characters max. Payloads exceeding these are rejected with `HTTP 400`. * Title and body are plain text only. HTML and markdown are not supported. * `targetUrl` is the **tap destination**: tapping **Open Mini App** opens your Mini App inline at this URL, when its **origin** matches your `senderId`. The `senderId` itself is the bare origin (scheme + host); any path or query is trimmed from it, so NS compares only the host and does not enforce the URL scheme. A `targetUrl` whose origin does not match is not rejected with a `400`. Instead that recipient's token is returned in `invalidTokens` and the notification is not delivered to it, and the app falls back to opening your Mini App's home instead of the deep link. * Use a stable `notificationId` per send. NS dedupes on it: re-sending the same `notificationId` to the same user does not deliver twice, so reuse the same id on retries. It is not returned in the send response. ## Violation consequences Notifications that breach this policy may result in warnings, temporary suspension, or permanent revocation of your notification access, depending on the severity and frequency of the violation. Serious abuse, such as impersonating Startale, security abuse, or illegal content, may lead to immediate suspension. The detailed enforcement process is still being finalized. Reach out to your Startale contact with any questions. ## Recalling a notification You can recall a notification within **5 minutes** of sending through the API, using its `notificationId`. Recall is best-effort: pushes already delivered to a device remain on the device; only the in-app inbox copy is suppressed. Use it for misfire recovery only (wrong cohort, content bug, typo caught immediately); it is not a moderation tool. # Notifications Source: https://docs.startale.com/miniapps/notifications Wire your Mini App backend to the Notification Server to receive lifecycle events and send push notifications to users. This is a guide for wiring a Mini App backend to the Startale App **Notification Server (NS)** so it can receive Mini App lifecycle events (a user added / removed the Mini App, enabled / disabled notifications) and send notifications back. It ships with two **drop-in** helper files you copy into your backend: [`src/ns-webhook.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook.ts) (parses and runtime-narrows the webhook payload) and [`src/ns-webhook-verify.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook-verify.ts) (verifies the Svix Ed25519 signature). The rule is: **call `verifyWebhookSignature()` before `parseWebhookPayload()`**. Reject forged requests before you trust the body. The [**Mustard demo Mini App**](https://github.com/StartaleGroup/mustard-demo-miniapp) is the reference example. **Status: signing scheme live.** NS signs every webhook with the **Svix** scheme (Ed25519 over `svix-id` / `svix-timestamp` / `svix-signature` headers) and carries the user address in the JSON body. Verification is **implemented** in [`src/ns-webhook-verify.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook-verify.ts) and **enforced** in [`src/index.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/index.ts): webhooks failing verification get a `401`. Set `NS_JWKS_URL` to enable it. ## What you get * **Receive** NS subscription lifecycle webhooks: a user added/removed the Mini App, or enabled/disabled notifications. * **Verify** each webhook's Svix Ed25519 signature against the NS JWKS, so forged or tampered requests are rejected. During a key rotation the header may carry multiple signatures; the webhook is accepted if any signature matches any JWKS key. * **Send** notifications to NS using the per-user token NS hands you through the webhook. ## Registering your Mini App Your Mini App is registered with Startale App **automatically as soon as it calls `ready()`**: there is **no need to call `addMiniapp()`** (or any explicit "add" action). Calling `sdk.actions.ready()` signals that your app has finished loading and registers it with the host; from that point Startale App treats it as an installed Mini App and NS can start delivering the lifecycle webhooks documented below. Mustard demo Mini App calls it once on mount, see [`src/App.tsx`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/src/App.tsx): ```tsx theme={null} import { sdk } from '@farcaster/miniapp-sdk' function App() { useEffect(() => { sdk.actions.ready() }, []) // ... } ``` Once registered, the user adding your Mini App or toggling notifications is what triggers NS to POST the `miniapp_added` / `notifications_enabled` webhooks. That is where you receive the token. You never request the token directly; NS hands it to you through the webhook. ## The webhook contract NS sends a POST to a webhook URL you register with the Mini App manifest. Each request looks like: ```http theme={null} POST /your-webhook-path HTTP/1.1 Content-Type: application/json svix-id: msg_1717245600000000000 svix-timestamp: 1717245600 svix-signature: v1a, { "event": "miniapp_added", "senderId": "https://your-miniapp.example.com", "userAddress": "0x1234...abcd", "notificationDetails": { "url": "https://ns.example.com/api/v1/miniapp/send-notification", "token": "" } } ``` Every event carries: * **`senderId`**: always present; identifies the subscription's sender (Mini App). It is your Mini App's **origin**: scheme + host only (e.g. `https://your-miniapp.example.com`). Any path or query string is trimmed, so per-language URL variants such as `.../?lang=ja` or `.../jp` all resolve to the same `senderId`. This is the domain-level identity NS validates `targetUrl` against when you send. It is still a URL string (with scheme), so URL-encode it if you ever pass it as a path parameter. * **`userAddress`**: the user's smart-account address, **in the body**. May be omitted if NS could not resolve it. Four event types, two carry `notificationDetails`, two don't: | `event` | Body shape | When it fires | | - | - | - | | `miniapp_added` | `{ event, senderId, userAddress?, notificationDetails: { url, token } }` | User added the Mini App. **Store the token.** | | `notifications_enabled` | `{ event, senderId, userAddress?, notificationDetails: { url, token } }` | User re-enabled notifications. **Rotate the token.** | | `notifications_disabled` | `{ event, senderId, userAddress? }` | User toggled notifications off. **Stop sending.** | | `miniapp_removed` | `{ event, senderId, userAddress? }` | User removed the Mini App. **Delete the token.** | `notificationDetails.url` is the fully-qualified NS send endpoint you POST to when delivering a push. **Use it verbatim, do not derive or rewrite it.** `notificationDetails.token` is the per-user token to include in that POST. Store both together when you receive `miniapp_added` / `notifications_enabled`; either can change on a token rotation. **Respond with HTTP 200** on success (any `2xx` body; most teams use `{ "success": true }`). Anything else makes NS retry: use `400` for malformed-body failures and `401` for signature failures so permanent errors don't loop forever. **Confirm the exact retry policy with the Startale team** before depending on this. ## Signature format NS signs each webhook using the **Svix** scheme. Three headers carry the signature: * **`svix-id`**: unique message id (e.g. `msg_`). * **`svix-timestamp`**: unix seconds at send time. * **`svix-signature`**: one or more space-separated `v1a,` entries, where `` is a **base64-standard** (not base64url) encoded raw **Ed25519** signature. **Key rotation → multiple signatures.** When Svix rotates its signing key it keeps signing with the *old* key for \~24h alongside the new one. During that window `svix-signature` carries **multiple** space-separated entries (`v1a, v1a,`); outside it, a single entry. Because more than one key may be in play, the receiver does **not** pin to a single key. It accepts the webhook if **any** signature verifies against **any** Ed25519 key published in the JWKS. (NS still publishes a `kid` per key, but it no longer selects *the* verifying key.) The signed string is: ``` ${svix-id}.${svix-timestamp}.${rawBody} ``` where `rawBody` is the exact bytes of the JSON request body. Verify by: 1. Fetching the NS JWKS (`NS_JWKS_URL`) and collecting **all** its Ed25519 keys. 2. Reconstructing `toSign` from the three values above (read the body as a **raw string**; re-serializing parsed JSON will not byte-match). 3. Accepting the webhook if **any** `v1a` signature in the header verifies over `toSign` against **any** of those public keys. This is exactly what [`src/ns-webhook-verify.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook-verify.ts)'s `verifyWebhookSignature()` does: it imports the JWKS key with `jose`'s `importJWK` and verifies with Node's built-in `crypto`. `jose` v6 `importJWK` returns a WebCrypto `CryptoKey`, which the helper converts via `KeyObject.from()` for `crypto.verify`. ## Install: files to copy Copy both files from the [Mustard demo Mini App repo](https://github.com/StartaleGroup/mustard-demo-miniapp/tree/main/backend) into your backend's source tree. Each is self-contained (no app imports), and the verifier reads no environment variables (you pass config in). | File | Responsibility | | - | - | | [`src/ns-webhook.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook.ts) | Parse the JSON body and narrow it to a typed event union. Throws on malformed input. | | [`src/ns-webhook-verify.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook-verify.ts) | Verify the Svix Ed25519 signature against the NS JWKS (any key matches). Throws on any failure. | ### Dependency * **`jose`**: used only to import the JWKS Ed25519 key. Ed25519 verification itself uses Node's built-in `crypto`. No other runtime dependency. (`ns-webhook.ts` is pure TypeScript with no dependency at all.) Do **not** add the `svix` npm package. It targets symmetric (HMAC) secrets and its own header handling; it does not fit this **Ed25519-via-JWKS** scheme. ## Configuration | Var | Value | Notes | | - | - | - | | `NS_JWKS_URL` | `https://ns-app.startale.com/.well-known/jwks.json` | Required for signature verification. | The verifier module does **not** read `process.env` itself; you read `NS_JWKS_URL` in your app and pass it as `{ jwksUrl }`. This keeps the module portable across projects with different config conventions. ## Wire-up Framework-agnostic shape: ```ts theme={null} import { NS_WEBHOOK_EVENTS, parseWebhookPayload } from './ns-webhook.js' import { verifyWebhookSignature } from './ns-webhook-verify.js' const NS_JWKS_URL = process.env.NS_JWKS_URL! async function handleNsWebhook(rawBody: string, headers: { svixId?: string; svixTimestamp?: string; svixSignature?: string }) { // 1. Verify FIRST: reject forged/tampered webhooks before parsing. await verifyWebhookSignature(rawBody, headers, { jwksUrl: NS_JWKS_URL }) // throws → respond 401 // 2. Parse & narrow the (now trusted) body. const payload = parseWebhookPayload(rawBody) const userAddress = payload.userAddress?.toLowerCase() // address is in the body // 3. Store / rotate / delete the token keyed by userAddress. switch (payload.event) { case NS_WEBHOOK_EVENTS.MINIAPP_ADDED: case NS_WEBHOOK_EVENTS.NOTIFICATIONS_ENABLED: if (!userAddress) throw new Error('missing userAddress') await saveToken(userAddress, payload.notificationDetails) // { url, token } break case NS_WEBHOOK_EVENTS.NOTIFICATIONS_DISABLED: case NS_WEBHOOK_EVENTS.MINIAPP_REMOVED: if (userAddress) await removeToken(userAddress) break } } ``` Hono (matches example repo). A `401` signature gate runs before parsing; malformed bodies return `400`: ```ts theme={null} app.post('/webhook', async (c) => { const rawBody = await c.req.text() // read ONCE: reuse for both verify and parse if (!NS_JWKS_URL) return c.json({ success: false, error: 'server misconfigured' }, 500) // 1. Verify svix-id / svix-timestamp / svix-signature → 401 on failure. try { await verifyWebhookSignature( rawBody, { svixId: c.req.header('svix-id'), svixTimestamp: c.req.header('svix-timestamp'), svixSignature: c.req.header('svix-signature'), }, { jwksUrl: NS_JWKS_URL }, ) } catch { return c.json({ success: false, error: 'invalid signature' }, 401) } // 2. Parse & narrow the trusted body → 400 on malformed input. let payload: ReturnType try { payload = parseWebhookPayload(rawBody) } catch { return c.json({ success: false, error: 'invalid payload' }, 400) } const userAddress = payload.userAddress?.toLowerCase() // 3. ...store / rotate / delete the token (see switch above), then: return c.json({ success: true }) }) ``` **Read the body as a raw string.** Verification signs the exact bytes (`${svix-id}.${svix-timestamp}.${rawBody}`); re-`JSON.stringify`-ing a parsed object would change key order/whitespace and break verification. ## API reference ### `parseWebhookPayload(rawBody: string): NsWebhookPayload` Lives in [`ns-webhook.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook.ts). Parses the JSON body and narrows it to a typed discriminated union. Throws on: * invalid JSON * unknown `event` * missing or wrong-typed `senderId` * a present-but-non-string `userAddress` * missing or wrong-typed fields in `notificationDetails` (for `miniapp_added` / `notifications_enabled`) ### `verifyWebhookSignature(rawBody, headers, { jwksUrl }): Promise` Lives in [`ns-webhook-verify.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/ns-webhook-verify.ts). Verifies the Svix Ed25519 signature; **call it before `parseWebhookPayload`**. Resolves on success, throws on any failure (missing headers, JWKS fetch error, no usable keys, or no signature matching any JWKS key); map a throw to a `401`. Reads no env vars; pass `jwksUrl` in. ## Sending a notification To deliver a notification, POST to the `notificationDetails.url` you stored (**use it verbatim**, do not derive or rewrite it) with the matching `token`(s). Mustard demo Mini App's `sendNotification()` in [`src/index.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/index.ts) is the reference; the request is a plain `Content-Type: application/json` POST: ```http theme={null} POST /api/v1/miniapp/send-notification HTTP/1.1 Content-Type: application/json { "notificationId": "mustard-test-1782380154189", "title": "Test notification", "body": "Alan wants to move to Bali", "targetUrl": "https://your-miniapp.example.com", "tokens": ["", "..."] } ``` | Field | Meaning | | - | - | | `notificationId` | Your stable id for this send (e.g. `mustard-test-`). NS dedupes on it: re-sending the same `notificationId` to the same user does not deliver twice (it counts as a success), so reuse the same id when you retry. It is not echoed back in the response. | | `title` / `body` | The notification's headline and message text. | | `targetUrl` | Optional. **The tap destination.** When its origin matches your `senderId`, tapping **Open Mini App** opens your Mini App inline on the notifications screen, loaded at this URL. If absent, or its origin doesn't match, the button falls back to opening your Mini App's home instead. NS also validates the origin at send time: a mismatched `targetUrl` is **not** rejected with a `400`, instead that recipient's token comes back in `invalidTokens` and is skipped. | | `tokens` | Array of per-user push tokens. Batch the tokens that share the **same** `notificationDetails.url` into one request. **Max 100 tokens per request.** More than that is rejected with `HTTP 400`. | **`targetUrl` is the tap destination, gated to your own origin.** Tapping **Open Mini App** opens your Mini App inline on the notifications screen, loaded at `targetUrl`, as long as its origin matches your registered Mini App. If there is no `targetUrl`, or its origin doesn't match, the button falls back to the old behavior: opening your Mini App's home. Because NS also rejects a mismatched `targetUrl` at send time (the token comes back in `invalidTokens` and the notification is never delivered to it), the app-side fallback is mainly a defense-in-depth backstop, not something to rely on for cross-origin links. `targetUrl` is a full URL you supply yourself, not the same mechanism as a shareable [deep link](/miniapps/deep-linking#deep-links-and-notification-tap-destinations) (`#appId`). See that page for how the two compare. **Content rules and rate limits are enforced by NS.** Payloads that violate format requirements (title/body length, ALL CAPS, emoji count, exclamation marks) are rejected with `HTTP 400` before delivery. Tokens that exceed the per-user daily cap or fan-out limit are returned in `rateLimitedTokens`. See the [Notification Policy](/miniapps/notification-policy) for the full rules, permitted notification types, and frequency guidance. NS responds **200** with a body that partitions the tokens by outcome: ```json theme={null} { "successfulTokens": [""], "invalidTokens": [], "rateLimitedTokens": [] } ``` Act on the response: * **`invalidTokens`**: the token is dead (user removed the Mini App / disabled notifications and you missed the webhook). **Delete it from your store.** * **`rateLimitedTokens`**: NS throttled these; **retry later with backoff**, don't hammer. * **`successfulTokens`**: delivered. **Never log full tokens.** Mustard demo Mini App previews them (last 8 chars) in both the outgoing-payload and NS-response logs, see `tokenPreview` / `previewNsResponseBody` in [`src/index.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/index.ts). ## Recalling a notification You can recall a notification within **5 minutes** of sending. POST to the same NS host as your send URL, at the path `/api/v1/miniapp/delete-notification`, with the `notificationId` you used on the send and the `tokens` you want to recall it from: ```http theme={null} POST /api/v1/miniapp/delete-notification HTTP/1.1 Content-Type: application/json { "notificationId": "mustard-test-1782380154189", "tokens": ["", "..."] } ``` Recall deletes the stored notification, removing it from the in-app inbox. It is best-effort: a push already delivered to a device stays on the device. The response partitions your tokens into `successfulTokens`, `invalidTokens`, `notificationTooOldTokens` (the notification is past the 5-minute window), and `notificationNotFoundTokens` (no notification matched that `notificationId` for that token). Use it for misfire recovery (wrong cohort, content bug, typo caught immediately), not as a moderation tool. Like send, recall accepts up to 100 tokens per request. See the [Notification Policy](/miniapps/notification-policy#recalling-a-notification) for the rules. ## Production checklist **Persistence: read before going to production.** Mustard stores tokens in an in-memory `Map` (`tokensByAddress` in [`src/index.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/index.ts)) and is **NOT** production-ready. That is a deliberate demo simplification to keep the example dependency-free. In-memory state is **lost on every restart / redeploy** (users silently stop receiving pushes) and **not shared across instances** (it breaks the moment you run more than one replica). **A database is mandatory for any production Mini App.** Persist `(userAddress → { token, url })` in a durable store (Postgres, Redis, etc.), updating it on `miniapp_added` / `notifications_enabled` and removing it on `miniapp_removed` / `notifications_disabled`. Ideally also dedupe on the `svix-id` header to make webhook delivery idempotent. The signature-verification and parsing files are production-ready as-is; only the **storage layer** in [`index.ts`](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/backend/src/index.ts) needs to be swapped for a real database. ### Replay protection Mustard demo Mini App does **not** enforce `svix-timestamp` freshness (the verifier only checks the signature). For production, consider rejecting webhooks whose `svix-timestamp` is outside a tolerance window, plus `svix-id` dedupe, to harden against replay. NS sends a stable `svix-id` per message; `(userAddress, event, token)` also works as a dedupe key. ## Local testing (ngrok) NS calls your `/webhook` endpoint over the public internet and **cannot reach `localhost`**. To test the webhook flow, expose your backend through a tunnel such as ngrok (or any equivalent) and register the resulting public `https://…/webhook` URL with the manifest / Startale team. The Mustard demo Mini App example ships an ngrok service ready to go, see the [repo README](https://github.com/StartaleGroup/mustard-demo-miniapp/blob/main/README.md#public-url-for-testing-ngrok). **Testing without a live NS.** Stand up a local JWKS server and sign test webhooks with a matching Ed25519 private key (mirror the `${id}.${ts}.${body}` signing string, base64-standard encode the signature). Useful for exercising `verifyWebhookSignature` without a live NS. ## Ask the Startale team for 1. **Retry policy** on non-2xx responses (so you can size your idempotency window). 2. The **send-side** request contract for delivering notifications to `notificationDetails.url`. # Mini Apps overview Source: https://docs.startale.com/miniapps/overview Ship your project as a Mini App inside the Startale App. Same wallet, less plumbing, native distribution. A Startale Mini App is a web application that runs inside the Startale App on mobile and desktop, in an embedded frame. It uses the host's wallet, has access to user STAR Points, and can read the user's verified linked EOAs. Mini Apps follow the [Farcaster Mini App](https://miniapps.farcaster.xyz) specification, with Startale-specific extensions documented here. **Protocol foundation.** Startale uses the Farcaster Mini App protocol as a technical communication standard, not as a social platform. Users do not need a Farcaster account to use your Mini App. Farcaster social features (sign-in with Farcaster, cast composition, social graph) are not available inside the Startale App. The protocol provides the iframe communication standard and manifest format only. ## Why ship a Mini App | Build a Mini App when | Stay a standalone application when | | - | - | | Your audience already lives in the Startale App | You need cross-wallet support beyond Startale | | You want to read user STAR Points or verified EOAs | You need full control of the auth layer | | You are running a campaign with Startale | You require a non-EVM stack | | You want native distribution in the explorer | You have an existing user base outside Startale | ## How a Mini App differs from a standalone application * Authentication is shared with the Startale App, no separate sign-in. * The wallet popup is replaced by the host's approval UI. * The signer is always the user's smart account; linked EOAs are read-only. * A handful of EIP-1193 methods (`wallet_switchEthereumChain`, `wallet_addEthereumChain`) are not available inside the host. See [Wallet integration](/miniapps/wallet-integration) for the full surface. ## What you ship 1. A web application served over HTTPS. 2. A signed manifest at `/.well-known/farcaster.json`. 3. An `fc:miniapp` meta tag on shareable pages. 4. A submission to Startale. ## Reusing a Farcaster or Base manifest If your project already lives on Farcaster or Base, your existing `farcaster.json` works on Startale. Add Startale-specific fields inside a `startale` object within your existing `frame` object. A single file works on all three platforms. See [Manifest](/miniapps/manifest). ## Get started Build, sign, register, and run your Mini App. Every field, what is required, what is optional, what gets used. Link straight into a specific screen inside your Mini App. Preview, sandbox, and device-test your Mini App before you submit it. QA, deadlines, and the intake form. Manifest validator, embed validator, and Mini App preview in one place. # Mini App quickstart Source: https://docs.startale.com/miniapps/quickstart Build, sign, register, and launch a Startale Mini App in the local sandbox. By the end of this guide you will have a working Mini App running inside the Startale Mini App sandbox, signed in with the host wallet. ## Prerequisites * Node.js 20 or later, plus pnpm. * An existing web application or a fresh Vite + React project. If you do not have a project, scaffold one: ```bash theme={null} pnpm create vite@latest my-miniapp --template react-ts cd my-miniapp pnpm install ``` ## 1. Install the SDKs ```bash theme={null} pnpm add @startale/app-sdk @farcaster/miniapp-sdk @farcaster/miniapp-wagmi-connector wagmi viem @tanstack/react-query ``` ## 2. Configure wagmi with the Startale connector ```ts src/wagmi.ts theme={null} import { startaleConnector } from '@startale/app-sdk' import { http, createConfig } from 'wagmi' import { soneium } from 'wagmi/chains' export const config = createConfig({ chains: [soneium], connectors: [startaleConnector()], transports: { [soneium.id]: http(), }, }) ``` The Startale connector replaces the default Farcaster wallet. Inside the Startale App, connections route to the user's smart account. ## 3. Initialize the Farcaster SDK ```tsx src/App.tsx theme={null} import { useEffect } from 'react' import { sdk } from '@farcaster/miniapp-sdk' export function App() { useEffect(() => { sdk.actions.ready() }, []) return } ``` `sdk.actions.ready()` tells the host the Mini App has loaded. ## 4. Add the manifest Create `public/.well-known/farcaster.json`. Once it is hosted, validate it at [app.startale.com/developers/manifest](https://app.startale.com/developers/manifest). ```json public/.well-known/farcaster.json theme={null} { "frame": { "version": "1", "name": "My Mini App", "tagline": "Predict, win, claim", "subtitle": "Prediction markets on Soneium", "iconUrl": "https://my-miniapp.com/icon.webp", "homeUrl": "https://my-miniapp.com", "splashImageUrl": "https://my-miniapp.com/splash.png", "splashBackgroundColor": "#0c0c0f", "heroImageUrl": "https://my-miniapp.com/hero.webp", "primaryCategory": "games", "featuredBannerImageUrl": "https://my-miniapp.com/banner.webp", "projectWebsite": "https://my-miniapp.com", "socialLinks": { "twitter": "https://x.com/myminiapp" } } } ``` See [Manifest](/miniapps/manifest) for every field, [Media specs](/miniapps/media-specs) for image requirements. ## 5. Sign the manifest (optional) `accountAssociation` is part of the Farcaster spec and links your domain to a Farcaster account. The Startale App does not validate or use it at registration time, it is ignored. You can include it for compatibility with other Farcaster clients such as Warpcast. If you need it, open the [Farcaster manifest tool](https://farcaster.xyz/~/developers/mini-apps/manifest), enter your hostname (no `https://` prefix), and follow the steps. Paste the resulting object into your manifest as a sibling of `frame`: ```json theme={null} { "accountAssociation": { "header": "...", "payload": "...", "signature": "..." }, "frame": { /* ... */ } } ``` ## 6. Add the embed meta tag In the `` of every page you want shareable, add the tag below. Validate it at [app.startale.com/developers/embed](https://app.startale.com/developers/embed). ```html theme={null} ``` See [Embed](/miniapps/embed) for the full schema. ## 7. Preview and test Open [app.startale.com/developers/preview](https://app.startale.com/developers/preview) to preview your Mini App instantly without any local setup. See [Test your Mini App](/miniapps/testing) for the sandbox, iOS Safari, and other testing steps to run before you submit. ## Where to next STAR Points and verified linked EOAs. Which RPC methods are available inside the host. QA, deadlines, and the intake form. Preview, sandbox, and device-test your Mini App before you submit it. # Runtime context Source: https://docs.startale.com/miniapps/runtime-context Read sdk.context to access the full host context (user identity, capabilities, and Startale-specific data) from inside your Mini App. When your Mini App runs inside the Startale App, the host populates `sdk.context` with three layers: Farcaster-standard fields, client/device metadata, and a `startale` extension object with Startale-specific user data. ## Full context shape ```ts theme={null} const context = await sdk.context // Farcaster-standard user identity context.user // { fid, username, displayName, pfpUrl } context.location // { type: 'launcher' } context.client // { platformType, clientFid, added, safeAreaInsets } // Device capabilities context.features // { haptics: boolean, cameraAndMicrophoneAccess: boolean } // Startale-specific context.startale // { starPoints, eoaWallets, language, legacyAddress, legacySignature } ``` | Field | Type | Description | | - | - | - | | `user.fid` | `number` | Internal Startale identifier. Present for protocol compatibility, not a Farcaster account ID. Do not use this to identify users; use the smart account address from `eth_requestAccounts` instead. | | `user.username` | `string` | The user's Startale display username. | | `user.displayName` | `string` | Display name. | | `user.pfpUrl` | `string` | Profile photo URL. | | `location.type` | `string` | Launch context. Always `'launcher'` when opened from the Mini App grid. | | `client.platformType` | `string` | Host platform identifier. | | `client.clientFid` | `number` | Internal Startale client identifier. Present for protocol compatibility, not a Farcaster account ID. | | `client.safeAreaInsets` | `object` | `{ top, bottom, left, right }` in px; use for notch-safe layout. | | `features.haptics` | `boolean` | Whether the host supports haptic feedback. Use before calling haptic actions. | | `features.cameraAndMicrophoneAccess` | `boolean` | Whether camera/mic access is available. `true` only in a secure context (HTTPS) on a device with media devices. Check before requesting access. See [SDK actions](/miniapps/sdk-actions#actions-requestcameraandmicrophoneaccess). | | `startale.starPoints` | `number` | The user's current STAR Points balance. | | `startale.eoaWallets` | `string[]` | Addresses verified as linked to the user's smart account. | | `startale.language` | `string` | The Startale App **edition** the user is in, as a code (`'en'` for Global, `'ja'` for Japan). It is not simply a UI language toggle, see [Reading the app edition](#reading-the-app-edition). | | `startale.legacyAddress` | `string` | The user's smart account address from before an account upgrade. Only present once the user has completed an upgrade; `undefined` otherwise. See [Legacy address after an account upgrade](#legacy-address-after-an-account-upgrade). | | `startale.legacySignature` | `string` | A signature from `legacyAddress` approving the current smart account as its successor. Set together with `legacyAddress`. Verify it before relying on `legacyAddress` for anything beyond display, see [Verifying the legacy signature](#verifying-the-legacy-signature). | **Identifying users.** Use the smart account address (retrieved via `eth_requestAccounts`) as the primary user identifier in your backend. `user.fid` and `client.clientFid` are internal protocol placeholders and are not stable user identifiers. ## Reading context `sdk.context` is a Promise. The bridge between your Mini App and the host is asynchronous, so you must `await` it before reading any field. The `startale` object is a Startale extension not included in the official `@farcaster/miniapp-sdk` TypeScript types. Cast the result to access it with full type safety: ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' type StartaleContext = { user?: { username?: string; displayName?: string; pfpUrl?: string } client?: { safeAreaInsets?: { top: number; bottom: number; left: number; right: number } } features?: { haptics?: boolean; cameraAndMicrophoneAccess?: boolean } startale?: { starPoints?: number eoaWallets?: string[] language?: 'en' | 'ja' legacyAddress?: string legacySignature?: string } } const context = (await sdk.context) as StartaleContext const starPoints = context.startale?.starPoints ?? 0 const eoaWallets = context.startale?.eoaWallets ?? [] const language = context.startale?.language ?? 'en' const username = context.user?.username ?? '' const legacyAddress = context.startale?.legacyAddress const legacySignature = context.startale?.legacySignature ``` In React, read context once on mount inside a `useEffect`: ```tsx theme={null} import { useEffect, useState } from 'react' import { sdk } from '@farcaster/miniapp-sdk' type StartaleContext = { user?: { username?: string; pfpUrl?: string } features?: { haptics?: boolean; cameraAndMicrophoneAccess?: boolean } client?: { safeAreaInsets?: { top: number; bottom: number; left: number; right: number } } startale?: { starPoints?: number eoaWallets?: string[] language?: 'en' | 'ja' legacyAddress?: string legacySignature?: string } } export function useStartaleContext() { const [context, setContext] = useState(null) useEffect(() => { sdk.context .then((raw) => setContext(raw as StartaleContext)) .catch(console.error) }, []) return context } ``` ```tsx theme={null} function App() { const context = useStartaleContext() const starPoints = context?.startale?.starPoints ?? 0 const eoaWallets = context?.startale?.eoaWallets ?? [] const username = context?.user?.username ?? '' return } ``` The `startale` type cast is necessary today because the Startale context extensions are not yet part of the upstream `@farcaster/miniapp-sdk` type definitions. The cast is safe; the host always populates these fields when your Mini App runs inside the Startale App. ## STAR Points STAR Points are the Startale App's user-engagement currency. Users earn them through daily activities in the Mission Center and through USDSC usage inside the app, and the balance is treated as a proof of contribution toward future token rewards. Mini Apps see the balance through `sdk.context.startale.starPoints` and can use it as an eligibility signal, for example, gating bonus content or rewarding power users. For the full earning rules and the latest mission catalog, link users to the [Startale App page](https://startale.com/en/app). ## Reading user STAR Points ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' const context = await sdk.context const points = context.startale?.starPoints ?? 0 console.log(`User has ${points} STAR Points`) ``` ## Reading the app edition `startale.language` tells you which edition of the Startale App the user is running at the moment your Mini App loads: Global (`'en'`) or Japan (`'ja'`). The host decides this, including any residency and compliance checks, before your Mini App ever sees it. Use it to pick the content or feature set your Mini App shows, for example JP-only copy or anything gated on Japan compliance requirements. Don't run your own geo or residency checks for this: if `startale.language` says `'ja'`, the host has already confirmed the user's edition, and any JP content it's willing to show them is already vetted for that edition. Edition and display language happen to match today, so you can also use the same field to pick your Mini App's own UI translation if it has one: ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' const context = await sdk.context const edition = context.startale?.language ?? 'en' // 'en' = Global, 'ja' = Japan if (edition === 'ja') { // show Japan-edition content } i18n.changeLanguage(edition) // optional: also use it for your own i18n ``` ## Discovering linked EOAs `eoaWallets` is the canonical way to discover personal wallets the user has linked to their Startale account. Each address has been verified server-side via SIWE. Common use cases: * Restoring user progress from an existing user base. * Granting NFT utility based on assets held in a personal wallet. * Co-marketing eligibility checks ("are you an existing player of project X?"). If your project issues NFTs that users hold directly in their smart account, see [NFT assets](/miniapps/nft-assets) for how those are surfaced in the Startale App's Collectibles UI and the metadata structure required for correct display. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' import { createPublicClient, http, erc721Abi } from 'viem' import { soneium } from 'viem/chains' const context = await sdk.context const eoaAddresses = context.startale?.eoaWallets ?? [] const publicClient = createPublicClient({ chain: soneium, transport: http(), }) for (const address of eoaAddresses) { const balance = await publicClient.readContract({ address: NFT_CONTRACT_ADDRESS, abi: erc721Abi, functionName: 'balanceOf', args: [address as `0x${string}`], }) if (balance > 0n) { // User holds the NFT in this linked EOA } } ``` ## EOAs are read-only You can read balances and onchain state for any address in `eoaWallets`. You cannot sign or send transactions from a linked EOA inside a Mini App, the active signer is always the user's smart account. If a user holds assets in a linked EOA that they want to use inside your Mini App: 1. Detect the asset using `eoaWallets` and a read-only contract call. 2. Inform the user that they need to transfer the asset to their smart account. 3. The transfer must happen outside the Mini App, on your project's standalone website where MetaMask connects directly. See [Wallet integration](/miniapps/wallet-integration) for the full constraint surface. ## Legacy address after an account upgrade Startale may, under rare circumstances, upgrade a user to a new smart account address. When this happens, the address your Mini App sees from `eth_requestAccounts` changes. `startale.legacyAddress` and `startale.legacySignature` let you detect the change and remap any offchain state (progress, inventory, points) you keyed to the old address. Both fields are set together, once the user has completed the upgrade. Before that, and for users who have never been upgraded, both are `undefined`; the two cases are indistinguishable from context alone. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' const context = await sdk.context const legacyAddress = context.startale?.legacyAddress const legacySignature = context.startale?.legacySignature if (legacyAddress) { // The user has upgraded. legacyAddress was their smart account address // before the upgrade; the current address is still the one returned by // eth_requestAccounts. } ``` `legacyAddress` is reported by the client SDK, like the rest of `sdk.context`. Do not trust it for a privileged operation, such as crediting a balance or transferring a paid entitlement, without verifying `legacySignature` first. `legacyAddress` is not guaranteed to be checksummed ([EIP-55](https://eips.ethereum.org/EIPS/eip-55)). Compare it case-insensitively, for example by lowercasing both sides, instead of with a strict string equality check against an address you already have on file in a different casing. ## Verifying the legacy signature `legacySignature` is a signature produced by the smart account at `legacyAddress`, over the current smart account address lowercased to a plain string. It certifies that the legacy account itself approved the current account as its successor. Verify it the same way you verify any Startale smart-account signature, see [Sign messages](/app-sdk/sign-messages): with viem's `verifyMessage`, which resolves EOA, ERC-1271, and [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492) signatures automatically. ```ts theme={null} import { createPublicClient, http } from 'viem' import { soneium } from 'viem/chains' const publicClient = createPublicClient({ chain: soneium, transport: http() }) async function verifyLegacyAddress( legacyAddress: string, legacySignature: string, currentAddress: string, ) { return publicClient.verifyMessage({ address: legacyAddress as `0x${string}`, message: currentAddress.toLowerCase(), signature: legacySignature as `0x${string}`, }) } ``` Reject the mapping unless all of the following hold: `legacyAddress` and `legacySignature` are both present, `verifyMessage` returns `true`, and the address you passed as `currentAddress` is the one you independently obtained from `eth_requestAccounts`, not a value read from context. [Reference verification snippet](https://gist.github.com/JGJP/2f0a0cd55b66f04206d3d1c2adf739ae) ## Sandbox simulation gap The Mini App sandbox simulates `sdk.context.startale.starPoints` as `100` and populates `location`, `client.platformType`, and `client.safeAreaInsets` with default values. `sdk.context.startale.eoaWallets`, `sdk.context.startale.language`, `user`, and `features` are not populated. Test EOA-discovery features, identity-dependent logic, and capability checks against a staging or production Startale App, not the local sandbox. # SDK actions Source: https://docs.startale.com/miniapps/sdk-actions Call host actions from inside your Mini App: closing the frame, opening URLs, adding to home, requesting camera access, and haptic feedback. The Startale App exposes a set of host actions through `@farcaster/miniapp-sdk`. These let your Mini App communicate with the host shell: close the frame, open external URLs, add the app to the user's home, and trigger haptic feedback. Import the SDK and call actions directly: ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' ``` ## Checking supported capabilities Before calling optional actions, query the host for what it supports: ```ts theme={null} const capabilities = await sdk.getCapabilities() // ['wallet.getEthereumProvider', 'actions.addMiniApp', 'actions.ready', 'actions.openUrl', 'actions.close', 'actions.requestCameraAndMicrophoneAccess'] ``` ## actions.ready **Required.** Call this once on mount to signal the host that your Mini App has fully loaded. The host shows a loading screen until `ready()` is called. ```tsx theme={null} import { useEffect } from 'react' import { sdk } from '@farcaster/miniapp-sdk' export function App() { useEffect(() => { sdk.actions.ready() }, []) return } ``` If `ready()` is never called, the host will display a timeout error to the user after 15 seconds. ## actions.close Closes the Mini App frame and returns the user to the Startale App home screen. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' function CloseButton() { return ( ) } ``` ## actions.openUrl Opens a URL in the device's default browser. The host validates that the URL uses `http:` or `https:`. Other protocols are silently blocked. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' sdk.actions.openUrl('https://your-project.com/terms') ``` Use this for links that need to leave the Mini App context: terms of service, external dashboards, social profile pages. URLs with non-http/https protocols (e.g. `javascript:`, `data:`) are rejected by the host without throwing an error. Always use full `https://` URLs. ## actions.addMiniApp Adds the Mini App to the user's home screen inside the Startale App. Returns `{ result: {} }` on success and fires a `miniAppAdded` SDK event you can listen to. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' async function handleAddToHome() { const result = await sdk.actions.addMiniApp() // result: { result: {} } } ``` Listen for the `miniAppAdded` event on the SDK if you want to react to the user saving your app: ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' sdk.on('miniAppAdded', () => { console.log('User added this Mini App to their home') }) ``` ## Haptic feedback Three haptic actions are available. Check `sdk.context.features.haptics` before calling them. On platforms that do not support haptics, the calls are no-ops. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' const context = await sdk.context const hasHaptics = context.features?.haptics ?? false if (hasHaptics) { // Light tap, use for UI selections await sdk.actions.impactOccurred({ style: 'light' }) // Notification, use for success, warning, or error feedback await sdk.actions.notificationOccurred({ type: 'success' }) // Selection change, use when a picker or slider value changes await sdk.actions.selectionChanged() } ``` | Action | When to use | | - | - | | `impactOccurred` | Button taps, drag start/end, collision feedback | | `notificationOccurred` | Transaction confirmed, error state, achievement unlocked | | `selectionChanged` | Scroll picker moved, toggle changed | ## actions.requestCameraAndMicrophoneAccess Use this to enable the camera and microphone inside your Mini App. The flow has three steps, in order: 1. **Check the feature flag.** Read `context.features.cameraAndMicrophoneAccess`. It is `true` only when the host runs your Mini App in a secure context (HTTPS) on a device that exposes media devices. If it is `false`, hide or disable your capture UI, `getUserMedia` would otherwise fail opaquely. 2. **Call `sdk.actions.requestCameraAndMicrophoneAccess()`.** This signals the host that your Mini App is about to use the camera/mic. Always `await` it before requesting a stream. 3. **Call `navigator.mediaDevices.getUserMedia(...)`.** This is the call that actually opens the stream and triggers the browser's native permission prompt. ```ts theme={null} import { sdk } from '@farcaster/miniapp-sdk' async function startCapture() { const context = await sdk.context if (!context.features?.cameraAndMicrophoneAccess) return await sdk.actions.requestCameraAndMicrophoneAccess() const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }) // attach `stream` to a