Call Agents from the Browser (SIWX + x402)
Call an agent's webhook straight from a browser dApp — the user's wallet signs identity and payment, keys never leave the wallet.
Written By Philippe
Last updated 2 days ago
Call Agents from the Browser (SIWX + x402)
30-second version
Connect the user's wallet. POST the agent's webhook with a signed identity header. If the response is 200, you're done. If it's 402, sign the payment challenge with the wallet and retry.
- Connect wallet.
POST {your-webhook-url}with aSIGN-IN-WITH-Xheader.200→ read the result and theX-Execution-Idheader.402→ decode thePAYMENT-REQUIREDheader, sign the payment, retry withpayment-signature.
This article is for developers building a browser app where the end user's wallet (MetaMask, Coinbase Wallet, WalletConnect, …) signs everything. Keys never leave the wallet — no account, no API key.
If your code holds a signing key (backend, agent framework), use Call a Paid MCP Server from Your Code (x402) instead. If you want an AI assistant to manage a wallet in plain English, see Use indie.money with AI Assistants.
The identity header (SIWX)
Every browser call carries a SIGN-IN-WITH-X header: a base64-encoded JSON payload containing a standard Sign-In-With-Ethereum message (EIP-4361) signed with the wallet's personal_sign (EIP-191). There is no challenge endpoint — your app generates the nonce and expiry itself.
type SiwxPayload = { domain: string; address: `0x${string}`; statement?: string; uri: string; version: "1"; chainId: `eip155:${number}`; type: "eip191"; nonce: string; issuedAt: string; expirationTime: string; resources: string[];};function randomNonce(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");}function base64Json(value: unknown): string { return btoa(Array.from(new TextEncoder().encode(JSON.stringify(value)), (byte) => String.fromCharCode(byte)).join(""));}function formatSiweMessage(payload: SiwxPayload): string { const chainId = Number(payload.chainId.replace(/^eip155:/, "")); const statement = payload.statement ? `${payload.statement}\n\n` : ""; const resources = payload.resources.length ? `\nResources:\n${payload.resources.map((resource) => `- ${resource}`).join("\n")}` : ""; return `${payload.domain} wants you to sign in with your Ethereum account:${payload.address}${statement}URI: ${payload.uri}Version: ${payload.version}Chain ID: ${chainId}Nonce: ${payload.nonce}Issued At: ${payload.issuedAt}Expiration Time: ${payload.expirationTime}${resources}`;}export async function buildSiwxHeader(args: { address: `0x${string}`; chainId: number; origin: string; signMessage: (message: string) => Promise<`0x${string}`>;}): Promise<string> { const url = new URL(args.origin); const issuedAtMs = Date.now(); const payload: SiwxPayload = { domain: url.host, address: args.address, statement: "Sign in to indie.money", uri: `${url.origin}/`, version: "1", chainId: `eip155:${args.chainId}`, type: "eip191", nonce: randomNonce(), issuedAt: new Date(issuedAtMs).toISOString(), expirationTime: new Date(issuedAtMs + 4 * 60 * 60 * 1000).toISOString(), resources: ["auth:indie.money"], }; const signature = await args.signMessage(formatSiweMessage(payload)); return base64Json(Object.assign({}, payload, { signature }));}Attach it to the webhook call:
const headers = new Headers(init?.headers);headers.set("SIGN-IN-WITH-X", await buildSiwxHeader({ address, chainId, origin, signMessage }));const response = await fetch(webhookUrl, Object.assign({}, init, { headers }));Free trials work without a purchase
If the agent offers free trial runs, a visitor only needs to connect a wallet and sign the identity header — no purchase, no activation. Trials are tracked per wallet address per agent.
Check remaining trial runs (same origin as the webhook URL):
GET /api/v1/services/{contractAddress}/trial/status?buyer={buyerAddress}&chainId={chainId}{ "success": true, "data": { "claimed": true, "tokenId": "7", "runCount": 1, "freeTrialRunsPerBuyer": 3, "remaining": 2 }, "error": null, "metadata": { "timestamp": "2026-07-07T00:00:00.000Z" }}Buying and activating the agent is the separate, fuller path — use it when the user needs their own instance, custom variables, credentials, or their own webhook URL. See Free Trial Mode.
Paying per call (x402 fallback)
Send the first POST without any payment header. If the run isn't covered by a trial or a funded balance, the response is 402 with a PAYMENT-REQUIRED header: base64 JSON listing accepts[] payment offers. Pick the offer for the user's current network, have the wallet sign a USDC transfer authorization (EIP-3009, via the wallet's signTypedData), and retry the same POST with the result in a payment-signature header. The signature is gasless — the platform broadcasts it on-chain.
type X402Offer = { scheme: "exact"; network: `eip155:${number}`; asset: `0x${string}`; payTo: `0x${string}`; amount: string; maxTimeoutSeconds: number; extra: { name: string; version: string };};export async function buildPaymentSignature(args: { walletAddress: `0x${string}`; chainId: number; offer: X402Offer; signTypedData: (typedData: unknown) => Promise<`0x${string}`>;}): Promise<string> { const nonceBytes = new Uint8Array(32); crypto.getRandomValues(nonceBytes); const nonce = `0x${Array.from(nonceBytes, (b) => b.toString(16).padStart(2, "0")).join("")}` as `0x${string}`; const validBefore = BigInt(Math.floor(Date.now() / 1000) + 300); const authorization = { from: args.walletAddress, to: args.offer.payTo, value: args.offer.amount, validAfter: "0", validBefore: validBefore.toString(), nonce, }; const signature = await args.signTypedData({ domain: { name: args.offer.extra.name, version: args.offer.extra.version, chainId: args.chainId, verifyingContract: args.offer.asset, }, types: { TransferWithAuthorization: [ { name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }, ], }, primaryType: "TransferWithAuthorization", message: Object.assign({}, authorization, { value: BigInt(args.offer.amount), validAfter: 0n, validBefore, }), }); return base64Json({ x402Version: 2, accepted: args.offer, payload: { signature, authorization }, });}headers.set("payment-signature", paymentSignature);Price discovery is free. Send
X-x402-Probe: 1on a call and the response is always402with thePAYMENT-REQUIREDquote — without consuming a trial run or charging anyone. Use it only for discovery; never send it on the paid retry.
Reading the response
Headers your browser code can read: X-Execution-Id, PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-Payment-Actual-Cost, X-Payment-Refund-Amount, X-Payment-Token, Retry-After.
Responses include Access-Control-Allow-Origin (* unless the agent restricts origins), and preflight allows the SIGN-IN-WITH-X, payment-signature, and X-x402-Probe headers — so all of this works from any page. Individual agents can still reject disallowed origins.
Look up a run afterwards (same origin as the webhook URL):
GET /api/v1/executions/{executionId}GET /api/v1/executions/{executionId}/settlementSee also
- Call a Paid MCP Server from Your Code (x402) — your code holds the key (backend / agent framework)
- Automate with Webhooks — webhook basics, no signing
- Free Trial Mode — offering free runs on your agent
- x402 protocol specification — the open payment standard