Skip to main content

Developer / API

Mail merge API: CSV import to personalized letters

Justin WinterBy · 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:

  1. Parse your CSV locally (Papa Parse in the browser importer, or csv.DictReader, pandas, whatever your script already uses).
  2. Send the header row and a few sample rows to POST /v1/csv-map. It proposes which column is name, line1, line2, city, state, and zip — 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.
  3. Loop your rows and POST /v1/orders once per recipient, substituting the mapped columns into to. 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 todayNot built — do not assume
CSV header→address mapping via /v1/csv-mapAutomatic body-text templating from a CSV column
One /v1/orders call per recipient, looped client-sideA server-side campaign/batch endpoint that sends N letters from one call
Letters, First-Class through PriorityPostcards, checks, or any non-letter format
US mailing addressesInternational recipients
15/day, 60/month per unverified actorUncapped 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 classPrice
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.

Mail merge API: CSV import to personalized letters

First-Class from $1.99/letter

Try /v1/orders now