Let your agent get paid without holding keys
Sooner or later an agent you build has to deal with money. A booking agent hits a deposit. A freelancing assistant needs to send an invoice. A support bot has to collect a fee before it ships a replacement part. The qui
Sooner or later an agent you build has to deal with money. A booking agent hits a deposit. A freelancing assistant needs to send an invoice. A support bot has to collect a fee before it ships a replacement part.
The quick answer is to hand the agent a wallet. Fund it, put the private key in an environment variable, let the model call a send tool. That works in a demo. In production it means a single prompt injection, a confused tool call or a leaked log line can empty the wallet, and nothing in the stack can tell a legitimate payment from a manipulated one, because the agent is the one signing.
There is a simpler split that removes most of that risk: the agent prepares the payment, and a human signs it in their own wallet. This article shows how to do that with send21, a non-custodial service that prepares payment instructions, over its MCP server.
The split: preparing is not paying
A payment has two halves:
- Preparing: who gets paid, how much, in which asset and network, with what reference, and at which exchange rate if the price is in euros or dollars.
- Authorising: signing a transaction with a private key and broadcasting it.
Agents are good at the first half. It is structured data, it benefits from context, and mistakes are cheap if someone checks before signing. The second half is where the risk lives, and it is exactly the part an agent does not need.
send21 only does the first half. It never holds keys or funds, never signs and never broadcasts. Funds go straight from the payer's wallet to the receiver's wallet. Since there is no signing code on the server, an API key you give an agent cannot move money, however it is misused. The worst an abused key can do is create payment instructions that a human then has to approve in their own wallet.
What the agent can do
The send21 MCP server runs at https://send21.io/mcp over Streamable HTTP and is listed in the official MCP Registry as io.github.send21io/mcp. It exposes 11 tools, including:
-
create_payment_request: create a pay link where you are the receiver. Price it in a fiat currency, list the assets you accept with your own receiving addresses, and get back a link. -
get_payment_request: read its status (Open, Completed, Cancelled, Expired). -
create_draftandget_transaction_template: the other direction, where the owner pays someone. The template gives the wallet everything it needs to fund, review and sign. -
get_rateandget_fee_schedule: rates with their source, and the public fee table.
Listing the tools and reading the fee schedule work without a key. Everything else needs a scoped API key that the account owner creates. For an agent that only issues pay links, drafts:write plus drafts:read is enough.
Supported today: BTC on-chain and over Lightning (paid to the receiver's own Lightning address), USDC on Solana, Ethereum, Base, Arbitrum and Polygon, USDT on Solana and Ethereum, EURC on Solana, Ethereum and Base, plus native SOL and ETH. Prices can be set in 161 fiat currencies.
Connect it in one block
In Cursor, .cursor/mcp.json from the send21-examples repo looks like this:
{
"mcpServers": {
"send21": {
"url": "https://send21.io/mcp",
"headers": {
"Authorization": "Bearer ${env:SEND21_API_KEY}"
}
}
}
}
Claude Desktop needs a stdio bridge, so the example uses mcp-remote:
{
"mcpServers": {
"send21": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://send21.io/mcp",
"--header", "Authorization:${SEND21_AUTH_HEADER}"],
"env": { "SEND21_AUTH_HEADER": "Bearer s21_your_key_here" }
}
}
}
Then ask: "Create a pay link for 40 EUR for invoice 1042, payable in USDC on Solana to my address." The model fills in create_payment_request and hands you a link.
Doing it from code
Inside your own agent you can call the same tool with the MCP TypeScript SDK. This is trimmed from examples/mcp-payment-request in the examples repo:
import { randomUUID } from "node:crypto";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "my-agent", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL("https://send21.io/mcp"), {
requestInit: { headers: { Authorization: `Bearer ${process.env.SEND21_API_KEY}` } },
});
await client.connect(transport);
const result = await client.callTool({
name: "create_payment_request",
arguments: {
fiatCurrency: "EUR",
fiatAmount: 40,
options: [
{ currency: "Usdc", network: "Solana", address: process.env.MY_SOLANA_ADDRESS },
{ currency: "Btc", network: "Mainnet", address: "[email protected]", method: "Lightning" },
],
orderId: "inv-1042",
memo: "Logo design, round 2",
idempotencyKey: randomUUID(),
},
});
The response carries an id, a payPath and an expiry. Join payPath with https://send21.io and send it to the payer. They open the page, pick a currency (the rate locks for 15 minutes at that point), scan one QR code with any wallet and sign.
The example also has a dry-run mode that needs no key. It connects, fetches the live tool list and checks your arguments against the published input schema without creating anything:
npm run dry-run -- 25 EUR --option Usdc:Solana:<your-solana-address>
That is a cheap way to catch a wrong enum value before an agent does.
Three details that matter for agents
Fix the addresses in code, not in the prompt. The receiving address is the one field an attacker would want to change. If your app passes its own addresses into options and ignores whatever the model suggests, a prompt injection can at worst change the amount or memo on a link that a human still has to pay.
Retries are safe. Agents retry. Every create call can carry an idempotency key: the same key with the same parameters returns the original request, and the same key with a different amount, currency or address is refused with a 422 and creates nothing. If your agent might retry with a new key, set reuseOpenForOrder: true with an orderId, and you get the existing open link back instead of a second one for the same order.
Say what happens if nobody pays. When an agent hits a purchase over its budget and hands it to a human, the pay link can carry a declineNote, shown on the pay page as "If you don't pay: the booking is released at 18:00." The human gets the context the agent had.
Knowing when it was paid
You do not need to poll. send21 sends HMAC-signed webhooks with your orderId, for example draft.confirmed when the payment has its confirmations. Verify the X-Send21-Signature-V2 header: t=<timestamp>,v1=<hex>, an HMAC-SHA256 of "<timestamp>.<delivery id>.<raw body>" keyed with your signing secret. Because the time and the delivery id are signed too, refuse anything older than 5 minutes and a copied delivery cannot be replayed. The webhook receiver in the examples repo does this in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignatureV2(rawBody, header, deliveryId, secret) {
if (!secret || typeof header !== "string" || typeof deliveryId !== "string" || !deliveryId) return false;
const parts = Object.fromEntries(header.split(",").map((p) => [p.slice(0, p.indexOf("=")).trim(), p.slice(p.indexOf("=") + 1).trim()]));
if (!/^\d+$/.test(parts.t ?? "") || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; // older than 5 minutes
const signed = Buffer.concat([Buffer.from(`${parts.t}.${deliveryId}.`), rawBody]);
const expected = Buffer.from(createHmac("sha256", secret).update(signed).digest("hex"));
const received = Buffer.from(parts.v1);
return received.length === expected.length && timingSafeEqual(received, expected);
}
The original X-Send21-Signature (HMAC of the raw body only, no timestamp) is still sent so existing integrations keep working; use it only as a fallback when no V2 header is present. The webhook guide at send21.io/docs/webhooks describes both. Deliveries are retried with the same X-Send21-Delivery id, so skip one you have already handled.
Real payments are messy, and the events reflect that. A transfer from an exchange often arrives a little short because the exchange took its withdrawal fee. When a transfer within 10% of the billed amount reaches the address, send21 does not mark it paid; it sends draft.amount_mismatch and the receiver accepts or declines it, with a reason. Your agent can surface that to a human instead of guessing.
What a human sees before signing
The person who signs is the safety net, so the pay page tries to make the unusual visible. It shows a short "what changed" line only when something is out of pattern, such as a first payment to an address or an amount about three times the usual. Agents get the same signals through get_payment_request as changeSignals and changeNotes, so an agent can stop and ask before forwarding a link that looks off. Addresses are shown with the characters a look-alike address would have to copy highlighted, so the payer can compare them with their wallet.
What this is not
To be precise about the boundaries:
- It is not an agent wallet. An agent cannot sign or pay on its own through send21.
- There is no escrow and no "pay now, release later". Money moves once, wallet to wallet.
- There are no payouts to bank accounts and no conversion to fiat.
If you need fully autonomous spending, this is the wrong tool. If you want an agent that does the paperwork while a human keeps the keys, it fits.
Try it
Fees are 0.49% on small payments, falling by size to 0.01%, capped at $100, and the receiver always gets the exact amount. You can try a real payment with test coins at https://send21.io/demo without an account or email, and the configs and examples above are in https://github.com/send21io/send21-examples. MCP setup details are at https://send21.io/docs/mcp. Questions go to [email protected].
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.