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

Settlement operators quote your payment

Settlement operators 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 SDK quickstart.

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 operator’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.
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 and Tron chains — the same approval pattern many DeFi apps use. Your USDC moves into the settlement vault only after you sign a payment.Details: Token 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 Token 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 second 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, read decline_summary before retrying (see Why nobody quoted).
Your terminal should output two streams back-to-back:
The client logs the signed request with console.error before submitting:
The CLI fills escrow_contract_address and fulfillment_verifier from /v1/defaults for the source chain. The gateway requires both on every payment request.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 waits as long as the gateway holds the submit call (see Track a payment), 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 once the payout is mined (finalizing), the payment fails or is refunded, the refund is ready (refund_available), or --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. To wait until the payout is final (completed) instead, pass --wait-for completed and raise --wait-seconds: on some destination chains that takes tens of minutes. See Finality. The exit code tells the endings apart, with or without --wait, so don’t branch on “zero or not” — a wait that runs out its budget is not a failure, and 5 means paid:

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 short (see Deadlines). Widen it with --quote-deadline-seconds if you intend to submit later, by seconds rather than minutes: settlement operators decline a window that ends after their quote would expire (QUOTE_WINDOW_TOO_LONG). See Deadlines.
--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 how far settlement got. Once status is finalizing, the payout is mined and your recipient has the USDC: its transaction is in transactions.fulfillment. The payment becomes completed once the payout is final on the destination chain. On this quickstart’s route (Celo Sepolia) that takes tens of minutes; see Finality. To follow it to the end, poll every few seconds until status is completed, failed or refunded. What each status means: Payment status.
completed, failed and refunded are the only 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.Any payment can pass through finalizing, and it is not terminal. 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. While the payment is finalizing, look up the payout from transactions.fulfillment in a Celo Sepolia block explorer: USDC has arrived. Once the payout is final the status is completed. transactions lists the deposit on the source chain and the payout on the destination chain:
View the source transaction on Arbiscan and the destination transaction on the relevant chain explorer.

Still settling

Delivery usually takes seconds on testnet. While status is pending, keep polling — do not resubmit the same payment under a new request_id. A finalizing payment is already paid; it is only waiting for the destination chain to make the payout final.

Failed (no route available)

If no settlement operator offers a price in time, you may see NO_QUOTES_RECEIVED. Your integration still worked — submit and signing succeeded. Read decline_summary in the status response to see why (see Why nobody quoted). An asset the gateway does not serve is refused at submit, with UNSUPPORTED_SOURCE_ASSET or UNSUPPORTED_DESTINATION_ASSET. An asset it serves but you did not mean, such as the right token on the wrong chain, passes submit and fails later, so also check your asset IDs against Supported assets. A payment that no one quoted looks like this:
NO_QUOTES_RECEIVED is not a spending approval or signing error. Nobody quoted before the quote window closed. Read decline_summary in the status response to see why; see Why nobody quoted.

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": "finalizing" or "completed", with the payout in transactions.fulfillment
The first three items confirm your integration. The last item confirms settlement — a NO_QUOTES_RECEIVED on testnet does not invalidate the first three.

Next steps