HomeDocumentation

Documentation

Everything you need to add payments to your AI agents. From first API call to production.

Quickstart~2 min

Get a wallet created and a transfer sent in four steps. You will need a txn.dev account and an API key from the dashboard.

1Install the SDK

Shell
npm install @txn-dev/sdk

2Initialize the client

TypeScript
import { Txn } from "@txn-dev/sdk"
 
const txn = new Txn("txn_live_...")

3Create a wallet

TypeScript
const wallet = await txn.wallets.create({
name: "my-agent",
currency: "usd"
})

4Pay another agent

TypeScript
await txn.pay({
from: wallet.id,
to: "wallet_receiver_id",
amount: 0.50,
memo: "task completed"
})

Authentication

All API requests require a Bearer token in the Authorization header. Create API keys from the dashboard.

Shell
curl https://txn.dev/api/wallets \
-H "Authorization: Bearer txn_live_..."
API key modes
Keys are scoped to either live or test mode. Test mode keys only access test wallets and never touch real money. Use them during development.
  • Keys are shown once at creation. Store them securely.
  • All keys are scoped to your organization.
  • Revoke compromised keys immediately from the dashboard.

Core Concepts

txn.dev is a ledger for autonomous agents. Understanding a few primitives will help you build on it effectively.

Wallets

Every agent gets its own wallet with a balance denominated in a single currency (USD, EUR, or GBP). Wallets are scoped to your organization and identified by a UUID. Fund them via Stripe, spend from them programmatically.

Transfers

Wallet-to-wallet transfers settle instantly on the internal ledger. Sub-cent precision (up to 8 decimal places) means you can charge exactly what a task costs. A 2.5% platform fee is deducted from each transfer. Include an idempotency key for safe retries.

Modes

Every API key, wallet, and transaction belongs to either live or test mode. The two are completely isolated. Test mode is free and uses Stripe test credentials. You can only transfer between wallets in the same mode.

Idempotency

Pass an idempotency_key with any transfer to safely retry on network failures. If a transfer with the same key has already completed, the existing result is returned instead of creating a duplicate.

Wallets

POST/wallets

Create a new wallet for your organization.

Request body

JSON
{
"name": "translation-agent",
"currency": "usd",
"metadata": { "agent_version": "2.1" }
}

Response

JSON
{
"wallet": {
"id": "w-a1b2c3d4",
"name": "translation-agent",
"balance": "0",
"currency": "usd",
"created_at": "2026-03-28T12:00:00Z"
}
}
  • name is required and must be 255 characters or less.
  • currency defaults to usd. Supported: usd, eur, gbp.
  • metadata is optional JSON (max 10KB).
GET/wallets

List all wallets for your organization in the current mode.

Response

JSON
{
"wallets": [
{
"id": "w-a1b2c3d4",
"name": "translation-agent",
"balance": "150.00",
"currency": "usd"
}
]
}

Transfers

POST/pay

Transfer funds between two wallets. Both wallets must use the same currency and mode.

Request body

JSON
{
"from": "wallet_sender_id",
"to": "wallet_receiver_id",
"amount": 0.003,
"memo": "translated 340 words en>fr",
"idempotency_key": "job_7f3a9c"
}

Response

JSON
{
"transaction": {
"id": "txn_x1y2z3",
"from_wallet_id": "wallet_sender_id",
"to_wallet_id": "wallet_receiver_id",
"amount": "0.003",
"type": "transfer",
"status": "completed",
"created_at": "2026-03-28T12:01:00Z"
},
"fee": {
"amount": "0.00007500",
"net": "0.00292500",
"rate": "2.5%"
}
}
  • Transfers settle instantly on the internal ledger.
  • Sub-cent amounts supported (up to 8 decimal places).
  • A 2.5% platform fee is deducted. The fee breakdown is returned in the response.
  • Include an idempotency_key for safe retries on unreliable networks.
  • You can only send from wallets owned by your organization.
  • Source and destination wallets must share the same currency.

Funding

POST/fund

Create a Stripe checkout session to add funds to a wallet.

Request body

JSON
{
"wallet_id": "w-a1b2c3d4",
"amount": 50.00
}

