Skip to main content

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.

Your mail server, your address

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.

Plan requirement

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 campaign100, or up to 10,000 on request
Recipient listCSV or JSON, 10 MB uploaded or 5 MB inline, 200 columns
AttachmentOne PDF per email, up to 10 MB
Campaigns sending at once1 per account
SpeedAbout 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​

FieldRequiredDescription
template_idYesA saved template
connection_idYesA sender from GET /mailmerge/connections
inputYesExactly 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.subjectYesUp to 500 characters
email.body_textYesThe plain-text body
email.body_htmlNoYour own HTML body; filled values are HTML-escaped
email.attachment_filenameNoDefaults to the template name; .pdf is added
email.list_unsubscribeNoA mailto: or https:// target for the List-Unsubscribe header
email_columnNoThe column with the addresses; detected from headers such as Email
mappingNoTemplate field key → column. Fields you leave out are matched by key or label
options.dedupeNoDefault true: an address repeated in several rows gets one email
nameNoUp to 120 characters
email_notificationNofalse skips the "campaign finished" email to you
start + consentNo"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 _. So Invoice # becomes {{invoice}} and First Name becomes {{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 99 and 20%.
  • 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.

ErrorWhy
400 MAILMERGE_CONSENT_REQUIREDNo consent
403 INSUFFICIENT_CREDITSNot enough credits for every valid row
409 CONNECTION_NOT_VERIFIEDSend a test email through the sender first
409 MAILMERGE_CAMPAIGN_ALREADY_ACTIVEAnother campaign is sending
409 MAILMERGE_NOT_DRAFTAlready 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:

StatusMeaning
draft, queuedNot sending yet
processingSending
completedEvery valid row was sent
partial_failedSome 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
failedNothing was sent
cancelledYou 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 statusMeaning
sentYour mail server accepted the email
rejectedThe server refused the address
failedThe PDF could not be made, or the server refused the message
unconfirmedThe connection dropped after the message was sent. It may have been delivered, so it is not sent again
skippedInvalid, 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."
}'
FieldRule
requested_rowsMore than your current limit, at most 10,000
use_case30 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.