Skip to main content

Overview

Send a multi-rail payment in the sandbox from your own code.
1

Approve USDC spend

One-time setup for your wallet on Arbitrum Sepolia — skip if you already did the CLI quickstart.
2

Build and sign

Your app calls the SDK to prepare the payment and signs it locally with your wallet.
3

Submit to the gateway

SDK posts the signed payment. Gateway returns a payment_id.
4

Gateway routes your payment to a settlement provider

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

USDC moves across both rails

The script polls until settlement completes and prints the confirmation with both transaction hashes.
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.
Building the unsigned payment happens client-side in the SDK or CLI (preparePaymentRequest() resolves gateway defaults; you pass full CAIP-19 asset identifiers). There is no gateway-side build endpoint — the HTTP tab covers only submit and status, using a payment you built and signed with the SDK/CLI.
For full API details, see the Payment Gateway API reference.

Prerequisites

The following is required to send a payment:
Verify:
Confirm you can reach the hosted testnet API:
A successful check returns HTTP 200 and the chain defaults for every chain the gateway serves. See Using the CLI → Access to Atum’s Payment Gateway for a field-by-field explanation of the response.
Same setup as the CLI quickstart:
  • Testnet ETH on Arbitrum Sepolia (for network fees)
  • Testnet USDC on Arbitrum Sepolia
  • Approve USDC spend once for that USDC on the source rail (step 3)
See Testnet wallet setup and Using the CLI → Approve USDC spend.
You cannot sign a payment from curl alone. The HTTP tab signs the unsigned payment (built with the SDK or CLI) using a short Node script with ethers. Run these in your working directory:

1. Install the SDK

Install the published package into your own project:

2. Configure addresses and key

Create .env in the directory from step 1:
You can use the same 0x address for SENDER and RECEIVER.
See Using the CLI → Configure addresses and key for how to copy your address and export your private key into .env.
Add .env to .gitignore and never commit private keys.
Load variables before running scripts in later steps:

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 USDC from your wallet during settlement, you must approve spending once on the source chain. This is separate from signing each payment.
Atum uses the standard Permit2 contract on EVM chains. Details: Permit2 and approvals.
The SDK can do this for you. ensureSourceApproval reads the current allowance and only sends a transaction if it falls short, so it is safe to call before every payment:
This is the only part of the SDK that broadcasts a transaction and spends gas — everything else builds and signs offline. If you are on the MPP path you will see the same helper imported from @atumlabs/mppx-atum-escrow/client: both packages re-export it from one shared implementation, so either import gives you the same function — use whichever package you already depend on. needsSourceApproval answers the same question read-only, with no key and no gas, so you can warn a payer before asking them to sign. Solana reports that no approval is needed rather than doing nothing.
The package ships approve-permit2, which does the same thing from the terminal once per wallet and token:
--check reports whether an approval is needed without sending anything or needing a key. See Using the CLI → Approve USDC spend.
If you would rather not use Atum’s helpers, the approval is an ordinary ERC-20 approve of the Permit2 contract for the source token — no Atum-specific call. Grant Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) an allowance on the token you are paying from, from the wallet that holds the funds. You can do it from a block explorer’s write tab, or from your own script; Using the CLI → Approve USDC spend has a complete one. The helpers above exist because measuring the existing allowance first is the fiddly part, not because the call is special.

4. Send your first payment

With USDC spend approved, write a script that builds your payment, signs it with your wallet, and sends it to the gateway.

Create script

Create send-payment.ts:
send-payment.ts
The short version: Your script builds a payment, signs it with your wallet, sends it to the gateway, then checks until USDC moves or the payment fails. Same four steps as the CLI.What the script does:
  1. Build the paymentpreparePaymentRequest() gathers route details and creates an unsigned payment your wallet still needs to sign. requestId is your idempotency key — see below
  2. Sign locallycreateSenderSigner() builds a signer from your private key and signPaymentRequest() applies it. Signing happens on your machine; the gateway never sees your key
  3. Send to the gatewaysubmitPayment() posts the signed payment. A payment_id in the response means the gateway accepted it — USDC has not moved yet
  4. Wait for the outcomewaitForTerminalStatus() polls getPaymentStatus() until the payment is terminal, or until the budget runs out
Remember:
  • Approve USDC spend (step 3 in this guide) is one-time setup — separate from signing each payment
  • A payment_id only proves submit worked — use step 5 to confirm settlement if you skip the wait in the sample script
  • A wait that times out is not a failure. outcome.timedOut means the payment is still live and has outrun your budget; re-run under the same requestId or read /status later. completed and failed are the only statuses waitForTerminalStatus() stops on
  • requestId is what makes a retry safe. It names one payment, not one attempt — every attempt at that payment reuses it. The sample mints sdk-quickstart-<timestamp> per run, so every run is a new payment; that’s fine for repeated testnet runs, but in your own integration derive it from something stable per payment (an order id, a job id) so a crashed or timed-out call can be retried under the same requestId instead of accidentally paying twice. idempotent_replay: true on the response tells you that happened. See Idempotency for the full guarantees and the 409 IDEMPOTENCY_TERMS_MISMATCH you get back if a retry’s terms don’t match the original.
Do not call wallet.signingKey.sign(message) on signed_messages[0].message. The gateway expects a typed signature built from message_prehash, and the shape differs per chain. createSenderSigner() picks the right one for the source chain — EVM, Tron, or Solana — so this is the only signing surface you need. Pass { provider: 'turnkey', ... } instead of privateKey to sign with a Turnkey wallet.
Step 1 fetches chain defaults and embeds Permit2 signing data in message_prehash. Step 2 calls wallet.signTypedData(...). See Permit2 and approvals.
The script pins one testnet route so you can focus on the flow:
  • SOURCE_ASSET — USDC on Arbitrum Sepolia (eip155:421614/erc20:0x75faf1…)
  • DESTINATION_ASSET — USDC on Celo Sepolia (eip155:11142220/erc20:0x01C5C012…)
  • AMOUNT10000 = 0.01 USDC (6-decimal atomic units, not dollars)
Copy other testnet asset IDs from Supported assets. Format explained in Corridors and assets.
Asset IDs follow the CAIP-19 standard.

Run it

Load your .env if you have not already:
From your project directory:
You should see output similar to:
A payment_id in the submit response means the gateway accepted your signed payment — not that settlement finished. The SDK script keeps checking status until USDC moves or the payment fails.
fulfillmentAmount 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.
The SDK script polls and prints the final status automatically — when you see "status": "completed" with both tx hashes, you’re done. If you want to check a payment manually (e.g. from a previous run):
Optional — event timeline for debugging:
isTerminalStatus() returns true for completed and failed, and nothing else. The package also declares finalizing, for a delivery transaction that is on chain but still gathering confirmations — nothing emits it yet, it is not terminal, and an exhaustive switch on PaymentStatus needs a branch for it. For status meanings, failure modes, and the troubleshooting table, see Try it (CLI) → Check payment status — same statuses apply.

Success criteria

You completed this guide when:
  • GET /v1/defaults returns chain defaults for the testnet gateway
  • Approve USDC spend confirmed on Arbitrum Sepolia
  • Build step returns an unsigned payment with signing data (preparePaymentRequest in the SDK, or the CLI)
  • Submit returns a payment_id (SDK: submitPayment; HTTP: POST /v1/payments)
  • GET /v1/payments/{payment_id}/status returns "status": "completed" with source_tx_hash and destination_tx_hash
The first four items confirm your integration. The last confirms settlement — verify asset IDs if you see NO_QUOTES_RECEIVED; that does not invalidate the first four.

Next steps