Skip to main content

Overview

Send a cross-rail payment using the Payment Gateway CLI. The CLI wraps the same prepare → sign → submit flow you would use with the SDK.
This guide walks through one example corridor — USDC on Arbitrum Sepolia → USDC on Celo Sepolia. Atum supports many source and destination combinations across Arbitrum, Base, Solana, Tron, and more. See Supported networks for the full list.
1

Approve token spend

One-time setup for your wallet on the source chain — not before every payment.
2

Run send-payment

The CLI builds, signs, and submits your payment in one command.
3

Gateway routes your payment to a settlement provider

Settlement providers compete to fulfill your payment — no action needed from you.
4

Funds move across both rails

Settlement completes. Poll /status to confirm.
New to tokenized currency payments? Read Atum infrastructure first — about five minutes.
You do not need to run any Atum backend services, only the hosted testnet gateway and your wallet. For SDK integration in your own app, see Build with the SDK.

Prerequisites

The following is required to use Atum to send a payment:
Verify (npm is included with Node.js):
Confirm you can reach the hosted testnet API from your machine:
A successful check returns HTTP 200 and one entry per chain the gateway serves, keyed by CAIP-2 id:
This tells you two things at once: the gateway is up, and which chains are enabled — not which source→destination pairs you can use. A chain that isn’t fully configured is left out of this listing, so what comes back is what you can actually pay on. Fetch these addresses rather than hardcoding them; they change when a chain’s contracts are redeployed. A non-200 is a gateway-side answer, not a network problem: 404 means it resolved no chains and 503 means a dependency is down — in both cases the gateway is up and you have nothing to fix locally.The Payment Gateway is an HTTP API, not a blockchain network.
This example uses an EVM wallet funded on Arbitrum Sepolia. Any EVM-compatible wallet works (MetaMask, Coinbase Wallet, Rainbow, etc.).You will need:
  • Testnet ETH for the one-time token approval on the source chain (settlement gas is the settlement provider’s)
  • A small amount of testnet USDC on the source chain
You can use the same 0x address for sender and receiver. See Testnet wallet setup if you need help getting funded.

1. Install the CLI

This installs five commands: send-payment, payment-status, request-quote, quote-status and approve-permit2. Skip the -g and use npx send-payment … if you would rather not install globally.

2. Configure addresses and key

Create .env in the directory you’ll run the commands from. You will need your wallet’s private key and address (in this example, we use the same address for sender and receiver): Example (replace with your own values):
Your MetaMask address and private key belong to the account, not to a network.The network dropdown (Ethereum, Arbitrum Sepolia, etc.) only changes which chain you are viewing — not your credentials.Balances change by network; your 0x address and key do not.

Copy your address

Click the copy icon next to your default Metamask address.Paste that value into SENDER and RECEIVER in your .env

Export your private key

  1. In MetaMask, click the three dots (⋮) next to your account name.
  2. Click Account details.
  3. Copy your account private key (it’s a long hex string starting with 0x).
  4. Enter your MetaMask password and confirm.
Paste it into PRIVATE_KEY in your .env.
Add .env to .gitignore and never commit this file.
Load the variables before running scripts to avoid putting the key in shell history:

3. Approve USDC spend

This step is required for EVM and Tron source chains. Solana uses a different token model and does not need this approval.
Before Atum can move funds from your wallet during settlement, you must approve spending once on the source chain. This is separate from signing each payment — do it one time per wallet and token, then send as many test payments as you like. Without this step, the gateway may accept your payment request, but on-chain settlement will fail.
Atum uses the standard Permit2 contract on EVM chains — the same approval pattern many DeFi apps use. The network pulls your USDC into a secure hold on the source chain only after you sign each payment.Details: Permit2 and approvals.
The package ships a command for this. It reads the current allowance first, so an already-approved wallet never has to produce a key to learn there was nothing to do:
It takes the same CAIP-19 asset string as send-payment. The private key comes from PRIVATE_KEY or an interactive prompt and is never accepted as a flag, which would put it in shell history and in the process list. Add --check to report whether an approval is needed without sending anything or needing a key at all. An approval is unlimited by default, so later payments need no further transaction. --amount bounds it instead, at the cost of renewing it as Permit2 consumes it.
This is the one command that spends gas. Everything else the CLI does builds and signs offline.If the transaction is broadcast but not confirmed before the timeout, the command exits non-zero and prints {"unconfirmed": true, "txHash": …}. That is not a revert — the approval may still land. Look that transaction up before re-running, rather than sending a second approval.

Approving by hand

You don’t have to use Atum’s command. The approval is an ordinary ERC-20 approve of the Permit2 contract — nothing Atum-specific — so any tool that can call it works.
Use spender 0x000000000022D473030F116dDEE9F6B43aC78BA3 (the standard Permit2 contract) and an amount in 6-decimal units (for example 1000000000 = 1000 USDC). Circle testnet USDC is a proxy — on Arbiscan use Write as Proxy, not Write Contract.
Save as accept-permit2.ts and run it once with npx tsx accept-permit2.ts:
Three things are going on: connect to the source chain, call approve on the USDC contract, and name Permit2 as the spender allowed to pull USDC when a payment settles. No money moves — it is a permission slip, and you still sign each payment separately. 1_000_000_000n is a 1000 USDC ceiling for test runs.
For token approval details, see Permit2 and approvals.

4. Send your first payment

With USDC spend approved, you are ready to submit a payment. Run the following to load your .env file first:
Once your .env is loaded, run:
send-payment
send-payment takes positional arguments in this order: sender, amount, source asset, receiver, destination asset.

