@chatterpay/sdk
v0.16.0
Published
Official TypeScript SDK for integrating Partner backends with the ChatterPay B2B API.
Maintainers
Readme
@chatterpay/sdk
The official TypeScript SDK for ChatterPay B2B.
Integrate ChatterPay into your backend to manage users and wallets, provision blockchain accounts, query balances, request quotes, execute supported blockchain operations, track their lifecycle, consume events, and verify signed webhooks.
The SDK is a typed client for the public ChatterPay B2B API. You can use it or integrate directly over HTTPS.
Install
npm install @chatterpay/sdkOr with Bun:
bun add @chatterpay/sdkThe package supports Node.js 22 or newer and Bun 1.3.14 or newer.
Authentication
Sandbox deployments accept a client secret:
import { ChatterPayClient } from "@chatterpay/sdk";
const client = await ChatterPayClient.connect({
baseUrl: process.env.CHATTERPAY_BASE_URL!,
clientId: process.env.CHATTERPAY_CLIENT_ID!,
clientSecret: process.env.CHATTERPAY_CLIENT_SECRET!,
});Production uses private_key_jwt. Supply a signer that creates a fresh RFC 7523 assertion whenever
the SDK asks for one. Back it with your KMS, HSM, vault, or signing service; do not embed or pass a
private key to the SDK.
import { ChatterPayClient } from "@chatterpay/sdk";
const client = await ChatterPayClient.connect({
baseUrl: process.env.CHATTERPAY_BASE_URL!,
clientId: process.env.CHATTERPAY_CLIENT_ID!,
clientAssertionProvider: () => partnerSigner.createClientAssertion(),
});Do not expose Partner credentials in browser or client-side applications.
Quickstart
const user = await client.createUser({ externalUserId: "customer-42" });
const wallet = await client.createWallet({ userId: user.userId });
const account = await client.provisionAccount(wallet.walletId, "arbitrum-sepolia");
console.log(account.address);Quote and execute
const quote = await client.quote({
walletId: wallet.walletId,
assetId: "assetrep_arb-sepolia_usdc_test",
amount: "12.50",
destination: "0x1111111111111111111111111111111111111111",
});
const operation = await client.transfer({ quoteId: quote.quoteId }, "order-42-transfer");Human-readable amounts such as amount and totalDebit are decimal strings. Fields ending in
BaseUnits are integer strings in the asset's smallest unit. Never use JavaScript floating-point
numbers for monetary values. Reuse the same idempotency key when retrying the transfer.
IdempotencyKeyStore can retain keys by logical operation name within a process.
What an operation executes
A transfer, a same-network swap and a transfer to another network all take the same two steps:
quote() states what to send and what to receive, and transfer() accepts the quote and creates
the ChatterPay Operation that moves the funds. The deployment builds the route behind the quote,
validates it against the quote's terms, and executes that route from the wallet's account within
the deployment's policy. The SDK's request types carry those terms and the quote identifier; they
have no field for a provider transaction or calldata.
A quote a provider priced carries the provider's answer in providerRoute, and lifiQuote()
returns the aggregator's quote as the aggregator answered it. Either may include a
transactionRequest, which shows what the provider proposed. Sending it back in a request executes
nothing: the API ignores the field and answers as if it were absent.
Asynchronous operations
Transfers return an Operation. Read it with operation(), wait for a terminal state with
waitForOperation(), and inspect operationSteps() and operationLegs() for detailed progress.
const completed = await client.waitForOperation(operation.operationId, { timeoutMs: 120_000 });
const steps = await client.operationSteps(completed.operationId);
const legs = await client.operationLegs(completed.operationId);Each entry of an operation's transactions names its network and links to that network's
explorer. A cross-chain operation a provider executed also carries providerExplorerUrl, the
provider's page for the whole route, such as LI.FI's explorer at the source transaction; it is
present together with the source transaction's entry, on a deployment whose networks are the public
networks their identifiers name.
A bridge leg of a provider route reports its progress in status, typed as
OperationLegStatus, which includes deposit_confirmed, delivery_pending,
receipt_confirmed and expired. Once its deposit is confirmed, the leg's quoteReconciliation
compares the source amount, the delivered amount, the provider fee, the service fee and the gas as
quoted and as realised. An operation's reconciliation verifies the ledger against the chain, and
reconciliation.assets carries one entry per asset compared: for a same-chain swap, the input asset
and the output asset.
Advanced: ERC-4337 correlation
An ERC-4337 operation may include userOpHash and entryPointAddress on the operation, steps, or
legs. Other execution paths omit those fields.
Balances
const { items: balances } = await client.balances(wallet.walletId);available is the wallet's current balance, the figure a spend is checked against. When source
is chain it is what the chain reports the address holds, less reserved and less the service fees
already charged that stay at the address until they are collected; ledger, reserved,
pendingIncoming, and shortfall expose the corresponding accounting state. When the chain could
not be read for the response, a row the deployment read before reports that last reading with
stale: true, and readAt and blockHeight are those of the earlier reading. A row with no stored
reading reports source: "ledger". A stale figure is informational: transfer() checks the spend
against a fresh chain reading and refuses it with CHAIN_NOT_READ when the chain cannot be read.
const { items: rows } = await client.balances(wallet.walletId);
for (const row of rows) {
const when = row.stale ? `last read at ${row.readAt}` : "read now";
console.log(row.assetId, row.available, row.source, when);
}Asset catalogue
assets() returns { items: AssetRepresentation[] }: every asset representation the deployment
can hold and move. A representation is identified by its network (chainId) and its token
contract (contractAddress); a network's own coin has no contract address. The symbol names an
asset and identifies none: one network can list two representations with the same symbol, told
apart by canonicality (canonical for the asset its official issuer issued on that network,
non-canonical-test for a representation the deployment deployed for testing) and by
contractAddress. Check the contract address, not the symbol, before sending funds to or from a
contract.
Each representation lists in operations the operations it can take. Every AssetOperation names
its operationType and the settlementProvider that settles it; a routed operation also states its
routeExecutionMode, and a swap or a transfer between networks lists in toAssetIds the
representations it can deliver. A quote for an operation the representation does not list is
refused with ROUTE_NOT_AVAILABLE. A route between networks is listed only while a recent check of
it has passed, so read the catalogue again rather than caching it indefinitely.
const { items: assets } = await client.assets();
const usdc = assets.find(
(asset) =>
asset.chainId === "base-sepolia" && asset.symbol === "USDC" && asset.canonicality === "canonical",
);
const bridges = usdc?.operations.filter(
(operation) => operation.operationType === "cross_chain_transfer",
);
console.log(usdc?.contractAddress, bridges?.flatMap((operation) => operation.toAssetIds ?? []));A quote a provider priced carries providerRoute.providerAssets: the provider-side token addresses
(fromToken, toToken) the price belongs to. A Cardano transfer quote carries
fees.networkFeeEstimate, the network fee the account pays from its own ADA, estimated when the
quote was issued; it is stated apart from totalDebit, and fees.estimatedTotalDebit adds the two.
The fee the network charged is reported on the operation as networkCost.
Cardano staking
The holder of a Cardano account can register its staking credential, delegate to a stake pool and
delegate their vote in Cardano governance, change either, withdraw the rewards, and leave, which
refunds the deposit. The holder pays the deposit and every network fee from their own wallet, the
service fee is zero, and each action runs once the holder confirms its quote. Staking runs on
cardano-preprod. Until a deployment offers staking on a network, every staking method and every
staking quote answers STAKING_NOT_ENABLED. Amounts in the staking types are lovelace, as integer
strings.
The four staking wallet methods, stakingState(), recordStakingConsent(),
revokeStakingConsent() and revokeStakingAuthorization(), and a staking quote's walletId take
the wallet as its wal_… identifier or as a phone number registered as a recipient alias of one of
your users. A phone resolves to the wallet of the user its alias is bound to, as it does for
balances(), and the SDK sends it percent-encoded as one path segment. On the four methods, a phone
that resolves to no wallet is refused with 404 RESOURCE_NOT_FOUND, as an unknown identifier is;
a staking quote refuses it as an unknown wallet. Every answer names the wallet by its wal_…
identifier.
Consent
Staking starts with the holder's consent, recorded with the version of the staking terms they
accepted, the surface where they gave it, the instant, and your reference to the record your own
authorization flow produced. The consent authorizes the registration the holder confirms at that
moment; every later action needs its own confirmed quote. stakingState() reports the version the
deployment offers in offeredTermsVersion, present while staking is enabled on the network, so a
first consent can name it. A termsVersion other than that one is refused with
STAKING_TERMS_VERSION_UNKNOWN, and details.currentTermsVersion names it.
const consent = await client.recordStakingConsent(
wallet.walletId,
"cardano-preprod",
{
termsVersion: "2026-10-01",
surface: "partner_app",
givenAt: new Date().toISOString(),
evidence: { method: "partner_app_step_up", reference: "consent-record-42" },
},
"wallet-42-staking-consent",
);
console.log(consent.consentId, consent.state);
const state = await client.stakingState(wallet.walletId, "cardano-preprod");
console.log(state.registered, state.poolId, state.holdings.withdrawableRewardsBaseUnits);stakingState() reads the registration, the pool, the vote delegation, the deposit and the
withdrawable rewards from the chain, with the consent and liveOperationId, the staking operation
in progress. holdings carries three figures that never overlap: spendable ada, the deposit the
holder owns, and the withdrawable rewards. poolAuthorization and voteAuthorization are the pool
and vote authorizations in force, each with its authorizationId, the pool or vote target the
holder last confirmed, and confirmedAt.
A consent given on the conversational channel and collected by confirmation in that channel
(surface conversational_channel with evidence.method channel_confirmation) may omit
givenAt, evidence.reference or both. The API then records the instant it received the request
as givenAt and the identifier it assigned to the request as evidence.reference, and the
consent's suppliedByApi names which values it supplied. Every other surface and method declares
both, and the SDK's types require them there.
const offered = await client.stakingState("+5491155550100", "cardano-preprod");
const channelConsent = await client.recordStakingConsent(
"+5491155550100",
"cardano-preprod",
{
termsVersion: offered.offeredTermsVersion ?? "",
surface: "conversational_channel",
evidence: { method: "channel_confirmation" },
},
"wallet-42-staking-channel-consent",
);
console.log(channelConsent.walletId, channelConsent.givenAt, channelConsent.suppliedByApi);State identifiers and idempotency keys
A consent and each revocation may name the state it was decided on. recordStakingConsent() takes
replacesConsentId, the revoked consent the staking state shows in consent.consentId;
revokeStakingConsent() takes consentId, the consent in force; and
revokeStakingAuthorization() takes authorizationId, from poolAuthorization or
voteAuthorization. A request that names one which is no longer the state in force is refused with
409 STAKING_STATE_CHANGED, retryable: false, and changes nothing: read the staking state again.
A consent that names a revoked consent is checked for this first, and STAKING_CONSENT_IN_FORCE
answers only a consent without replacesConsentId. Without the identifier, a consent is refused
with STAKING_CONSENT_IN_FORCE while another is in force, and a revocation revokes the consent, or
the authorization of its kind, in force.
A client that derives its idempotency key from the request body includes these identifiers. Without
them, a consent with the same terms after a revocation, or a revocation of a later consent or
authorization, carries the body of the first one, and its key replays the first answer. A repeat of
a request whose transition already took effect, under the same key and body, is answered with that
transition and never with STAKING_STATE_CHANGED.
Pools and vote targets
stakingPools() lists the pools the deployment signs a delegation to, each with its state read
from the chain; the holder may choose any listed pool whose state is valid, and recommended
and suggested are yours and informative. stakingVoteTargets() returns one page of vote targets:
always_abstain and always_no_confidence first, then the registered DReps with their
registration and activity. Pass nextCursor back as cursor for the next page, and drepId
to look one DRep up. A target outside these lists is refused with STAKING_TARGET_NOT_ALLOWED.
Quote, confirm and follow
A staking action is a quote: quote() with a StakingQuoteRequest, then transfer() with its
quoteId once the holder confirms it. Each operation type names its targets:
| operationType | poolId | voteTarget |
| --- | --- | --- |
| staking_registration | Required | Optional: present when the holder accepted a vote target |
| staking_pool_delegation | Required | Refused |
| staking_vote_delegation | Refused | Required |
| staking_reward_withdrawal | Refused | Refused |
| staking_deregistration | Refused | Refused |
The SDK's types enforce the table, and the quote answers a StakingQuote whose operationType is
the request's, unchanged, and whose staking terms carry the targets of that action, with
staking.action that type without its staking_ prefix: staking_registration is
registration. Show the holder staking.disclosures, the deposit, the pool or
vote target, and fees.networkFeeEstimate, the network fee they pay from their own wallet as an
estimate; staking.maximumNetworkFeeBaseUnits is the highest fee the transaction may carry. A
registration without voteTarget carries no vote delegation, and a withdrawal requires one.
import { isStakingOperation } from "@chatterpay/sdk";
const { items: pools } = await client.stakingPools({ chainId: "cardano-preprod" });
const pool = pools.find((candidate) => candidate.state === "valid");
if (!pool) throw new Error("No pool can be delegated to");
const quote = await client.quote({
walletId: wallet.walletId,
chainId: "cardano-preprod",
operationType: "staking_registration",
poolId: pool.poolId,
voteTarget: { kind: "always_abstain" },
});
for (const disclosure of quote.staking.disclosures) console.log(disclosure.text);
console.log(quote.staking.poolId, quote.staking.depositBaseUnits, quote.fees.networkFeeEstimate);
// Once the holder confirms this quote:
const accepted = await client.transfer({ quoteId: quote.quoteId }, "wallet-42-staking-registration");
const settled = await client.waitForOperation(accepted.operationId, { timeoutMs: 600_000 });
if (isStakingOperation(settled)) console.log(settled.status, settled.staking.action, settled.error);A staking operation has type staking, the operationType of the quote it executes, and its
staking detail: the action and targets the holder confirmed and the deposit, withdrawal and refund the chain settled. It ends completed,
failed or cancelled, and waitForOperation() returns on each of the three. The operation is
checked again before signing and before broadcast; a check that refuses it ends it failed, or
cancelled when it never reached signing, with its error starting with the refusal's code, and
nothing is broadcast. An operation cancelled by revokeStakingConsent() carries the error
STAKING_CONSENT_REQUIRED: the holder revoked the consent before the operation was signed, and one
cancelled by revokeStakingAuthorization() carries STAKING_AUTHORIZATION_REVOKED: the holder
revoked the <kind> authorization before the operation was signed, with <kind> pool_delegation or
vote_delegation. Both are the operation's record of why it ended; the revocation itself succeeds.
Once the chain shows the change, the operation
publishes staking.registered, staking.pool_delegated, staking.vote_delegated,
staking.rewards_withdrawn or staking.deregistered; a registration publishes the first three, the
third when the holder accepted a vote target. isStakingEvent() narrows an event to
StakingEventData.
Revoke
revokeStakingConsent() records the holder's opt-out. From that instant no registration or pool
delegation is quoted or executed for the wallet on that network, every staking operation not yet
signed ends cancelled, and the answer lists them in cancelledOperationIds. The position stays on
chain until the holder leaves, and leaving stays offered. revokeStakingAuthorization() revokes the
pool or vote authorization the holder last confirmed and keeps the consent; the delegation on chain
stays as it is. A revoked consent publishes staking.consent_revoked, which
isStakingConsentRevokedEvent() narrows.
const current = await client.stakingState(wallet.walletId, "cardano-preprod");
if (current.consent.state === "in_force" && current.consent.consentId) {
const revoked = await client.revokeStakingConsent(
wallet.walletId,
"cardano-preprod",
{ consentId: current.consent.consentId, surface: "conversational_channel" },
`wallet-42-staking-opt-out-${current.consent.consentId}`,
);
console.log(revoked.state, revoked.cancelledOperationIds ?? []);
}
if (current.voteAuthorization) {
const authorization = await client.revokeStakingAuthorization(
wallet.walletId,
"cardano-preprod",
{
kind: "vote_delegation",
authorizationId: current.voteAuthorization.authorizationId,
surface: "partner_app",
},
`wallet-42-staking-vote-revocation-${current.voteAuthorization.authorizationId}`,
);
console.log(authorization.cancelledOperationIds);
}The three writes take an idempotency key before RequestOptions, as every mutating method does.
Staking errors
ApiError.stakingErrorCode returns the staking code of a refusal, one of STAKING_ERROR_CODES, and
undefined on any other. retryable states whether the same request can succeed later, and the
contract states it for every code.
| Code | retryable | Answered when |
| --- | --- | --- |
| STAKING_NOT_ENABLED | false | The deployment does not offer staking on the network. |
| STAKING_CONSENT_REQUIRED | false | A registration or pool delegation is quoted with no consent in force, and that refusal's details.consentState is none or revoked; or a revocation without consentId finds no consent in force. |
| STAKING_CONSENT_IN_FORCE | false | A consent without replacesConsentId is recorded while one is in force. details.consentId names it. |
| STAKING_TERMS_VERSION_UNKNOWN | false | termsVersion is not the version the deployment offers. details.currentTermsVersion names it, as offeredTermsVersion does. |
| STAKING_OPERATION_LIVE | false | The credential already has a staking operation in progress. details.operationId names it. The condition clears once that operation reaches a terminal state: follow it instead of repeating the request. |
| STAKING_NOT_REGISTERED | false | The action needs a registered credential. |
| STAKING_ALREADY_REGISTERED | false | A registration is asked for on a registered credential. |
| STAKING_TARGET_NOT_ALLOWED | false | The pool or vote target is outside the deployment's lists. details.target names it. |
| STAKING_POOL_INVALID | false | The pool is not registered or retires before the delegation takes effect. details.reason is not_registered or retiring. |
| STAKING_DREP_INVALID | false | The DRep is not registered on the network. details.reason is not_registered. An inactive DRep is accepted. |
| STAKING_VOTE_DELEGATION_REQUIRED | false | A withdrawal, or leaving with rewards, is asked for with no vote delegation. Delegate the vote first. |
| STAKING_REFUND_UNKNOWN | false | The deposit that was paid can be read from neither the chain nor the operation that paid it. |
| STAKING_CREDENTIAL_UNAVAILABLE | false | The account's address carries no staking credential the deployment holds. The account is provisioned again before it stakes. |
| STAKING_NO_REWARDS | true | A withdrawal is asked for with no withdrawable rewards. Rewards become withdrawable at an epoch boundary. |
| STAKING_NO_SPENDABLE_INPUT | true | The account holds no output it can spend. The request is answered once the account holds one. |
| STAKING_AUTHORIZATION_NOT_FOUND | false | A revocation without authorizationId finds no authorization of that kind in force. |
| STAKING_STATE_CHANGED | false | A consent's replacesConsentId, or a revocation's consentId or authorizationId, is no longer the state in force. Read the staking state again. |
| QUOTE_NOT_DURABLE | true | A staking quote was priced and could not be stored, so none was issued. |
A staking quote can also answer INSUFFICIENT_BALANCE when the account cannot cover the deposit and
the network fee, and every staking read answers CHAIN_NOT_READ (retryable: true) when the chain
could not be read. stakingState() also answers ACCOUNT_NOT_PROVISIONED (retryable: false)
when the wallet has no account on the network, and STAKING_CREDENTIAL_UNAVAILABLE as a staking
quote does. transfer() repeats the quote's checks with the same codes.
Errors
Failed API responses throw ApiError, with status, machine-readable code, structured details,
retryable, and optional retryAfterSeconds. Branch on code; never parse the human-readable
message. A rejected credential is the exception: when /oauth/token refuses the client secret or
the assertion, the SDK throws a plain Error whose message names the HTTP status.
import { ApiError } from "@chatterpay/sdk";
try {
await client.operation("unknown-operation");
} catch (error) {
if (error instanceof ApiError && error.status === 404 && error.code === "RESOURCE_NOT_FOUND") {
console.error("No such operation", error.message);
}
}POST /v1/quotes refuses a route with one of three codes, each with a getter on ApiError:
| Getter | Code | Meaning | Retry |
| --- | --- | --- | --- |
| routeNotAvailable | ROUTE_NOT_AVAILABLE | The representation does not list the operation, or the operation does not deliver the requested representation. | Refused again until the catalogue lists it. |
| providerRouteUnavailable | PROVIDER_ROUTE_UNAVAILABLE | The provider that settles a listed operation answered that it has no route. | retryable is true. |
| ambiguousAsset | AMBIGUOUS_ASSET | toAssetId is a symbol that matches more than one representation on the destination network. | Answered when toAssetId is a representation identifier. |
ambiguousAssetCandidates returns the representations an AMBIGUOUS_ASSET refusal lists, each
with assetId, chainId, canonicality and, for a token, contractAddress.
import { ApiError } from "@chatterpay/sdk";
try {
await client.quote({
walletId: wallet.walletId,
assetId: "assetrep_base-sepolia_eth",
amount: "0.01",
destination: "0x1111111111111111111111111111111111111111",
toAssetId: "USDC",
});
} catch (error) {
if (error instanceof ApiError && error.ambiguousAsset) {
const choices = error.ambiguousAssetCandidates ?? [];
console.log(choices.map((candidate) => `${candidate.assetId} (${candidate.canonicality})`));
}
}Error reference
Every code below is read from ApiError.code, and ApiError.retryable carries what the API
declared for the refusal. The retryable column states the value the contract fixes for a code;
"Not stated" marks a code whose contract fixes none, and there ApiError.retryable is the answer. The codes of a staking request are listed under
Staking errors.
quote()
| Code | Status | Answered when | retryable |
| --- | --- | --- | --- |
| INSUFFICIENT_BALANCE | 409 | The wallet cannot fund the amount and the fees. details carries required and available, in decimal and base units. A quote is not a reservation, so transfer() can still refuse an affordable quote. | Not stated |
| FUNDS_NOT_YET_SPENDABLE | 409 | The ledger holds the amount and the chain does not let the account spend it yet, as with a deposit on a UTXO network that is not yet deep enough. details carries required, spendableOnChain, ledgerAvailable and address. | true |
| CHAIN_NOT_READ | 409 | The chain that authorises the spend could not be read on this request. | true |
| AMOUNT_BELOW_MINIMUM_TRANSFERABLE | 409 | On a UTXO network, the amount is below the minimum of the output that carries it. details carries the operands. | Not stated |
| INSUFFICIENT_FUNDS_FOR_NETWORK_FEE | 409 | On a UTXO network, the inputs cannot cover the amount and the network fee. details.maximumSendable is the most the account can send. | Not stated |
| AMOUNT_WOULD_BURN_CHANGE | 409 | On a UTXO network, the leftover is too small to return as change and the transfer would burn it into the fee. details.maximumKeepingChange and, when present, details.sendEverything are the two amounts that avoid it, and details.maximumSendable is the most the account can send: offer the smaller of maximumKeepingChange and maximumSendable. | Not stated |
| INSUFFICIENT_FUNDS_FOR_FEES | 409 | On Cardano, what stays at the address after the transfer would not cover the service fees accrued there, this transfer's included. details carries requested, networkFee, serviceFees, required, spendableOnChain and maximumSendable. | Not stated |
| ACCOUNT_DERIVATION_DEPRECATED | 409 | The wallet's account on the network was issued under a key derivation the deployment retired. It keeps its address and deposits, moves no funds, and gets a new address when the operator reprovisions it. ApiError.accountDerivationDeprecated is true. | false |
| WALLET_ADDRESS_NOT_SIGNABLE | 409 | No key the deployment holds authorises the account the funds are in. details carries address and derivedAddress. The account is provisioned again before it moves funds. | false |
| ACCOUNT_MODEL_RETIRED | 409 | The account belongs to an account model the deployment withdrew. | false |
| ACCOUNT_DEPLOYMENT_MISMATCH | 409 | The account's signing identity belongs to another deployment, or was never recorded. details carries accountDeploymentId and servedDeploymentId. | Not stated |
| ROUTE_NOT_AVAILABLE, PROVIDER_ROUTE_UNAVAILABLE, AMBIGUOUS_ASSET | 409 | The route refusals in the table above. | As stated there |
| UNKNOWN_ASSET | 400 | toAssetId is a symbol the destination network does not carry. A symbol never resolves across networks. | Not stated |
| SLIPPAGE_OUT_OF_RANGE | 400 | slippage is above the deployment's maximum or is not a fraction greater than 0. details carries requested and maximum. | Not stated |
| EXECUTION_PAUSED | 409 | The quote would execute a provider route and the deployment paused that execution. details.scope names what is paused. | true |
| LIFI_UNAVAILABLE | 503 | The LI.FI aggregator is unreachable or answered a server error, or the deployment holds requests to it back after repeated failures until a cooldown passes. | true |
| ROUTE_PLAN_NOT_DURABLE | 503 | A provider route was priced and the deployment could not record the transaction it would sign for it, so it issued no quote. | true |
| QUOTE_NOT_DURABLE | 503 | A staking quote was priced and could not be stored, so none was issued. | true |
| LIFI_TIMEOUT | 504 | The aggregator did not answer within the deployment's request budget, retries included. | true |
A refused quote stores nothing. A quote request carries no idempotency key, so the SDK sends it once
and never retries it: on a retryable refusal, send the same request again.
transfer()
| Code | Status | Answered when | retryable |
| --- | --- | --- | --- |
| RESOURCE_NOT_FOUND | 404 | The quote does not exist or was issued to another Partner. Both cases get the same answer, and nothing is created. | Not stated |
| QUOTE_EXPIRED | 409 | The quote's validity ended before an operation was created for it. Request a new quote. | Not stated |
| QUOTE_ALREADY_EXECUTED | 409 | The quote already produced an operation under another idempotency key, so this confirmation created nothing. ApiError.quoteAlreadyExecuted is true and ApiError.executedOperationId names the operation to follow. The answer is the same after the quote expires. | false |
| INSUFFICIENT_BALANCE | 409 | The hold cannot be taken: two quotes issued against the same funds can both be affordable, and only the first transfer executes. | Not stated |
| CHAIN_NOT_READ | 409 | The chain that authorises the spend could not be read on this request. A retry of a transfer that was already accepted answers with the operation it created. | true |
| ACCOUNT_DERIVATION_DEPRECATED | 409 | As for quote(). | false |
A refusal answered before an operation was created leaves the quote executable until it expires. A staking quote's checks are repeated here with the same codes.
A quote whose routeExecutionMode is live_provider is checked before anything is signed, and the
transfer is refused with one of these codes, all 409:
| Code | Answered when | retryable |
| --- | --- | --- |
| EXECUTION_MODE_MISMATCH | The deployment changed its provider execution mode after the quote was issued. A quote executes only in the mode it was issued in: request a new one. | Not stated |
| PROVIDER_EXECUTION_DISABLED | The deployment does not execute this provider route. details.gate names why: mode, manifest, route, signer or durable_state. | Not stated |
| EXECUTION_PAUSED | The deployment paused this execution, for the whole deployment, the tenant, the source network or the route, or the account paused the module that executes it. details.scope names what is paused. | true |
| EXECUTION_LIMIT_EXCEEDED | The execution is outside one of the deployment's limits. details.limit is per_operation, tenant_daily, deployment_daily, native_value or slippage, and details carries the limit and the amount in base units of the source asset. | true for tenant_daily and deployment_daily, which reset each UTC day |
| LIFI_MODULE_NOT_ENABLED | The account has not enabled the module that executes provider routes on its network. Enabling it is an act of the account's root authority, which a transfer never performs. | Not stated |
| STALE_ALLOWANCE | The account already allows an address the route would use to spend its token. details reports the allowance; a transfer never changes it. | Not stated |
| ROUTE_PLAN_MISMATCH | The transaction recorded for the quote is missing, altered, belongs to another quote or outlives the quote, or the provider's router on the network now runs the route through other code. details.fields names what differs. | Not stated |
| WALLET_ADDRESS_NOT_SIGNABLE | The key the account accepts commands from is not the key the deployment signs them with. | false, as for quote() |
An accepted operation is checked again before signing and immediately before broadcast. When a check
fails there, the operation ends failed and its error starts with the code; nothing is broadcast.
import { ApiError } from "@chatterpay/sdk";
async function confirmQuote(quoteId: string, idempotencyKey: string) {
try {
return await client.transfer({ quoteId }, idempotencyKey);
} catch (error) {
// Another key already executed this quote: follow the operation it produced.
if (error instanceof ApiError && error.quoteAlreadyExecuted && error.executedOperationId) {
return client.operation(error.executedOperationId);
}
throw error;
}
}
const confirmed = await confirmQuote("quote-42", "order-42-transfer");
console.log(confirmed.operationId, confirmed.status);Accounts, recipients and keys
| Method | Code | Status | Answered when | retryable |
| --- | --- | --- | --- | --- |
| provisionAccount() | ACCOUNT_DERIVATION_DEPRECATED | 409 | The wallet's account on the network was issued under a retired key derivation, and is not replaced on request. | false |
| provisionRecipient() | RECIPIENT_BLOCKED | 409 | The recipient alias is blocked and may not receive. | Not stated |
| provisionRecipient() | ACCOUNT_DERIVATION_DEPRECATED | 409 | As for provisionAccount(), on the requested network. | false |
| revokeNativeDelegation() | NATIVE_DELEGATION_ALREADY_REVOKED | 409 | The account carries no delegation on the network. A second revocation is refused, and nothing is sent. | Not stated |
| revokeNativeDelegation() | REVOKE_NATIVE_DELEGATION_REQUIRES_EIP7702_NATIVE_MODE | 409 | The network does not run native EIP-7702 delegation; nothing is sent. | Not stated |
| revokeNativeDelegation() | CHAIN_STATE_STALE | 503 | The network reads the account with no delegation while the deployment recorded one it confirmed, or reads it at a block older than that record. Nothing is sent. The SDK retries it under the same idempotency key, as it retries any 5xx. | true |
| setRecoveryDelay() | RECOVERY_DELAY_OUT_OF_RANGE | 400 | The delay is outside the account contract's bounds. details carries requested, minimum and maximum, in seconds. | Not stated |
resolveRecipient() answers a wallet whose account was issued under a retired derivation with that
account's address until the operator reprovisions it; resolving moves nothing.
LI.FI and Sandbox
| Method | Code | Status | Answered when | retryable |
| --- | --- | --- | --- | --- |
| lifiQuote(), lifiStatus() | LIFI_UNAVAILABLE | 503 | As for quote(). | true |
| lifiQuote(), lifiStatus() | LIFI_TIMEOUT | 504 | As for quote(). | true |
| faucet() | ASSET_NOT_FUNDABLE | 400 | The faucet cannot produce that representation on that network: on an account-model network it mints only the deployment's own test tokens, so neither the network's own coin nor a representation whose canonicality is canonical can be granted. Also answered when the environment declares no default amount for an asset the request names no amount for. | Not stated |
| faucet() | TEST_FUNDING_NOT_CONFIGURED | 409 | The deployment's configuration carries no granting account for the network. | false |
| faucet() | BITCOIN_EXTERNAL_FUNDING_REQUIRED | 409 | On a Bitcoin network whose coins come from outside the platform, every confirmed output of the wallet's address is already attributed. A balance is granted once coins arrive at the wallet's address, from that network's public faucet or from anybody else. | false |
lifiStatus() is a read, which the SDK retries on a 5xx; lifiQuote() carries no idempotency
key and is sent once.
Every authenticated method
RATE_LIMIT_EXCEEDED (429) answers a caller over one of the deployment's bounds; details.scope
is client, tenant or deployment. ApiError.retryAfterSeconds carries the wait the API
instructed. The SDK waits it out before retrying a read or a request carrying an idempotency key,
and throws at once when the wait is longer than the request timeout. Retrying sooner is refused
again.
Idempotency
Mutating methods accept an idempotency key before RequestOptions. The SDK generates one when it is
omitted, but callers must persist and reuse their own key across process restarts and uncertain
outcomes. A key identifies one request: reusing it with a different payload is refused with
IDEMPOTENCY_KEY_CONFLICT, and while an earlier request with the same key and payload is still
being processed the API answers IDEMPOTENCY_KEY_IN_PROGRESS, with retryable: true. A quote is
executed by at most one operation whatever key confirms it: transfer() with the same key and
payload answers with the operation the first request created, before and after the quote expires,
and another key on an executed quote is refused with QUOTE_ALREADY_EXECUTED.
Timeouts, cancellation, and retries
The default request timeout is 30 seconds; set ClientOptions.timeoutMs or pass an AbortSignal in
the method's RequestOptions. maxRetries defaults to 2. The SDK retries GET requests and mutations
carrying an idempotency key after transient network failures and 429 or 5xx responses. It never
retries a mutation without an idempotency key.
const controller = new AbortController();
const balances = await client.balances(wallet.walletId, { signal: controller.signal });
console.log(balances.items.length);Events
events() reads one page. eventsIterator() drains the currently available backlog and then ends;
it does not poll forever. Save the returned event cursor in your application if you need to resume.
Deduplicate on eventId. An operation that bridges through a provider route publishes one
operation.leg.updated event per state its bridge leg reaches, under the identifier
evt_leg_<legId>_<status> for the first arrival in that state and evt_leg_<legId>_<status>_<n>
for the nth, counting from 2, and operation.manual_review_required once if the leg needs a
person.
isOperationLegUpdatedEvent() and isOperationManualReviewRequiredEvent() narrow an event to its
typed data:
import { isOperationLegUpdatedEvent } from "@chatterpay/sdk";
for await (const event of client.eventsIterator()) {
if (isOperationLegUpdatedEvent(event)) {
console.log(event.data.legId, event.data.previousStatus, "->", event.data.status);
}
}Webhook verification
Verify the signature against the exact raw request body before parsing JSON:
import { verifyWebhook } from "@chatterpay/sdk";
const valid = verifyWebhook(webhookSecret, timestampHeader, rawBody, signatureHeader);
if (!valid) throw new Error("Invalid webhook signature");SDK or direct HTTPS API
The SDK is optional. The SDK and direct HTTPS are equal integration paths over the same published API. Use the SDK for TypeScript types, token renewal, retry rules, idempotency headers, operation polling, and webhook verification. Use direct HTTPS when your backend uses another language or must own its HTTP stack; follow the OpenAPI contract and implement the same authentication, retry, and idempotency rules.
Documentation
Versioning
The package follows semantic versioning. Pin a compatible range and review the changelog before upgrading.
License
MIT
