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 xrpl

Step 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:

  1. requests the resource and receives the 402 quote with its x402Secure challenge;
  2. calls your provider to build the L1→L3 chain bound to that exact invoice;
  3. presigns the XRPL Payment and embeds the chain under extensions.x402Secure.verifiableIntentChain;
  4. retries with the PAYMENT-SIGNATURE header → 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.ts

On 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.x402Secure policy (VI is only evaluated for x402-Secure-enabled resources) and that your XRPL_NETWORK matches 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