Skip to main content

Overview

Send a multi-rail payment on testnet 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

Settlement operators quote your payment

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

USDC moves across both rails

The script polls until USDC arrives and prints the payment’s status and transactions.
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 Quickstart (using 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 Quickstart (using 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 Quickstart (using 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.
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 and Tron chains. Details: Token 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.
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 Quickstart (using 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; Quickstart (using 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 payment — preparePaymentRequest() gathers route details and creates an unsigned payment your wallet still needs to sign. requestId is your idempotency key — see below
  2. Sign locally — createSenderSigner() 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 gateway — submitPayment() posts the signed payment. A payment_id in the response means the gateway accepted it — USDC has not moved yet
  4. Wait for the outcome — waitForStatus() polls getPaymentStatus() until the payout is mined (finalizing), the payment fails, or 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. With waitFor: 'finalizing', waitForStatus() stops on finalizing, completed, failed, refunded and refund_available. finalizing means paid: your recipient has the funds, and the payment becomes completed once the payout is final (Finality). Pass waitFor: 'completed' with a longer budget to wait for that instead
  • 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 Token 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…)
  • AMOUNT — 10000 = 0.01 USDC (6-decimal atomic units, not dollars)
  • MAX_SOURCE_AMOUNT — the most that may leave your wallet, in atomic units of the source token. It is required. Set it to the amount plus the settlement operator’s charge. A quote tells you that charge: get a price quote and use capFromQuote(source_amount, slippageBps). Quotes above the cap are not accepted
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 (finalizing) 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 status automatically. When you see "status": "finalizing" with transactions.fulfillment, USDC has arrived. The payment becomes completed once the payout is final on Celo Sepolia, which takes tens of minutes; see Finality. If you want to check a payment manually (e.g. from a previous run):
Optional — event timeline for debugging:
What each status means: Payment status. isTerminalStatus() returns true for completed, failed and refunded, and nothing else. Any payment can pass through finalizing; it is not terminal, and an exhaustive switch on PaymentStatus needs a branch for it. For failure modes and the troubleshooting table, see Quickstart (using CLI) → Check payment status.

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": "finalizing" or "completed", with the payout in transactions.fulfillment
The first four items confirm your integration. The last confirms settlement. NO_QUOTES_RECEIVED does not invalidate the first four: it means no settlement operator quoted for this payment. Read decline_summary to see why; see Why nobody quoted.

Next steps