ai newsDo., 22. Okt.
EilmeldungClaude refused to finish patching my servers. My local LLM took over

Let an AI agent create crypto pay links over MCP (without handing it your keys)

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

dev.toDonnerstag 20:42 · 22.10.8 Min. Lesezeit
Bild: dev.to

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.

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 Enter fullscreen mode Exit fullscreen mode 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" } } } } Enter fullscreen mode Exit fullscreen mode VS Code (.vscode/mcp.json ):

{ "servers": { "send21": { "type": "http", "url": "https://send21.io/mcp", "headers": { "Authorization": "Bearer s21_YOUR_KEY" } } } } Enter fullscreen mode Exit fullscreen mode Claude Code with a key:

claude mcp add --transport http send21 https://send21.io/mcp \ --header "Authorization: Bearer s21_YOUR_KEY" Enter fullscreen mode Exit fullscreen mode Listing the tools and reading the fee schedule work without a key. Every other tool needs one (or a sign-in).

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." ] } Enter fullscreen mode Exit fullscreen mode 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.

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" } } Enter fullscreen mode Exit fullscreen mode 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.

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>" } ] } } Enter fullscreen mode Exit fullscreen mode Two arguments are worth having from day 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": [] } ] } Enter fullscreen mode Exit fullscreen mode 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.

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.

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.

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>.

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 ); Enter fullscreen mode Exit fullscreen mode 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.

The send21 docs ship a rules block for AGENTS.md, CLAUDE.md or Cursor rules. The short version:

Quelle: dev.to (dev.to). Original öffnen