Overview
Send a multi-rail payment in the sandbox from your own code.Approve USDC spend
Build and sign
Submit to the gateway
payment_id.Gateway routes your payment to a settlement provider
USDC moves across both rails
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.Prerequisites
The following is required to send a payment:Node.js 20+ and npm
Node.js 20+ and npm
Access to Atum's Payment Gateway
Access to Atum's Payment Gateway
A funded sandbox account
A funded sandbox account
- 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)
If using the HTTP API: signing dependencies
If using the HTTP API: signing dependencies
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
- SDK
- HTTP API
2. Configure addresses and key
Create.env in the directory from step 1:
0x address for SENDER and RECEIVER.
Using a MetaMask wallet?
Using a MetaMask wallet?
.env.3. Approve USDC spend
Already completed Using the CLI?
Already completed Using the CLI?
Technical name: Permit2
Technical name: Permit2
ensureSourceApproval reads the current allowance and only sends a transaction if it falls short, so it is safe to call before every payment:
@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.
Prefer a one-off command?
Prefer a one-off command?
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.Approving by hand instead
Approving by hand instead
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
- SDK
- HTTP API
send-payment.ts:What's happening?
What's happening?
- Build the payment —
preparePaymentRequest()gathers route details and creates an unsigned payment your wallet still needs to sign.requestIdis your idempotency key — see below - Sign locally —
createSenderSigner()builds a signer from your private key andsignPaymentRequest()applies it. Signing happens on your machine; the gateway never sees your key - Send to the gateway —
submitPayment()posts the signed payment. Apayment_idin the response means the gateway accepted it — USDC has not moved yet - Wait for the outcome —
waitForTerminalStatus()pollsgetPaymentStatus()until the payment is terminal, or until the budget runs out
- Approve USDC spend (step 3 in this guide) is one-time setup — separate from signing each payment
- A
payment_idonly 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.timedOutmeans the payment is still live and has outrun your budget; re-run under the samerequestIdor read/statuslater.completedandfailedare the only statuseswaitForTerminalStatus()stops on requestIdis what makes a retry safe. It names one payment, not one attempt — every attempt at that payment reuses it. The sample mintssdk-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 samerequestIdinstead of accidentally paying twice.idempotent_replay: trueon the response tells you that happened. See Idempotency for the full guarantees and the409 IDEMPOTENCY_TERMS_MISMATCHyou get back if a retry’s terms don’t match the original.
Why not sign the message yourself?
Why not sign the message yourself?
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.Technical note: Permit2 and EIP-712
Technical note: Permit2 and EIP-712
message_prehash. Step 2 calls wallet.signTypedData(...). See Permit2 and approvals.Asset IDs and amount
Asset IDs and amount
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)
Technical name: CAIP-19
Technical name: CAIP-19
Run it
Load your.env if you have not already:
- SDK
- HTTP API
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.Amount (atomic units)
Amount (atomic units)
fulfillmentAmount is not dollars — it is USDC in 6-decimal atomic units (smallest on-chain increment):10000) to conserve testnet USDC."status": "completed" with both tx hashes, you’re done.
If you want to check a payment manually (e.g. from a previous run):
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/defaultsreturns chain defaults for the testnet gateway - Approve USDC spend confirmed on Arbitrum Sepolia
- Build step returns an unsigned payment with signing data (
preparePaymentRequestin the SDK, or the CLI) - Submit returns a
payment_id(SDK:submitPayment; HTTP:POST /v1/payments) -
GET /v1/payments/{payment_id}/statusreturns"status": "completed"withsource_tx_hashanddestination_tx_hash
NO_QUOTES_RECEIVED; that does not invalidate the first four.