Skip to content

Migrate from the legacy Java SDK

The Ripple Custody client SDKs replace the legacy Java SDK (Maven artifact com.metaco:sdk, loaded with HarmonizeContext.load(signer)). If your application uses the legacy SDK, use this page to move it to the new Java SDK. The page maps legacy concepts, configuration, and calls to the new SDK, and shows before-and-after code for common tasks.

The new SDK is also available for TypeScript and Python, with the same configuration model and intent lifecycle. If you're moving off the legacy SDK anyway, you can choose any of the three. For an overview of all three, see SDKs.

What changes

AreaLegacy SDKNew Java SDK
Dependencycom.metaco:sdk, installed from a JAR with mvn install:install-filecom.ripple.custody.platform.contrib:ripple-custody-client-lib-java, also installed from a JAR and a Project Object Model (POM) file with mvn install:install-file. Requires Java 17 or later
Package rootcom.metaco.harmonizecom.ripple.custody
Configurationharmonize.json file or environment variables (GATEWAY_URL, OAUTH_URL, and others)config.toml file or RC_* environment variables. Environment variables override the file
Entry pointHarmonizeContext.load(signer), then harmonizeCtx.harmonizeCustodyClient.fromConfig(CustodyClientConfig.loadConfiguration())
SigningYou implement the Signer interfaceBuilt-in signer for a PEM private key file, or your own ChallengeSigner and IntentSigner
Intent authorDerived from the signerSet with RC_USER_ID and RC_DOMAIN_ID or config.author(...), or looked up through GET /v1/me
Resource accessAction classes per call, for example harmonize.users(domainId)Fixed namespaces on one client, for example client.governance().users(), with the domain ID passed to each method
IDsStringjava.util.UUID
Entity creation and updatesFluent builders ending in .submit(), returning IntentDataNamespace methods that build, sign, and submit the intent, returning PendingIntent
Waiting for resultswaitForSucceed, waitForIntentStatus, and resource-specific waits, with a TimeoutwaitAccepted, waitExecuted, and waitTxHash, with a timeout in seconds
Proposal expirySet per call with ExpireFixed at one hour from submission
ErrorsRuntimeExceptionCustodyError (unchecked), with the HTTP ApiException as its cause
Response signaturesNot verifiedThe SDK verifies them by default, which requires the notary API public key

To install the new SDK, get the JAR and POM files from the Ripple support team. For full installation and setup steps, see Integrate via SDK.

Install the files into your local Maven repository, as you did for the legacy SDK. Replace {version} with the version you received:

mvn install:install-file \
  -Dfile=ripple-custody-client-lib-java-{version}.jar \
  -DpomFile=ripple-custody-client-lib-java-{version}.pom

Then replace the legacy dependency with the new one:

repositories {
    mavenLocal()
    mavenCentral()
}

dependencies {
    implementation 'com.ripple.custody.platform.contrib:ripple-custody-client-lib-java:{version}'
}

Map your configuration

The legacy SDK read harmonize.json from the home directory, the working directory, the --conf flag, or HARMONIZE_CONF, then applied environment variables. The new SDK reads the file named by RC_CONFIG_FILE, or ./config.toml if it exists, then applies RC_* environment variables, which take precedence.

The following table maps each legacy key to its replacement:

Legacy keyNew environment variableNew config.toml keyNotes
HARMONIZE_CONFRC_CONFIG_FILENot applicablePath to the configuration file. The new SDK has no home-directory lookup and no --conf flag.
GATEWAY_URLRC_API_BASE_URLapi_base_urlBase URL of the Ripple Custody API.
OAUTH_URLRC_TOKEN_ENDPOINTtoken_endpointOAuth 2.0 token endpoint. To have the SDK discover the token endpoint, set RC_OIDC_ISSUER_URL (oidc_issuer_url) instead.
OAUTH_CLIENT_IDRC_CLIENT_IDclient_idDefaults to customer_api when you sign with a private key.
Not applicable (the Signer implementation)RC_PRIVATE_KEY_FILEprivate_key_filePath to the user's EC private key in PEM format. See Replace your signer.
Not applicableRC_USER_IDrc_user_idID of the user that authors intents.
Not applicableRC_DOMAIN_IDrc_domain_idDefault domain of the authoring user.
Not applicableRC_NOTARY_API_PUBLIC_KEYnotary_api_public_keyThe SDK requires this key while response verification is on. See Behavior differences.
Not applicableRC_VERIFY_RESPONSESverify_responsestrue by default.
TIMEOUTNoneNoneSet timeouts on the underlying HTTP client, for example client.getApiClient().setReadTimeout(milliseconds) or setConnectTimeout(milliseconds).
HTTP_VERSIONNoneNoneTo control the HTTP client, pass your own OkHttpClient to ApiClient.newBuilder().httpClient(...).
OPEN_API_VALIDATE_WARN_ONLY, OPEN_API_VALIDATE_STRICT_ENUMSNoneNoneThe new SDK doesn't check requests for conformance with the OpenAPI document at runtime.

