# Work with intents

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](/pt-br/products/custody/governance/intents/manage-intents-and-approvals) and [Intent request structure](/pt-br/products/custody/governance/intents/intent-request-structure).

The SDK examples assume a configured client. To set one up, see [Integrate via SDK](/pt-br/products/custody/get-started/integrate-via-sdk).

## How the SDK submits an intent

When you call a method that changes state, for example `users.lock(...)` or `transactions.transfer(...)`, the SDK does the following:

1. Builds the typed intent payload from your arguments, for example a `v0_LockUser` payload.
2. Wraps the payload in a `Propose` request with these standard fields:
  - `id`: a new random UUID for the proposal.
  - `author`: the configured user and domain. See [Intent author](#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.
3. Canonicalizes the request (removes null values, sorts keys, and strips whitespace) and signs it with the configured intent signer.
4. Submits the signed request to `POST /v1/intents` and returns a `PendingIntent` bound to the `requestId` in 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](/pt-br/products/custody/developer-experience/sdks/authentication-and-signing).

### Intent author

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(...)`.

## Submit an intent and wait for the result

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:

TypeScript
```typescript
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);
```

Java
```java
PendingIntent pending = client.governance().users().lock(domainId, userId, null);

CoreRequestState request = pending.waitAccepted(60);    // timeouts are in seconds
System.out.println(pending.getIntentId() + " " + request.getStatus());

CoreTrustedIntent intent = pending.waitExecuted(300);  // returns on any final state
if (!"Executed".equals(String.valueOf(intent.getData().getState().getStatus()))) {
    throw new IllegalStateException("Intent did not execute: " + intent.getData().getState().getStatus());
}
System.out.println(intent.getData().getState().getStatus());
```

Python
```python
pending = client.governance.users.lock(user_id)

request = pending.wait_accepted(timeout=60)    # timeouts are in seconds
print(pending.intent_id, request.status)

intent = pending.wait_executed(timeout=300)    # returns on any final state
if intent.data.state.status != "Executed":
    raise RuntimeError(f"Intent did not execute: {intent.data.state.status}")
print(intent.data.state.status)
```

## PendingIntent wait methods

| 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 `TransactionResult` with `hash`, `isSuccess`, `fee`, and the raw transaction and transfers. Call `waitExecuted` first, because `waitTxHash` doesn't wait for execution itself.
- **Java** calls `waitExecuted` first if needed, shares one timeout across both phases, and returns the raw `CoreApiTransaction`.
- **Python** calls `wait_executed` first if needed and returns a `TransactionResult` with `hash`, `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.

TypeScript
```typescript
await pending.waitAccepted(60_000);
await pending.waitExecuted(120_000);
const result = await pending.waitTxHash(600_000);
console.log(result.hash, result.isSuccess);
```

Java
```java
pending.waitAccepted(60);
CoreApiTransaction tx = pending.waitTxHash(600);   // waits for execution first
System.out.println(tx.getLedgerTransactionData().getLedgerTransactionId());
```

Python
```python
pending.wait_accepted(timeout=60)
intent = pending.wait_executed(timeout=120)
if intent.data.state.status != "Executed":
    raise RuntimeError(f"Intent did not execute: {intent.data.state.status}")
result = pending.wait_tx_hash(timeout=600)
print(result.hash, result.is_success)
```

For a complete program, see example `13` (create transfer order). To get the examples, see [Examples](/pt-br/products/custody/developer-experience/sdks#examples).

## Timeouts and IntentTimeoutError

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](#list-intents-and-check-remaining-approvers).

The following example catches the timeout and reports the last polled state:

TypeScript
```typescript
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;
  }
}
```

Java
```java
import com.ripple.custody.errors.IntentTimeoutError;

try {
    pending.waitExecuted(120);
} catch (IntentTimeoutError e) {
    System.out.println("Still pending: " + pending.getIntentId() + " " + e.getLastState());
}
```

Python
```python
from ripple_custody_client_lib_python import IntentTimeoutError

try:
    pending.wait_executed(timeout=120)
except IntentTimeoutError as exc:
    print("Still pending:", pending.intent_id, exc.last_state)
```

Don't block on intents that need human approval
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`.

### Poll interval

The wait methods poll every 2 seconds by default. You can change the interval for the whole client or for a single call:

TypeScript
```typescript
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);
```

Java
```java
config.pollIntervalSeconds(5.0);

// Or per call: timeout, then poll interval, both in seconds
pending.waitExecuted(300, 10.0);
```

Python
```python
from ripple_custody_client_lib_python import CustodyClient, load_configuration

client = CustodyClient(load_configuration(), poll_interval=5.0)

