Furlpay Docs
Open App

Payments

Agent Trust & Mandates (TAP)

An AI agent that can spend money needs more than a payment rail — it needs provable permission. @furlpay/agent-trust implements the trust chain behind Visa's Trusted Agent Protocol (production-live since July 2026): every agent-initiated payment carries cryptographic proof of who the agent is, that the user consented, and what exactly it is allowed to do.

The trust chain

1 · Identity
generateKeypair()Ed25519 · uk_ / ak_ SPKI fingerprints
2 · Consent
issueMandate()user signs: cap · MCC · expiry · single-use
3 · Intent
createBookingToken()agent signs ONE amount/mcc/source + nonce
4 · Verify
trust.verifyBookingToken()chain + constraints + replay → credential
User key signs the mandate → mandate names one agent key → agent key signs one exact intent → verifier checks the whole chain before any credential is issued.

The design has one central invariant: an agent can never widen its own permissions. Only the user's key can sign a mandate, and the verifier refuses any execution whose parameters differ — even by one cent or one merchant-category code — from what the agent signed.

How a booking token is judged

Verification is a strict decision tree — the first failed check refuses the execution, and nothing later can override it:

Rendering diagram…
Mandate evaluation: every gate must pass before a single-use credential is issued.

User-signed spend mandates

A mandate binds one agent key to explicit constraints. It is a base64url payload signed by the user's Ed25519 key — tamper with any field and the signature fails:

typescript
import { AgentTrust, generateKeypair, issueMandate } from "@furlpay/agent-trust";

const user  = generateKeypair("user");   // uk_…
const agent = generateKeypair("agent");  // ak_…

const mandate = issueMandate({
  userPrivateKeyPem: user.privateKeyPem,
  userPublicKeyPem:  user.publicKeyPem,
  agentKeyId: agent.keyId,               // this ONE agent, no other
  constraints: {
    maxTotalUsd: 500,                    // spend allowance across all bookings
    maxPerBookingUsd: 400,               // per-transaction ceiling
    mccAllowlist: ["7011", "4511"],      // lodging + airlines only
    sourceAllowlist: ["travala", "legacy"],
    expiresAt: new Date(Date.now() + 7 * 864e5).toISOString(),
    singleUse: false,                    // true → consumed by first booking
  },
});

Booking tokens: one signed intent

For each payment the agent wraps the mandate around one concrete intent and signs it with its own key. The token carries a fresh nonce, so it clears verification exactly once:

typescript
import { createBookingToken } from "@furlpay/agent-trust";

const token = createBookingToken({
  mandate,
  agentPrivateKeyPem: agent.privateKeyPem,
  agentPublicKeyPem:  agent.publicKeyPem,
  intent: { amountUsd: 320, source: "legacy", mcc: "7011", merchant: "Hotel CDMX" },
});

// Rail side (MCP server, API, x402 facilitator):
const trust = new AgentTrust();
trust.registerUser(user.publicKeyPem);
trust.registerAgent(agent.publicKeyPem);

const decision = await trust.verifyBookingToken(token, { amountUsd: 320, mcc: "7011", source: "legacy" });
// → { ok: true, agentKeyId: "ak_…", mandateId: "mnd_…", remainingUsd: 180 }

Verification is stateful and fails closed. In order, the verifier checks:

  • Agent identity — the agent key is registered and really signed this intent.
  • User consent — the mandate verifies against a registered user key and names this agent.
  • Claims equality — the amount/mcc/source the rail is about to execute equal the signed intent byte-for-byte.
  • Constraints — total cap (budgets decrement on success), per-booking cap, MCC and source allowlists, expiry, single-use consumption.
  • Replay — the nonce is burned atomically; a replayed token is rejected and reported as such.

RFC 9421 HTTP signatures

The same identity keys authenticate raw HTTP calls — TAP's transport binding. signRequest() produces Signature-Input, Signature and Content-Digest headers over @method, @target-uri and the body digest, with tag="agent-payment":

typescript
import { signRequest } from "@furlpay/agent-trust";

const body = JSON.stringify({ action: "create", cardId: "card_x", merchant: "Estadio Azteca", amount: 180 });
const headers = signRequest({
  method: "POST",
  url: "https://furlpay.com/api/escrow",
  body,
  keyId: agent.keyId,
  privateKeyPem: agent.privateKeyPem,
});

await fetch("https://furlpay.com/api/escrow", {
  method: "POST",
  headers: { "content-type": "application/json", ...headers },
  body,
});

Capability tokens: per-call, one server, once