What are the eip155:… strings?

Those are asset IDs — one string that identifies an exact token on an exact chain. The CLI does not accept symbols like USDC; it needs the full ID. Different corridors use different asset IDs — copy them from Supported assets.
Asset IDs follow the CAIP-19 standard. Chain prefixes use CAIP-2.Full catalog: Supported assets. Concept page: Corridors and assets.

Why are the asset IDs already filled in?

This quickstart pins one route so you can focus on the payment flow — not hunting token addresses at runtime.
  • GET /v1/defaults lists which chains are enabled and returns their on-chain contract addresses — not which tokens or asset IDs to use, and not “USDC on Arbitrum Sepolia.”
  • Token contract addresses come from Supported assets.
In production, store supported source/destination asset pairs in your own config. For other testnet routes, copy asset IDs from that catalog.
The command above omits --request-id, so send-payment generates a new random one (pmt-gw-cli-<timestamp>-<random>) every time you run it — each run is a new payment.If you re-run the exact same command to retry a submission that failed or timed out, pass the same --request-id explicitly so the gateway recognizes it as the same payment instead of charging a second one. See Idempotency.
The third argument to send-payment is not dollars — it is USDC in 6-decimal atomic units (smallest on-chain increment):Use a small test amount (for example 10000) to conserve testnet USDC. If settlement fails with NO_QUOTES_RECEIVED, verify asset IDs against Supported assets before retrying with a larger amount.
Your terminal should output two streams back-to-back:
The client logs the signed request with console.error before submitting:
Followed by the recovery line. Copy the request_id from here if you need to retry:
Continue to Check payment status with the payment_id from stdout. By default, the CLI does not poll after submit — send-payment may pause for up to ~30 seconds while the gateway finds a route and price before returning, then prints the PaymentResponse above and exits. Pass --wait if you’d rather have the CLI poll for you: send-payment then blocks on /status itself and prints the payment JSON when settlement reaches completed or failed, or when --wait-seconds expires. Add --wait-seconds <n> to change the default 60-second cap — giving up does not affect the payment itself, it’s a convenience bound only. The exit code distinguishes the three endings, so don’t branch on “zero or not” — a wait that runs out its budget is not a failure:

Review before you send

send-payment in one shot builds, signs and submits. You can also split it in two and read the signed request first:
What --prepare-only prints is byte-for-byte what that run would have POSTed, so it can be reviewed or approved before any money moves.
Treat the file like a signed cheque, not a draft — it is a signed authorization and --submit needs no private key. Delete it once the payment is sent or abandoned.A prepared request is submittable only while its quote window is open, and the default is 10 seconds. Widen it with --quote-deadline-seconds if you intend to submit later, but by tens of seconds rather than minutes: that same window is how long the gateway waits before selecting a settlement quote, and quotes expire on their own, so an over-long window ends in a terminal QUOTES_EXPIRED.
--prepare-only still calls the gateway — that is where the corridor addresses come from — so it is not an offline signing mode. The exit codes above describe a submitted payment, so they don’t apply to a --prepare-only run, where 0 means the request was built and signed and no payment exists. Don’t chain a payment-conditional step off one.

The other commands

Every command takes --gateway, and both send-payment and request-quote take --request-id (or --request-id-prefix to change the generated one’s prefix). Run any of them with --help for the full flag list.

5. Check payment status

This CLI flow talks to the Payment Gateway directly, so you poll /status after submit. That is the right pattern here. For x402 / MPP HTTP payments, prefer re-attempting the same purchase when settlement is still running; keep /status for dashboards and out-of-band reconciliation. Copy payment_id from stdout in step 4, then poll every few seconds:
Optional — event timeline for debugging:
The /status response tells you whether settlement finished. Poll every few seconds until status is completed or failed, or until you are confident it is stuck.
completed and failed are the only two statuses that end a payment. Treat anything else as still in flight and keep polling — in particular, do not stop on a status you do not recognize, because giving up on a live payment is how a second charge happens.The client package declares one more, finalizing, for a payment whose delivery transaction is on chain but still gathering confirmations. Nothing emits it yet, and it is not terminal when it arrives. If you write an exhaustive switch on the SDK’s PaymentStatus type, you need a branch for it.
A payment_id from step 4 only proves submit succeeded. This step confirms settlement.

Completed

USDC moved across both rails. The full response includes rail IDs, the source block number, and both tx hashes:
View the source transaction on Arbiscan and the destination transaction on the relevant chain explorer.

Still settling

Settlement can take up to a few minutes on testnet. While status is pending, keep polling — do not resubmit the same payment under a new request_id.

Failed (no route available)

If no settlement provider offers a price in time, you may see NO_QUOTES_RECEIVED. Your integration still worked — submit and signing succeeded. Settlement providers may skip payments they cannot route; check destination asset IDs first:
NO_QUOTES_RECEIVED is not a spending approval or signing error. A wrong destination asset ID is a common cause — copy asset IDs verbatim from Supported assets. See Troubleshooting.

Troubleshooting

Success criteria

You completed this quickstart when:
  • GET /v1/defaults returns chain defaults for the testnet gateway
  • Approve USDC spend confirmed on Arbitrum Sepolia
  • send-payment returns a payment_id on stdout
  • GET /v1/payments/{payment_id}/status returns "status": "completed" with source_tx_hash and destination_tx_hash
The first three items confirm your integration. The last item confirms settlement — on testnet, verify asset IDs if you see NO_QUOTES_RECEIVED; that does not invalidate the first three.

Next steps