The new SDK also supports the client credentials grant (RC_CLIENT_ID with RC_CLIENT_SECRET), TLS settings, and address validation options. For the full list, see Configure the SDK.

The following example converts a legacy harmonize.json file:

{
  "GATEWAY_URL": "https://custody.example.com/api",
  "OAUTH_URL": "https://custody.example.com/oauth/token",
  "OAUTH_CLIENT_ID": "customer_api"
}

Load the configuration and create a client

A single CustodyClientConfig and a long-lived CustodyClient replace the legacy HarmonizeContext, Context, and harmonize instance. CustodyClient.fromConfig resolves the signer, obtains OAuth tokens, and configures the HTTP client for you.

The following example creates a client with each SDK:

var harmonizeCtx = HarmonizeContext.load(signer);
// we get the Ripple Custody instance from the context
var harmonize = harmonizeCtx.harmonize;

For a complete program, see example 00 (getting started). To get the examples, see Examples.

To load a specific file instead of RC_CONFIG_FILE or ./config.toml, call CustodyClientConfig.loadConfiguration(Path.of("/path/to/config.toml")). To read environment variables only, call CustodyClientConfig.fromEnvironment().

The legacy harmonizeCtx.switchSigner(newSigner) has no replacement in the new SDK. To act as a different user, create a second CustodyClientConfig and CustodyClient with that user's key and IDs.

Replace your signer

The legacy SDK required you to implement the Signer interface and to serialize each message to canonical JSON before signing it. The new SDK canonicalizes every request for you and offers two signing options.

Private key file. If your user's private key is available as a PEM file, set RC_PRIVATE_KEY_FILE (or private_key_file). The SDK uses the key both to sign the OAuth login challenge and to sign intents. You don't write any signing code.

Your own key management system. If the key lives in a hardware security module (HSM) or a key management service, implement two signing callbacks and build the client yourself:

  • com.ripple.custody.auth.ChallengeSigner signs the OAuth login challenge and returns the user's public key.
  • com.ripple.custody.IntentSigner signs the canonical JSON of each intent, approval, and rejection. It receives the canonical JSON string and returns a base64-encoded signature.

In the following example, myKeyService stands for your own integration with your key management system:

import com.ripple.custody.CustodyClient;
import com.ripple.custody.CustodyClientConfig;
import com.ripple.custody.auth.ChallengeSigner;
import com.ripple.custody.invoker.ApiClient;

CustodyClientConfig config = CustodyClientConfig.loadConfiguration();

ChallengeSigner challengeSigner = new ChallengeSigner(
        challenge -> myKeyService.sign(challenge),          // base64 signature
        unused -> myKeyService.publicKeyBase64Der());       // base64 DER public key

ApiClient apiClient = ApiClient.newBuilder()
        .tokenEndpoint(config.getEffectiveTokenEndpoint())
        .clientId("customer_api")
        .grantType("password")
        .signer(challengeSigner)
        .build();
apiClient.setBasePath(config.getApiBaseUrl());

config.apiClient(apiClient)
        .signer(canonicalJson -> myKeyService.sign(canonicalJson));

CustodyClient client = new CustodyClient(config);
KMS providers

The Java SDK doesn't include signers for cloud key management services or HashiCorp Vault. Setting RC_KMS_PROVIDER makes CustodyClient.fromConfig fail. Use custom ChallengeSigner and IntentSigner callbacks, as shown in the preceding example. The Java examples Example08HashiCorpVaultTransit, Example09AzureKeyVaultKeys, and Example10AwsKms show a signer for each provider. For details, see Authentication and signing.