# Or per call, in seconds
pending.wait_executed(timeout=300, poll_interval=10.0)
```

## Dry-run an intent

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 a `DryRunEstimate` with `success`, `fee`, and `errors`. 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 from `CoreDryRunTransactionParameters`.


To dry-run another intent type from TypeScript or Python, call the API directly. See [Dry run an intent with the API](/pt-br/products/custody/governance/intents/manage-intents-and-approvals#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:

TypeScript
```typescript
const estimate = await client.transactions.dryRun(accountId, tickerId, {
  toAddress: destinationAddress,
  amount: '10000000',
});
console.log(estimate.success, estimate.fee, estimate.errors);
```

Java
```java
CoreV0LockUser payload = new CoreV0LockUser()
    .reference(new CoreEntityIdAndRevision().id(userId).revision(revision))
    .type(CoreV0LockUser.TypeEnum.V0_LOCK_USER);

CoreIntentDryRunResponse result = client.governance().intents()
    .dryRun(domainId, config.getAuthor(), new CoreUserIntentPayload(payload));
System.out.println(result.getActualInstance());
```

Python
```python
estimate = client.transactions.dry_run(
    account_id,
    ticker_id,
    to_address=destination_address,
    amount="10000000",
)
print(estimate.success, estimate.fee, estimate.errors)
```

For a complete program, see example `16` (fee management). To get the examples, see [Examples](/pt-br/products/custody/developer-experience/sdks#examples).

## List intents and check remaining approvers

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](/pt-br/products/custody/developer-experience/sdks/errors-and-http#pagination).

The following example lists intents, reads one intent, and lists its remaining approvers:

TypeScript
```typescript
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);
}
```

Java
```java
CoreTrustedIntentsCollection page = client.governance().intents().list(domainId, 10, null);

CoreTrustedIntent intent = client.governance().intents().get(domainId, intentId);
System.out.println(intent.getData().getState().getStatus());

List<CoreRemainingDomainUsers> remaining =
    client.governance().intents().getRemainingUsers(domainId, intentId);
for (CoreRemainingDomainUsers group : remaining) {
    System.out.println(group.getDomainId() + " " + group.getUsers());
}
```

Python
```python
page = client.governance.intents.list(limit=10)

intent = client.governance.intents.get(intent_id)
print(intent.state.status)

remaining = client.governance.intents.get_remaining_users(intent_id)
for group in remaining:
    print(group.domain_id, group.users)
```

In Java, `intents().find(domainId, intentId)` returns an empty `Optional` instead of throwing when the intent doesn't exist.

## Approve or reject an intent submitted by someone else

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:** `approve` and `deny` fetch the intent, read its `proposalSignature` and target domain, then sign and submit the decision. They return a `PendingIntent` for the decision request, so you can call `waitAccepted` on it.
- **Java:** `approve` and `reject` take the proposal signature as an argument and return nothing. Read the signature from the intent first. Pass the intent's target domain as `domainId`.


The following example approves one intent and rejects another:

TypeScript
```typescript
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);
```

Java
```java
CoreTrustedIntent intent = client.governance().intents().get(domainId, intentId);
String proposalSignature = intent.getData().getDetails().getProposalSignature();

client.governance().intents()
    .approve(domainId, intentId, proposalSignature, "Reviewed and approved");

// To reject instead:
// client.governance().intents()
//     .reject(domainId, intentId, proposalSignature, "Wrong destination");
```

Python
```python
approval = client.governance.intents.approve(intent_id, comment="Reviewed and approved")
approval.wait_accepted(timeout=30)

rejection = client.governance.intents.deny(other_intent_id, comment="Wrong destination")
rejection.wait_accepted(timeout=30)
```

For a complete program, see example `20` (governance intents). To get the examples, see [Examples](/pt-br/products/custody/developer-experience/sdks#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.

## Update, lock, and revisions

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:

TypeScript
```typescript
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);
```

Java
```java
CoreTrustedUser user = client.governance().users().get(domainId, userId);

// Reads the ID and revision from the entity you fetched
PendingIntent pending = client.governance().users().lock(domainId, user);
pending.waitAccepted(60);
```

Python
```python
user = client.governance.users.get(user_id)

pending = client.governance.users.lock(user_id, revision=user.metadata.revision)
pending.wait_accepted(timeout=60)
```

## Related topics

- [Manage intents and approvals](/pt-br/products/custody/governance/intents/manage-intents-and-approvals): the intent lifecycle, approvals, and dry runs with the API.
- [Intent request structure](/pt-br/products/custody/governance/intents/intent-request-structure): the fields of a `Propose` request.
- [Authentication and signing](/pt-br/products/custody/developer-experience/sdks/authentication-and-signing): configure the signing key that the SDK uses to sign intents.
- [Errors, pagination, and HTTP settings](/pt-br/products/custody/developer-experience/sdks/errors-and-http): handle `CustodyError` and page through list results.