Developer / API
Mail merge API: CSV import to personalized letters
By Justin Winter · Updated August 22, 2026
paperplane doesn't have a single 'send campaign' endpoint — it has two primitives that add up to mail merge: POST /v1/csv-map reads your spreadsheet's header row and proposes which column is the name, street, city, state, and ZIP, then you loop POST /v1/orders once per row to print and mail each letter, First-Class from $1.99.
Search for a mail merge API and you will find hosted services charging a monthly fee to guess spreadsheet columns. paperplane's version of that is one small endpoint (POST /v1/csv-map) plus the same order endpoint every single letter already uses (POST /v1/orders) — called once per recipient. No campaign object, no subscription, no minimum volume.
What actually happens
There is no server-side batch job. The flow is two calls, run by your own code:
- Parse your CSV locally (Papa Parse in the browser importer, or
csv.DictReader, pandas, whatever your script already uses). - Send the header row and a few sample rows to
POST /v1/csv-map. It proposes which column isname,line1,line2,city,state, andzip— using an LLM call when an API key is configured, a deterministic alias match otherwise. It never sees your full recipient list, and you confirm or override the mapping before anything gets mailed. - Loop your rows and
POST /v1/ordersonce per recipient, substituting the mapped columns intoto. Each call is a complete, independent order — its own price, its own status, its own tracking number if the mail class has one.
A real request
Map the columns once. This is the exact shape /v1/csv-map accepts and returns — nothing invented:
curl -X POST https://paperplane.com/v1/csv-map \
-H 'Content-Type: application/json' \
-d '{
"headers": ["Full Name", "Street", "City", "ST", "ZIP"],
"samples": [["Ada Lovelace", "1 Analysis Way", "San Francisco", "CA", "94107"]]
}'
# { "status": "ok",
# "mapping": { "name": ["Full Name"], "line1": ["Street"],
# "city": ["City"], "state": ["ST"], "zip": ["ZIP"] },
# "source": "llm" }Then loop rows and create one order per recipient, personalizing text yourself per row:
import csv, requests
mapping = {"name": ["Full Name"], "line1": ["Street"],
"city": ["City"], "state": ["ST"], "zip": ["ZIP"]}
for row in csv.DictReader(open("recipients.csv")):
body = {
"mail_class": "first_class",
"sandbox": True, # flip to False once the run looks right
"to": {
"name": row[mapping["name"][0]],
"line1": row[mapping["line1"][0]],
"city": row[mapping["city"][0]],
"state": row[mapping["state"][0]],
"zip": row[mapping["zip"][0]],
},
"from": {"name": "Acme LLC", "line1": "12 Grove Ave",
"city": "Richmond", "state": "VA", "zip": "23221"},
# your own merge field: build the body text per row before you send it
"text": f"Hi {row['First Name']}, your renewal is due {row['Renewal Date']}.",
}
r = requests.post("https://paperplane.com/v1/orders", json=body)
print(row[mapping["name"][0]], r.json().get("status"))Try the letter-send call live
Every row your loop produces is this same call — here it is in curl, Python, and JavaScript, and a button that actually fires it at this site's live POST /v1/orders with sandbox: true, so you can see the real response shape (including a real rate-limit error, if you trip one) before you wire up your own loop.
curl -X POST https://sendpaperplane.com/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"mail_class": "first_class",
"sandbox": true,
"text": "Dear Alex,\n\nThis is a real letter sent through an API.",
"to": {
"name": "Alex Rivera",
"line1": "12 Grove Ave",
"city": "Richmond",
"state": "VA",
"zip": "23221"
},
"from": {
"name": "Jordan Lee",
"line1": "1 Main St",
"city": "Richmond",
"state": "VA",
"zip": "23220"
}
}'Try it — real sandbox request
Fires the request above at this site's live API with sandbox: true. Nothing is charged or mailed.
What this is not
| Real today | Not built — do not assume |
|---|---|
CSV header→address mapping via /v1/csv-map | Automatic body-text templating from a CSV column |
One /v1/orders call per recipient, looped client-side | A server-side campaign/batch endpoint that sends N letters from one call |
| Letters, First-Class through Priority | Postcards, checks, or any non-letter format |
| US mailing addresses | International recipients |
| 15/day, 60/month per unverified actor | Uncapped marketing-list volume |
If your spreadsheet is a few dozen renewal notices, invoices, or welcome letters, this is exactly the shape of tool that fits. If you are trying to run a five-thousand-piece direct-mail campaign, the per-actor caps above will stop you well before row 100 — talk to us about a verified account first, or this is the wrong tool for that job today.
Pricing per letter
Every row in the loop is priced individually, same as a single send — no bulk discount tier exists yet:
| Mail class | Price |
|---|---|
| First-Class | $1.99 |
| Certified Mail | $12.99 |
| Certified + electronic Return Receipt | $14.99 |
| Priority Mail | $24.99 |
Run it in sandbox: true first — every row goes through the full validation and mapping path with nothing actually printed or mailed, so you can catch a bad column mapping before it costs anything.
Related guides
Common questions
Is this the same as Word or Gmail mail merge?
The starting point is familiar — a spreadsheet of recipients, one letter template — but the mechanics differ. Word mail merge produces one merged document; paperplane's flow produces one API order per row, each independently priced, tracked, and status-checkable. There is no single "merge and print" button — /v1/csv-map only maps address columns (name, line1, line2, city, state, zip). It does not template your letter body for you; you build the personalized text per row yourself before each POST /v1/orders call, the same way you would insert a merge field.
Is there a bulk-send or campaign endpoint?
No. Every letter, whether it is your first or your five-hundredth, goes through the same POST /v1/orders that a single send uses. "Bulk" is a client-side loop over your CSV rows, not a server-side batch job — which also means a bad row fails on its own without touching the others.
How many letters can I send in one run?
Without a verified account, sending is capped per actor (API key, or IP if you have none) at 15 letters/day and 60/month, plus a $50/day and $200/month spend cap — enough to test a real workflow but not a marketing campaign. A 300-row CSV will hit the cap partway through and paperplane returns a velocity_limit error with a next step rather than silently queueing the rest. Contact support for a verified higher-volume account before you build against a bigger list.
Does the CSV importer see my recipient list?
CSV parsing happens in your browser or your own script. POST /v1/csv-map only ever receives the header row plus up to three sample rows — never your full recipient list — to propose the column mapping. The full list only leaves your machine one row at a time, as each becomes its own POST /v1/orders call.