Skip to content

Use this page to obtain JWT tokens, send read-only API requests, and sign state mutation intent requests. For the conceptual model, see Authentication.

Prerequisites

Before authenticating API requests, you must complete:

You also need:

  • Your private key (generated during registration)
  • Your public key (in DER format with Base64 encoding)
  • The OpenID authorization server URL (contact your administrator)
  • Your client ID (provided by your administrator)

Obtaining a JWT token

All API requests require a JWT (JSON Web Token) passed as a Bearer token in the Authorization header. You need to obtain a JWT when you first start using the API and whenever your token expires.

PropertyValue
Default expiration4 hours
Token typeBearer
IssuerRipple OIDC server (default)
OIDC discovery URLhttps://openid.{environment_URL}/realms/Metaco/.well-known/openid-configuration
Token endpointhttps://openid.{environment_URL}/realms/Metaco/protocol/openid-connect/token

How JWT authentication works

  1. You generate a random challenge string.
  2. You sign the challenge with your private key.
  3. You submit the public key, challenge, and signature to the auth server.
  4. The server verifies your signature and issues a JWT.

For security reasons, you can only use a challenge once. For every new authentication request, you must generate a new challenge and create a new signature.

Step 1: Create and sign a challenge

Create a random string (such as a UUID) and sign it with your private key.

# Create a random challenge (use -n to prevent trailing newline)
echo -n "$(uuidgen)" > challenge.txt

# Sign the challenge
openssl dgst -sha256 -sign privateKey.pem challenge.txt | base64

Step 2: Request a token

Submit your credentials to the token endpoint:

POST https://openid.{environment_URL}/realms/Metaco/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=password
&client_id=YOUR_CLIENT_ID
&signature=<base64_encoded_signature>
&challenge=<challenge_string>
&public_key=<base64_encoded_public_key>

Some API clients can accept the OIDC discovery URL instead of the direct token endpoint. In that case, the client reads the token_endpoint field from the discovery document and sends the token request there.

Step 3: Receive and store the token

The server returns a JWT:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 14400
}

The expires_in value is in seconds (14400 = 4 hours).

Step 4: Use the token

Include the JWT in the Authorization header of all API requests:

GET /v1/domains
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Obtain a token for a service caller

System-signed intents let an authorized service submit proposals authenticated by a JWT alone, without a user-bound signing key. Instead of the challenge-response flow above, a service caller authenticates as a confidential client at your identity provider (IdP) and receives a token through the OAuth 2.0 client_credentials grant.

The platform keeps no separate identity store for service callers. It trusts a correctly shaped, IdP-signed token. To provision a service account at your IdP:

  1. Create a confidential OAuth 2.0 client with the client_credentials grant enabled. Disable standard, direct-access-grant, and implicit flows.
  2. Set the subject (sub) to a non-UUID string of 1-256 characters, such as omnibus-svc. A service caller is identified by a sub that is not a UUID; a UUID-shaped subject is treated as a user login.
  3. Add a custody_roles array claim whose values match the role names used in your SystemSigned policy conditions. A token without this claim resolves to an empty roles array at the policy engine, so the caller matches only policies that require no role. The claim name is configurable through silo.gateway.authentication.roles-claim; the default is custody_roles.
  4. Add an audience mapper that emits the audience your deployment expects with silo.gateway.authentication.expected-jwt-audience. A token without the expected audience is rejected.

In Keycloak, emit the roles claim with a client protocol mapper. For a service that always carries the same roles, add a Hardcoded claim mapper on the client's dedicated scopes:

FieldValue
Mapper typeHardcoded claim
Namecustody_roles
Token Claim Namecustody_roles (or your configured roles claim)
Claim valueJSON array of role names, such as ["gas-station-service"]
Claim JSON TypeJSON
Add to access tokenOn

The policy engine reads the roles from the access token, so Add to access token must be on. Issue each service only the roles it needs: a shared value such as ["omnibus-service","gas-station-service"] grants every listed role to every token the client issues, so prefer a separate client per service.

A client_credentials grant returns an access token whose claims include sub, aud, and, when configured, custody_roles. Use it as the bearer token when you submit a system-signed intent.

The platform trusts the service-caller roles claim verbatim and keeps no server-side role store for service callers. Issue roles that match SystemSigned policy conditions only to authorized service accounts, and apply the same change control to IdP role assignments as to the policies themselves. For defense in depth, write policy conditions that combine context.submitter.custodyRoles.includes(...) with a check on context.submitter.subject, so a single leaked or mis-issued role is not sufficient.

