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
8. GET /mailmerge/campaigns/{id}/unsent CSV of the rows that were not sent, to fix or send again
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), s3_key (from POST /mailmerge/uploads) or retry_of (a finished campaign whose unsent rows to send; the other fields then default to that campaign's) |
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.
After it finishes, a campaign that left rows unsent has unsent_available: true, with unsent_count and an unsent_summary by reason. outcome_counts says how every row ended, keyed by sent or the row's error code. See unsent rows.
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.
Unsent rows
A campaign that finishes with rows it did not send keeps those rows for 30 days, so you can fix them or send them again. Only emails that are sent are ever charged.
GET /mailmerge/campaigns/{id}/unsent
Returns a one-hour link to a CSV with your list's own columns, followed by why each row was not sent:
Email,Name,Amount,unsent_status,unsent_error_code,unsent_error,original_row
bo@example,Bo Chan,88,skipped,DATA_INVALID,Email is not a valid email address,3
cy@example.com,Cy Diaz,-12.50,rejected,RECIPIENT_REJECTED,550 no such user,4
original_row is the row's number in the list you sent (the header is row 1). Correct the file and use it as the input of a new campaign; the four added columns are ignored unless you map them. A campaign with nothing unsent, or one that finished more than 30 days ago, answers 404 MAILMERGE_UNSENT_NOT_AVAILABLE.
Send them again
Create a campaign with input.retry_of set to the finished campaign. The new draft takes that campaign's template, sender, column mapping and email, so input is all you need to send; any field you do include replaces the saved one.
curl -X POST https://api.podpdf.com/mailmerge/campaigns \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"input": { "retry_of": "YOUR_CAMPAIGN_ID", "include": ["failed", "not_attempted"] }
}'
include | Rows | Default |
|---|---|---|
failed | The email could not be sent, or its PDF could not be made | Yes |
not_attempted | The campaign stopped or was cancelled before reaching them | Yes |
rejected | Your mail server refused the address. It will refuse it again unless you fix it in the CSV | No |
unconfirmed | The connection dropped after sending. They may have arrived, so sending again can deliver a second copy | No |
Rows with an invalid address or missing data are in the CSV only. The response is the usual 201 draft, with retry_of naming the first campaign; start it like any other. It is checked against your row limit and credits, and needs your consent, in the same way.
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.