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
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:
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:
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:
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":
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:
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 burnsverifyCapability 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
verifyRequestto bind the request to that key). - Expired, action mismatch, or a
maxUsdabove the server ceiling. - Replayed — the
jtiburns 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 theak_…key id the agent uses as its RFC 9421keyid. 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.GETlists your keys;DELETErevokes 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 returns401before any hold is touched. A spend outside the key's policy returns403with the reason (per_tx_limit_exceeded,daily_budget_exceeded,category_not_allowed) — and both allows and denies land in the audit trail.
# 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
npm i @furlpay/agent-trust