Common token issues:

SymptomCauseResolution
Token rejected for audienceThe token aud does not match silo.gateway.authentication.expected-jwt-audience.Configure the IdP audience mapper to emit the expected audience.
Caller treated as a user, not a service callerThe sub is UUID-shaped, so it is read as a user login.Issue a non-UUID sub.
Policy does not match the callerThe token subject or roles do not satisfy the policy condition.Confirm the IdP emits the expected sub and, if the policy requires a role, the configured roles claim. Check that the policy tests the correct context.submitter.subject or context.submitter.custodyRoles value.

Send read-only requests

Use GET requests to query information about your Ripple Custody environment. List endpoints return multiple entities, and detail endpoints return a single entity.

List queries

List queries return instances of a particular entity. For example:

GET /v1/domains
Authorization: Bearer <your_jwt_token>

List responses include:

FieldDescription
itemsEntities returned by the query.
countNumber of entities returned.
currentStartingAfterID of the last item returned by the previous query.
nextStartingAfterID of the last item returned by the current query. Use this value as startingAfter on the next request to page through results.

By default, list queries return up to 100 items, which is also the maximum page size.

Common query parameters include:

ParameterDescription
limitMaximum number of results to return.
startingAfterID from which to start reporting results, used for pagination.
sortByProperty to use for sorting.
sortOrderSort direction, either ascending ASC or descending DESC.
lockReturn only entities matching the specified lock status: Unlocked, Locked, or Archived. Archived items are permanently deactivated and cannot be reactivated.
aliasReturn only entities with the specified alias.

Some entities support additional filters. For endpoint-specific parameters, see the API reference.

Detail queries

Detail queries return one entity. For example:

GET /v1/domains/{domainId}
Authorization: Bearer <your_jwt_token>

Detail responses contain a data object and a signature string. The data object includes entity-specific properties and common fields such as id, alias, domainId, metadata, and lock where applicable.

The id and metadata.revision fields are part of the anti-replay model. For more information, see Anti-rewind file.


Signing intents (state mutation operations)

Most state mutation operations require a digital signature in addition to the JWT. These operations are called "intents" and include:

  • Creating entities (users, accounts, domains)
  • Updating entities
  • Approving or rejecting intents
  • Creating transactions

For user-signed intents, the signature proves that you explicitly authorized the specific action and creates an immutable audit trail.

System-signed intent submission is the exception. A service caller can submit a SystemSigned proposal without a client-side payload signature when the deployment and policies allow it. The platform signs the proposal internally before policy evaluation. Approving or rejecting intents still requires a user-signed request.

How to sign an intent

  1. Prepare the request object: Build the request object containing author, expiry, target domain, and payload (see User-signed intent request structure below)

  2. Canonicalize: Convert the request object to canonical JSON format (see Canonicalization rules below)

  3. Sign: Hash the canonical JSON with SHA-256 and sign with your private key

  4. Encode the signature: Serialize the signature in DER format with Base64 encoding

  5. Add to request body: Include the signature in the signature field of the request body (not a header)

User-signed intent request structure

User-signed intent proposals follow this structure. The signature is computed over the canonicalized request object only:

{
  "request": {
    "author": {
      "id": "<your-user-uuid>",
      "domainId": "<your-domain-uuid>"
    },
    "expiryAt": "2026-02-25T12:00:00.000Z",
    "targetDomainId": "<target-domain-uuid>",
    "id": "<new-intent-uuid>",
    "customProperties": {},
    "payload": {
      "type": "v0_CreatePolicy",
      "...": "payload-specific fields"
    },
    "description": "Human-readable description",
    "type": "Propose"
  },
  "signature": "<base64-signature-of-canonicalized-request-object>"
}
Required fields

The customProperties field is mandatory at the request level. Use an empty object {} if you have no custom properties. Missing this field causes a validation error.

System-signed intent request structure

System-signed proposal submission is API-only. Use it only when system-signed intent processing is configured and a matching policy with intentOrigin: "SystemSigned" allows the service caller to submit the intent type.

Security note

Because system-signed intents carry no user signature, the security of the operation rests on two controls: the roles and subject your identity provider issues to the service caller, and the SystemSigned policy that admits the proposal. Treat both with the same rigor as privileged access. Approving and rejecting intents still requires a user signature.

The request body contains request.type: "SystemSigned", omits request.author, and omits the top-level signature field:

