Agent Integration Guide
Six steps to make every x402 request your agent sends carry an L1→L3 Verifiable Intent chain and automatically receive the XRPL Facilitator risk service.
Prerequisites
- A funded XRPL wallet (seed) to pay from.
- The x402-xrpl SDK — Python or TypeScript.
- A Trustline Portal account (Steps 1–3 below).
- New to plain x402? Read the Quickstart first; VI is additive on top of it. See also the Verifiable Intent concept page.
pip install x402-xrpl xrpl requests python-dotenv # (or) npm install x402-xrpl xrplStep 1 · Register on the Trustline Portal
Create an account and organization at portal.t54.ai . Verify your email and sign in. Your organization is the identity that issues L1 credentials and that owns the transactions you will later see in the Portal.
Step 2 · Enable Verifiable Intent
In the Portal, open the Integrations page and Enable Verifiable Intent for your organization. Enabling VI provisions a dedicated issuer signing key for your org — this is what Trustline uses to sign your L1 credentials, and what the verifier trusts when your chain is checked.
Step 3 · Get your Issuer Secret
The first time you enable VI, the Portal shows your Issuer Secret exactly once. Copy it immediately and store it as a deployment secret.
The secret is shown once and is stored only as a hash — Trustline cannot show it again. If you lose it, use Rotate on the Integrations page to generate a new one (the old secret stops working). Treat it like a password: never commit it or expose it client-side.
The Issuer Secret is separate from your API key: the Issuer Secret authenticates L1 issuance (/issue-l1); API keys authenticate the Portal/other APIs. For VI you need the Issuer Secret.
Step 4 · Configure the Issuer Secret in your backend
Put the secret in your server-side environment as TRUSTLINE_VI_ISSUER_SHARED_SECRET. Your backend sends it as the X-VI-Issuer-Secret header on POST {TRUSTLINE_API_URL}/api/v1/validation/issue-l1. Keep this on the server only — it must never reach the browser/agent client.
# Trustline (production) TRUSTLINE_API_URL=https://api.trustline.t54.ai # Your Issuer Secret — copied once from the Portal Integrations page (store securely) TRUSTLINE_VI_ISSUER_SHARED_SECRET=vi_sec_... # The protected resource you are paying, and your XRPL payer wallet RESOURCE_URL=https://your-merchant.example/echo/resource XRPL_PAYER_SEED=sEd... # your funded XRPL wallet seed XRPL_RPC_URL=https://s1.ripple.com:51234 XRPL_NETWORK=xrpl:0 # mainnet
For multi-tenant or sample/staging setups you can instead mint a scoped TRUSTLINE_VI_ISSUER_TOKEN (sent as X-VI-Issuer-Token) that resolves to the same org key without exposing the parent secret.
Step 5 · Sign the L1 → L3 chain
You generate two P-256 key pairs — an owner key (signs L2) and an agent key (signs L3). The SDK's RemoteIssuerProvider orchestrates the whole chain: it calls your issue_l1 hook for L1, signs the owner delegation (L2) and the agent final action (L3), and binds each link by hash. You never hand-assemble SD-JWTs.
The example below is a complete, runnable client. It mirrors the production Sample Agent shipped with the Facilitator (the Sample Agent splits the same logic across a server route that holds the Issuer Secret and a browser client that holds the keys — see the note after the code).
Python
import os
import uuid
from decimal import Decimal
import requests
from dotenv import load_dotenv
from xrpl.wallet import Wallet
from x402_xrpl.clients.requests import x402_purchase
from x402_xrpl.vi import (
RemoteIssuerProvider,
generate_ec_private_jwk,
public_jwk_from_private_jwk,
)
load_dotenv()
TRUSTLINE_URL = os.environ["TRUSTLINE_API_URL"].rstrip("/")
ISSUER_SECRET = os.environ["TRUSTLINE_VI_ISSUER_SHARED_SECRET"] # from the Portal, shown once
# --- Step 5a: your backend mints L1 by calling Trustline with your Issuer Secret ---
def issue_l1(request, _input_data):
resp = requests.post(
f"{TRUSTLINE_URL}/api/v1/validation/issue-l1",
headers={
"X-VI-Issuer-Secret": ISSUER_SECRET,
"Idempotency-Key": f"l1-{uuid.uuid4().hex}",
},
json=dict(request),
timeout=60,
)
resp.raise_for_status()
return resp.json() # -> { "l1Credential": { "sdJwt": ... } }
# --- Step 5b: build a provider that signs L2 (owner) + L3 (agent) locally ---
def build_provider(payer_address):
owner = generate_ec_private_jwk(kid=f"owner-{uuid.uuid4().hex[:8]}")
agent = generate_ec_private_jwk(kid=f"agent-{uuid.uuid4().hex[:8]}")
owner_public = public_jwk_from_private_jwk(owner)
def issue_request(input_data):
req = input_data["selectedPaymentRequirements"]
drops = str(req.get("maxAmountRequired") or req.get("amount") or "0")
ceiling = str(Decimal(drops) / Decimal(1_000_000)) # drops -> XRP
return {
"subject": f"did:pkh:xrpl:{payer_address}",
"ownerPublicJwk": owner_public,
"allowedChains": ["xrpl"],
"allowedAssets": ["XRP"],
"spendingCeiling": ceiling,
}
def constraints(input_data):
req = input_data["selectedPaymentRequirements"]
drops = str(req.get("maxAmountRequired") or req.get("amount") or "0")
return {
"per_transaction_max": str(Decimal(drops) / Decimal(1_000_000)),
"allowed_chains": ["xrpl"],
"allowed_assets": ["XRP"],
}
return RemoteIssuerProvider(
issuer=issue_l1,
issue_request=issue_request,
owner_private_jwk=owner,
agent_private_jwk=agent,
constraints=constraints,
)
# --- Step 6: pay — the SDK attaches the L1->L3 chain and submits to the Facilitator ---
def main():
wallet = Wallet.from_seed(os.environ["XRPL_PAYER_SEED"])
result = x402_purchase(
os.environ["RESOURCE_URL"],
wallet=wallet,
rpc_url=os.environ["XRPL_RPC_URL"],
network_filter=os.environ["XRPL_NETWORK"],
scheme_filter="exact",
verifiable_intent_provider=build_provider(wallet.classic_address),
confirmation_mode="auto",
user_intent="Pay the protected resource with a Verifiable Intent chain.",
)
print("status:", result.status) # -> "success"
print("response:", result.payment_response) # x402Secure receipt + settled tx
if __name__ == "__main__":
main()TypeScript
import { Wallet } from "xrpl";
// The VI chain helpers are exported from the package root.
import { x402Purchase, RemoteIssuerProvider } from "x402-xrpl";
const TRUSTLINE_URL = process.env.TRUSTLINE_API_URL!;
const ISSUER_SECRET = process.env.TRUSTLINE_VI_ISSUER_SHARED_SECRET!;
// The TS SDK doesn't ship a key generator — make P-256 JWKs with WebCrypto
// (global `crypto` on Node 18+). The SDK derives the public halves itself.
async function generateP256PrivateJwk(kid: string) {
const { privateKey } = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const jwk = await crypto.subtle.exportKey("jwk", privateKey);
return { ...jwk, kid };
}
// Step 5a: mint L1 by calling Trustline with your Issuer Secret (server-side).
async function issueL1(request: Record<string, unknown>) {
const resp = await fetch(`${TRUSTLINE_URL}/api/v1/validation/issue-l1`, {
method: "POST",
headers: {
"content-type": "application/json",
"X-VI-Issuer-Secret": ISSUER_SECRET,
"Idempotency-Key": `l1-${crypto.randomUUID()}`,
},
body: JSON.stringify(request),
});
if (!resp.ok) throw new Error(`issue-l1 failed: ${resp.status}`);
return await resp.json(); // -> { l1Credential: { sdJwt } }
}
async function main() {
const wallet = Wallet.fromSeed(process.env.XRPL_PAYER_SEED!);
// Step 5b: owner key signs L2, agent key signs L3.
const owner = await generateP256PrivateJwk(`owner-${crypto.randomUUID().slice(0, 8)}`);
const agent = await generateP256PrivateJwk(`agent-${crypto.randomUUID().slice(0, 8)}`);
const provider = new RemoteIssuerProvider({
// The provider injects ownerPublicJwk into the request before calling issuer.
issuer: (request) => issueL1(request as Record<string, unknown>),
issueRequest: (input) => {
const req = input.selectedPaymentRequirements as Record<string, string>;
const drops = req.maxAmountRequired ?? req.amount ?? "0";
const ceiling = (Number(drops) / 1_000_000).toString();
return {
subject: `did:pkh:xrpl:${wallet.classicAddress}`,
allowedChains: ["xrpl"],
allowedAssets: ["XRP"],
spendingCeiling: ceiling,
};
},
ownerPrivateJwk: owner,
agentPrivateJwk: agent,
constraints: () => ({ allowed_chains: ["xrpl"], allowed_assets: ["XRP"] }),
});
// Step 6: pay — the SDK builds + attaches the L1->L3 chain and submits.
const result = await x402Purchase({
url: process.env.RESOURCE_URL!,
wallet,
network: process.env.XRPL_NETWORK ?? "xrpl:0", // ws endpoint is derived from the network
schemeFilter: "exact",
verifiableIntentProvider: provider,
confirmationMode: "auto",
});
console.log(result.status, result.paymentResponse);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});Sample Agent reference. The Facilitator ships a full Next.js Sample Agent that implements exactly this flow with a production-grade split: the server route /api/vi/issue-l1 holds the Issuer Secret and calls Trustline, while the browser derives the owner key from the wallet signature, signs L2/L3, and pays. The owner/agent private keys never leave the client; the Issuer Secret never leaves the server. The single-file example above collapses that split into one process for clarity — use the split architecture in production so the Issuer Secret stays server-side.
Step 6 · Bundle into the transaction & submit
This is automatic. When you pass verifiable_intent_provider to x402_purchase (Python) / verifiableIntentProvider to x402Purchase (TS), the SDK:
- requests the resource and receives the
402quote with itsx402Securechallenge; - calls your provider to build the L1→L3 chain bound to that exact invoice;
- presigns the XRPL
Paymentand embeds the chain underextensions.x402Secure.verifiableIntentChain; - retries with the
PAYMENT-SIGNATUREheader → the Facilitator verifies + forwards to Trustline → settles on XRPL if the risk decision allows.
See the chain payload shape on the concept page for exactly what ends up in the header.
Run it
python agent.py # (or) npx tsx agent.tsOn success, result.status is "success" and the PAYMENT-RESPONSE carries an x402Secure receipt (a decision_id, a receipt id, and the settled XRPL transaction hash). The same call is now visible in your Portal under your organization's production environment.
Troubleshooting
VI_ISSUER_NOT_ALLOWLISTED— the org that issued L1 must be the org whose credentials verify the chain. For a direct SDK integration this is automatically your org (you issue L1 and Trustline verifies under the same org), so the usual cause is enabling VI for a different org than the Issuer Secret belongs to, or a secret that was rotated. Re-copy the current Issuer Secret from the Integrations page.- Verified chain but the payment is still denied — the chain proves intent, but the risk engine makes the final call. A wallet flagged as elevated-risk (e.g. rapid repeated payments from one test wallet) is denied even with a valid chain. Test with a clean/funded wallet and realistic spacing.
- 402 instead of success on the paid retry — confirm the merchant's 402 quote actually carries an
extensions.x402Securepolicy (VI is only evaluated for x402-Secure-enabled resources) and that yourXRPL_NETWORKmatches the quote.
You're done
Every x402 request from your agent now carries an L1→L3 chain and is gated by the Facilitator risk service. Revisit the concept page any time for the trust model.
Back to the Verifiable Intent overview