> ## Documentation Index
> Fetch the complete documentation index at: https://paperplane-justin-winter-s-projects.vercel.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Bulk sending (CSV import)

> Send many letters from a spreadsheet, with automatic header mapping.

## Overview

Import recipients from a CSV to send many letters. Two steps:

1. **Map the columns** — `POST /v1/csv-map` figures out which header is the name, street, city, state, zip, and the variable letter content.
2. **Create orders** — one `POST /v1/orders` per recipient with the mapped fields.

## Map the columns

Send the raw headers plus a few sample rows. The API returns a mapping onto
the fields it recognizes — `name`, `line1`, `line2`, `city`, `state`, `zip`
— each one an **array of matching header names**, not a single string
(usually one element; only present for the headers it actually found, so
`line2` is routinely absent from the response entirely):

```bash theme={null}
curl -X POST https://sendpaperplane.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"]]
  }'
```

```json theme={null}
{
  "status": "ok",
  "mapping": {
    "name": ["Full Name"],
    "line1": ["Street"],
    "city": ["City"],
    "state": ["ST"],
    "zip": ["ZIP"]
  },
  "source": "llm"
}
```

There is no `text` key in this mapping — the letter body isn't something
`/v1/csv-map` maps for you. If your spreadsheet has a per-row message column,
read it yourself by header name and pass it as `text` when you build each
order (see the example below).

<Note>
  The mapping is guessed via a model with a deterministic fallback (`source: "heuristic"` when no LLM is configured). Always **review** the mapping before sending real mail — a wrong mapping mails to the wrong address.
</Note>

## Send one per row

Iterate rows and create an order for each, substituting the mapped columns. Keep rate limits in mind (see [Rate limits](/docs/guides/rate-limits)).

```python theme={null}
import requests
import csv

mapping = { ... }  # from /v1/csv-map — each value is a list of header names, e.g. mapping["name"] == ["Full Name"]
MESSAGE_HEADER = "Message"  # your own per-row content column, not something csv-map maps

def first_header(field):
    headers = mapping.get(field)
    return headers[0] if headers else None

for row in csv.DictReader(open("recipients.csv")):
    payload = {
        "mail_class": "first_class",
        "sandbox": True,
        "to": {
            "name": row[first_header("name")],
            "line1": row[first_header("line1")],
            "city": row[first_header("city")],
            "state": row[first_header("state")],
            "zip": row[first_header("zip")],
        },
        "from": { "name": "You", "line1": "12 Grove Ave",
                  "city": "Richmond", "state": "VA", "zip": "23221" },
        "text": row[MESSAGE_HEADER],
    }
    requests.post("https://sendpaperplane.com/v1/orders", json=payload)
```

## Row validation

Bad rows (missing zip, non-numeric state, etc.) fail with a validation error and a fix instruction — handle each and continue. Never send a letter for a row that failed validation.
