Mail merge
Email a personalised PDF to every row of a spreadsheet. A campaign takes one of your saved templates, a recipient list and an email with {{variables}}. It renders one PDF per row and sends it as an attachment from your own mailbox.
Campaigns send through a sender connection: the SMTP details of your own mailbox (Gmail, Microsoft 365, Zoho, Brevo or any other provider). Recipients see your address, replies come to you, and your provider's sending limits apply. Add senders in the dashboard under Mail merge → Senders. They hold a password, so an API key can list them but not create them.
Mail merge is included on paid plans (mailmerge_enabled); the Free plan returns 403 MAILMERGE_NOT_ENABLED.
For a step-by-step walkthrough, see the mail merge guide.
Flow
1. GET /mailmerge/connections pick a verified sender
2. POST /mailmerge/campaigns draft + validation report (or start: true to send now)
3. POST /mailmerge/campaigns/{id}/preview one row's email and PDF, free
4. POST /mailmerge/campaigns/{id}/test one row to yourself, real PDF
5. POST /mailmerge/campaigns/{id}/start queue it (needs consent)
6. GET /jobs/{id} progress, or wait for a mailmerge.campaign.* webhook
7. GET /mailmerge/campaigns/{id}/report CSV with what happened to every row
Limits
| Rows per campaign | 100, or up to 10,000 on request |
| Recipient list | CSV or JSON, 10 MB uploaded or 5 MB inline, 200 columns |
| Attachment | One PDF per email, up to 10 MB |
| Campaigns sending at once | 1 per account |
| Speed | About 100 emails a minute per parallel connection, depending on your mail server (senders use 1–4, default 2) |
Each email your mail server accepts costs one PDF credit. Rows that fail, are rejected or are skipped are not charged. Starting needs credits for every valid row.
GET /mailmerge/connections
Lists your senders; passwords are never returned. Only verified senders can start a campaign.
curl https://api.podpdf.com/mailmerge/connections \
-H "X-API-Key: your_api_key_here"
{
"connections": [
{
"connection_id": "01J9A1B2C3D4E5F6G7H8J9K0LM",
"label": "Billing mailbox",
"from_email": "billing@acme.com",
"from_name": "Acme Billing",
"smtp": { "host": "smtp.office365.com", "port": 587, "security": "starttls", "username": "billing@acme.com" },
"max_parallel": 2,
"status": "verified",
"has_secret": true
}
],
"count": 1,
"max_connections": 3
}
POST /mailmerge/campaigns
Creates a draft. Every row is checked against the template before anything is sent.
curl -X POST https://api.podpdf.com/mailmerge/campaigns \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "October statements",
"template_id": "YOUR_TEMPLATE_ID",
"connection_id": "YOUR_CONNECTION_ID",
"input": {
"rows": [
{ "Email": "ann@example.com", "Name": "Ann Lee", "Amount": "1204.50" },
{ "Email": "bo@example.com", "Name": "Bo Chan", "Amount": "88.00" }
]
},
"mapping": { "customer.name": "Name", "amount": "Amount" },
"email": {
"subject": "Your statement, {{customer.name}}",
"body_text": "Hello {{customer.name}},\nYour statement for {{amount}} is attached.",
"attachment_filename": "Statement {{customer.name}}"
}
}'
Request fields
| Field | Required | Description |
|---|---|---|
template_id | Yes | A saved template |
connection_id | Yes | A sender from GET /mailmerge/connections |
input | Yes | Exactly one of rows (array of objects, or arrays with input.columns), csv (a CSV string, up to 5 MB) or s3_key (from POST /mailmerge/uploads) |
email.subject | Yes | Up to 500 characters |
email.body_text | Yes | The plain-text body |
email.body_html | No | Your own HTML body; filled values are HTML-escaped |
email.attachment_filename | No | Defaults to the template name; .pdf is added |
email.list_unsubscribe | No | A mailto: or https:// target for the List-Unsubscribe header |
email_column | No | The column with the addresses; detected from headers such as Email |
mapping | No | Template field key → column. Fields you leave out are matched by key or label |
options.dedupe | No | Default true: an address repeated in several rows gets one email |
name | No | Up to 120 characters |
email_notification | No | false skips the "campaign finished" email to you |
start + consent | No | "start": true, "consent": {"confirmed": true} queues the campaign straight away (202). If starting is refused, the draft is kept and the error's details.campaign_id names it |
Variables
{{variables}} in the subject, body and attachment name can name:
- a template field:
{{customer.name}}, formatted exactly as in the PDF (currency, dates) - any column by its key: the column name lowercased, with other characters turned into
_. SoInvoice #becomes{{invoice}}andFirst Namebecomes{{first_name}}
The response lists any variable that matches nothing as unknown_tokens.
Spreadsheet values
- Numbers. Cells are text. Numeric fields accept
1,234.50,$12.00,USD 99and20%. - Dates must be ISO:
2026-10-31. - Lists. A list field needs a JSON array in its cell.
Response 201 Created
{
"campaign": {
"campaign_id": "7c1e4b2a-...",
"status": "draft",
"name": "October statements",
"row_count": 2,
"valid_row_count": 2,
"invalid_row_count": 0,
"duplicate_count": 0
},
"validation": {
"row_count": 2,
"valid_row_count": 2,
"invalid_row_count": 0,
"duplicate_count": 0,
"email_column": "Email",
"mapping": { "customer.name": "Name", "amount": "Amount" },
"unmapped_required_fields": [],
"unknown_tokens": [],
"invalid_rows": [],
"duplicates": []
}
}
invalid_rows gives each problem row with its spreadsheet row number (row, where the header is row 1) and the reasons. At most 100 are listed.
POST /mailmerge/uploads
For lists over 5 MB: get a signed URL, PUT the file, then pass its s3_key as input.s3_key.
curl -X POST https://api.podpdf.com/mailmerge/uploads \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{ "content_length_bytes": 48213, "content_type": "text/csv" }'
curl -X PUT "$UPLOAD_URL" -H "Content-Type: text/csv" --data-binary @recipients.csv
POST /mailmerge/campaigns/{id}/preview
{"row_index": 3} (0 is the first data row; defaults to the first valid row). Returns the recipient, subject, text and HTML bodies, attachment name, headers, and the template HTML for that row. Free.
POST /mailmerge/campaigns/{id}/test
{"row_index": 3, "to": "you@acme.com"}. Renders that row's real PDF and sends it to to (default: your account email) through the sender, with [Test] before the subject. Charged as one PDF, up to 5 per campaign. A successful test also verifies an unverified sender.
POST /mailmerge/campaigns/{id}/start
curl -X POST https://api.podpdf.com/mailmerge/campaigns/YOUR_CAMPAIGN_ID/start \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{ "consent": { "confirmed": true } }'
confirmed: true states that every recipient agreed to receive these emails and that you send them in line with anti-spam and privacy law. It is recorded with the time and IP address.
| Error | Why |
|---|---|
400 MAILMERGE_CONSENT_REQUIRED | No consent |
403 INSUFFICIENT_CREDITS | Not enough credits for every valid row |
409 CONNECTION_NOT_VERIFIED | Send a test email through the sender first |
409 MAILMERGE_CAMPAIGN_ALREADY_ACTIVE | Another campaign is sending |
409 MAILMERGE_NOT_DRAFT | Already started |
Progress: GET /jobs/{id}
A campaign is a job with job_type: "mailmerge":
{
"job_id": "7c1e4b2a-...",
"job_type": "mailmerge",
"status": "processing",
"campaign_name": "October statements",
"valid_row_count": 2398,
"sent_count": 1204,
"failed_count": 2,
"rejected_count": 3,
"unconfirmed_count": 0,
"skipped_count": 5,
"report_available": false
}
Statuses:
| Status | Meaning |
|---|---|
draft, queued | Not sending yet |
processing | Sending |
completed | Every valid row was sent |
partial_failed | Some rows were not sent. error_code names a campaign-wide stop: CONNECTION_AUTH_FAILED (the server stopped accepting the password), PROVIDER_UNAVAILABLE (it kept deferring) or INSUFFICIENT_CREDITS |
failed | Nothing was sent |
cancelled | You stopped it |
Stop a campaign with POST /jobs/{id}/cancel. Emails already sent are charged; the rest are skipped.
GET /mailmerge/campaigns/{id}/recipients
?status=failed&limit=100&cursor=... returns per-row results in row order.
| Row status | Meaning |
|---|---|
sent | Your mail server accepted the email |
rejected | The server refused the address |
failed | The PDF could not be made, or the server refused the message |
unconfirmed | The connection dropped after the message was sent. It may have been delivered, so it is not sent again |
skipped | Invalid, duplicate, or not reached because the campaign stopped |
POST /mailmerge/limit-request
Campaigns send up to 100 recipients each. For more (up to 10,000), ask with the number you need and what you will send. We review requests within one business day. Once granted, GET /accounts/me returns the new mailmerge.max_rows.
curl -X POST https://api.podpdf.com/mailmerge/limit-request \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"requested_rows": 2500,
"use_case": "Monthly statements to our 2,400 business customers, who asked to receive them by email."
}'
| Field | Rule |
|---|---|
requested_rows | More than your current limit, at most 10,000 |
use_case | 30 to 2,000 characters: what you send and who receives it |
You can send one request a day (429 LIMIT_REQUEST_PENDING otherwise). A list over your limit is refused with 400 MAILMERGE_TOO_MANY_ROWS, whose details.max_rows is your current limit.
GET /mailmerge/campaigns/{id}/report
Once the campaign has finished, this returns a one-hour link to a CSV:
row,email,status,error_code,error_message,message_id,pages,sent_at
2,ann@example.com,sent,,,<abc@acme.com>,1,2026-09-30T10:04:12.000Z
3,bo@example,skipped,DATA_INVALID,Email is not a valid email address,,,
The report is kept for 30 days. GET /jobs/{id}/download returns the same link.
Webhooks
mailmerge.campaign.completed, mailmerge.campaign.partial, mailmerge.campaign.failed and mailmerge.campaign.cancelled are sent when a campaign finishes. They carry the counts, error_code and a report_url. See webhooks.