send21 教學:如何透過 MCP 讓 AI 智能體建立加密貨幣付款連結而不交出私鑰
Let an AI agent create crypto pay links over MCP (without handing it your keys)
send21 的教學示範如何透過 MCP 讓 AI 智能體建立加密貨幣付款連結,付款人再用自己的錢包直接付款至收款地址。收款地址由帳戶持有人批准;send21 不保管私鑰或資金,也不簽署或廣播交易。伺服器可透過簽署 webhook 接收付款狀態,教程亦提供 MCP 連線、付款狀態查詢及 webhook 驗證步驟。
Agents are good at preparing payments and bad places to keep money. This tutorial shows a split that works: your AI agent creates a crypto pay link over the Model Context Protocol (MCP), the payer pays from their own wallet straight to your address, and your server learns about it through a signed webhook.
We use send21: Crypto pay links and API for people and AI agents. send21 is non-custodial payment software. It prepares payment instructions and watches the chain. It never holds private keys or funds, never signs and never broadcasts. No API key can move funds.
What you will build:
- Connect an MCP client to the send21 MCP server.
- Ask the agent what is still missing (
get_account_status). - Approve a receiving address yourself, in the send21 app.
- Let the agent create a pay link (
create_payment_request). - Let the agent check it (
get_payment_request). - Optional: verify webhooks on your own server.
Full docs: send21.io/docs/mcp.
1. Connect your MCP client
The server lives at https://send21.io/mcp (Streamable HTTP, stateless). There are two ways in.
Option A: sign in, no key to copy. Clients that support MCP sign-in (OAuth) can add https://send21.io/mcp/oauth as a remote server with no headers. The client opens send21 in your browser, you sign in, see what the app asks for, and approve. In Claude Code:
claude mcp add --transport http send21 https://send21.io/mcp/oauth
# then run /mcp in Claude Code and choose send21 to sign in
A signed-in connection only ever uses receiving addresses you approved, whatever your account setting. It shows up under API keys as a connected app, and Disconnect ends it at once.
Option B: a scoped API key. Sign in, open API keys and create a key with only the scopes the job needs. For this tutorial that is drafts:read and drafts:write, plus addressbook:write if the agent should propose addresses and webhooks:manage for step 6. Keys look like s21_... and are shown once.
Cursor (.cursor/mcp.json) and other clients that use mcpServers:
{
"mcpServers": {
"send21": {
"url": "https://send21.io/mcp",
"headers": { "Authorization": "Bearer s21_YOUR_KEY" }
}
}
}
VS Code (.vscode/mcp.json):
{
"servers": {
"send21": {
"type": "http",
"url": "https://send21.io/mcp",
"headers": { "Authorization": "Bearer s21_YOUR_KEY" }
}
}
}
Claude Code with a key:
claude mcp add --transport http send21 https://send21.io/mcp \
--header "Authorization: Bearer s21_YOUR_KEY"
Listing the tools and reading the fee schedule work without a key. Every other tool needs one (or a sign-in).
2. Ask the agent what is missing
Start every session with get_account_status. It reports whether your email is confirmed, your receiving-address policy, how many addresses are approved or waiting, unsettled fees, the key's scopes, and the next steps.
Here is a real response from our test account (email redacted):
{
"email": "<redacted>",
"emailVerified": true,
"receivingAddressPolicy": "InlineAllowed",
"approvedReceivingAddresses": 0,
"approvedPayingAddresses": 0,
"proposedAddresses": 0,
"ownerCanApprove": false,
"keyScopes": ["drafts:read", "drafts:write", "webhooks:manage"],
"outstandingFeesUsd": 0,
"feesBlockCreation": false,
"nextSteps": ["Ready: create pay links."]
}
Our test account is an older one with the InlineAllowed policy. New accounts start with ApprovedOnly, and older accounts can turn it on in Settings. On ApprovedOnly, get_account_status is how the agent learns what is still missing before it can create a pay link. We recommend ApprovedOnly for any account an agent touches.
3. You approve the receiving address, not the agent
This is the step that keeps a prompt-injected agent or a leaked key from sending your customers' money somewhere else.
On ApprovedOnly, an API key can only use addresses the owner approved. The agent can propose one:
{
"name": "propose_receiving_address",
"arguments": {
"label": "My Phantom wallet",
"address": "YourSolanaAddress",
"network": "Solana"
}
}
The address is saved unapproved, and you get an email. That email has no approve link. You sign in to send21, compare the address character by character, and approve it with a passkey or an authenticator code. An emailed code is never enough, because an agent that reads your inbox could use it. No API key and no scope can approve an address.
If the agent names any other address in a pay link, the call fails with 422 address_not_approved and nothing is created. A good agent then calls get_account_status, tells you, and waits.
4. Create a pay link
create_payment_request makes a pay link priced in fiat (any of 161 currencies). You list the currencies you accept, each with your own receiving address. The payer opens the link, picks one, the rate locks for 15 minutes, and they send a single transfer from their own wallet straight to your address.
The call we ran against the live server (addresses redacted):
{
"name": "create_payment_request",
"arguments": {
"fiatCurrency": "EUR",
"fiatAmount": 1,
"orderId": "TPL-MCP-DEVTO-20261022-1",
"memo": "MCP tutorial test run",
"expiryHours": 1,
"idempotencyKey": "tpl-mcp-devto-20261022-1",
"reuseOpenForOrder": true,
"options": [
{ "currency": "Usdc", "network": "Solana", "address": "<your-solana-address>" },
{ "currency": "Usdt", "network": "Solana", "address": "<your-solana-address>" }
]
}
}
Two arguments are worth having from day one:
-
idempotencyKey: a retry with the same key and the same parameters returns the original pay link. The same key with another amount, currency or address is refused (422), and nothing is created. -
orderIdwithreuseOpenForOrder: true: if the agent retries with a new key, it still gets the open link for that order instead of a second one.
Other useful options: declineNote (what happens if the payer does not pay, shown on the pay page), shortfallAbsorbUpToPercent (accept small short payments automatically, 0 to 10; overpayments always wait for a person), and expiryHours (default 24).
Want a different asset mix? USDT works on Solana and Ethereum. On Base you can take USDC, EURC or ETH. Bitcoin works on-chain or over Lightning ("method": "Lightning" with your Lightning address).
The real response, with ids, token and address redacted:
{
"id": "<uuid>",
"shareToken": "<token>",
"payPath": "/pay/<token>",
"orderId": "TPL-MCP-DEVTO-20261022-1",
"fiatCurrency": "EUR",
"fiatAmount": 1,
"memo": "MCP tutorial test run",
"status": "Open",
"expiresAt": "2026-10-22T19:38:58.466Z",
"createdAt": "2026-10-22T18:38:58.466Z",
"options": [
{ "currency": "Usdc", "network": "Solana", "address": "<solana-address>", "method": "MultiOutput" },
{ "currency": "Usdt", "network": "Solana", "address": "<solana-address>", "method": "MultiOutput" }
],
"activeDraftId": null,
"draftStatus": null,
"fee": null,
"amountMismatch": null,
"declineNote": null,
"shortfallPolicy": null,
"changeSignals": [],
"changeNotes": [],
"optionChangeSignals": [
{ "currency": "Usdc", "network": "Solana", "method": "MultiOutput", "signals": [], "notes": [] },
{ "currency": "Usdt", "network": "Solana", "method": "MultiOutput", "signals": [], "notes": [] }
]
}
The link to share is https://send21.io plus payPath. The payer scans one standard QR code (Solana Pay here) with any wallet. Hand the link to a human. The agent does not pay it.
changeSignals is empty here. It fills with new_address, new_asset or unusual_amount when something differs from your paid payments, and the pay page shows the payer the same lines.
5. Check the status
get_payment_request takes the paymentRequestId and returns the same shape. Right after creation ours was identical to the response above: status: "Open", draftStatus: null.
How to read it:
| Pay link state | Meaning |
|---|---|
Open, draftStatus: null
|
Nobody picked a currency yet |
Open, draftStatus: AwaitingPayment
|
The payer picked a currency, the price is locked |
Open, draftStatus: Seen
|
Payment seen, not confirmed yet |
Completed |
Confirmed |
Expired / Cancelled
|
Unpaid / cancelled |
A transfer within 10% of the billed amount never confirms on its own. It waits for you to accept or decline it. That decision is yours, not the agent's.
Polling is fine for a chat session. For anything that ships goods, use webhooks.
6. Optional: verify webhooks on your server
Register an endpoint with POST /api/v1/webhooks (scope webhooks:manage). The signing secret is shown once. Each delivery carries X-Send21-Delivery (retries reuse it) and X-Send21-Signature-V2: t=<unix>,v1=<hex>, an HMAC-SHA256 of <t>.<delivery id>.<raw body>.
Your receiver has three jobs:
- Verify the V2 signature over the raw bytes, in constant time.
- Reject deliveries older than 5 minutes.
- Dedupe by delivery id. send21 retries failed deliveries (up to 8 attempts), so the same delivery can arrive more than once. Use its delivery id to ignore repeats.
A minimal Node receiver, no dependencies. The signature lines come from the send21 docs. The HTTP wrapper and the in-memory dedupe are ours, and we tested them locally with self-signed sample deliveries, not live send21 traffic:
import http from 'node:http';
import crypto from 'node:crypto';
const secret = process.env.SEND21_WEBHOOK_SECRET; // shown once when you register the endpoint
const handled = new Set(); // demo only: use a database table with a unique key in production
http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/send21') return res.writeHead(404).end();
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks); // raw bytes, before any JSON parsing
// X-Send21-Signature-V2: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<delivery id>.<raw body>">
const sig = String(req.headers['x-send21-signature-v2'] ?? '');
const t = /(?:^|,)t=(\d+)/.exec(sig)?.[1];
const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(sig)?.[1];
const deliveryId = String(req.headers['x-send21-delivery'] ?? '');
const fresh = t !== undefined && Math.abs(Date.now() / 1000 - Number(t)) <= 300;
const expected = crypto.createHmac('sha256', secret)
.update(t + '.' + deliveryId + '.').update(rawBody).digest('hex');
const ok = fresh && v1 !== undefined &&
crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
if (!ok) return res.writeHead(401).end();
// Retries reuse X-Send21-Delivery, so ignore ids you've already handled.
if (handled.has(deliveryId)) return res.writeHead(200).end();
handled.add(deliveryId);
const { event, data } = JSON.parse(rawBody);
if (event === 'draft.confirmed') {
// Check orderId, amount and asset against your own order before you ship anything.
console.log('paid', data.orderId, data.sentAmount, data.sentCurrency, data.network);
}
res.writeHead(200).end();
});
}).listen(3000);
Any 2xx counts as delivered. Endpoints must be public HTTPS. Reconcile on orderId and the exact amount, not on arrival time, and never mark an order paid from the browser.
Rules worth pasting into your agent
The send21 docs ship a rules block for AGENTS.md, CLAUDE.md or Cursor rules. The short version:
- Create pay links with
create_payment_request, withorderIdandreuseOpenForOrder: true. - Only use approved addresses. On
422 address_not_approved, propose the address and tell the owner. - Call
get_account_statuswhen something is blocked. - Amount mismatches are the owner's to decide.
- Never hold keys, sign or send payments. The payer signs in their own wallet.
Next steps
- Runnable code: send21-examples has an MCP pay-link example, webhook receivers, and LangChain, Vercel AI SDK and Shopify samples.
- No account yet? The testnet demo runs the whole flow on test networks, with no sign-up.
- MCP reference (tools, resources, prompts): send21.io/docs/mcp.
Questions: info@send21.io.
Written by send21.
來源:DEV Community · dev.to