Map classes and methods

Each legacy action class maps to a namespace on CustodyClient, as the following table shows. Namespace accessors are methods. Where the API requires a domain ID, every namespace method takes it as its first argument.

Legacy SDKNew Java SDK
HarmonizeContext.load(signer)CustodyClient.fromConfig(CustodyClientConfig.loadConfiguration())
ConfigCustodyClientConfig
SignerChallengeSigner and IntentSigner, or RC_PRIVATE_KEY_FILE
harmonize.domains()client.governance().domains()
harmonize.users(domainId)client.governance().users()
harmonize.policies(domainId)client.governance().policies()
harmonize.tickers()client.governance().tickers()
harmonize.intents(domainId)client.governance().intents()
harmonize.requests(domainId)client.governance().requests()
harmonize.events(domainId)client.governance().events()
harmonize.accounts(domainId)client.accounts()
harmonize.vaults()client.vaults()
harmonize.endpoints(domainId)client.endpoints()
harmonize.ledgers()client.ledgers()
harmonize.transactions(domainId)client.transactions()
harmonize.genesis()client.genesis()
harmonize.me()No namespace method. Call new UsersApi(client.getApiClient()).getMe().
harmonize.notarize()No namespace method. Use the generated IntentsApi directly.
.get(id) returning Optionalget(domainId, id) throws CustodyError if the entity doesn't exist. find(domainId, id) returns Optional
.list() returning a Streamstream(domainId) pages through all results. list(domainId, limit, startingAfter) returns one page
.create()...submit() and .create(entity, expire)create(...) on the namespace, returning PendingIntent
.update(...), .lock(...), .unlock(...)update(...), lock(...), unlock(...) on the namespace, returning PendingIntent. Overloads accept a fetched entity
IntentDataPendingIntent
IntentData.getIntentId()PendingIntent.getIntentId(), available after waitAccepted
waitForSucceed(Timeout), waitForProcessingOrSucceeded(Timeout)waitAccepted(timeoutSeconds)
waitForIntentStatus(Set.of(IntentStatus.Executed), Timeout)waitExecuted(timeoutSeconds)
TransactionIntentData.waitForOrderTransactionProcessingStatus(...)waitTxHash(timeoutSeconds)
harmonize.intents(domainId).approve(intentId, expire)pendingIntent.approve(comment), or client.governance().intents().approve(domainId, intentId, proposalSignature, comment)
harmonize.intents(domainId).reject(intentId, reason, expire)pendingIntent.reject(comment), or client.governance().intents().reject(domainId, intentId, proposalSignature, comment)
MapJSON get(Class, key) and set(key, value)Typed model classes in com.ripple.custody.models, each with getAdditionalProperties() and putAdditionalProperty(key, value) for fields the model doesn't declare

The new SDK also has a client.webhooks() namespace for webhook channels, which the legacy SDK didn't offer.

Track intents with PendingIntent

In the legacy SDK, each action returned an IntentData and you chained a waitFor... method to poll the request and then the intent. In the new SDK, every method that proposes an intent signs and submits it, and returns a PendingIntent:

  • waitAccepted(timeoutSeconds) polls the request until Ripple Custody accepts or rejects it, and records the intent ID. It throws CustodyError if the request fails. Call it first.
  • waitExecuted(timeoutSeconds) polls the intent until it reaches a terminal status: Executed, Failed, Rejected, or Expired.
  • waitTxHash(timeoutSeconds) supports transfer and transaction-order intents. It polls until the on-chain transaction reaches Completed, Failed, or Interrupted.

If the timeout elapses first, the wait methods throw IntentTimeoutError. A timeout isn't a failure verdict: the intent might still execute later. If an intent needs approval from other users, don't block on waitExecuted. Check the intent later with client.governance().intents().get(domainId, intentId).

For more on the intent lifecycle, see Intents.

Common tasks before and after

The examples in this section assume a configured client, as shown in Load the configuration and create a client.

Create a user

The new create method requires at least one login ID and a public key in base64 DER format. It validates both before it sends the request. The following example creates a user with each SDK:

