DEV Community

send21
send21

Posted on

Let your agent pay a local helper, while you still sign

Here's a small, concrete case: your own agent arranges a physical task with someone nearby you already know and trust. A photo check of a delivery at the front door, for example, for 3 EUR. When the helper reports it done, somebody has to pay them.

You probably don't want the agent to hold funds for that. You also don't want to type an address and an amount by hand every time. This post shows a middle path, in an open-source example: agent-hires-local-helper.

The split is simple: the agent prepares the payment, you sign it in your own wallet. send21 (crypto pay links and API for people and AI agents) sits in between. It creates the payment draft; it never holds keys, never signs and never broadcasts. The agent's key can create and read drafts, but no key can move funds.

The owner review page:

The flow

  1. Task card. The task, where, the agreed amount, and the helper's receiving address in groups of four. The helper gave you that address once; it lives in helper.json. The agent reads it from there and can't change it.
  2. Task done → two MCP checks and one draft. The agent checks the address rules over the send21 MCP server (https://send21.io/mcp) and creates a payment draft with the REST API, with the same scoped key.
  3. Owner review. You see the amount, a QR code, the full address, whether it matches the saved one, and a plain list of what the agent did. One button: Open the payment to sign in your wallet.
  4. Paid. After a signed draft.confirmed webhook (or the draft status turning Confirmed).

Why a draft, not a pay link

send21 has two building blocks. A pay link asks to get paid, and send21 says pay links need the owner's own approved receiving addresses, while drafts may pay any approved address. The helper's address isn't yours, so paying it is a draft. That also works on default accounts, where the receiving-address policy is ApprovedOnly, once you've approved the helper's address.

The calls

From lib/agent.js, shortened:

const status = await mcp.callTool("get_account_status");
// -> receivingAddressPolicy

const book = await mcp.callTool("list_address_book");
const entry = book.find((e) => e.address === helper.address && e.network === helper.network);
const approved = Boolean(entry?.verifiedAt);

if (status.receivingAddressPolicy === "ApprovedOnly" && !approved) {
  // The agent can't approve addresses. It stops, tells the owner, creates nothing.
  return { stopped: true, reason: "The helper's address is not approved in send21 yet..." };
}

const draft = await api.post("/drafts", {
  receiverAddress: helper.address,          // from helper.json, never from a message
  network: helper.network,                  // "Solana"
  currency: helper.currency,                // "Usdt"
  paymentMethod: "MultiOutput",
  singleOutput: true,                       // one QR payment from any wallet
  fiatCurrency: task.fiatCurrency,          // "EUR"
  fiatAmount: task.fiatAmount,              // 3
  memo: `${task.title}: done (task ${task.id})`,
}, { idempotencyKey: `helper-task-${task.id}` });
Enter fullscreen mode Exit fullscreen mode

The draft goes through POST /drafts rather than the MCP create_draft tool because singleOutput: true makes it one QR payment any wallet can pay, with the send21 fee billed to the account. create_draft puts the fee in a separate output, which suits batching wallets.

The Idempotency-Key from the task id is the part we'd copy into any agent. Agents retry. A webhook fires twice, a message gets processed again, someone presses "done" twice. With the same key, send21 returns the original draft instead of creating a second one, and the same key with a different receiver, amount, currency or network is refused. npm run agent shows it: it asks twice and gets the same draft id back. Drafts have no orderId or reuseOpenForOrder; those exist only for pay links, so the task id lives in the key and the memo.

A fiat price is locked for 15 minutes. If the lock runs out while the draft is still within its life, the agent re-quotes it (POST /drafts/{id}/requote) instead of creating another one.

Asset and network are checked before anything is sent: USDT only on Solana or Ethereum, USDC for example on Solana, Polygon or Arbitrum. The example run used USDT on Solana.

After creating the draft, the example compares the draft's receiverAddress with the saved one, every character. Only an entry you verified in your send21 address book is shown as pre-approved; otherwise the UI says Matches the helper address you saved.

The webhook check

The task is marked paid on the server, from a signed webhook, never from the browser. First the signature (lib/webhook.js):

// X-Send21-Signature-V2: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<delivery id>.<raw body>">
if (!t || !v1 || Math.abs(now - Number(t)) > 300) return false;   // older than 5 minutes: refuse
const expected = crypto.createHmac("sha256", secret)
  .update(`${t}.${deliveryId ?? ""}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
Enter fullscreen mode Exit fullscreen mode

Then the content (server.js). A valid signature isn't enough; the payment has to be this task's payment:

if (handledDeliveries.has(delivery)) return res.end("already handled"); // retries reuse X-Send21-Delivery
const expected = event === "draft.confirmed" && data?.draftId === draft.id
  && String(data.sentCurrency).toLowerCase() === String(draft.currency).toLowerCase()
  && data.network === draft.network
  && Number(data.amountSats) === Number(draft.amountBaseUnits);  // billed amount in base units
Enter fullscreen mode Exit fullscreen mode

orderId is only set for pay-link payments, so a draft is matched by its draftId. An unsigned delivery gets a 401. To see the Paid screen without paying, npm run local-paid signs a test delivery with a local secret, posts it twice (the second is ignored as already handled), and the UI labels it Local test webhook so it's never mistaken for a real payment.

Use it from your own agent

Any MCP client can make the same checks:

{ "mcpServers": { "send21": { "url": "https://send21.io/mcp", "headers": { "Authorization": "Bearer s21_YOUR_KEY" } } } }
Enter fullscreen mode Exit fullscreen mode

with a rule like: When a helper task is reported done, create a payment draft to the helper's saved address with the task id in the Idempotency-Key (create_draft with idempotencyKey, or POST /drafts), then give me the draft to sign. Never sign or send payments.

The limits, honestly

  • Nobody checks the work. "Done" means someone pressed Task done. send21 doesn't hold the money in between and doesn't judge the result. Use this for helpers you know and trust.
  • It doesn't find helpers. You bring the person and their address.
  • Address rules on new accounts. New accounts use the receiving-address policy ApprovedOnly: API keys can only use addresses the owner approved in the send21 app with a passkey or an authenticator code, and the agent stops if the helper's isn't one. Approve the helper's address first. Our test run used an account with inline addresses allowed, so the UI said Matches the helper address you saved.
  • It's a demo. One task in memory, no database. The webhook needs a public HTTPS endpoint.
  • Live networks. Account keys use live networks; a draft moves no money until someone signs it. Test with a small amount to your own address and cancel the draft (npm run agent -- --cancel <id>). The testnet demo runs in the browser with no account.
  • Check the address in your wallet. The review screen helps, but the wallet's confirm screen is the last word. A phished or forged link is still a risk.

Run it

git clone https://github.com/send21io/send21-examples
cd send21-examples/examples/agent-hires-local-helper
npm install
npm test                 # no key, no network
cp .env.example .env     # scoped key from https://send21.io/api-keys
npm start                # http://localhost:3022
npm run agent            # the agent flow in the terminal
Enter fullscreen mode Exit fullscreen mode

If you've built something similar, we'd like to hear how you decide when "done" is done. Questions: info@send21.io

Top comments (0)