{
  "request": {
    "type": "SystemSigned",
    "targetDomainId": "<target-domain-uuid>",
    "id": "<new-intent-uuid>",
    "expiryAt": "2026-02-25T12:00:00.000Z",
    "customProperties": {},
    "payload": {
      "type": "v0_CreateTransactionOrder",
      "...": "payload-specific fields"
    },
    "description": "Service-submitted transaction order"
  }
}

The service caller still sends a bearer token:

Authorization: Bearer <service_jwt_token>

For service callers, policy conditions can use the service submitter context, such as context.submitter.subject and context.submitter.custodyRoles. The default JWT claim used for service roles is custody_roles, unless your deployment configures a different roles claim. If the token does not include this claim, policy evaluation resolves context.submitter.custodyRoles as an empty array.

Service-caller identity: submitter, author, and requester

A system-signed proposal has no user author. The service caller's identity, the JWT sub, appears under three names, depending on where you read it:

NameContextWhere it appears
context.submitter.subjectPolicy evaluationIn a SystemSigned policy condition, with context.submitter.custodyRoles.
author.subjectThe stored intent recorddetails.author in an intent response, for example Get intent or List intents.
requester.subjectThe stored request-state recordrequester in a request-state response.

For a service caller, all three carry the same value: the JWT sub, which is a non-UUID service subject. For a user-signed intent, the equivalent identity is the user's id and domainId (context.submitter.reference, author.id / author.domainId, and requester.id / requester.domainId). The author and requester objects use a discriminated shape: a user identity carries id and domainId, and a service identity carries subject. A client that parses these responses should branch on which fields are present.

For the end-to-end procedure, see Submit a system-signed intent with the API.

Canonicalization rules

Canonical JSON ensures that identical data always produces identical signatures, regardless of how the JSON was originally formatted. The rules are:

RuleDescriptionExample
Remove whitespaceNo spaces, tabs, or newlines{"a":"b"} not { "a": "b" }
Sort keys alphabeticallyAll object keys sorted A-Z, recursively through nested objects{"a":1,"b":2} not {"b":2,"a":1}
Omit null valuesDo not include fields with null values in your JSON{"a":1} not {"a":1,"b":null}
Preserve array orderArrays of objects keep their original order—do not sort array elements[{"z":1},{"a":2}] stays as-is
No trailing newlineThe canonicalized output must not end with a newline characterUse jq -j flag
Null values cause signature failures

Do not include fields with null values in your JSON. The server removes null values during canonicalization, so if your JSON contains "field": null, your canonicalized version will differ from the server's, causing InvalidSignatureError.

Wrong: {"intentTypes": null, "customProperties": {}}

Correct: {"customProperties": {}} (omit the null field entirely)

Example transformation:

Original JSON:

{
  "type": "v0_CreateTransactionOrder",
  "note": null,
  "details": {
    "amount": "100",
    "asset": "USD"
  }
}

Canonical JSON:

{"details":{"amount":"100","asset":"USD"},"type":"v0_CreateTransactionOrder"}

Example: Sign an intent payload

Use jq to canonicalize the JSON before signing:

cat intent.json | jq -j -S -c > intent_sorted.json

jq flags explained:

  • -S — Sort object keys alphabetically
  • -c — Compact output (removes whitespace)
  • -j — No trailing newline (critical—a newline changes the hash)

The -j flag is essential. Without it, jq adds a trailing newline that becomes part of the hashed content, causing signature verification to fail.

# Canonicalize the JSON payload
cat intent.json | jq -j -S -c > intent_sorted.json

# Sign the sorted payload
openssl dgst -sha256 -sign privateKey.pem intent_sorted.json | base64 -b0

Complete example: Create a policy

This example shows the full flow for submitting a signed intent to create a policy.

1. Create the request object (request.json):

{
  "author": {
    "id": "461dac3d-55c6-4ce8-9ce2-dfb97cfbe279",
    "domainId": "67241e7d-f345-459e-9293-475693c45d85"
  },
  "expiryAt": "2026-02-25T12:00:00.000Z",
  "targetDomainId": "67241e7d-f345-459e-9293-475693c45d85",
  "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  "customProperties": {},
  "payload": {
    "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
    "alias": "Daily Operations Policy",
    "rank": 100,
    "intentTypes": ["v0_CreateTransferOrder"],
    "scope": "Self",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "expression": "true",
      "type": "Expression"
    },
    "workflow": [{"role": "admin", "quorum": 1, "type": "RoleQuorum"}],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  },
  "description": "Create a policy for daily operations",
  "type": "Propose"
}

2. Canonicalize and sign:

