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.

  1. Connect wallet.
  2. POST {your-webhook-url} with a SIGN-IN-WITH-X header.
  3. 200 → read the result and the X-Execution-Id header.
  4. 402 → decode the PAYMENT-REQUIRED header, sign the payment, retry with payment-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.

ItemValue
HeaderSIGN-IN-WITH-X
Encodingbase64(JSON.stringify(payloadWithSignature))
Signingpersonal_sign — the wallet's standard signMessage
Nonceclient-generated random 16-byte hex string
ExpiryissuedAt plus a required expirationTime
Resourceauth:indie.money in resources[]
Chaineip155:<chainId>
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.

CaseResult
Trial agent, missing identity header401 { "error": "siwx_required" }
Trial agent, invalid identity header401 { "error": "invalid_siwx" }
Trial runs remaining200, the run is free
Trial exhausted, no funded payment rail402 payment challenge

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: 1 on a call and the response is always 402 with the PAYMENT-REQUIRED quote — without consuming a trial run or charging anyone. Use it only for discovery; never send it on the paid retry.

Reading the response

StatusMeaning
200HTTP success; the body can still report a workflow-level failure
401 / 403identity or security failure
402payment required — inspect PAYMENT-REQUIRED
500platform failure

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}/settlement

See also