@catena/sdk
v0.13.0
Published
Typed client SDK for the Catena agent API
Downloads
1,077
Readme
@catena/sdk
Build agents that can move money without bypassing policy or approvals.
@catena/sdk is the typed Node.js client for the Catena agent API. Use it to
read financial data, manage counterparties, move money through policy-checked
intents, and pay MPP or x402 HTTP 402 challenges.
Every Catena API request is signed. Every money movement follows Catena policy and approval rules.
Node.js 20+. ESM. Framework-free.
Install
npm install @catena/sdkContents
Quick start
Create a client with your linked agent's private key:
import { createCatenaClient } from "@catena/sdk"
const client = createCatenaClient({ privateKeyHex })Inspect the available source accounts and counterparty rails:
const { accounts } = await client.listAccounts()
const { counterparties } = await client.listCounterparties()Choose the intended IDs, then submit a send intent. counterpartyRailId is a
rail ID, not a counterparty ID.
const sourceAccountId = "acct_..."
const counterpartyRailId = "cpr_..."
const intent = await client.submitIntent({
action: {
type: "send",
accountId: sourceAccountId,
counterpartyRailId,
amount: "12.50",
method: "ach",
},
})
console.log(intent.status)You may omit accountId when policy allows exactly one source account. USD
intent amounts are decimal strings: "12.50" means $12.50, not cents or atomic
units.
Common workflows
Read account data
const agent = await client.whoami()
const setup = await client.getSetupState()
const policy = await client.getPolicy()
const { accounts } = await client.listAccounts()
const account = accounts[0]
if (!account) throw new Error("No account is available")
const balance = await client.getAccountBalance(account.id)
const totalBalance = balance.balances?.total ?? balance.balance
const availableBalance = balance.balances?.available
const { transactions } = await client.listAccountTransactions(account.id, {
limit: 25,
})
const { intents } = await client.listIntents({
status: ["pending", "processing"],
accountId: account.id,
})Account figures are USD at par across assets. balances.available is aggregate
unreserved funds across the networks this agent may use. It describes the
account and does not determine whether a particular payment can be funded. It is
zero while the account is not active, and so is every balances.byNetwork
available. The deprecated flat balance field equals balances.total. If
balances is absent on an older API, the available balance is unknown.
balances.byNetwork gives total and available for each network that the
account holds and agents may use, so it says where the money sits:
for (const { network, available } of balance.balances?.byNetwork ?? []) {
console.log(network, available.amount)
}balances.byNetwork is absent until the money of the account is split by
network. An empty array means the money is split, but no network of the account
is open to agents. The entries can add up to less than balances.total, which
also counts a network agents may not use. Their available values add up to
balances.available.
balances.holdings is an optional breakdown by network and token. Each line has
token (the CAIP-19 asset identifier), network, networkLabel, asset,
assetLabel, total and available. Amounts are USD at par, like the
headline. When holdings are absent, every figure is USDC. Holdings are omitted
for bank accounts and until the money is split by network, and on older API
deployments. The lines include only networks the agent may use. Their
available values sum to balances.available; their total values sum to
balances.total only when every account network is inside the agent network
fence. Closed networks and inactive accounts have zero available; retired
wallets contribute to total alone.
Account transaction amount, fee, and currency are USD. The amount is what
the recipient received when tokenDetails is present; the fee includes the
Catena fee, conversion cost, and customer-paid gas. A movement within the same
account shows amount zero and its net cost as the fee. A net conversion credit
can make the fee negative. Without token details, the recorded transaction
amount and Catena fee are retained. Optional tokenDetails.debited and
tokenDetails.credited carry one { asset, network, amount } entry per token
and network that netted out of or into this account, with each amount in USD at
par. asset uses the same lower-case token symbols as balance holdings.
tokenDetails.paid, when present, names the destination token of a payment that
converted another funding token. Older servers and rows without token provenance
omit these details. Transfers name the other account as the counterparty from
either account's perspective.
Each entry's status is open while agents can spend on that network, and
closed while Catena has made the network temporarily unavailable. A closed
entry keeps its total, which is safe, with available zero. Treat any value
other than open as a network you cannot spend on now. An older API omits
status, and reports only open networks.
policy.capabilities lists broad permissions. policy.policyCapabilities
contains per-account rules. policy.counterpartyRules controls counterparty
access, while kind and expiresAt identify an active temporary override.
getSetupState() returns an AgentSetupState with the authenticated agent,
organization, policy readiness, accessible account count, signer registration
and outstanding setup steps. organization.heldAssets lists additional assets
beside USDC. Modern servers return an empty array when USDC alone is available,
including failed server-side entitlement reads. Older servers may omit the
field; treat omission as USDC alone. Future asset identifiers and setup-step
values are accepted; unknown object fields are ignored.
const availableAssets = ["usdc", ...(setup.organization.heldAssets ?? [])]Discovery describes organization choices, not authorization. Account access,
network availability and current policy still apply. In particular,
organization.closedNetworks identifies temporarily closed networks; asset
admission does not open them.
For an on-chain deposit address:
const deposit = await client.getAccountDepositAddress(walletAccountId, {
network: "base",
asset: "usdc",
})deposit.source is wallet or liquidation. x402, MPP, and viem require an
account whose deposit address returns source: "wallet" on the chosen network.
Before a deposit-address or wallet-payee creation request with any explicit
asset other than "usdc", the SDK reads fresh setup state. This includes
different casing and unknown asset identifiers, which stay unchanged on the
wire. If successful discovery omits organization.heldAssets, the SDK refuses
locally with onchain_network_asset_disabled for deposits or
counterparty_rail_asset_unavailable for payees, before requesting an address
or creating an intent. If the field is present, even empty or without the
requested asset, the SDK sends the request and lets the server validate, replay
or refuse it. Such a server resolves an existing idempotent creation before
checking current asset admission, so a retry with the same idempotencyKey can
replay after entitlement changes.
Failed discovery preserves its original API, timeout, transport or
response-validation error, including authentication, upgrade and retry guidance;
no operation follows it. Explicit "usdc" and omitted wallet assets keep their
existing behavior without this extra read. The server checks admission and
supported network/token pairs for new operations, including changes after
discovery. A successful discovery is never cached as permission for a later
operation.
Manage counterparties
Create a counterparty with contact information:
const created = await client.submitIntent({
action: {
type: "create_counterparty",
name: "Acme Vendor",
email: "[email protected]",
},
})The action may require approval. Poll it to a final state, then narrow the runtime result before reading the new ID:
let result = created
while (result.status === "pending" || result.status === "processing") {
await new Promise((resolve) => setTimeout(resolve, 2_000))
result = await client.getIntent(result.id)
}
const counterparty = result.data?.counterparty
if (result.status === "completed" && result.data === null) {
throw new Error(
"Counterparty details are not visible under the current policy",
)
}
if (
result.status !== "completed" ||
typeof counterparty !== "object" ||
counterparty === null ||
!("id" in counterparty) ||
typeof counterparty.id !== "string"
) {
throw new Error(`Counterparty is not ready: ${result.status}`)
}
const counterpartyId = counterparty.idAn email-only counterparty has no rails, and creating it does not send an email.
Creation, replay and intent reads return data: null when the current policy
excludes the counterparty, for every asset. The intent's ID and status remain
readable. Null can also mean no result exists yet; it does not itself mean the
operation failed.
Request those details through Catena. Bank details require a KYB-verified organization with fiat rails enabled.
await client.submitIntent({
action: {
type: "request_counterparty_details",
counterpartyId,
methods: { bank: true, wallet: false },
},
})The request follows policy and may wait for approval. A second request while an
invitation is active returns HTTP 409 with
counterparty_payment_request_conflict; error.details.invite identifies the
existing invitation. Set at least one requested method to true. The request
can go ahead for a counterparty the current policy does not list, but its
response and reads then return data: null in every status.
You can also create a counterparty with a wallet rail already attached:
await client.submitIntent({
action: {
type: "create_counterparty",
name: "Treasury",
rail: {
type: "wallet",
walletAddress: "0x1111111111111111111111111111111111111111",
network: "base",
},
},
})Wallet rails accept an optional asset; omission means USDC. Use
getSetupState() to discover additional choices before selecting one. Returned
wallet rails preserve asset when present, including separate methods for two
assets at one address. An absent asset means USDC. A rail ID alone does not
independently establish a recipient address.
In an organization that holds OUSD, a send to an OUSD rail is paid from held
OUSD or by converting USDC at approval; see
Payments that convert. Anywhere else, it refuses with
counterparty_rail_asset_unsupported before an intent exists; surface that
refusal without substituting USDC.
Bank rails require a KYB-verified organization with fiat rails enabled.
await client.submitIntent({
action: {
type: "create_counterparty",
name: "Acme Vendor",
email: "[email protected]",
rail: {
type: "bank",
bankName: "Example Bank",
routingNumber: "021000021",
accountNumber: "123456789",
accountType: "checking",
addressStreet: "1 Main Street",
addressCity: "New York",
addressState: "NY",
addressPostalCode: "10001",
addressCountry: "USA",
},
},
})routingNumber must contain 9 digits. accountNumber accepts 4–17 digits.
accountType is checking or savings. accountType, addressCountry, and
the action-level email are optional; the remaining fields are required.
When sending, match the method to the rail: ach and wire require a bank
rail; on-chain requires a wallet rail.
Transfer between accounts
const intent = await client.submitIntent({
action: {
type: "transfer",
accountId: sourceAccountId,
toAccountId: destinationAccountId,
amount: "100.00",
memo: "Treasury rebalance",
},
})ACH and wire send actions accept an optional memo, which is sent with the
bank payment. All send actions accept an optional description for an
internal Catena note. On-chain memo remains accepted for compatibility but is
deprecated and is not sent to the blockchain. transfer accepts both fields.
Sends to another network
A send to a counterparty wallet on another network is an ordinary send. When
the account cannot pay it from its balance on the rail's network, Catena moves
the money between networks under the same intent, carried by a movement plan.
Follow it with getIntent and read movement, as described in
Payments a person approves.
Payments that convert
In an organization that holds OUSD, a payment can convert funds at approval: USDC on Base or Arc converted to pay an OUSD payee, or held OUSD converted to pay a USDC payee. Transfers between the organization's own accounts deliver USDC and OUSD as themselves on each source's network, without conversion or bridging. The amount is their combined dollar value. A destination that cannot receive the funds in kind is refused. Custodial accounts cannot transfer. Catena offers the approval as signing steps: one body to stamp for each conversion and one for the payment, each with its effects (what it does) and a description for a person to read.
submitIntent signs it like any other payment. Before it stamps anything, it
checks every step against your action's amount, and against expected when
you pass one. Only when every step passes does it stamp each one with your key,
and it submits them together. If any step fails, or is in a form this client
cannot read, nothing is signed and the error reports not-submitted with the
intent's ID. submitIntent then cancels that intent, so the approval Catena
prepared for it does not hold your wallet, and the error's cancellation says
whether the cancel went through. A conversion is never signed on its own.
The result's steps lists each stamped step's id, description and
effects. An IntentSubmitError carries the same list once the stamps exist,
so a submit whose outcome is unknown still says what was signed. The
description is Catena's wording; act on effects.
The check proves that every step it is given is valid and that together they pay what you asked. It cannot prove the list is complete, because the list comes from the same response as the steps. A payment whose conversion is left out can still verify when what remains pays on its own, as from held funds; Catena's submit refuses any approval that is missing a stamp for one of its steps.
A USDC payment comes as one step and is signed the same way. A server that
offers no steps prepares one request to stamp, or a batch of digests with the
payment they commit to, and submitIntent checks it before it signs:
- An on-chain
sendor atransfermust move youramount, as a gas-sponsored USDC or OUSD transfer, as a relay-paid USDC transfer authorization valid for at most about a day, or as digests this client rebuilds from the payment they commit to. Who it pays is not checked: a counterparty is named by ID, and this client cannot resolve one to an address. - An
x402payment must be a USDC transfer authorization that pays the requirement's recipient its amount, or exactly theauthorizationyou passed. - An
mpppayment must be the one USDC transfer authorization its challenge determines.
Any other request is refused before anything is signed, with not-submitted: a
login, an API key, a key import, a policy, a raw transaction, a hash to sign, a
Solana send, a contract call that is not a transfer, a conversion, an ACH or
wire send in either form (a wallet pays a bank account only through signing
steps), and a signature for an action that pays nothing. The check of a single
request is published as verifyPreparedPayment in @catena/sdk/movements.
Pass expected to bind the payment to what you know yourself:
await client.submitIntent({
action: {
type: "send",
accountId,
counterpartyRailId,
amount: "25.00",
method: "on-chain",
},
expected: { recipient: payeeAddress, network: "base", asset: "ousd" },
})recipient: the 0x address the payment must reach.network: the network it must land on, such asbase.asset:usdcorousd.
Each field you give must match, or nothing is signed. Your amount is always
checked. Without expected, the payment is checked against the destination
Catena names for it, which is Catena's account of the payee's rail and which a
rail ID alone cannot confirm. Pass the fields you know from somewhere else. An
asset you leave out is taken from that destination.
expected binds only what this client signs:
- A malformed
expectedthrows aTypeErrorbefore anything is created: anything but a plain object (a Promise left unawaited, aMap, a class instance), an unknown field, anullfield, a recipient that is not an address, a network other thanbase,base-sepolia,arcandarc-testnet, or an asset other thanusdcandousd. - Against a server that offers no steps,
expectedrefuses before anything is signed, withnot-submitted. A plain object with no field given is the same as leaving it out. - A payment completed without your signature, or approved by a person, is not checked against it.
Payments a person approves
A send or transfer over the organization's approval threshold parks for a
person rather than being refused. Once a person approves it, an admin signs it
in the Catena console. You do not finish it: poll it until it is done or
blocked.
Poll the intent with getIntent and read movement. It is served on a read,
not on the submitIntent result, and only for a payment carried by a movement
plan. movement.nextStep is what status cannot tell you: a payment waiting on
a person and one an admin is signing both read processing.
const action = {
type: "send",
accountId,
counterpartyRailId,
amount: "2500.00",
method: "on-chain",
} as const
const submitted = await client.submitIntent({ action })
// `movement` is served by getIntent, not by submitIntent, so read it first.
let intent = await client.getIntent(submitted.id)
while (intent.movement?.nextStep === "wait") {
await new Promise((resolve) => setTimeout(resolve, 30_000))
intent = await client.getIntent(intent.id)
}wait— a person is deciding it, an admin is signing it, or its signature is in flight. Read it again withgetIntent.done— the payment completed.blocked— it ended without paying.reasonssays why, and is empty when an approval lapsed.
movement.nextStep never reads continue. Sending the same request again on
the same idempotency key hands back the payment's intent without signing
anything. That is an answer, not a failure: keep polling.
A payment nobody had to approve is still yours to sign, and submitIntent hands
you its stamp. If you restarted before signing it, send the original request
again on the same idempotency key. Within about five minutes of the first send
it answers movement_authorization_in_progress; after that it hands the payment
back for you to sign.
One wallet signs one payment at a time. submitIntent signs as soon as it
creates, so parallel calls from one wallet go through in turn: each waits while
the payments ahead of it are signed. A send answers
movement_wallet_approval_open when one other payment holds the wallet for more
than a few seconds (a person approving in the console, say) or its wait passes
20 seconds. Nothing is held. The error's details.heldUntil says when the
payment holding the wallet lets it go, and retryAfterSeconds how long that is;
when Catena cannot tell, as while that payment is being signed, heldUntil is
null and retryAfterSeconds is undefined. Wait that long, or a few seconds,
then send the request again. details.intentCancelled says on which key. When
it is true, the send's intent is cancelled, and its idempotency key answers with
that cancelled intent from then on: use a new key (omit idempotencyKey and
submitIntent makes one up). When it is false, the intent is still open: send
it again on the same key, so the payment is not made twice. If the intent has
been cancelled since, that resend returns it and nothing moved: start again on a
new key. If submitIntent made that key up, nothing else can reach the intent,
and a new key is safe; it counts against your limits until it expires.
details.blockingIntentId names the payment holding the wallet when it is one
of your own intents; if it is an attempt you have given up on, cancelIntent
frees the wallet at once. If the refusal lasts more than a few minutes, report
it and stop resending: a signing that has to be recovered can hold the wallet
until an operator resolves it.
The SDK checks a movement stamp against the action you submitted before it
signs. It is the one check that compares the payment against something Catena
did not supply. A payment that would not pay the amount you asked for is refused
before anything is signed. The amount is summed over every part that delivers,
whether it is stated in a signed field or inside the call data of a raw
transaction or a batched call. A call this client does not recognise is refused.
The recipient is checked against an expected recipient you pass, and a payment
signed as steps is checked against the destination Catena names for it.
Otherwise the recipient is not checked, because a counterparty is named by ID
and this client cannot resolve one to an address.
A movement is signed as pre-hashed digests, and the SDK rebuilds them locally
rather than trust what it is handed — it will not sign digests it cannot check.
It needs nothing beyond @catena/sdk for this: the hashing it uses ships inside
the package rather than coming from your own viem, so your viem version
cannot change what it checks. The same check is published as
@catena/sdk/movements (verifyPreparedMovement, rebuildInstructionDigest)
for an agent that signs some other way.
verifyPreparedMovement(prepared, { amount }) keeps the existing USDC review
contract. Pass { amount, inKind: true } to refuse crossings when your own
request transfers between different accounts. Use verifyMovementSteps from the
same entry for an approval described as signing steps:
import { verifyMovementSteps } from "@catena/sdk/movements"
const verdict = verifyMovementSteps(steps, totals, {
signerAddress: walletAddress,
amount: "6",
expected: {
recipient: knownRecipientAddress,
network: "base",
asset: knownRecipientAsset,
},
})
if (!verdict.ok) throw new Error(verdict.reason)
for (const step of verdict.steps) {
console.log(step.id, step.description, step.effects)
}Each step carries a prepared provider body, its ingredients, claimed effects and
a display description. The verifier rebuilds raw digests, checks each conversion
body against its ingredients, and independently derives every effect using
pinned token, spender and delegate contracts. Unknown encodings, assets,
networks, effects or body fields refuse. It checks body hashes, stated expiries,
signer consistency, nonce order and the complete payment before returning
detached snapshots of the verified bodies. It never signs or calls a provider.
Stamp only verdict.steps[*].preparedBody after an ok verdict.
Every provider body must equal JSON.stringify(JSON.parse(body)) byte for byte.
Duplicate keys, extra whitespace and alternate escapes refuse, preventing
different JSON parsers from reading different activities. Compact key reordering
is accepted and preserved in the verified snapshot. The legacy verifier and
submitIntent enforce the same check, with the client refusing before stamping.
Effects describe token outputs, conversions, CCTP bridges, allowances,
delegations and native gas. Decimal token quantities are exact, with the
registry's pinned decimals. Payout effects must deliver the caller's amount to
the reviewed destination. Optional expected recipient, network and asset
constraints come from the caller; an omitted asset uses
totals.destination.asset. Malformed expectations refuse rather than throw.
Pass ownAccounts: true only when your own action is a transfer between your
organization's accounts: the payment must then arrive at every location
totals.destinations lists, one per network and asset, and nowhere else. Mixed
USDC and OUSD deliveries include an exact amount for every location. The
verifier checks those amounts against the signed transfers and refuses swaps or
bridges in a mixed delivery. Older clients refuse mixed plans before signing;
update before submitting one. Without ownAccounts, totals.destinations, when
present, must name exactly totals.destination. Each totals.sponsoredFees
entry, a crossing fee Catena restores to your wallet, counts toward the ceiling
only up to the fee your signed burns into that wallet on that network may take.
submitIntent also checks the account IDs in your own transfer action. When
they name different accounts, it refuses any verified conversion or bridge
before signing, including a transfer that delivers only USDC. It enforces this
rule for both signing steps and older digest-only movement approvals. Network
moves within the same account and payments to counterparties keep their existing
review rules. This check needs no new field from the server; older clients
continue to handle USDC-only approvals, and must update before signing mixed
delivery plans.
Held funds may supplement conversion minima. Every conversion must fund a later
token debit, and, per output network and asset, aggregate minima times 0.9 may
not exceed those later debits. Each conversion's minimum must also be at least
90% of its input. Aggregate wallet debit, including bridges and rounded-up
wallet-paid gas, stays within totals.maxTotalDebit; that ceiling is supplied
by the caller of the verifier and is not automatically an independent spending
instruction. The existing wallet gas bound remains 2 USDC.
The verifier proves consistency with the supplied signer and destination, not
their ownership. A rail ID cannot independently establish an address. An
execute-swap body has no signer address: its organization must match the raw
batch's, whose signWith must equal the supplied signerAddress. Conversions
must be sponsored and have no output-destination override. Quote IDs and stated
expiries are not venue attestations or independent price quotes.
The step list has no separately trusted manifest. Omitting a conversion can
still verify when the remainder is a valid held-funded payment; an independently
valid extra conversion can also verify within all bounds. Insufficient held
funds then cause execution to fail on chain. Server submission must enforce its
persisted step set separately. Opaque ID renames, display descriptions and
permutations of independent conversions do not establish authorization. The
verifier checks SHA-256 of each body and a future preparation expiry, and keeps
the opaque prepareToken verbatim; it cannot authenticate that token offline.
MovementStep, MovementStepEffect, MovementStepTotals, PreparedStepBody,
VerifiedMovementStep, MovementStepsReviewVerdict and ExpectedDestination
are exported from @catena/sdk/movements. submitIntent runs this same check
before it stamps any step, and signs a conversion only as one of a server's
signing steps, never as an approval's own request (see
Payments that convert).
An approval does not wait forever. If no admin signs the payment before it lapses, it expires unpaid and has to be asked for again.
Request a temporary policy override
Policy overrides always wait for human approval. Each operation names the
blocking limit by its scope. This example asks for a higher daily send limit
shared across all accounts, for one hour:
const intent = await client.submitIntent({
action: {
type: "policy_override",
operations: [
{
type: "increase_limit",
capability: "send",
limitType: "daily_amount",
scope: {
accounts: { type: "all" },
counterparties: { type: "all" },
},
newLimit: { amount: "5000.00" },
},
],
durationSeconds: 3_600,
reason: "Pay an approved vendor invoice",
},
})scope.accounts is { type: "all" } for one limit shared across all accounts
combined, or { type: "account", accountId } for one account's own limit. In
getPolicy(), a rule with accountAggregationScope: "across_accounts" is a
shared limit, repeated on each account's row, and a block it causes says "for
all accounts combined". A per_account rule is the limit of its row's account.
Raising an account's own limit does not lift a block from a shared limit. For a
transfer limit, the account is the source account that funds move out of. A send
limit's scope also names its counterparties: { type: "all" }, or
{ type: "counterparty", counterpartyId } for a counterparty-scoped rule. A
transfer limit's scope has no counterparties. When several limits block one
action, include an operation for each.
Amount limits use per_transaction_amount, daily_amount, weekly_amount, or
monthly_amount with newLimit: { amount } in USD. Count limits use
hourly_count or daily_count with newLimit: { count }. capability is
send or transfer. Amounts are positive decimal strings with up to six
fractional digits; counts are positive integers. Overrides last from one minute
to seven days, starting at approval. Submit 1–10 operations, each naming a
different capability, limit type, and scope. reason must contain 1–280
characters.
Propose a policy
An agent with no policy, or one whose policy no longer fits its work, can propose a complete policy for itself. Every proposal waits for a person to approve or deny it, whatever its rules say, and grants nothing while it waits. Approval replaces the agent's current policy, and any temporary override, at that moment. This proposal lets the agent read and send from every account and caps its sends at 500 USD a day in total:
const intent = await client.submitIntent({
action: {
type: "policy_proposal",
rationale: "Pay approved vendor invoices",
policy: {
name: "Vendor payments",
description: "Pays approved vendor invoices",
counterpartyAccess: { mode: "open" },
permissions: [
{ type: "read", accounts: { mode: "all" } },
{ type: "send", accounts: { mode: "all" } },
],
rules: {
send: [
{
scope: {
accounts: { type: "all" },
counterparties: { type: "all" },
},
limit: { type: "daily_amount", threshold: { amount: "500.00" } },
action: { type: "block" },
},
],
transfer: [],
},
},
},
})The SDK sends it as version 1 of the proposal format, with every threshold in
USD. Callers of the REST API send schemaVersion: 1 and
threshold: { amount, assetId: "USD" } themselves. policy.description is
optional, up to 280 characters; approval gives the policy that description.
Access comes only from permissions and counterpartyAccess. read, send,
and transfer each select accounts: { mode: "all" }, every account that is
active when a person approves, or { mode: "selected", entries }, each entry
naming one account by ID. Read is never implied: every account selected for
send or transfer must be selected for read, and { mode: "all" } for
either needs read on all; the server refuses a proposal that misses this. The
SDK checks only field types and the structure it needs before sending a
proposal, and passes fields and values it does not know to the server.
parsePolicyProposal(value) runs that check on a proposal read from JSON and
returns the typed action. counterpartyAccess is { mode: "open" } for every
payable counterparty, or { mode: "restricted", counterparties } for named
ones. requiresApproval on create_counterparty and
request_counterparty_details governs those later actions under the approved
policy; it does not change the proposal's own approval.
Rules limit sends and transfers and never grant access. A rule with
scope.accounts: { type: "all" } is one combined limit over everything the
agent sends from any account, not a limit per account. A rule with
{ type: "account", accountId } limits that account alone; repeat the rule for
each account that needs its own. Every rule that applies must pass, so combined
and per-account rules add up. A send rule names its counterparties:
{ type: "all" }, or { type: "counterparty", counterparty } with a reference
{ type: "existing", counterpartyId } or { type: "proposed", ref }.
proposedCounterparties lists the counterparties approval creates, or reuses
when an existing one matches every rail (approval refuses a proposal whose rails
match only partly, or match different counterparties); the policy names each by
its ref.
While the proposal waits, status is pending and proposal.reason says
whether it can go ahead: null when a person could approve it now,
awaiting_resources or temporarily_unavailable to wait, and
referenced_resource_unavailable or proposal_invalid to fix it and submit
again. It expires after 24 hours. A new proposal replaces the agent's pending
one. Submitting again with the same idempotencyKey returns the original
proposal, even one refused at approval, and never submits it again; a corrected
proposal needs a new key, which the SDK generates when you pass none. Once a
person approves it, read what it created:
import { policyProposalResult } from "@catena/sdk"
const result = policyProposalResult(await client.getIntent(intent.id))
// { policy: { id, name }, counterparties: [{ ref, id, name, outcome }] }The server refuses a proposal it could never approve with
policy_proposal_invalid and each problem in details.issues, and one naming
an account or counterparty it cannot use with
policy_proposal_reference_unavailable. An organization without proposals
answers 403 feature_disabled. None of these records anything.
How intents work
Policy-controlled actions use submitIntent. Catena evaluates policy, collects
any required approval, and co-signs the action when needed.
submitIntent returns the intent's current state. It does not guarantee that
execution is finished.
| Status | Resolution | Meaning |
| ------------ | ------------------- | -------------------------------------------------------- |
| completed | completed | The action succeeded. |
| pending | null | The intent is waiting for human approval. |
| processing | null | Catena accepted the intent and is still working. |
| blocked | blocked, denied | Policy or an operator stopped the intent. This is final. |
| failed | expired, failed | The intent did not complete. This state is final. |
Use status for stable control flow. resolution is the exact terminal outcome
and is null while the intent is nonterminal. The known resolution vocabulary
is completed, blocked, denied, expired, failed, and cancelled; the
SDK preserves future nonblank values so clients can forward them without
rejecting the response.
Read reasons for a non-completed outcome and expiresAt for an approval
deadline. For ordinary actions, poll pending and processing intents with a
delay until they reach a final state. Treat only completed as success.
Two kinds of intent never reach a final state by polling alone. MPP and x402
payments: after approval, repeat the original paid request so its fresh
challenge can use the approval. For these protocols, completed means Catena
delivered the payment authorization, and on-chain settlement is verified
separately. A send or transfer a person approved: it rests at processing
while an admin signs it, and movement.nextStep reads wait until it is done
or blocked — see Payments a person approves.
Cancel an intent while it is waiting for approval or approved but not yet executing:
const cancelled = await client.cancelIntent(intent.id)Repeating a successful cancellation is safe. The method returns the same
IntentResult as getIntent, with resolution: "cancelled". It cannot stop or
reverse an executing payment; ineligible intents return HTTP 409 with
intent_not_cancellable.
Result data
IntentResult.data is runtime data. Narrow it before use.
| Action | Result data when available |
| ------------------------------ | ---------------------------------------------------------------- |
| send, transfer | data.transaction |
| create_counterparty | data.counterparty |
| request_counterparty_details | data.counterpartyId, data.counterpartyName, data.invite |
| x402, mpp | data.paymentCredential, data.transaction |
| policy_override | data.policyOverride.policyId |
| policy_proposal | data.policy, data.counterparties; see policyProposalResult |
metadata.dataUrl, when present, links to the relevant approval or result in
the Catena console.
Source selection
For send, omit accountId only when policy allows exactly one source account.
Zero eligible accounts returns HTTP 403 with policy_send_account_unavailable;
multiple eligible accounts returns HTTP 400 with policy_send_account_required.
Catena pins the selected source to the intent. A later policy change does not
silently switch accounts. Transfers always require accountId.
Idempotency
The SDK generates a fresh idempotency key for every submitIntent call unless
you provide one. Use an explicit key when separate calls or processes must refer
to the same logical operation.
Reuse a key only with the same action and source. The source account stays
pinned even if policy changes. A different action or source returns HTTP 409.
When intent.replayed is true, Catena returned the existing intent without
starting the action again.
Protocol payments
| Use case | Import |
| ------------------------------- | ------------------ |
| Managed x402 payment loop | @catena/sdk/x402 |
| Managed or custom MPP loop | @catena/sdk/mpp |
| viem-compatible x402 account | @catena/sdk/viem |
| Direct agent API payment intent | @catena/sdk |
Pay x402 challenges
The helper supports x402 v2 exact EIP-3009 payments on structurally valid EVM
networks and token contracts. The connected Catena server decides which networks
and canonical assets are payable. Native USDC on Base and Base Sepolia is
supported where enabled. Arc support is rolling out. Wrap fetch to pay a
compatible challenge and retry once with PAYMENT-SIGNATURE:
import { createCatenaClient } from "@catena/sdk"
import { wrapFetchWithX402Payment } from "@catena/sdk/x402"
const client = createCatenaClient({ privateKeyHex })
const fetchWithPayment = wrapFetchWithX402Payment(client, {
accountId: walletAccountId,
maxAtomicAmount: 1_000_000n,
})
const response = await fetchWithPayment("https://api.example.com/paid-thing")maxAtomicAmount uses atomic USDC units, so 1_000_000n is 1 USDC. Ordinary
responses and 402s without a decodable v2 challenge pass through untouched. A
decoded but unpayable challenge throws X402PaymentError.
Request bodies must be replayable. Put a string, URLSearchParams, Blob,
ArrayBuffer, typed array, or DataView in the init argument. Body-bearing
Request objects and streams are rejected before payment.
The helper examines at most the first five exact candidates and stops at the
first one Catena can pay.
For direct control, use decodePaymentRequired(response) and
payX402Challenge(client, options). The latter returns a receipt containing the
intent ID, selected requirements, and paymentSignature header value. It pays
but does not retry the HTTP request for you.
A structurally valid challenge must use x402 v2, the exact scheme, an EVM
CAIP-2 network, a valid token contract, and an EIP-3009 authorization. The
amount must be a canonical atomic-unit integer, payTo must be a valid EVM
address, and maxTimeoutSeconds must be from 1 to 3,600. The requirement must
include an EIP-712 domain name and version. The server separately validates
network support, the canonical asset and domain, and the saved counterparty.
Pay MPP challenges
Install the optional peers with the SDK:
npm install @catena/sdk mppx@^0.9.0 viem@^2.54.0The managed wrapper recognizes evm/charge and usdc/charge for structurally
valid EVM networks and token contracts. The connected Catena server decides
which networks and canonical USDC contracts are payable. Native USDC on Base and
Base Sepolia is supported where enabled. Arc support is rolling out:
import { createCatenaClient } from "@catena/sdk"
import {
MppCounterpartyNotFoundError,
wrapFetchWithMppPayment,
} from "@catena/sdk/mpp"
const client = createCatenaClient({ privateKeyHex })
const fetchWithPayment = wrapFetchWithMppPayment(client, {
accountId: walletAccountId,
maxAtomicAmount: 1_000_000n,
})
const response = await fetchWithPayment("https://api.example.com/paid-thing")The wrapper tries up to five supported candidates, creates at most one
credential, and retries the request once. onPayment runs after payment and
before the retry. Its receipt contains no credential. Non-replayable bodies and
cross-origin redirects are rejected before payment.
The receipt contains intentId, method, atomicAmount, network,
recipient, and resourceUrl.
When a recipient is not saved, the server-resolved requiredRail can be passed
directly to a deliberate counterparty intent:
try {
await fetchWithPayment(url)
} catch (error) {
if (error instanceof MppCounterpartyNotFoundError && error.requiredRail) {
await client.submitIntent({
action: {
type: "create_counterparty",
name: "Approved seller",
rail: error.requiredRail,
},
})
}
}X402CounterpartyNotFoundError.requiredRail has the same contract.
Need custom transport, hooks, or candidate selection? Add Catena's methods to your own mppx client:
import { createCatenaClient } from "@catena/sdk"
import { catena } from "@catena/sdk/mpp"
import { Mppx } from "mppx/client"
const client = createCatenaClient({ privateKeyHex })
const mppx = Mppx.create({
methods: [
catena({
client,
accountId: walletAccountId,
maxAtomicAmount: 1_000_000n,
}),
],
maxPaymentRetries: 1,
polyfill: false,
})
const response = await mppx.fetch("https://api.example.com/paid-thing")catena.charge(options) is an alias for catena(options). By default, mppx can
make up to three payment attempts per fetch. Set maxPaymentRetries: 1 to allow
one. Separate fetch calls can still pay again. maxAtomicAmount applies to each
attempt, not the combined total.
With mppx 0.9, pass the HTTP method and body in the fetch init argument, not
only in a Request. Buffer the body first; streams and raw FormData are not
safe to replay.
The client forwards structurally valid EVM networks and token contracts to the server. The server decides which are payable. Native USDC on Base and Base Sepolia is supported where enabled. Arc support is rolling out. A challenge must also meet these rules:
methodisevmorusdc, andintentischarge;idandrealmcontain 1–256 characters, the serialized request is at most 16 KiB, and the full challenge is at most 32 KiB;expiresis UTC with a trailingZ, has at most three fractional digits, and leaves between 5 seconds and 1 hour;- the amount is a positive canonical integer of at most 78 digits and does not
exceed
maxAtomicAmount, when configured; - the currency and recipient are valid EVM addresses;
- method details use 6 decimals and a single authorization credential; splits are unsupported, and an optional Permit2 address must be valid;
descriptionis at most 1,024 characters andexternalIdis at most 256;headeris omitted or exactlyPayment-Authorization;digestusessha-256=:<base64>:orsha-256=<base64>with canonical, padded standard Base64;opaque, when present, is nonempty unpadded Base64url of at most 8 KiB;- addresses are lowercase or valid EIP-55;
descriptionandexternalIdcontain valid Unicode.
mppx may report No method found for challenges when one of these requirements
fails, even if evm.charge or usdc.charge appears available.
Use a Catena wallet with viem
createCatenaAccount returns a viem Account for x402 clients that expect
{ address, signTypedData }:
npm install @catena/sdk viem@^2.21import { createCatenaClient } from "@catena/sdk"
import { createCatenaAccount } from "@catena/sdk/viem"
const client = createCatenaClient({ privateKeyHex })
const account = await createCatenaAccount(client, {
accountId: walletAccountId,
network: "base",
})Pass account to any x402 client that accepts a viem account or
{ address, signTypedData } signer.
A Catena wallet is custodial. The account signs only structurally valid x402
EIP-3009 TransferWithAuthorization data. The server decides which EVM networks
and canonical token contracts are payable. signMessage, signTransaction, and
raw-hash signing are unavailable.
The account must resolve to a deposit address whose source is wallet.
network defaults to base; pass arc for an Arc wallet when enabled or
base-sepolia for a testnet wallet. The network must match the wallet account.
Error handling
Branch on stable fields and error classes, not message text.
Core client errors
| Error | Recovery |
| ------------------------- | --------------------------------------------------------------------------------- |
| InvalidKeyError | Supply a valid linked P-256 private key. |
| ApiError | Branch on code; inspect status, fields, and details. |
| NetworkClosedError | Do not retry on network; use one of alternatives, or tell the user. |
| InvalidApiResponseError | Treat the request as uncertain; reconcile before retrying. |
| TimeoutError | Treat the request as uncertain until its operation-specific recovery is complete. |
| IntentSubmitError | Branch on outcome; use intentId to reconcile an uncertain submission. |
The SDK combines the API's error summary and field explanations in message,
identifying the affected request fields and what needs correction. fields
preserves the individual explanations keyed by request field path, such as
action.memo. The request key represents a request-wide issue rather than a
field; its explanation appears in message without a field-path prefix.
Malformed fields are ignored without discarding the error's message or code.
InvalidApiResponseError comes from a successful HTTP response, so the server
accepted the request and may have acted. Reconcile a state-changing operation
through its read API before retrying. If reconciliation is impossible and a
retry is necessary, reuse the same persisted idempotencyKey; a new key can
duplicate the operation.
For IntentSubmitError:
not-submittedmeans no money moved. Fix the cause and submit again with a new idempotency key. WhensubmitIntentrefused before signing anything, it cancelled the intent:cancellationis{ outcome: "cancelled" }, or{ outcome: "failed", cause }when the cancel did not go through. A failed cancel, or a refusal after something was signed, leaves the intent holding its wallet for about a minute and counting against your limits until it expires;cancelIntent(error.intentId)releases both at once. Earlier versions of this SDK do not cancel, so every intent they refuse holds its wallet and counts against your limits that way.unknownmeans Catena may have acted. PollgetIntent(error.intentId)and submit again only after the intent becomesblockedorfailed.
NetworkClosedError is the ApiError for onchain_network_closed: Catena has
made network temporarily unavailable, with no known end. No money moved, money
already on the network is safe, and payments already in progress complete. It
has no retryAfterSeconds. Do not retry on that network, and do not back off
and try again. Use one of alternatives, or tell the user when the list is
empty. A submit it refuses is an IntentSubmitError with outcome
not-submitted and this error as its cause. The x402 and MPP wrappers move on
to the challenge's next candidate when they meet it, and throw it when every
candidate's network is closed. A refusal whose details the SDK cannot read
stays a plain ApiError with that code, so branch on code to catch both.
A timeout means the SDK stopped waiting, not necessarily that the server stopped
working. For an exact submitIntent retry after a create timeout, you must have
chosen and persisted an idempotencyKey before the first call. Retry the
unchanged action with that key.
If an exact retry returns wallet_send_intent_not_executing,
x402_intent_state_mismatch, or mpp_intent_state_mismatch, do not create a
replacement payment. Reconcile the existing intent with the operator.
Payment errors use CAIP-2 network IDs. Missing-counterparty errors may also
include a server-resolved requiredRail whose product network identifier can be
passed directly to a create_counterparty intent. Known mappings are
eip155:8453 to base, eip155:84532 to base-sepolia, and eip155:5042 to
arc.
| Error | Recovery |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| X402PaymentError | Do not retry automatically. If no subclass provides a safe recovery path, escalate to an operator. |
| X402NetworkMismatchError | Use an account on one of requiredNetworks. |
| X402CounterpartyNotFoundError | Create a counterparty from requiredRail when present; otherwise resolve the rail manually. |
| X402ApprovalPendingError | Approve the intent, then repeat the original request. |
| X402RetryFailedError | Payment completed. Retry manually with receipt.paymentSignature; do not run the payment loop again. |
| X402SubmitInterruptedError | Check getIntent(intentId) before paying again. |
A create-phase TimeoutError from the x402 wrapper means no payment was
authorized, so the request can be run again. For an interrupted submission:
completed: reusedata.paymentCredential.valueasPAYMENT-SIGNATURE;processing: keep polling; andblockedorfailed: a new payment attempt is safe.
| Error | Recovery |
| ------------------------------ | ----------------------------------------------------------------- |
| MppFetchPaymentError | If intentId is present, reconcile it before paying again. |
| MppNetworkMismatchError | Fund the payment from one of requiredNetworks. |
| MppCounterpartyNotFoundError | Create a counterparty from requiredRail when present. |
| MppApprovalPendingError | Approve the intent, then repeat the original request. |
| MppPaymentDeclinedError | Treat the result as final. |
| MppSubmitInterruptedError | Reconcile intentId before authorizing another payment. |
| MppRetryFailedError | Payment completed. Reconcile receipt.intentId before any retry. |
An MppPaymentError from a custom mppx integration exposes intentId,
status, reasons, and expiresAt. A completed MPP intent proves that Catena
produced an authorization, not that the seller settled it or delivered the
resource.
When mppx makes multiple attempts, the final error identifies only its own intent. Reconcile every known attempt before paying again.
| Error | Meaning |
| ----------------------------------- | ------------------------------------------------------------------------------------ |
| UnsupportedTypedDataError | Refused client-side; nothing was submitted. Inspect reason. |
| UnsupportedAccountCapabilityError | The requested signing method is not available. |
| NotAWalletAccountError | Choose an account whose deposit-address source is wallet. |
| X402ApprovalPendingError | Approve the intent, then let the x402 tooling retry. |
| X402PaymentDeclinedError | Policy blocked or failed the payment. This result is final. |
| X402PaymentInFlightError | Reconcile intentId. Polling will not advance it; repeat the original paid request. |
| X402SubmitInterruptedError | Reconcile intentId; do not pay again. |
| X402PaymentSignatureUnusableError | Payment completed. Reconcile intentId; do not pay again. |
| SignatureVerificationFailedError | Payment completed. Do not use the signature or pay again. |
data.paymentCredential.value is the Base64 x402 envelope used as the
PAYMENT-SIGNATURE header. signTypedData returns the inner hex signature.
They are not interchangeable.
API reference
Client methods
getSetupState() returns the authenticated AgentSetupState, including
organization asset choices and outstanding setup steps.
| Method | Purpose |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| whoami() | Return the linked agent. |
| submitFeedback(body) | Send integration feedback to Catena operators. This has no money effect. |
| getPolicy() | Return the agent's effective policy. |
| listAccounts() | List accounts visible to the agent. |
| getAccountBalance(accountId) | Return total and available balances, overall and per network. |
| listAccountTransactions(accountId, params?) | List transactions with optional start, end, limit, and offset. |
| getAccountDepositAddress(accountId, params) | Resolve an address for a network and asset. |
| listCounterparties(params?) | List counterparties; address and network filters check wallet ownership within a network class. A wallet rail omits network when its configured destination is unavailable to the agent and cannot receive an agent send until that changes. |
| submitIntent({ action, idempotencyKey? }) | Submit a policy-checked action. |
| listIntents(params?) | List this agent's intent summaries with optional status, type, start, accountId, limit, and offset filters. |
| getIntent(id) | Read the current intent state. |
| cancelIntent(id) | Cancel an intent that is awaiting approval or approved but not yet executing. |
| unlinkAgent() | Permanently revoke the current agent link and its active keys. |
| reportSettlement(params) | Report an on-chain settlement. Managed payment wrappers call this automatically. |
Transaction ranges use inclusive ISO 8601 start and end timestamps. If
start is after end, the request fails validation on end. limit defaults
to 50 and is capped at 200; offset defaults to 0. reportSettlement accepts
{ intentId, txHash, receipt? }.
Intent history defaults to every status, newest first. Pass more than one
status or type to include any of those values. List entries contain the
stored context needed to identify work; call getIntent(id) for the full
intent-specific result.
Intent actions
| Action | Purpose |
| ------------------------------ | ------------------------------------------------- |
| send | Send by ACH, wire, or on-chain rail. |
| transfer | Transfer between Catena accounts. |
| create_counterparty | Save a counterparty with contact or rail details. |
| request_counterparty_details | Request bank or wallet details. |
| x402 | Submit a low-level x402 payment requirement. |
| mpp | Submit a low-level MPP charge. |
| policy_override | Request a temporary increase to policy limits. |
| policy_proposal | Propose a new policy for a person to approve. |
Use the x402 and MPP helpers for ordinary protocol payments. They parse the challenge, enforce replay safety, submit the intent, and handle the paid retry.
For direct intents, x402 requires accountId and paymentRequirements, with
optional resource, authorization, and signedOffer. mpp requires
accountId and challenge, with optional resource.
Payment helper options
| API | Required options | Optional options |
| -------------------------- | ------------------------------ | ------------------------------------------- |
| wrapFetchWithX402Payment | accountId | baseFetch, maxAtomicAmount, onPayment |
| payX402Challenge | accountId, paymentRequired | resource, maxAtomicAmount |
| wrapFetchWithMppPayment | accountId | baseFetch, maxAtomicAmount, onPayment |
| catena / catena.charge | client, accountId | maxAtomicAmount |
| createCatenaAccount | accountId | network |
baseFetch injects a custom transport. onPayment runs after Catena completes
the payment and before the paid retry. If it throws, the payment remains
complete and the wrapper throws a retry error carrying the receipt.
Client options
| Option | Purpose |
| ---------------- | --------------------------------------------------------------------------- |
| privateKeyHex | Linked 64-character P-256 private key. |
| baseUrl | API origin. Defaults to the hosted Catena API. |
| identityUrl | Stable signer identity URL. The default follows the key thumbprint. |
| appInfo | { name, version?, url? } added to the User-Agent. |
| defaultHeaders | Headers merged into every request. The SDK strips auth and sets User-Agent. |
| fetch | Custom fetch transport for proxies, instrumentation, or tests. |
| timeout | Per-exchange deadline in milliseconds. Defaults to 80,000. |
You can omit privateKeyHex and set CATENA_SECRET_KEY instead. An explicit
value always wins, including when it is empty or invalid. The SDK validates the
key at construction. It never stores credentials or reads CLI profiles.
Requests carry two P-256 signatures: the standard profile uses
Signature-Input, Signature, Signature-Agent, and Content-Digest; the
dedicated profile uses those four names with a Catena- prefix. Each signature
covers the method, full URL, and its own digest and identity fields. Both share
the key, timestamp, and nonce, so changing profiles cannot replay a request
against the same API.
Existing APIs can verify the standard profile. Compatible APIs prefer the dedicated profile; an invalid or partial dedicated signature fails without falling back to the standard signature. Proxies that replace standard headers must preserve all four dedicated headers and the signed URL and body, and the API must support that profile. Authentication failures do not trigger another request with a different profile. Existing keys and links remain valid.
Each operation may make more than one HTTP exchange. Every exchange receives a
fresh timeout; timeout is not an overall operation dea