# Canonicalize (sort keys, compact, no trailing newline)
cat request.json | jq -j -S -c > request_canonical.json

# Sign (P-256/secp256k1 on macOS)
SIGNATURE=$(openssl dgst -sha256 -sign privateKey.pem request_canonical.json | base64 | tr -d '\n')

3. Build and submit the full request:

# Build the full request body with signature
REQUEST=$(cat request.json)
cat > intent_body.json << EOF
{
  "request": $REQUEST,
  "signature": "$SIGNATURE"
}
EOF

# Submit to the API
curl -X POST "https://api.example.com/v1/intents" \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d @intent_body.json

Expected response:

{
  "requestId": "8737c2a2-e8e1-46bf-a2a4-9918437f4a29"
}

The signature is computed from the canonicalized request object. The server canonicalizes your request and verifies the signature matches. Any difference in canonicalization (including null values or trailing newlines) will cause signature verification to fail.


Approving intents

Approving an intent uses a different request structure than proposing. For complete details on the approval workflow, see Approve and reject intents.

Approval request structure

FieldProposalApproval
idRequired (new intent UUID)Not used
intentIdNot usedRequired (existing intent UUID)
payloadRequired (the operation details)Not used
proposalSignatureNot usedRequired (from original proposal)
expiryAtRequiredRequired
type"Propose""Approve"

Example approval request:

{
  "request": {
    "author": {
      "id": "828b554c-c9c9-11eb-a79b-dcfb48cfb3cb",
      "domainId": "455ad43e-cdd9-11eb-8465-dcfb48cfb3cb"
    },
    "targetDomainId": "455ad43e-cdd9-11eb-8465-dcfb48cfb3cb",
    "expiryAt": "2026-02-26T12:00:00.000Z",
    "intentId": "846b5c3c-27e3-4146-ab18-ce6732624d35",
    "proposalSignature": "<signature-from-original-proposal>",
    "approvalReason": "Approved after review",
    "type": "Approve"
  },
  "signature": "<base64-signature-of-canonicalized-request-object>"
}

The proposalSignature is retrieved from the original intent's data.details.proposalSignature field.


JWT and intent author identity

Critical: user JWT must match user-signed intent author

The JWT token identifies the API caller. When submitting a user-signed proposal, approval, or rejection, the author.id in the request must correspond to the user identified by the JWT.

You cannot use one user's JWT to submit intents on behalf of another user. This is enforced at the API layer.

Why this matters

The two-layer authentication model enforces:

  1. JWT identifies the caller — The server extracts user identity from the JWT token
  2. Signature proves authorization — The digital signature proves the author explicitly authorized the action
  3. Both must be consistent — The JWT user and the intent author must be the same person

This prevents delegation attacks where a compromised JWT could be used to forge actions for other users.

System-signed proposals do not include author.id. The service caller is recorded by service subject in intent and request-state responses. For details, see Service-caller identity.


Troubleshooting

ErrorCauseResolution
InvalidJwtErrorJWT token is expired or malformedObtain a new JWT token
InvalidSignatureErrorSignature doesn't match the payloadVerify payload is properly canonicalized (sorted, no whitespace, no nulls)
InvalidSignatureErrorWrong private key usedEnsure you're signing with the private key that matches your registered public key
InvalidSignatureErrorTrailing newline in challengeUse echo -n when creating the challenge to prevent trailing newlines
PermissionDeniedErrorJWT user doesn't match intent authorEnsure the JWT token belongs to the user specified in author.id
PermissionDeniedErrorUser lacks permission for the operationCheck your user roles and domain permissions with your administrator
PermissionDeniedErrorUser not in domain's readAccessEnsure your roles are included in the domain's readAccess permissions
InvalidIntentError: This user has already signedProposer attempting to approve own intentA different user must approve; proposers cannot self-approve (dual control)
SystemSignedDisabled or SystemSignedProcessingDisabledSystem-signed intent processing is not activeConfirm the Gateway public key is registered in Notary, NOTARY_SYSTEM_SIGNED_INTENTS_ENABLED is set to enabled: true, and a matching SystemSigned policy exists
MissingSystemSignedFieldsSystem-signed request is missing required fieldsInclude targetDomainId, id, expiryAt, payload, customProperties, and type: "SystemSigned" inside request
UnexpectedSystemSignedFieldsSystem-signed request includes fields that are not allowedRemove request.author and top-level signature from the system-signed request body
401 UnauthorizedMissing or invalid Authorization headerEnsure the JWT is included as Bearer <token>