Every change to Ripple Custody data, such as creating a user, locking an account, or sending a transfer, is an intent. The SDKs build, sign, and submit the intent for you. They return a PendingIntent handle that you use to wait for the outcome. This page describes what the SDK does on your behalf and how to track, approve, and reject intents.
For the intent model itself, see Manage intents and approvals and Intent request structure.
The SDK examples assume a configured client. To set one up, see Integrate via SDK.
When you call a method that changes state, for example users.lock(...) or transactions.transfer(...), the SDK does the following:
- Builds the typed intent payload from your arguments, for example a
v0_LockUserpayload. - Wraps the payload in a
Proposerequest with these standard fields:id: a new random UUID for the proposal.author: the configured user and domain. See Intent author.targetDomainId: the domain you pass, or the configured default domain.expiryAt: the current time plus one hour, truncated to millisecond precision.customProperties: an empty object.
- Canonicalizes the request (removes null values, sorts keys, and strips whitespace) and signs it with the configured intent signer.
- Submits the signed request to
POST /v1/intentsand returns aPendingIntentbound to therequestIdin the response.
You can't change the one-hour expiry, because none of the SDKs expose an option for it. If an intent needs approvals that can take longer than an hour, submit it with the API instead, where you set expiryAt yourself.
Signing an intent requires a signing key. A client that you configure only for the client_credentials grant can read data but can't submit, approve, or reject intents. For details, see Authentication and signing.
The author is the user whose key signs the intent.
The SDK uses RC_USER_ID and RC_DOMAIN_ID when you set both. Otherwise, it calls GET /v1/me once to find the caller's user and domain. If you set only RC_DOMAIN_ID, the SDK uses the matching domain from the response. The lookup fails if the caller belongs to more than one domain and you don't set RC_DOMAIN_ID. In Java, you can also set the author with CustodyClientConfig.author(...).
Each state-changing method returns a PendingIntent. Call waitAccepted to confirm that Ripple Custody accepted the request, then waitExecuted to wait for the intent to reach a final state. The following example locks a user:
const pending = await client.governance.users.lock(userId);
const request = await pending.waitAccepted(60_000); // timeouts are in milliseconds
console.log(pending.intentId, request.status);
const intent = await pending.waitExecuted(300_000); // returns on any final state
if (intent.data?.state?.status !== 'Executed') {
throw new Error(`Intent did not execute: ${intent.data?.state?.status}`);
}
console.log(intent.data.state.status);| Method | What it polls | Returns when | Notes |
|---|---|---|---|
waitAccepted (wait_accepted) | Request state | The request is Succeeded or Failed. | Sets the intent ID on success. Throws CustodyError if the request is Failed, with the failure hint when the platform provides one. |
waitExecuted (wait_executed) | Intent state | The intent is Executed, Failed, Rejected, or Expired. | Call waitAccepted first, so that the SDK knows the intent ID. |
waitTxHash (wait_tx_hash) | Transactions for the order | The on-chain transaction is Completed, Failed, or Interrupted. | Only for intents that create a transaction order, such as transfers. Other intents throw CustodyError. |
refresh | Request state, then intent state | Immediately, after one poll. | Updates the intent ID and status without waiting. |
waitExecuted returns the intent in any final state, including Failed, Rejected, and Expired, so check the status before you continue. Only a timeout makes it throw.
You can wait on an intent that targets a domain other than your own user's domain. The SDK polls the request in your domain and the intent in the domain where the server created it.
They also differ in what waitTxHash does and returns:
- TypeScript returns a
TransactionResultwithhash,isSuccess,fee, and the raw transaction and transfers. CallwaitExecutedfirst, becausewaitTxHashdoesn't wait for execution itself. - Java calls
waitExecutedfirst if needed, shares one timeout across both phases, and returns the rawCoreApiTransaction. - Python calls
wait_executedfirst if needed and returns aTransactionResultwithhash,is_success,fee, and the raw objects. Each phase gets the full timeout.
The following example waits for the on-chain result of a transfer. pending is the PendingIntent that the transfer call returned.
await pending.waitAccepted(60_000);
await pending.waitExecuted(120_000);
const result = await pending.waitTxHash(600_000);
console.log(result.hash, result.isSuccess);For a complete program, see example 13 (create transfer order). To get the examples, see Examples.
Every wait method takes a timeout. TypeScript takes milliseconds. Java and Python take seconds.
If the timeout elapses before a final state, the method throws IntentTimeoutError, a subclass of CustodyError. The error carries the last state the SDK polled, so you can see how far the intent got without another request. A timeout only stops the SDK from waiting. The intent keeps running on the platform, and you can check it later with refresh() or the intents namespace.
The following example catches the timeout and reports the last polled state:
import { IntentTimeoutError } from '@ripple/ripple-custody-client-lib-typescript';
try {
await pending.waitExecuted(120_000);
} catch (err) {
if (err instanceof IntentTimeoutError) {
console.log('Still pending:', pending.intentId, err.lastState);
} else {
throw err;
}
}If other users must approve the intent, it can stay open until its one-hour expiry. Call waitAccepted and store the intent ID. Check the intent later instead of holding a thread in waitExecuted.
The wait methods poll every 2 seconds by default. You can change the interval for the whole client or for a single call:
import { CustodyClient, loadConfiguration } from '@ripple/ripple-custody-client-lib-typescript';
const client = new CustodyClient(loadConfiguration(), { pollIntervalMs: 5_000 });
// Or per call: timeout, then poll interval, both in milliseconds
await pending.waitExecuted(300_000, 10_000);A dry run asks the platform to validate an intent and estimate its effect without creating it. The SDKs expose dry runs for specific cases rather than for every payload:
- TypeScript and Python:
transactions.dryRun(transactions.dry_run) estimates the fee of a transfer and returns aDryRunEstimatewithsuccess,fee, anderrors. The manifests namespace also has its own dry-run methods. - Java:
governance().intents().dryRun(...)dry-runs any intent payload.transactions().dryRun(...)estimates a transaction fee fromCoreDryRunTransactionParameters.
To dry-run another intent type from TypeScript or Python, call the API directly. See Dry run an intent with the API.
The following examples estimate a transfer fee in TypeScript and Python, and dry-run a lock-user intent in Java:
const estimate = await client.transactions.dryRun(accountId, tickerId, {
toAddress: destinationAddress,
amount: '10000000',
});
console.log(estimate.success, estimate.fee, estimate.errors);For a complete program, see example 16 (fee management). To get the examples, see Examples.
Use the governance intents namespace to read intents that you or other users submitted. List methods return one page by default. For pagination options, see Errors, pagination, and HTTP settings.
The following example lists intents, reads one intent, and lists its remaining approvers:
const page = await client.governance.intents.list({ limit: 10 });
const intent = await client.governance.intents.get(intentId);
console.log(intent.state.status);
const remaining = await client.governance.intents.getRemainingUsers(intentId);
for (const group of remaining) {
console.log(group.domainId, group.users);
}In Java, intents().find(domainId, intentId) returns an empty Optional instead of throwing when the intent doesn't exist.
An approver approves or rejects an intent by its ID, without the PendingIntent that the proposer received. The SDK signs the approval with the approver's own configured key, and the platform attributes it to the approver's user.
- TypeScript and Python:
approveanddenyfetch the intent, read itsproposalSignatureand target domain, then sign and submit the decision. They return aPendingIntentfor the decision request, so you can callwaitAcceptedon it. - Java:
approveandrejecttake the proposal signature as an argument and return nothing. Read the signature from the intent first. Pass the intent's target domain asdomainId.
The following example approves one intent and rejects another:
const approval = await client.governance.intents.approve(intentId, {
comment: 'Reviewed and approved',
});
await approval.waitAccepted(30_000);
const rejection = await client.governance.intents.deny(otherIntentId, {
comment: 'Wrong destination',
});
await rejection.waitAccepted(30_000);For a complete program, see example 20 (governance intents). To get the examples, see Examples.
A rejection always carries a reason. If you don't pass a comment, the SDK sends Rejected.
PendingIntent also has approve(comment) and reject(comment) methods. They work only on the handle the proposer received, after waitAccepted returns, because they reuse the proposal signature that the SDK captured at submission. Use the intents namespace for approvals by other users.
Intents that change an existing entity, such as update, lock, and unlock, include the entity's current revision. The platform rejects the intent if the entity changed after that revision, which prevents an intent from overwriting a change it didn't account for.
If you don't pass a revision, the SDK reads the entity first and uses its current revision. This costs an extra request and means the intent applies to whatever version is current at submission. To make sure the intent changes the version you reviewed, read the entity yourself and pass its revision. If the platform rejects the intent because of a revision conflict, read the entity again, check that the change still makes sense, and resubmit.
Update intents replace the entity's editable fields. Pass every field you want to keep, not only the ones you change.
The following example reads a user and passes its revision to the lock intent:
const user = await client.governance.users.get(userId);
const pending = await client.governance.users.lock(userId, {
revision: user.metadata.revision,
});
await pending.waitAccepted(60_000);- Manage intents and approvals: the intent lifecycle, approvals, and dry runs with the API.
- Intent request structure: the fields of a
Proposerequest. - Authentication and signing: configure the signing key that the SDK uses to sign intents.
- Errors, pagination, and HTTP settings: handle
CustodyErrorand page through list results.