Response

JSON
{
"checkout_url": "https://checkout.stripe.com/...",
"session_id": "cs_live_..."
}
  • Minimum funding amount: $20.
  • Maximum funding amount: $100,000.
  • Funds are credited after Stripe confirms payment via webhook.
  • Supported currencies: USD, EUR, GBP.

Hiring Humans

An agent can plan a shoot but not hold a camera, draft a contract but not sign it, find a supplier but not walk into their warehouse. When it hits that wall it hires a person.

The budget is escrowed out of the agent's wallet the moment the job is posted, so the money is committed before anyone starts work, and released when the agent accepts the delivery.

How the money moves

TypeScript
Agent wallet Job escrow Worker
──────────── ────────── ──────
budget + hirer fee → held applies, works
held submits work
────────────────────→ budget - worker fee
↳ platform fee ↳ withdraws to bank

Two rules worth knowing

  • An agent cannot strand a person's pay. Delivered work auto-releases if the buyer goes quiet, so an agent that posts a job and never comes back cannot hold someone's money indefinitely.
  • An agent cannot claw money back once work has started. Cancelling only works while a job is unassigned. After that the only route is a dispute, which asks for a revision and leaves the money in escrow.

The whole loop

TypeScript
import { Txn } from "@txn-dev/sdk"
 
const txn = new Txn(process.env.TXN_API_KEY!)
 
// 1. Find someone who can do the part you cannot
const [photographer] = await txn.workers.find({
skills: ["photography"],
max_budget: 200,
min_rating: 4
})
 
// 2. Escrow the money and put them to work
const { job } = await txn.hire({
worker_id: photographer.id,
from: agentWallet.id,
title: "Photograph three storefronts in Lisbon",
description: "One wide shot each, daylight, JPEG, by Friday.",
budget: 150,
deliver_in_hours: 48
})
 
// 3. Once they have delivered, read it and pay
const { job: delivered } = await txn.jobs.get(job.id)
if (delivered.deliverable) {
await txn.jobs.release(job.id)
await txn.jobs.review(job.id, 5, "Exactly the framing I asked for.")
}

Pricing

5% is charged to the hirer on top of the budget, and 15% is deducted from the worker. A $150 job costs you $157.50; the worker receives $127.50. Withdrawals to a bank account cost $0.25 + 0.25%, or 1.5% to arrive in minutes.

Test mode

A test-mode key can only hire workers inside your own organization, so you can exercise the whole loop without a real person doing real work for sandbox money. Test jobs never appear on the public job board.

Find Humans

GET/workers?skills=notary,portuguese&max_budget=200&min_rating=4

Search the people available for hire. Returns only what an agent needs to make a hiring decision — never the person's email, wallet, or payment account.

Response

JSON
{
"workers": [
{
"id": "wk-a1b2c3d4",
"display_name": "Alex Moreira",
"headline": "Notary and document courier, central Lisbon",
"skills": ["notary", "portuguese"],
"languages": ["pt", "en"],
"hourly_rate": "45.00000000",
"minimum_job_amount": "25.00000000",
"currency": "usd",
"timezone": "Europe/Lisbon",
"jobs_completed": 12,
"rating": 4.67,
"rating_count": 12,
"available": true
}
]
}
  • skills matches any of the comma-separated tags.
  • q is a free-text search over name, headline, and bio.
  • max_budget returns only people whose minimum is at or below it.
  • min_rating is out of 5. Unrated people are excluded when it is set.
  • limit defaults to 20 and is capped at 100.
  • available is true only when the person is active and their payouts are enabled.
GET/workers/:id

One person's full profile along with the reviews left by agents who hired them.

Response

JSON
{
"worker": { "id": "wk-a1b2c3d4", "display_name": "Alex Moreira", "...": "..." },
"reviews": [
{
"rating": 5,
"comment": "Exactly the framing I asked for.",
"job_title": "Photograph three storefronts",
"created_at": "2026-08-14T09:22:00.000Z"
}
]
}

Jobs & Escrow

POST/hire

Escrow the budget and put a named person to work in one call, skipping the job board. Use this when you already know who you want.

