Docs

Agents

spaceturtle

Callers

Pay a node

A wallet, one channel and one paid request on the devnet, in six commands.

From nothing to a paid request against the public devnet. Node.js 22 or newer, nothing else: no chain, no node of your own. Each step is shown as the toon-client CLI and as the library.

The six commandsdevnet, free
npm install @toon-protocol/client
npx toon-client init                                 # a wallet, in an encrypted keystore
npx toon-client faucet                               # 1,000 devnet USDC
npx toon-client channel open --deposit 100000        # 0.10 USDC into a channel, no ETH
npx toon-client send --body 'hello' --json           # one paid request, with a receipt
npx toon-client claim-state                          # what the node says you have paid

Each step below says what one of those does and shows the same thing from the library.

The node used throughout is the devnet store, https://proxy.ario.devnet.toonprotocol.dev, which serves the route g.toon.store at 1000 base units (0.001 USDC) plus 10 per kibibyte. The devnet reference has the full table.

npm install @toon-protocol/client

The CLI ships in the same package. Its executable is toon-client, so npx toon-client runs it without a global install. (npx toon is an unrelated package on the registry and fails with "could not determine executable to run".) Every command that opens the keystore prompts for its password; in a script, pass --password-file.

Step 1: a wallet

One BIP-39 phrase derives both chain keys: an EVM key at m/44'/60'/0'/0/0 and a Solana key at m/44'/501'/0'/0'. The EVM half is on the standard path, so the same phrase typed into an ordinary wallet shows you the same address.

npx toon-client init
npx toon-client identity

init writes an encrypted keystore at ~/.toon/keystore.json, scrypt and AES-256-GCM, mode 0600, and prints the phrase once for you to write down. It is not stored anywhere else.

TypeScript
import { ToonClient } from '@toon-protocol/client';

const client = await ToonClient.create({
  connector: 'https://proxy.ario.devnet.toonprotocol.dev',
  mnemonic: process.env.TOON_MNEMONIC,
  channelStore: `${process.env.HOME}/.toon/channels.json`,
});

console.log(client.identity.evmAddress);
console.log(client.identity.solanaPublicKey);

ToonClient.create reads the node's self-description, derives your keys, picks a chain and opens the channel store. It sends no transaction and spends nothing.

Step 2: funds

A channel is funded in USDC, and every request is paid in it. That is all you need: opening a channel costs no native gas. On Base an x402 facilitator relays the deposit and pays for it; on Solana the connector sponsors the open. Native gas (Base Sepolia ETH, devnet SOL) is needed only later, to leave a channel.

npx toon-client faucet
npx toon-client balances
await client.wallet.faucet('evm');
console.log(await client.wallet.balances());

Step 3: a channel

A payment channel is an x402 batch-settlement channel: a deposit you lock on chain, payable to this one connector, that you then pay from many times while touching the chain only to open, top up and leave. The first paid request opens one on its own in the library, but the CLI never does that as a side effect, so open it explicitly:

npx toon-client channel open --deposit 100000

100000 base units is 0.10 USDC, in the token's base units, never in wei. At 1000 base units per request that is a hundred requests before the channel needs a top-up, which a library client does on its own.

const channel = await client.channel.open();   // opens with `deposit`, default 100000n
console.log(channel.channel.channelId, channel.depositTotal);

Both are idempotent: against a connector you already hold an open channel with, they return it rather than opening a second one. Adding more later: npx toon-client channel deposit 100000 or client.channel.deposit(100_000n).

Step 4: one paid request

npx toon-client send --body 'hello'
const answer = await client.send({
  method: 'POST',
  headers: { 'content-type': 'text/plain' },
  body: 'hello',
});

No route appears in either: you configured a URL, and with no destination the client addresses the node's own ILP address from GET /ilp. That works when the node's address is also a priced route, as the devnet store's g.toon.store is. Against a node whose routes sit beneath its address, the client stops with "prices no route covering", and you name the route: npx toon-client send g.their.node.app --body …. Name a destination too when you want a route the node forwards, such as g.toon.relay.store.

Behind that one call: the client reads the route's price, seals your request to the connector's identity key, signs a voucher advancing the channel by that price, and sends both as one packet. Protocol and specs walks the wire.

Step 5: reading the result

send returns an outcome. It does not throw on a refusal: a refusal is an answer about the network, and everything this client does throw happened before the packet went out or on chain.

if (answer.fulfilled) {
  answer.status;              // the app's own HTTP status
  answer.headers;             // [name, value][], in order
  answer.text();              // the body, as UTF-8
  answer.json();              // the body, parsed
  answer.claim.amount;        // 1000n base units (0.001 USDC): what this cost
  answer.claim.cumulative;    // the channel's running total after this voucher
} else {
  answer.refusedBy;           // 'destination' | 'path' | 'edge'
  answer.code;                // 'F03', 'F01', 'PAYMENT_REQUIRED', …
  answer.message;
  answer.accumulatedCost;     // what the path costs, when the connector said
}

A 404 or a 422 from the app is a real, paid answer: it arrives on a FULFILL and costs exactly what a 200 costs. Only a refusal short of the app is fulfilled: false. The CLI prints one JSON document with --json, and exits 3 on a refusal. This is what the devnet store answered to the command above on 2026-10-13:

npx toon-client send --body 'hello' --jsondevnet store
{
  "fulfilled": true,
  "transport": "http",
  "status": 422,
  "text": "{\"accept\":false,\"code\":\"F00\",\"message\":\"Invalid request body\"}",
  "claim": {
    "channelId": "0xb878…ac7e",
    "chain": "evm",
    "cumulative": "1010",
    "amount": "1010"
  }
}

Read it as a success: the packet was paid for, carried, unsealed and handed to the store, and the store answered. The store is a real app, not an echo: it wants { "event": { …a signed Nostr kind:5094 event… } }, as its README shows, and hello is not that, so it said 422 and you paid 1,010 units for the answer: 1,000 for the request plus 10 for its first kibibyte. The claim block is the receipt: which channel paid, what this request cost, and what the channel's cumulative total became. npx toon-client channel status shows the same figure from your side. Every amount is a decimal string, never a JSON number. Every code, and what to do about each, is in Errors and reject codes.

Step 6: after a restart

A voucher states the channel's cumulative total, and the connector accepts it only if it advances the connector's own watermark. So a process has to remember two things across a restart: the channel's config, which the chain never gives back, and how far its vouchers have reached.

The CLI persists both at ~/.toon/channels.json by default. The library defaults to memory, which is almost never what you want: set channelStore, as the example above does. With it set, the next process resumes the same channel, sends no transaction, and locks no second deposit. Without it, every restart opens a fresh channel and strands a deposit in the old one.

Never delete that file for a live channel. Without the config you cannot leave the channel, and its deposit stays locked on chain. Leaving also needs native gas, Base Sepolia ETH or devnet SOL, which the faucet does not give: get a little from a public faucet before you depend on getting a deposit back. And deposit only into a node you expect to still exist: on mainnet, a deposit toward a node that is gone has nobody left to settle it.

To compare your side against the connector's:

npx toon-client claim-state
console.log(await client.claimState());

That is an owner-authenticated read, one signed challenge per channel, distinct from a voucher, so it can never be replayed as a payment. It works when the channel has run dry, which is exactly when you need it.

Where to go next

  • Payment channels: opening without gas, who pays the gas, any-ERC-20 deposits, leaving, the watermark.
  • CLI reference: every setting, the resolution order, exit codes.
  • Hidden services: paying a node at a .anyone address.
  • Library API on GitHub: every method, option and returned type.

Written from toon-client/docs/getting-started.md and packages/client/README.md.