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 is a developer library for agent identity, signed spending mandates, replay-aware intent tokens and request signatures. It supplies controls for an integration to evaluate; it does not itself execute or settle payments.
Library support does not enable a FurlPay payment rail
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 an intent token is judged
Verification is a strict decision tree — the first failed check refuses the token. A valid token is evidence for an integrating service; it is not a payment, booking confirmation or settlement receipt:
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: "prepare", amountUsd: 180, purpose: "example only" });
const headers = signRequest({
method: "POST",
url: "https://merchant.example/api/agent-actions",
body,
keyId: agent.keyId,
privateKeyPem: agent.privateKeyPem,
});
await fetch("https://merchant.example/api/agent-actions", {
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://your-tool-server.example", // valid at this server only
action: "prepare:payment",
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://your-tool-server.example", // must be THIS server
action: "prepare:payment",
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).
FurlPay agent route boundaries
FurlPay exposes agent-key management and an escrow-hold route in the codebase. These routes do not establish a live card or payment product, and the escrow route can return 503 service_unavailable when durable hold storage is not configured:
POST /api/agents/keys— register an agent's Ed25519 public key (SPKI PEM). Returns a key identifier for the agent's public key. When registered to an account, the key can carry a spend policy with a transaction limit, daily budget and category allowlist. Requests to guarded endpoints may be checked against that policy.GETlists owned keys;DELETErevokes a key. Check each route response for the result.POST /api/escrow— an implementation of create/capture/release hold operations. It is not a card authorization or an on-chain settlement service. Its backing store must be durable for the route to work; check the response and do not promise a hold from a successful signature alone.
Package and integration status