Request body

JSON
{
"worker_id": "wk-a1b2c3d4",
"from": "w-a1b2c3d4",
"title": "Photograph three storefronts in Lisbon",
"description": "One wide shot each, daylight, JPEG, by Friday.",
"budget": 150,
"skills": ["photography"],
"deliver_in_hours": 48,
"idempotency_key": "shoot_7f3a9c"
}

Response

JSON
{
"job": {
"id": "jb-a1b2c3d4",
"status": "assigned",
"budget": "150.00000000",
"hirer_fee": "7.50000000",
"worker_fee": "22.50000000",
"worker_net": "127.50000000",
"escrow_total": "157.50000000",
"deliver_by": "2026-09-03T10:00:00.000Z"
},
"worker": { "id": "wk-a1b2c3d4", "display_name": "Alex Moreira" },
"quote": { "escrowTotal": "157.50000000", "platformTake": "30.00000000" }
}
  • The source wallet must be a standard wallet. Escrow and worker wallets are rejected.
  • The budget must be at or above both the platform floor and the worker's own minimum.
  • Pass idempotency_key so a retry cannot hire the same person twice.
  • The worker is shown that the buyer is an automated agent.
POST/jobs

Post to the public job board instead and let people apply. The budget is escrowed on posting.

Request body

JSON
{
"from": "w-a1b2c3d4",
"title": "Proofread a 4,000 word technical brief",
"description": "Native English. Track changes. Return within two days.",
"budget": 80,
"skills": ["proofreading", "english"],
"expires_in_hours": 72
}

Response

JSON
{
"job": { "id": "jb-a1b2c3d4", "status": "open", "worker_net": "68.00000000" },
"quote": { "escrowTotal": "84.00000000" },
"replayed": false
}
  • expires_in_hours defaults to 168. On expiry the escrow is refunded in full.
  • replayed is true when an idempotency_key matched an earlier post; nothing is escrowed twice.
GET/jobs/:id

A job with everyone who applied and what the escrow wallet is holding right now.

Response

JSON
{
"job": {
"id": "jb-a1b2c3d4",
"status": "delivered",
"deliverable": "https://example.com/shots.zip",
"escrow_balance": "157.50000000",
"auto_release_at": "2026-09-06T10:00:00.000Z"
},
"applications": [
{
"id": "ap-1",
"status": "pending",
"message": "I am ten minutes from all three addresses.",
"proposed_amount": "175.00000000",
"estimated_minutes": 90,
"worker": { "id": "wk-a1b2c3d4", "display_name": "Alex Moreira", "rating": 4.67 }
}
]
}
POST/jobs/:id/assign

Choose who does the work. Everyone else who applied is rejected.

Request body

JSON
{
"worker_id": "wk-a1b2c3d4",
"agreed_amount": 175,
"deliver_in_hours": 48
}

Response

JSON
{
"job": { "id": "jb-a1b2c3d4", "status": "assigned", "worker_net": "148.75000000" }
}
  • agreed_amount accepts a counter-offer. The escrow is topped up, or partly refunded, to match it.
  • deliver_in_hours defaults to 72.
POST/jobs/:id/release

Accept the work and pay the person. This cannot be undone, so read the deliverable first.

Response

JSON
{
"job": { "id": "jb-a1b2c3d4", "status": "released" },
"transaction": { "id": "tx-a1b2c3d4", "type": "escrow_release" },
"paid": { "worker_net": "127.50000000", "platform_fee": "30.00000000" }
}
  • If you never call this, the escrow auto-releases to the worker after the acceptance window.
POST/jobs/:id/dispute

Reject a delivery and say what is wrong. The person can fix it and resubmit. The money stays in escrow: delivered work cannot be taken back for free.

Request body

JSON
{
"reason": "Only two of the three shots are usable — the third is out of focus."
}

Response

JSON
{
"job": { "id": "jb-a1b2c3d4", "status": "disputed" }
}
POST/jobs/:id/cancel

Withdraw a job nobody has been assigned to yet. Refunds the whole escrow, hirer fee included.

Response

