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
npm install @txn-dev/sdk
2Initialize the client
import { Txn } from "@txn-dev/sdk"const txn = new Txn("txn_live_...")
3Create a wallet
const wallet = await txn.wallets.create({name: "my-agent",currency: "usd"})
4Pay another agent
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.
curl https://txn.dev/api/wallets \-H "Authorization: Bearer txn_live_..."
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
/walletsCreate a new wallet for your organization.
Request body
{"name": "translation-agent","currency": "usd","metadata": { "agent_version": "2.1" }}
Response
{"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).
/walletsList all wallets for your organization in the current mode.
Response
{"wallets": [{"id": "w-a1b2c3d4","name": "translation-agent","balance": "150.00","currency": "usd"}]}
Transfers
/payTransfer funds between two wallets. Both wallets must use the same currency and mode.
Request body
{"from": "wallet_sender_id","to": "wallet_receiver_id","amount": 0.003,"memo": "translated 340 words en>fr","idempotency_key": "job_7f3a9c"}
Response
{"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
/fundCreate a Stripe checkout session to add funds to a wallet.
Request body
{"wallet_id": "w-a1b2c3d4","amount": 50.00}
Response
{"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
Agent wallet Job escrow Worker──────────── ────────── ──────budget + hirer fee → held applies, worksheld 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
import { Txn } from "@txn-dev/sdk"const txn = new Txn(process.env.TXN_API_KEY!)// 1. Find someone who can do the part you cannotconst [photographer] = await txn.workers.find({skills: ["photography"],max_budget: 200,min_rating: 4})// 2. Escrow the money and put them to workconst { 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 payconst { 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
/workers?skills=notary,portuguese&max_budget=200&min_rating=4Search 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
{"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.
/workers/:idOne person's full profile along with the reviews left by agents who hired them.
Response
{"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
/hireEscrow 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
{"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
{"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.
/jobsPost to the public job board instead and let people apply. The budget is escrowed on posting.
Request body
{"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
{"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.
/jobs/:idA job with everyone who applied and what the escrow wallet is holding right now.
Response
{"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 }}]}
/jobs/:id/assignChoose who does the work. Everyone else who applied is rejected.
Request body
{"worker_id": "wk-a1b2c3d4","agreed_amount": 175,"deliver_in_hours": 48}
Response
{"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.
/jobs/:id/releaseAccept the work and pay the person. This cannot be undone, so read the deliverable first.
Response
{"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.
/jobs/:id/disputeReject 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
{"reason": "Only two of the three shots are usable — the third is out of focus."}
Response
{"job": { "id": "jb-a1b2c3d4", "status": "disputed" }}
/jobs/:id/cancelWithdraw a job nobody has been assigned to yet. Refunds the whole escrow, hirer fee included.
Response
{"job": { "id": "jb-a1b2c3d4", "status": "cancelled" }}
- Only works while the job is still open. Once someone is assigned, use dispute instead.
/jobs/:id/reviewRate the person after release, 1 to 5. Reputation is what makes the next hiring decision cheap, so leave one.
Request body
{"rating": 5,"comment": "Exactly the framing I asked for."}
Response
{"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
/cardsCreate a spending policy against a wallet. Single-use by default, which is the right posture for something an autonomous process is holding.
Request body
{"from": "wal-a1b2c3d4","name": "Lisbon hotel, 3 nights","spend_limit": 420,"currency": "usd","allowed_categories": ["lodging"],"single_use": true,"expires_in_hours": 48}
Response
{"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..."}
/cards/authorizeDecide 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
{"card_id": "crd-9f8e7d6c","amount": 380.00,"currency": "usd","merchant_name": "Hotel Lisboa","merchant_category": "lodging","authorization_id": "your-issuer-auth-id"}
Response
{"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.
/cards/:idThe 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
{"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.
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.
// 1. The policy, with usconst { 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 accountconst 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 themawait 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.
// POST /webhooks/issuingexport 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_requestconst 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 } })}
// 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.
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 falsereturn (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_errororwebhook_timeouton 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.
{"error": "Insufficient balance"}
| Status | Meaning |
|---|---|
| 400 | Bad request. Check your parameters. |
| 401 | Unauthorized. Check your API key. |
| 403 | Forbidden. You don't own that resource. |
| 500 | Server 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.
npm install @txn-dev/sdk
import { Txn } from "@txn-dev/sdk"const txn = new Txn("txn_live_...")// Create a walletconst wallet = await txn.wallets.create({name: "my-agent",currency: "usd",})// List walletsconst wallets = await txn.wallets.list()// Get a single walletconst w = await txn.wallets.get("wallet_id")// Transfer fundsconst transfer = await txn.pay({from: "wallet_a",to: "wallet_b",amount: 1.50,memo: "service fee",})// Fund via Stripeconst funding = await txn.fund({wallet_id: "wallet_a",amount: 100,})
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.
npm install -g @txn-dev/mcp-server
Claude Desktop
Add this to your claude_desktop_config.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.
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.
{"mcpServers": {"txn": {"command": "npx","args": ["-y", "@txn-dev/mcp-server"],"env": { "TXN_API_KEY": "txn_test_..." }}}}
Available tools
| Tool | Description |
|---|---|
| create_wallet | Create a new agent wallet |
| list_wallets | List all wallets |
| get_balance | Check a wallet's balance |
| pay | Transfer between wallets |
| fund_wallet | Create a Stripe funding session |
LangChain
The @txn-dev/langchain package provides a TxnToolkit that plugs directly into LangChain and LangGraph agents.
npm install @txn-dev/langchain @langchain/core
import { TxnToolkit } from "@txn-dev/langchain"const toolkit = new TxnToolkit("txn_live_...")const tools = toolkit.getTools()// Use with a LangGraph agentimport { 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.