@atumlabs/payment-gateway-client
v3.0.1
Published
TypeScript client for Atum Payment Gateway API
Readme
Payment Gateway Client
TypeScript client for Atum Payment Gateway API with CLI support. Full documentation: docs.atum.xyz.
Copyright 2026 Atum Labs, Inc.
Licensed under the Apache License, Version 2.0. See the
LICENSEfile distributed with this package.
NOTE: Before your first payment from an EVM or Tron chain, the source token must be approved for the Permit2 contract. Without it a payment is built, signed and accepted by the gateway, and then reverts on-chain at settlement. It is a one-off transaction per wallet and token — see Approving the source token for
ensureSourceApproval, which does it for you.
Installation
@atumlabs/payment-gateway-client is on the public npm registry.
To use the SDK in a project:
npm install @atumlabs/payment-gateway-clientTo use the command-line tools, install globally so they are on your PATH:
npm install -g @atumlabs/payment-gateway-clientA project-local install also provides the commands, under node_modules/.bin, so
npx send-payment ... works without a global install.
Supported corridors
A payment moves value from a source asset to a destination asset. The two can be on different chains, and in different chain families.
Assets are named with CAIP-19 identifiers:
| Chain family | Network | Example asset identifier |
| --- | --- | --- |
| EVM | Base Sepolia | eip155:84532/erc20:0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Solana | Devnet | solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU |
| Tron | Shasta | tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs |
Every row above is a testnet, so nothing here settles real value if it is copied by mistake. The mainnet identifier for the same asset is a different string on every chain — take it from supported assets, never by editing one of these.
For the identifier of each supported token and network, see supported assets and supported networks.
Copy identifiers exactly: base58 values, such as Solana chain references, token mints
and account addresses, are case-sensitive. The escrow and verifier addresses for a
chain are fetched by preparePaymentRequest, so you never supply them yourself.
CLI Usage
The package provides five commands: send-payment, approve-permit2, request-quote,
quote-status, and payment-status. After a global install:
send-payment [options] <sender> <amount> <sourceAsset> <receiver> <destinationAsset>
send-payment --submit <file> [options]Arguments
sender- Depositor address (e.g.,0xF0D0...)amount- Amount to send in smallest units (e.g.,100000for 0.10 USDC)sourceAsset- Source asset in CAIP-19 formatreceiver- Destination account addressdestinationAsset- Destination asset in CAIP-19 format
Options
-V, --version- Output the version number--gateway <url>- Payment gateway URL (default: testnet)--request-id <id>- Idempotency key; seeds the on-chain deposit nonce. Re-run with the same id to re-attempt one payment. Omitted, an id is generated and printed, which makes every bare re-run a separate payment (see Idempotency and retries)--request-id-prefix <prefix>- Prefix for the auto-generated request id (default:pmt-gw-cli)--prepare-only- Build and sign the request, print it to stdout, and stop without submitting. For inspecting an authorization, or for review before paying; send it later with--submit(see Preparing a payment for review). Cannot be combined with--wait--submit <file>- Send a request built earlier by--prepare-only, instead of building one. Signs nothing and needs no private key;-reads stdin. Takes no corridor arguments--wait- After submitting, keep checking until the payment settles or fails instead of reporting it as still pending--wait-seconds <n>- How long--waitkeeps checking (default:60). A convenience bound only; giving up does not affect the payment-h, --help- Display help
Exit codes
send-payment reports the payment's ending, so send-payment ... && <next step>
runs the next step only for a settled payment:
| Code | Meaning |
| ---- | --------------------------------------------------------------------------- |
| 0 | completed — settled |
| 2 | pending — accepted, no result yet |
| 3 | failed — terminal; a fresh attempt needs a new --request-id |
| 1 | no outcome obtained (rejected, gateway unreachable, bad arguments) |
On 2, follow the payment with payment-status <payment_id> — the id is in the
output — or use --wait to have send-payment do it for you. Re-running
send-payment with the same --request-id also returns the current state, but it
rebuilds and re-sends the payment to do so, which is more work than a status
lookup.
1 is the case that reverses that order. It means the outcome is unknown, not
that nothing happened: a request that timed out may still have been accepted, and
you may not have a payment_id to look up. Re-run with the same --request-id,
which resolves to the payment if one was created and creates it if not. The id is
printed on failure for exactly this.
payment-status is a lookup, so its exit code reports whether the lookup worked
(0) or not (1), never the payment's outcome — read status for that.
The table describes a submitted payment, so it does not apply to --prepare-only.
There, 0 means the request was built and signed — nothing was submitted and no
payment exists. Do not chain a payment-conditional step off a --prepare-only run.
Asset format
sourceAsset and destinationAsset are CAIP-19 identifiers — see
Supported corridors.
Signing provider
Choose how the sender authorization is signed with --provider (default: raw).
Whichever provider you use, pass pinnedAddress to createSenderSigner with the payment's
source account. The SDK checks that the signing key resolves to that address before anything
is signed, so the wrong key fails immediately instead of producing a signature the gateway
rejects. This works on every chain and on both providers.
Private key (--provider raw, default)
Provide your private key via environment variable:
PRIVATE_KEY=0x... send-payment <args...>If not provided, you will be prompted securely.
Turnkey (--provider turnkey)
Sign via a Turnkey-managed wallet instead of a local private key. Set the
Turnkey credentials in the environment; the <sender> argument is verified
against the wallet's address for the source chain (a mismatch fails fast):
export TURNKEY_ORGANIZATION_ID=...
export TURNKEY_WALLET_ID=...
export TURNKEY_API_PUBLIC_KEY=...
export TURNKEY_API_PRIVATE_KEY=...
send-payment --provider turnkey <args...>Everything Turnkey signing needs is already declared as a dependency, so no extra
setup is required. The Turnkey code is loaded on demand, so the private-key path
does not pull in Turnkey or @solana/web3.js.
Configuration file
The command-line tools also read an optional .env file from the directory you
run them in, so credentials and settings do not have to be retyped each time:
PRIVATE_KEY=0x...
GATEWAY_URL=https://payment-gw.production-testnet.atum.xyz
SOLANA_RPC_URL=
TURNKEY_ORGANIZATION_ID=
TURNKEY_WALLET_ID=
TURNKEY_API_PUBLIC_KEY=
TURNKEY_API_PRIVATE_KEY=Where a setting has a matching flag, the flag takes precedence, then a value
already set in your environment, then .env, then the built-in default. Keep the
file out of version control — it holds signing keys.
This applies to the command-line tools only. The SDK does not read .env; pass
configuration as arguments, or set GATEWAY_URL in your own application's
environment.
Output
- PaymentRequest JSON →
stderr(for debugging/logging) - PaymentResponse JSON →
stdout(for parsing/automation)
Under --prepare-only there is no response, and the signed request is the result, so
it goes to stdout instead and the stderr copy is dropped. That keeps
send-payment --prepare-only ... | jq working.
Example
PRIVATE_KEY=0xabc... send-payment \
0xF0D0... \
100000 \
eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831 \
TRX123... \
tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozsPreparing a payment for review
send-payment builds, signs and submits in one step. To have the payment approved
before it is sent, split it in two: --prepare-only stops after signing and prints
the request instead, so you can see exactly what you are about to authorize — the
document is not a description of the payment, it is the payment. --submit sends
one that was prepared earlier.
# 1. build and sign, submit nothing
send-payment --prepare-only --quote-deadline-seconds 30 \
0xF0D0... 100000 \
eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831 \
0xMerchant... \
eip155:42220/erc20:0xcebA9300f2b948710d2653dD7B07f33A8B32118C \
> request.json
# 2. inspect it, have it reviewed
# 3. send it
send-payment --submit request.json --waitOr piped, with no file on disk:
send-payment --prepare-only <args...> | send-payment --submit -Four things to know:
--prepare-onlystill contacts the gateway. The corridor addresses come from it, so this is not an offline signing mode.- Widen the quote window if you intend to submit later — but only by tens of seconds.
A prepared document is submittable only while its quote window is open, and the default
is 10 seconds. That window is also the auction: the gateway waits it out before choosing
a settler, and settler quotes expire on their own, so an over-long window ends in
QUOTES_EXPIRED. That failure is terminal, and a fresh attempt needs a new request id. Give the reviewer enough time and no more. --submitsigns nothing and needs no private key — the authorization is already in the file. It also cannot repair one: a request whose signature does not match its contents is rejected, and the fix is to prepare a new one.- Treat the prepared file like a signed cheque. It is a signed authorization, not a
draft: anyone who has it can send it, because
--submitneeds no key and the signature is already inside. Keep it where you would keep a credential, and delete it once the payment is sent or the request is abandoned.
--submit takes no corridor arguments, since the request already carries them, and it
rejects the options that only shape a request being built (--request-id,
--quote-deadline-seconds, --provider and the rest) rather than accepting and ignoring
them. It reports with the same exit codes and streams as an ordinary run.
Re-preparing with the same --request-id targets the same payment — the deposit nonce
is derived from the id, so it cannot pay twice — but it does not reproduce the same bytes.
The deadlines are stamped from the clock on each run and the signature covers them, so two
prepared documents for one payment always differ in quote_deadline,
fulfillment_deadline and the signature.
Approving the source token from the CLI
approve-permit2 grants the one-off approval described in
Approving the source token. Run it once per wallet and token,
before the first send-payment from that token:
approve-permit2 <sender> <sourceAsset> --rpc-url <url># is one needed at all? reads the allowance, sends nothing, costs no gas
approve-permit2 0xF0D0... eip155:421614/erc20:0xaf88... --rpc-url https://... --check
# grant it
approve-permit2 0xF0D0... eip155:421614/erc20:0xaf88... --rpc-url https://...This is the only command in the package that broadcasts a transaction and spends gas.
--rpc-url <url>- RPC endpoint for the source chain. Also read fromRPC_URL.--check- report whether an approval is needed, then exit. Needs no private key.--amount <units>- approve only this many smallest units instead of an unlimited approval.--spender <address>- approve a contract other than the canonical Permit2.
The private key comes from PRIVATE_KEY or an interactive prompt, never a flag — a flag would
put it in your shell history and in the process list. It is only requested once the allowance has
been read and an approval is genuinely due, so an already-approved wallet never has to produce
one, and it is checked against <sender> before anything is sent. The result JSON goes to stdout.
EVM and Tron. Solana needs no approval at all, and the command says so rather than doing nothing.
Tron additionally needs the tronweb package, which is an optional peer dependency so that
EVM-only installs do not carry it. Install it alongside the CLI when you pay from Tron:
npm install -g tronwebRemoving the command-line tools
npm uninstall -g @atumlabs/payment-gateway-clientSDK Usage
Basic Setup
import {
PaymentGatewayClient,
createSenderSigner,
signPaymentRequest,
getErrorResponse,
ApiError,
} from '@atumlabs/payment-gateway-client';
const client = new PaymentGatewayClient({
BASE: 'https://payment-gw.production-testnet.atum.xyz' // or your gateway URL
});Gateway URL
The client uses the production-testnet gateway by default. To target another environment, in order of precedence:
- Pass it explicitly — SDK
new PaymentGatewayClient({ BASE }), or the CLI--gatewayflag. - Set the
GATEWAY_URLenvironment variable. - Otherwise the built-in default
https://payment-gw.production-testnet.atum.xyzis used.
Mainnet access is granted by Atum; use the mainnet gateway URL provided to you.
Timeouts
Requests are bounded so an unresponsive gateway fails rather than hanging. There are two budgets, because some endpoints are held open by the gateway on purpose while it collects quotes or settles a payment:
| Budget | Default | Applies to |
| --- | --- | --- |
| timeoutMs | 30s | requests the gateway answers as soon as it can |
| syncWaitTimeoutMs | 120s | POST /v1/payments, POST /v1/payments/awards, and any wait: true call |
const client = new PaymentGatewayClient({ timeoutMs: 10_000, syncWaitTimeoutMs: 60_000 });Exceeding either raises a GatewayTimeoutError (see Error Handling). Raise
syncWaitTimeoutMs if your gateway is configured with a larger
payment_sync_wait_seconds than the 30s default, since it must clear that cap.
For a single request, an interceptor that sets timeout takes precedence over
both.
Example: Tron to Celo Sepolia Payment
async function sendPayment() {
// Private key from environment variable
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
throw new Error('PRIVATE_KEY environment variable required');
}
// Tron sender address (base58 format)
const tronSender = 'TYourTronAddressHere';
// Prepare the payment request
const paymentRequest = await client.preparePaymentRequest({
depositor: tronSender,
fulfillmentAmount: '100000', // 0.10 USDT (6 decimals)
sourceAsset: 'tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', // USDT on Tron Shasta
destinationAccount: '0xYourEthereumAddressHere',
destinationAsset: 'eip155:11142220/erc20:0x456a3D042C0DbD3db53D5489e98dFb038553B0d0', // USDC on Celo Sepolia
// Required. Your stable idempotency key — reuse the SAME value on retry so a
// retry never becomes a second payment (see "Idempotency and retries" below).
requestId: 'order-4711',
});
// Sign the prepared request with the SDK signer helper. It handles the
// per-chain signing differences internally (EVM typed-data, Tron hash,
// Solana Ed25519), so you never touch a chain library directly.
const sourceChainId = 'tron:shasta'; // CAIP-2 chain id, i.e. sourceAsset.split('/')[0]
const signer = createSenderSigner(sourceChainId, {
provider: 'raw',
privateKey,
pinnedAddress: tronSender,
});
await signPaymentRequest(paymentRequest, signer);
try {
// Submit the payment
const response = await client.payments.submitPayment({
requestBody: paymentRequest
});
// A 2xx means the payment was accepted, NOT that it settled. Branch on `status`:
// cross-chain settlement can outrun the gateway's synchronous window, so an accepted
// payment may still have no result yet. See "Idempotency and retries" below.
console.log('Payment ID:', response.payment_id);
if (response.idempotent_replay) {
// This request_id had been used before, so this is the payment your earlier attempt
// created. Nothing additional was charged.
console.log('Handed back an existing payment');
}
switch (response.status) {
case 'completed':
console.log('Settled.');
console.log('Source TX:', response.fulfillment_confirmation?.source_tx_hash);
console.log('Destination TX:', response.fulfillment_confirmation?.destination_tx_hash);
break;
case 'failed':
// Terminal, and this request_id stays bound to it. A fresh attempt needs a NEW id.
console.error('Failed:', response.error?.code, response.error?.message);
break;
default:
// Still settling. Do not release goods, and do not pay again under a new id.
// Follow it with getPaymentStatus(payment_id); re-submitting this same request
// also returns the current state, but it re-sends the payment to do so.
console.log('Still settling — see "Tracking a payment" below.');
}
return response;
} catch (error) {
const gwError = getErrorResponse(error);
if (gwError) {
console.error('Gateway error:', gwError.code, gwError.message, gwError.request_id);
} else if (error instanceof ApiError) {
console.error('HTTP', error.status, error.statusText, error.url);
}
throw error;
}
}Tracking a payment
A 2xx from submitPayment means the payment was accepted, not that it settled. Read
status on the response, and follow the payment while it is still pending.
Store your request_id before you call submitPayment, not after. It is the only
handle that survives a crash between sending the request and receiving the response, and
it is what you need to find the payment again.
| Status | Meaning |
| --- | --- |
| pending | Accepted, not yet settled |
| finalizing | Paid on chain, collecting confirmations. Not final |
| completed | Delivered |
| failed | Did not settle |
completed and failed are final. A payment that reaches one of them keeps
its request_id for good, so re-submitting that id returns the same finished payment
rather than trying again — a genuine new attempt needs a new request_id. Any status
this client does not recognise is treated as still in flight. isTerminalStatus is exported
so you can ask that question without keeping your own copy of the list: a value added to the
contract then cannot silently read as finished in your code.
getPaymentStatus returns the payment's state at that moment:
import { isTerminalStatus } from '@atumlabs/payment-gateway-client';
const snapshot = await client.payments.getPaymentStatus({ paymentId });
if (!isTerminalStatus(snapshot.status)) {
// Still in flight. Do not release goods.
}
if (snapshot.error) {
// Present when the payment failed. The chain, transaction hash and revert reason are
// under `blockchain_context`, not on the error itself.
console.error(
snapshot.error.code,
snapshot.error.message,
snapshot.error.blockchain_context?.transaction_hash ?? '',
);
}Waiting for an outcome
getPaymentStatus answers once and does not wait. To follow a payment to its end, use
waitForTerminalStatus, which is the same helper behind the send-payment --wait flag:
import { waitForTerminalStatus } from '@atumlabs/payment-gateway-client';
const outcome = await waitForTerminalStatus({
fetchStatus: () => client.payments.getPaymentStatus({ paymentId }),
budgetMs: 60_000,
onError: (error) => console.warn('status check failed, still waiting:', error),
});
if (outcome.timedOut) {
// Still in flight when the budget ran out. Not a failure — check again later.
} else {
console.log(outcome.snapshot?.status);
}A failed status check does not end the wait: a network blip while a payment is settling is
the worst moment to stop looking, so failures are counted in outcome.errorCount, reported
through onError, and polling continues.
budgetMs defaults to DEFAULT_WAIT_BUDGET_MS (one minute) and is in milliseconds.
Checks are DEFAULT_POLL_INTERVAL_MS apart
(2 seconds) by default; pass intervalMs to widen it, and if you are following many
payments at once, stagger their start times so their polls do not align.
Pass a signal to stop a wait early, for a process draining on shutdown or a request that
has been cancelled. The outcome then reports aborted, and still carries the last status it
read. timedOut is true whenever the wait ended without a terminal status, so branching on
it stays correct either way; read aborted when you need to tell the two apart.
const controller = new AbortController();
process.once('SIGTERM', () => controller.abort());
const outcome = await waitForTerminalStatus({
fetchStatus: () => client.payments.getPaymentStatus({ paymentId }),
signal: controller.signal,
});
if (outcome.aborted) {
// Shutting down. The payment is unaffected; pick it up again on the next start.
}Running out of budget says nothing about the payment: timedOut means you stopped
watching, not that anything failed. Check again later rather than paying again — see
Idempotency and retries.
Finding a payment again
If a submit call threw, you may hold no payment_id — but the payment may still have been
created. resolvePayment turns the request_id you stored back into one. It throws an
ApiError with status 404 when the gateway has no payment recorded for that id:
import { isApiError } from '@atumlabs/payment-gateway-client';
try {
const { payment_id } = await client.payments.resolvePayment({ requestId: 'order-4711' });
const snapshot = await client.payments.getPaymentStatus({ paymentId: payment_id });
console.log(snapshot.status);
} catch (error) {
if (isApiError(error) && error.status === 404) {
// No payment recorded for that request_id yet.
} else {
throw error;
}
}A 404 is not proof that nothing happened. A submit that timed out may still be in flight,
and the gateway records the payment when it lands. Never start a fresh payment under a new
request_id to recover — that is a second payment, with real money on both. Re-submit the
same request_id instead: it returns the payment if one was created, and creates it if
not.
Step-by-step detail
getPaymentTimeline returns the payment's individual steps, each with its own status and,
for on-chain steps, a chain id and transaction hash. Use it to show progress, or to
investigate a payment that did not settle:
const timeline = await client.payments.getPaymentTimeline({ paymentId });
for (const step of timeline.steps) {
console.log(step.status, step.label, step.transaction_hash ?? '');
}
if (timeline.error) {
// Why it failed, and where: for an on-chain failure this carries the chain id, the
// transaction hash and the revert reason.
console.error(
timeline.error.code,
timeline.error.message,
timeline.error.blockchain_context?.revert_reason ?? '',
);
}A payment reads the same on every endpoint: getPayment, getPaymentStatus and
getPaymentTimeline all report the one PaymentStatus. finalizing means the
transaction paying your recipient is on chain and accruing confirmations — it is not
final, and it is not a reason to treat the payment as delivered.
Solana signing
Solana signs with Ed25519, which does not let a verifier recover the signer's key from
the signature, so the key travels with the message as payload.delegate_signer.
signPaymentRequest and the command-line tools set it for you.
The key you sign with must be the payment's source account, which is what pinnedAddress
checks — see Signing provider.
Error Handling
The gateway returns a typed, public error body — ErrorResponse
({ code, message, request_id?, payment_id?, docs_url?, domain? }). Use
getErrorResponse to narrow a caught error to it; it returns undefined for
anything that is not a gateway error with that shape. payment_id is present only
when the error is about a specific payment, so it is absent on the validation and
authentication failures that happen before a payment exists. For transport-level
detail (HTTP status, URL), the thrown value is still an ApiError.
Input validation happens before any request, so those failures are a plain Error carrying the
message and no ErrorResponse — getErrorResponse returns undefined for them. Read
error.message.
import { getErrorResponse, isGatewayTimeoutError, ApiError } from '@atumlabs/payment-gateway-client';
try {
await client.payments.submitPayment({ requestBody: paymentRequest });
} catch (error) {
const gwError = getErrorResponse(error);
if (gwError) {
console.error('Code:', gwError.code); // machine-readable category
console.error('Message:', gwError.message); // safe, human-readable text
console.error('Request ID:', gwError.request_id); // quote this to support
console.error('Docs:', gwError.docs_url);
} else if (isGatewayTimeoutError(error)) {
// The gateway never answered within the client's budget. There is no
// response and no status, so this is not an ApiError.
console.error(error.message, error.timeoutMs);
} else if (error instanceof ApiError) {
// Transport-level failure with no typed body (e.g. a proxy/gateway error).
console.error('HTTP', error.status, error.statusText, error.url);
} else {
throw error;
}
}A thrown error does not mean no payment was created — a request that timed out may still have been accepted. Treat it as unknown rather than as a failure:
- If you already hold the
payment_idfrom an earlier attempt, readgetPaymentStatus— the cheapest way to find out what happened. - If you do not, call
resolvePaymentwith the samerequest_idto get thepayment_id, then read its status. A404there means the gateway has no payment recorded for that id yet — see Finding a payment again. - Re-submitting the same
request_idalso works and needs no lookup: it resolves to the payment if one was created, and creates it if not. Never recover by starting a payment under a newrequest_id: that is a second payment, not a retry.
One code is worth handling by name:
const gwError = getErrorResponse(error);
if (gwError?.code === 'IDEMPOTENCY_TERMS_MISMATCH') {
// 409 — this request_id already identifies a payment with different economics.
// Re-submitting cannot amend that payment; a genuinely different one needs a new
// request_id. See "Idempotency and retries".
}Asset Identifiers
Assets are identified using the CAIP-19 format,
{chain_id}/{asset_namespace}:{asset_reference} — see
Supported corridors for an example per chain family.
Identifiers are validated before a request is built, so a malformed one is reported with the value
and what to correct rather than surfacing from a chain library. The rules: both namespaces are
canonicalized to lowercase, an eip155 chain id is decimal, an EVM token address is 20-byte hex
and, when mixed-case, must carry a valid EIP-55 checksum,
and a native asset (slip44) has no contract to deposit so it is refused. parseAssetIdentifier
and assetChainId are exported if you want the same parsing and canonicalization in your own code.
The checksum rule applies to the destination as well as the source, which is stricter than the gateway, and it is typo protection rather than a settlement guard. For a destination address you cannot edit, the all-lowercase form is the same twenty bytes and is accepted. Do not reach for that on the source: settler configuration matches the source asset identifier byte for byte, so lowercasing it moves the failure to settlement instead of removing it.
Approving the source token
On EVM and Tron the escrow moves your token through Permit2, so the token contract has to be
told that Permit2 may move it. That is a one-off transaction per (wallet, token) — every payment
afterwards is just an offline signature and costs nothing.
Skipping it produces the worst kind of failure: the request is built correctly, signed correctly and accepted by the gateway, and then settlement reverts on-chain minutes later.
ensureSourceApproval reads the current allowance and sends an approval only if it falls short:
import { ensureSourceApproval, needsSourceApproval } from '@atumlabs/payment-gateway-client';
import { Wallet, JsonRpcProvider } from 'ethers';
const wallet = new Wallet(process.env.PRIVATE_KEY!, new JsonRpcProvider(RPC_URL));
const result = await ensureSourceApproval({
network: 'eip155:11142220',
token: '0x456a3D042C0DbD3db53D5489e98dFb038553B0d0',
owner: wallet.address,
signer: wallet,
});
if (!result.alreadySufficient) {
console.log(`approved in ${result.txHash}`);
}You supply the signer, so the SDK never picks an RPC endpoint for you — everything else in this package is offline, and this is the one call that broadcasts a transaction and spends gas.
It waits for the approval to be mined before returning, so txHash means confirmed rather than
merely sent. That wait is bounded — one minute by default, confirmation.timeoutMs to change it —
and a timeout is reported as unconfirmed, not failed, because the transaction may still land.
Pass onSubmitted to receive the hash the moment it is broadcast, so a slow approval can still be
looked up:
await ensureSourceApproval({
network, token, owner, signer,
onSubmitted: (txHash) => console.log(`watching ${txHash}`),
confirmation: { timeoutMs: 120_000 },
});isUnconfirmed(error) tells the two apart. It matters because the responses are opposite: a
failed approval needs another one, an unconfirmed approval needs a look at the transaction and
nothing else. Sending a second one on top of the first only pays for an allowance you are already
getting.
try {
await ensureSourceApproval({ network, token, owner, signer });
} catch (error) {
if (isUnconfirmed(error)) {
// Broadcast, not yet seen to confirm. Check the hash from onSubmitted; do not re-send.
} else {
throw error;
}
}On EVM, some tokens — mainnet USDT is the one you are most likely to meet — refuse to move an
allowance straight from one non-zero value to another. If your wallet already holds a partial
allowance on such a token, the approval is reset to zero first, automatically, and the result
carries a resetTxHash alongside the usual txHash. The check costs nothing and sends nothing,
and a wallet with no allowance skips it entirely, so an ordinary approval still takes exactly one
transaction.
Tron does not do this. An ordinary TRC-20 accepts the change in place, so guessing otherwise would burn an extra transaction at Tron's fee limit; if a Tron token ever does refuse, the error tells you to approve zero first and retry.
Paying from Tron takes a TronWeb instance in place of the ethers signer. The Permit2 address for the network is resolved for you:
await ensureSourceApproval({ network: 'tron:shasta', token, owner, tronWeb });Solana needs no approval at all — the signed deposit authorizes the transfer itself — so the call
reports alreadySufficient without touching the chain.
Checking without spending gas
needsSourceApproval takes the same arguments and answers the same question, read-only. Use it to
warn a payer that a transaction is coming before asking them to sign:
if (await needsSourceApproval({ network: 'eip155:11142220', token, owner, signer })) {
// a one-off approval transaction is due first
}Bounded approvals
The approval sent is unlimited by default, which is why later payments need no further
transaction. Pass approvalAmount to bound the approval this sends instead.
On its own the amount is treated as its own requirement, so an allowance still holding the full
bound is left alone rather than re-approved — the right answer for a one-off approval. Across
repeated payments, pair it with requiredAllowance, as below: Permit2 decrements the
allowance on every payment, so a bound measured against itself stops covering itself the moment
the first charge lands, and every later call would send another approval. Either way a bounded
approval is consumed as it is spent and eventually has to be granted again:
await ensureSourceApproval({
network: 'eip155:11142220',
token,
owner,
signer,
requiredAllowance: 1_000_000n, // what this payment needs
approvalAmount: 5_000_000n, // approve only this much, not unlimited
});These helpers raise an allowance to what a payment needs; they never lower one. A wallet already
holding more than approvalAmount is left exactly as it is and reports alreadySufficient
without sending anything. Reducing or revoking an allowance is a separate operation, and not one
these perform.
Permit2 contract addresses, if you would rather approve by hand:
| Chain | Address |
| --- | --- |
| EVM (virtually all chains) | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| Tron mainnet | TJhMXTHQHeQyMD7TcKQFqAePNgG4b31H9m |
| Tron Shasta | TPUqJPASUn1zLvbLBgRZ5pBYrx7WSe5ahp |
Idempotency and retries
Every write request carries a request_id. Pass a stable value and reuse the
exact same one on every retry (a timeout, a network blip, a restart) and you end
up with one payment, not two — and you get the same answer each time. One contract
covers submitPayment and createQuoteRequest alike.
- Using this SDK,
request_idis required on both builders (preparePaymentRequest,prepareQuoteRequest) and neither invents one — an id the library chose is an id you cannot reuse on a retry. On the payment path it is the only thing you manage: the SDK derives the on-chain deposit nonce deterministically from it, so retries line up on their own. - On the CLIs,
--request-idis optional; when omitted an id is generated and printed, so you can re-run with--request-id <id>to re-attempt the same payment or recover the same batch. A bare re-run, with no--request-id, is a separate payment or batch. - Re-sending an id returns what it created the first time — the original payment, or the original quote-request batch — and creates nothing new.
- A genuinely new request needs a new
request_id. Reusing one with different economics (a corrected amount, a different token) is rejected with409IDEMPOTENCY_TERMS_MISMATCH— one id identifies one payment, or one batch. - Deadlines and the signature are exempt, so a re-submission of the original
bytes is fine even though its deadlines have expired by then. This is what makes
a lost
createQuoteRequestresponse recoverable: re-send it and you get yourquote_request_idback. idempotent_replayon the response tells you whether you were handed something that already existed.pendingis not success. It means accepted and still settling: re-submit the samerequest_idto collect the result. Afailedpayment is terminal and keeps its id, so recovering from one needs a new id.
See docs/idempotency.md for the full model: the two
guarantees and which endpoints each covers, when the 409 applies, how to read
the outcome, and the per-chain differences (EVM/Tron vs Solana).
Signing
A PaymentRequest carries three unrelated signatures: signPaymentRequest authorizes the
on-chain transfer, signDeclaration attests the document, and an identity envelope carries the
originator. None substitutes for another.
Both signing surfaces take a signer, never a private key, so a key held in a KMS, an HSM or
a custodial provider works without leaving its boundary; signerFromEthersWallet covers a local
key. See docs/signing.md for which signature to use when, what the proof does
and does not cover, and the two details a custodial signer must get right.
What is in the package
dist/— compiled JavaScript and TypeScript declarations, ready to usedist/cli/— the five command-line toolsdocs/— reference documentation, including idempotency and retries and signingLICENSE,THIRD-PARTY-NOTICES.txt— license terms and third-party attribution