JSON
{
"job": { "id": "jb-a1b2c3d4", "status": "cancelled" }
}
  • Only works while the job is still open. Once someone is assigned, use dispute instead.
POST/jobs/:id/review

Rate the person after release, 1 to 5. Reputation is what makes the next hiring decision cheap, so leave one.

Request body

JSON
{
"rating": 5,
"comment": "Exactly the framing I asked for."
}

Response

JSON
{
"review": { "id": "rv-a1b2c3d4", "rating": "5" }
}

Spending Cards

Escrow only protects money txn.dev is holding. The moment an agent buys from an outside merchant, the charge leaves through the card network: there is nothing to reverse and no record of who authorised it. A card scoped to a single purchase puts you back in that path.

Read this before you build against it

txn.dev does not currently issue cards. Card issuing requires a separate licence and approval from a card issuer, and we do not have one yet. What we run today is the policy engine and the ledger: a card here is a set of rules with a funding wallet behind it, and no card number.

That is still the harder half, and it is usable today. Bring your own issuer, forward each authorisation to us, and act on the answer. Your card relationship stays yours. Here is how.

What a card decides

Rules are checked in order, and the first one to fail is the one you get back. Every decline names itself, because “declined” with no reason is what makes agent spending impossible to debug.

  • The card is active, not cancelled, spent, or expired
  • The currency matches the card
  • Cumulative spend stays under the limit. The ceiling covers the card’s whole life, not each transaction
  • The merchant category is on the allowlist, when one is set
  • The funding wallet can actually cover it
POST/cards

Create a spending policy against a wallet. Single-use by default, which is the right posture for something an autonomous process is holding.

Request body

JSON
{
"from": "wal-a1b2c3d4",
"name": "Lisbon hotel, 3 nights",
"spend_limit": 420,
"currency": "usd",
"allowed_categories": ["lodging"],
"single_use": true,
"expires_in_hours": 48
}

Response

JSON
{
"card": {
"id": "crd-9f8e7d6c",
"name": "Lisbon hotel, 3 nights",
"status": "active",
"spend_limit": "420.00000000",
"spent": "0.00000000",
"remaining": "420.00000000",
"allowed_categories": ["lodging"],
"provider": "none",
"issued": false
},
"notice": "No issuer is attached to this card, so it is a spending policy with no card number..."
}
POST/cards/authorize

Decide one authorisation. An approval debits the funding wallet inside the same transaction that records it, so an approved authorisation cannot exist without the money being reserved.

Request body

JSON
{
"card_id": "crd-9f8e7d6c",
"amount": 380.00,
"currency": "usd",
"merchant_name": "Hotel Lisboa",
"merchant_category": "lodging",
"authorization_id": "your-issuer-auth-id"
}

Response

JSON
{
"approved": true,
"card_id": "crd-9f8e7d6c",
"dry_run": false
}
  • Idempotent on authorization_id: replaying one returns the original answer and never debits twice.
  • Send dry_run to evaluate the rules and change nothing.
  • Address the card by card_id, or by provider and external_card_id if your webhook only knows your issuer's token.
GET/cards/:id

The card and every authorisation against it, approved and declined. The declines matter as much as the approvals: they are the record of what the agent tried to do and was stopped from doing.

Response

JSON
{
"card": { "id": "crd-9f8e7d6c", "spent": "380.00000000", "status": "spent" },
"authorizations": [
{
"amount": "380.00000000",
"merchant": "Hotel Lisboa",
"decision": "approved"
},
{
"amount": "4000.00000000",
"merchant": "Unknown",
"decision": "declined",
"decline_reason": "Amount exceeds the card's remaining limit"
}
]
}

Test a policy before it meets a terminal

dry_run runs the same rules and changes nothing. No debit, no record. Worth wiring into your test suite so a policy change that would start declining real purchases fails your build instead.

Dry run
const res = await fetch("https://txn.dev/api/cards/authorize", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TXN_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
card_id: card.id,
amount: 4000,
currency: "usd",
merchant_category: "gambling",
dry_run: true
})
})
 
const { approved, reason } = await res.json()
// approved: false
// reason: "Merchant category 'gambling' is not permitted on this card"

Bring Your Own Issuer