A mandate says what an agent may spend; a capability says which tool-server it may call, for how long, and exactly once. It is the third tier of the multi-tier session model — a short-lived, audience-restricted token the agent mints per call, so an intercepted token can't be replayed by another holder or against another server:

typescript
import { mintCapability } from "@furlpay/agent-trust";

// Agent mints a single-use capability for ONE tool-server call:
const cap = mintCapability({
  agentPrivateKeyPem: agent.privateKeyPem,
  agentPublicKeyPem:  agent.publicKeyPem,
  audience: "https://travel-mcp.furlpay.com",   // valid at this server only
  action:   "pay:travala",
  maxUsd:   400,
  ttlSeconds: 120,                               // minted per call, not held
});

// Tool-server side — accepts the token only when every check passes:
const d = await trust.verifyCapability(cap, {
  audience: "https://travel-mcp.furlpay.com",    // must be THIS server
  action:   "pay:travala",
  presenterKeyId: verified.keyId,                // PoP: same key that minted it
  maxUsd:   1000,                                // server ceiling
});
// → { ok: true, agentKeyId, jti, audience, action, maxUsd } — and the jti burns

verifyCapability rejects, with a specific reason, any token that:

  • Wrong audience — minted for a different tool-server (no cross-server replay).
  • Wrong presenter — presented by a key other than the one that minted it (proof-of-possession; pair with verifyRequest to bind the request to that key).
  • Expired, action mismatch, or a maxUsd above the server ceiling.
  • Replayed — the jti burns on first success via the same nonce store as bookings.

Together the tiers form one chain: mandate (user consent + budget) → request signature (who is calling now) → capability (this call, this server, once).

Live in the Furlpay API

Two production routes speak this protocol today:

  • POST /api/agents/keys — register an agent's Ed25519 public key (SPKI PEM). Returns the ak_… key id the agent uses as its RFC 9421 keyid. Registered with a session, the key is bound to your account and carries a spend policy: per-transaction limit, daily budget, and a category allowlist. The agent then acts on your account — never anyone else's — and every spend is checked against the policy first. GET lists your keys; DELETE revokes one, effective immediately on every instance.
  • POST /api/escrow — signed agent requests create/capture/release pre-auth holds without a session cookie. A tampered body (content-digest mismatch), an unknown key, a revoked key, or an expired signature returns 401 before any hold is touched. A spend outside the key's policy returns 403 with the reason (per_tx_limit_exceeded, daily_budget_exceeded, category_not_allowed) — and both allows and denies land in the audit trail.
bash
# 1. Register the agent key under your account, with a spend policy
curl -X POST https://furlpay.com/api/agents/keys \
  -H "content-type: application/json" -b "furlpay_session=…" \
  -d '{"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…",
       "name": "travel-booker",
       "policy": {"perTxLimitUsd": 400, "dailyBudgetUsd": 1000, "allowedCategories": ["escrow", "travel"]}}'
# → { "keyId": "ak_tWLNNMkyM5S6PM…", "owned": true, "key": { "policy": { … }, "status": "active" } }

# 2. Signed escrow hold (headers from signRequest)
curl -X POST https://furlpay.com/api/escrow \
  -H "content-type: application/json" \
  -H "content-digest: sha-256=:…:" \
  -H "signature-input: furlpay=(\"@method\" \"@target-uri\" \"content-digest\");…" \
  -H "signature: furlpay=:…:" \
  -d '{"action":"create","cardId":"card_x","merchant":"Estadio Azteca","amount":180}'
# → { "hold": { … }, "agentKeyId": "ak_tWLNNMkyM5S6PM…" }

# 3. Over policy? Denied before any money moves, with the reason:
# → 403 { "error": "agent_spend_denied", "reason": "daily_budget_exceeded", "policy": { … } }

# 4. Revoke the key — immediate everywhere
curl -X DELETE https://furlpay.com/api/agents/keys \
  -H "content-type: application/json" -b "furlpay_session=…" \
  -d '{"keyId": "ak_tWLNNMkyM5S6PM…"}'

Why this matters in 2026

Visa's TAP executed its first live agentic transactions at European merchants in July 2026; Mastercard Agent Pay and the x402 Foundation (Linux Foundation — Visa, Mastercard, Amex, Stripe members) all converge on the same idea: signed agent identity + user mandates. Furlpay ships an MIT-licensed implementation you can run today — zero dependencies, Ed25519 via node:crypto, 30-test suite (mandates, capability tokens, RFC 9421). npm i @furlpay/agent-trust