How AI agents mail physical letters via MCP
By Justin Winter · Updated August 22, 2026
paperplane runs a real MCP server at /api/mcp with exactly three tools: quote_letter (free, read-only, mints a confirmation_token), send_letter (spends money, mails a real letter, requires that token), and get_letter_status (read-only). The quote→send split is a deliberate safety boundary, not incidental API design: an agent can never mail a real, priced object on its first call.
If you are building or configuring an AI agent that needs to put ink on paper and a stamp on an envelope, this page documents the actual mechanism: what the three MCP tools do, why sending is gated behind a separate pricing step, and how to handle the errors that step produces. Everything below matches the live implementation at app/api/[transport]/route.ts — nothing here is aspirational.
The MCP endpoint
paperplane exposes an MCP server over streamable HTTP at /api/mcp. Point any MCP-compatible client at that URL and three tools appear:
quote_letter— read-only, free, no auth. Prices a letter (postage, class fee, page and color add-ons) and returns the all-in total. Mail classes:first_class($1.99, 1 page),certified($12.99, proof of mailing + USPS tracking),certified_err($14.99, adds a court-admissible electronic Return Receipt), andpriority($24.99, flat-rate envelope). Pass it alone and you get a price with noconfirmation_token; pass it the sametoaddress andtext(orpdf_url) you actually intend to send, and it also mints a token bound to that exact letter.send_letter— write, spends money, puts a real physical object in the mail. Not reversible once printed. It requires theconfirmation_tokenfrom a matchingquote_lettercall and re-derives the same binding from its own arguments — any change to the recipient, mail class, color, tracking, content, or price invalidates it. Content is text (which paperplane typesets) or apdf_url. On success it returns a Stripepayment_urlfor a human to approve, or — ifsandbox: truewas passed — completes the whole pipeline instantly with no payment and nothing actually mailed.get_letter_status— read-only. Looks up an order byorder_idand returns its lifecycle state (e.g. pending payment, screening, submitted, mailed, delivered).
There is also an agent discovery document at /.well-known/agent.json, and each tool's MCP annotations (readOnlyHint, destructiveHint, etc.) mark exactly which of the three is the one write operation — so a client or reviewer that gates tools by risk can see the shape of the surface without reading the implementation.
Why quote and send are two separate tools
This is the part worth understanding, not just using. send_letter is about as consequential as an agent-invoked tool call gets: it spends real money and puts a physical object in a stranger's mailbox, and once the letter is printed there is no undo. A single-call "send this letter" tool would mean any agent that got steered — by a bad prompt, a poisoned web page, or a simple mistake in the arguments it constructed — could mail something nobody actually priced or approved.
So sending is never the first call. quote_letter is free, read-only, and creates nothing — it mints a short-lived confirmation_token that cryptographically binds the recipient address, mail class, color/tracking options, a digest of the letter content, and the exact price. send_letter requires that token and independently recomputes the same binding from the arguments it was actually given. If anything drifted — the address was "corrected," the letter text was edited, tracking was toggled — the hashes no longer match and the send is rejected. The token also expires after 30 minutes and can be used exactly once, so neither an old quote nor a replayed token can authorize a second letter.
The result: an agent (or a human driving one) always sees a firm price before anything is mailed, and cannot substitute a different letter underneath a price the user already approved.
Worked example
Step 1 — price the letter and get a token, passing the real recipient and content:
quote_letter({
mail_class: "certified_err",
page_count: 1,
to: { name: "Property LLC", line1: "1 Main St",
city: "Richmond", state: "VA", zip: "23220" },
text: "Formal demand for return of security deposit..."
})Response:
{
"status": "ok",
"total": "$14.99",
"total_cents": 1499,
"breakdown": [ "..." ],
"confirmation_token": "ppq_eyJ2...ab12.9f3c...",
"expires_in_minutes": 30,
"next": [
"Show the total to the user and get their go-ahead.",
"Then call send_letter with this confirmation_token and the SAME recipient, content, and options — any change invalidates it.",
"The token authorises exactly one letter."
]
}Step 2 — show the user the price, then call send_letter with that token and the identical recipient and content:
send_letter({
mail_class: "certified_err",
to: { name: "Property LLC", line1: "1 Main St",
city: "Richmond", state: "VA", zip: "23220" },
from: { name: "Alex Rivera", line1: "12 Grove Ave",
city: "Richmond", state: "VA", zip: "23221" },
text: "Formal demand for return of security deposit...",
confirmation_token: "ppq_eyJ2...ab12.9f3c...",
sandbox: true // omit or set false to actually mail it
})With sandbox: true, this returns immediately with a completed order and no payment step — a clean way to test an integration end-to-end before wiring in a real Stripe approval. Drop sandbox (or set it false) for a live send, and the response instead carries a payment_url: a human clicks it, approves the Stripe charge, and screening/printing/mailing happen automatically from there. Either way, poll get_letter_status(order_id) to watch the order move through its lifecycle.
Error handling
Every tool failure comes back as structured JSON — { status: "failed", code, reason, next } — so an agent can branch on code without parsing prose. The confirmation flow has its own error family:
confirmation_required—send_letterwas called with no token at all.confirmation_malformed— the token isn't a well-formed paperplane quote token.confirmation_invalid— the token's signature doesn't verify.confirmation_expired— more than 30 minutes passed sincequote_letterminted it.confirmation_mismatch— the token is real and unexpired, but at least one bound field (recipient, class, color, tracking, content, or price) doesn't match whatsend_letterwas just given.confirmation_used— the token already sent a letter; each quote authorizes exactly one send.
Example — the agent edited the letter text after quoting it:
{
"status": "failed",
"code": "confirmation_mismatch",
"reason": "This letter does not match the quote the confirmation_token was
issued for. Recipient, mail class, colour, tracking, letter
content, and price must all be unchanged between quote and send.",
"next": ["Re-quote with the parameters you actually intend to send, then
send with the new token."]
}In every case the fix is the same, and the tool says so in next: call quote_letter again with the parameters you actually intend to send, then send_letter with the fresh token. There is no way to patch an existing token — re-quoting is the only path forward, which is what keeps the binding meaningful.
MCP vs. the REST API
The MCP tools are a thin wrapper over the same backend a plain REST API uses, so pick whichever fits how your agent is built:
POST /v1/quotes— the REST equivalent ofquote_letter.POST /v1/orders— the REST equivalent ofsend_letter, sameconfirmation_tokenrequirement.GET /v1/orders/:id— the REST equivalent ofget_letter_status.
curl -X POST https://paperplane.app/v1/quotes \
-H 'Content-Type: application/json' \
-d '{ "mail_class": "certified", "page_count": 1 }'Use MCP when your agent already runs inside an MCP-speaking host (Claude Desktop, Claude Code, or another MCP client) and you just want to add a server URL. Use REST when you're calling from your own backend, a script, or a framework that has no MCP client and would rather make a plain HTTP request. The safety model — free quote, bound token, single-use, 30-minute expiry — is identical either way, because both surfaces sit on top of the same lib/confirmation.ts logic and the same order pipeline.
This page is the general technical reference for agent-driven mail over MCP. If you specifically want to connect Claude, see the Claude & MCP integration page for the client-config snippet; the full API surface (uploads, address validation, webhooks) is documented on the developer docs.
Related guides
Common questions
Can an agent accidentally mail something?
Not through a single call. send_letter requires a confirmation_token, and the only tool that mints one is quote_letter — which is read-only and free. A send always has a priced, bound quote behind it, and quote_letter itself never sends anything.
What exactly does the confirmation_token bind?
A hash of the recipient address, mail class, color, tracking, letter content, and price — all as passed to quote_letter. send_letter recomputes the same hash from the arguments it was actually given and rejects the send if any of them drifted, even a whitespace-only change to the letter text.
How long is a confirmation_token valid, and can it be reused?
Thirty minutes from when quote_letter mints it, and it is single-use — send_letter records it as consumed, so replaying the same token on a second send_letter call fails even if every parameter still matches.
Does the agent ever handle payment directly?
No. send_letter returns a Stripe-hosted payment_url; a human approves the actual charge. Passing sandbox: true skips payment entirely and runs the full pipeline (screening, "printing", tracking) for free, so an agent or its developer can validate the integration before any money is involved.
Why MCP instead of just a REST API?
Both exist and share the same backend. MCP is for agents already living inside an MCP-speaking client (Claude Desktop, Claude Code, other MCP hosts) — add the server URL and the model calls the tools directly. REST (POST /v1/quotes, POST /v1/orders, GET /v1/orders/:id) is for your own backend code, scripts, or any HTTP client that would rather not run an MCP client at all. The confirmation_token pattern and error shape are identical either way.