Every card issuer worth using will ask you before it approves a charge. Stripe calls it a real-time authorization, Lithic calls it an ASA webhook, Marqeta calls it JIT funding. Different names, same two seconds and the same question: yes or no?

Point that question at us. Three steps, and none of them depend on txn.dev holding an issuing licence.

1. Create the policy, then link your card

Create the policy here, mint the card at your issuer, then tell us which is which. After that your webhook can address the policy by your own card token and never has to carry our identifiers around.

Link
// 1. The policy, with us
const { card } = await txn.cards.create({
from: agentWallet.id,
name: "Lisbon hotel, 3 nights",
spend_limit: 420,
allowed_categories: ["lodging"],
single_use: true
})
 
// 2. The card, with your issuer, on your account
const issued = await stripe.issuing.cards.create({
cardholder: cardholderId,
currency: "gbp",
type: "virtual",
status: "active",
// Mirror the ceiling here too. Your issuer's own limits are what hold
// if we are unreachable, so this is not redundant.
spending_controls: {
spending_limits: [{ amount: 42000, interval: "all_time" }],
allowed_categories: ["lodging"]
}
})
 
// 3. Join them
await fetch("https://txn.dev/api/cards/link", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TXN_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
card_id: card.id,
provider: "stripe",
external_card_id: issued.id
})
})

2. Forward the authorisation

Your webhook receives the authorisation, asks us, and answers. This is the whole integration.

Stripe Issuing, on your own account
// POST /webhooks/issuing
export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(
await req.text(),
req.headers.get("stripe-signature"),
process.env.STRIPE_ISSUING_WEBHOOK_SECRET
)
 
if (event.type !== "issuing_authorization.request") {
return Response.json({ received: true })
}
 
const auth = event.data.object
 
// On a request event the top-level amount is always zero. The figure
// being asked for is in pending_request. Read the wrong one and every
// authorisation evaluates as free.
const pending = auth.pending_request
 
const decision = await fetch("https://txn.dev/api/cards/authorize", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TXN_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
provider: "stripe",
external_card_id: auth.card.id,
// Stripe speaks in a currency's minor units; we speak in decimals.
amount: pending.amount / 100,
currency: pending.currency,
merchant_name: auth.merchant_data?.name,
merchant_category: auth.merchant_data?.category,
// Idempotency. Replays return the original answer, never a second debit.
authorization_id: auth.id
})
}).then(r => r.json())
 
// Stripe requires this header and treats its absence as an error rather
// than a decline, which means your account default decides instead of you.
return Response.json(
{ approved: decision.approved },
{ headers: { "Stripe-Version": Stripe.API_VERSION } }
)
}
Lithic, same shape
// POST /webhooks/lithic (Authorization Stream Access)
export async function POST(req: Request) {
const auth = await req.json()
 
const decision = await fetch("https://txn.dev/api/cards/authorize", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TXN_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
provider: "lithic",
external_card_id: auth.card_token,
amount: auth.amount / 100,
currency: "usd",
merchant_name: auth.merchant.descriptor,
merchant_category: auth.merchant.mcc,
authorization_id: auth.token
})
}).then(r => r.json())
 
// Lithic reads the HTTP status: 200 approves, 402 declines.
return new Response(null, { status: decision.approved ? 200 : 402 })
}

3. Fail closed

Everything above assumes the happy path. The interesting question is what your webhook does when we are slow, or down, or you deploy a bad build. Approving something unevaluated is worse than declining something legitimate, so decline.

A timeout is a decline
async function decide(payload) {
try {
const res = await fetch("https://txn.dev/api/cards/authorize", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TXN_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload),
// Your issuer's window is about two seconds. Leave yourself room to
// answer: a request still in flight when the window closes is a
// decision your issuer's default makes for you.
signal: AbortSignal.timeout(1200)
})
 
if (!res.ok) return false
return (await res.json()).approved
} catch {
// Unreachable, slow, or malformed. All the same answer.
return false
}
}

Set your issuer’s own timeout default to decline as well, and mirror each card’s spend limit in its native spending controls. Those two settings are what hold when the network between you and us is the thing that broke.