var domainId = "";
var userId = "";
var userAlias = "";
var userRoles = List.of("admin");
var userPublicKey = "";
var userDescription = "";

var user = Users.CreateUser.builder()
        .id(userId)
        .alias(userAlias)
        .description(userDescription)
        .roles(userRoles)
        .publicKey(userPublicKey)
        .build();

harmonize.users(domainId)
        .create(user, Expire.hours(24))
        .waitForSucceed(Timeout.minutes(1));

For a complete program, see example 10 (create user). To get the examples, see Examples.

To create a domain, the new Java SDK has no create method on client.governance().domains(). Build a CoreV0CreateDomain payload and submit it with Envelope.submitIntent(client.getConfig(), payload, parentDomainId, null), which returns a PendingIntent.

Create a transfer

The legacy TransactionActions builder maps to client.transactions().transfer(...) for transfers. When you need chain-specific transaction parameters, use client.transactions().createTransactionOrder(...) instead. A transfer names the ticker to send rather than a chain-specific parameter builder. The following example creates a transfer with each SDK:

var domainId = "";
var fromAccount = "";
var toAccount = "";
var amount = "5432";
var maxFee = "5432";

var transaction = Transactions.CreateTransactionOrder.builder()
        .fromAccount(fromAccount)
        .orderParameters(Transactions.btcParameters()
                .maxFee(maxFee)
                .priorityHigh()
                .toAccount(domainId, toAccount)
                .amount(amount)
                .build()
        )
        .build();

harmonize.transactions(domainId).create(transaction, Expire.hours(24))
        .waitForSucceed(Timeout.minutes(1));

Approve an intent

If you proposed the intent in the same process, approve it through the returned PendingIntent, which already holds the proposal signature. To approve an intent that someone else proposed, read the proposal signature from the intent and pass it to client.governance().intents().approve(...). To reject an intent, use reject the same way. The following example approves an intent with each SDK:

var domain = harmonize.domains().get(domainId).orElseThrow();

harmonize.intents(domain.getId())
        .approve(intentData.getIntentId(),
        Expire.hours(1))
        .waitForSucceed(Timeout.minutes(1));

Intents.IntentEntity intent = harmonize.intents(domain.getId()).get(intentData.getIntentId()).orElseThrow();

Behavior differences

These differences change runtime behavior, not only names. Check each one before you switch over.

  • The SDK verifies response signatures by default. The new SDK checks the notary signature on API responses and throws ResponseSignatureError if verification fails. Creating a client fails unless you set RC_NOTARY_API_PUBLIC_KEY (or config.notaryApiPublicKey(...)), or explicitly opt out with RC_VERIFY_RESPONSES=false (or config.verifyResponses(false)). Get the notary API public key for your environment from your Ripple Custody administrator. For details, see Response verification.
  • The SDK handles canonical JSON for you. You no longer call toJson() or GsonUtils before signing. An IntentSigner receives the canonical JSON string and signs it as is.
  • No automatic retries. The SDK doesn't retry failed HTTP requests. Add your own retry logic where your application needs it, and make sure retried intent submissions are safe to repeat. For details, see Errors and HTTP.
  • Every intent must have an author. Set RC_USER_ID and RC_DOMAIN_ID, or call config.author(...) with a CoreUserReference. Otherwise, the SDK looks up your user through GET /v1/me. If you belong to several domains, set RC_DOMAIN_ID.
  • Waiting for execution doesn't throw on a failed intent. waitExecuted returns the intent when it reaches any terminal status: Executed, Failed, Rejected, or Expired. Check pending.getStatus() or the returned intent's state. The legacy waitFor methods threw a RuntimeException for failure states.
  • Proposals have a fixed expiry. Intents that you propose through the new SDK expire one hour after submission. The new SDK has no Expire argument.
  • Updates replace list fields. update methods on users, policies, endpoints, and tickers replace list fields such as roles, login IDs, and workflow conditions in full. Read the current entity and pass its existing values back unless you intend to remove them.
  • Address validation is on by default. Before it sends the request, transfer rejects a destination address that isn't in a recognized format for any supported chain. To turn this off, set RC_VALIDATE_ADDRESSES=false.

Next steps