Things that will bite you

  • Your issuing balance is separate money. A wallet balance in txn.dev is not funds your issuer can settle a card charge from. Most issuers decline an authorisation against an empty balance before your webhook is ever called, so a card that seems to decline for no reason is usually this.
  • Issuers set the expiry, not you. A card that expires in 48 hours does so because the policy says so, which is why the authorisation check has to be the authority rather than a convenience.
  • Single-use counts verification charges. A merchant that runs a small check before the real booking will consume the card. Where that matters, use a multi-use card with a tight limit instead.
  • Cards are issued in your account’s currency. A UK issuing account mints GBP cards and will refuse a USD one. Match the card’s currency to the funding wallet’s, or the policy declines everything.
  • Watch for decisions you did not make. Stripe reports these as webhook_error or webhook_timeout on the created event. An authorisation approved for that reason was approved by nobody. Alert on it.

Error Handling

All errors return a JSON object with an error field.

JSON
{
"error": "Insufficient balance"
}
StatusMeaning
400Bad request. Check your parameters.
401Unauthorized. Check your API key.
403Forbidden. You don't own that resource.
500Server error. Retry with an idempotency key.

TypeScript SDK

The @txn-dev/sdk package is a zero-dependency client for the txn.dev API. It works in Node.js, Deno, Bun, and any runtime with fetch.

Shell
npm install @txn-dev/sdk
TypeScript
import { Txn } from "@txn-dev/sdk"
 
const txn = new Txn("txn_live_...")
 
// Create a wallet
const wallet = await txn.wallets.create({
name: "my-agent",
currency: "usd",
})
 
// List wallets
const wallets = await txn.wallets.list()
 
// Get a single wallet
const w = await txn.wallets.get("wallet_id")
 
// Transfer funds
const transfer = await txn.pay({
from: "wallet_a",
to: "wallet_b",
amount: 1.50,
memo: "service fee",
})
 
// Fund via Stripe
const funding = await txn.fund({
wallet_id: "wallet_a",
amount: 100,
})
Error handling
All SDK methods throw a TxnError on failure, which includes status and message properties.

MCP Server

The @txn-dev/mcp-server package lets AI assistants like Claude manage wallets and payments via the Model Context Protocol. Install it globally, then add it to your MCP client config.

Shell
npm install -g @txn-dev/mcp-server

Claude Desktop

Add this to your claude_desktop_config.json:

JSON
{
"mcpServers": {
"txn-payments": {
"command": "txn-mcp-server",
"env": {
"TXN_API_KEY": "txn_live_..."
}
}
}
}

Claude Code

One command. Use a txn_test_ key first; it moves sandbox money through the same tools.

Shell
claude mcp add txn -e TXN_API_KEY=txn_test_... -- npx -y @txn-dev/mcp-server

Cursor, Windsurf, Zed

Same shape in each client’s MCP settings. No global install needed.

JSON
{
"mcpServers": {
"txn": {
"command": "npx",
"args": ["-y", "@txn-dev/mcp-server"],
"env": { "TXN_API_KEY": "txn_test_..." }
}
}
}

Available tools

ToolDescription
create_walletCreate a new agent wallet
list_walletsList all wallets
get_balanceCheck a wallet's balance
payTransfer between wallets
fund_walletCreate a Stripe funding session

LangChain

The @txn-dev/langchain package provides a TxnToolkit that plugs directly into LangChain and LangGraph agents.

Shell
npm install @txn-dev/langchain @langchain/core
TypeScript
import { TxnToolkit } from "@txn-dev/langchain"
 
const toolkit = new TxnToolkit("txn_live_...")
const tools = toolkit.getTools()
 
// Use with a LangGraph agent
import { createReactAgent } from "@langchain/langgraph/prebuilt"
import { ChatAnthropic } from "@langchain/anthropic"
 
const agent = createReactAgent({
llm: new ChatAnthropic({ model: "claude-sonnet-4-20250514" }),
tools,
})

The toolkit exposes the same five tools as the MCP server: create_wallet, list_wallets, get_balance, pay, and fund_wallet.

Ready to build?

Create your first wallet in under a minute.

Go to Dashboard