Skip to content

API keys and webhooks

Mail Merge has a REST API and outgoing webhooks for developers and no-code builders. With an API key your scripts can do what the web app does: read your account, list templates and contacts, send campaigns and follow their recipients. With a webhook Mail Merge calls your endpoint the moment a campaign finishes or a recipient opens, clicks, replies, bounces or unsubscribes, so a CRM, a spreadsheet or a Slack channel stays in step without polling.

Both live under Settings → Developers in the web app and are part of the Professional plan (see Pricing). If you would rather talk to Mail Merge from an AI assistant than write code, use the MCP server instead: it exposes the same features as tools, on every plan.

  1. Open Settings → Developers → API keys and click Create key.
  2. Name it after where it will be used (Zapier, CRM sync), so you can revoke the right one later.
  3. Copy the key. It is shown once: Mail Merge keeps only a hash of it. If you lose it, revoke it and create a new one.

You can have up to 10 keys per account. Revoking a key fails every request made with it immediately; the row disappears from the list.

Send the key as a Bearer token on every request, against the base URL shown on the card:

Terminal window
curl https://merge.email/api/v1/me \
-H "Authorization: Bearer mm_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

A key acts as the account that created it, exactly like a signed-in session: the same Google grant, the same daily quota, the same plan limits. There is no separate “API user”.

The API is the same /v1 surface the web app uses, so anything you can do on a screen has an endpoint behind it. The ones integrations reach for most:

CallWhat it does
GET /v1/meThe account: plan, daily quota (max, used, remaining), timezone, template metadata.
GET /v1/templates · GET /v1/templates/{templateId}Your saved templates; the body loads per template.
GET /v1/contacts · POST /v1/contactsThe contact book (paginated with limit / cursor → nextCursor).
POST /v1/campaignsSend, schedule or autopilot a campaign from a Google Sheet. The body is the same one the campaign builder posts: the sheet, the recipient column, the subject and body with {{merge tags}}, and the sending options.
GET /v1/campaigns · GET /v1/campaigns/{campaignId}/recipientsYour campaigns and each recipient’s status (sent, read, clicked, replied, bounced, unsubscribed).
POST /v1/campaigns/{campaignId}/statusPause, resume or stop a running campaign.
POST /v1/recipients/verifyThe email verifier.

Request and response bodies are JSON. The full typed surface is generated from the server as an OpenAPI document; contact us if you need the schema for a client generator.

Failures are real HTTP statuses with one envelope:

{ "error": { "code": "professional_plan_required", "message": "API access is a Professional plan feature" } }
StatuscodeMeaning
401Unknown or revoked key, or the account’s Google access needs reconnecting.
403professional_plan_requiredThe key’s account is no longer on the Professional plan. Keys are not deleted on a downgrade; they work again when the plan does.
429rate_limitedMore than 600 requests per minute on one key. Wait a minute and retry.
400 / 404 / 409bad_request, not_found, conflict, …The request itself, with a message saying what to fix.

The rate limit is counted per key on each API server instance, so the effective ceiling is “about 600 per minute”: do not design around hitting it.

  1. Open Settings → Developers → Webhooks and click Add webhook.
  2. Enter an HTTPS URL (plain http:// is refused) and tick the events it should receive.
  3. Click Send test on the new row: Mail Merge posts a ping and the toast tells you how your endpoint answered.

Each endpoint gets its own signing secret (whsec_…), shown on the row with a copy button. You can have up to 5 endpoints per account.

EventWhendata
campaign.completedThe last email of a campaign was processed.campaignId, name, status: "completed", sentCount, campaignSize
campaign.paused · campaign.resumed · campaign.stoppedYou paused, resumed or stopped a campaign.campaignId, name, status (paused / active / stopped), sentCount
email.openedA recipient opened an email, first time only per campaign.campaignId, recipient, at
email.clickedA recipient clicked a tracked link, every click.campaignId, recipient, at, url
email.repliedA reply was detected on a sent thread.campaignId, recipient, at
email.bouncedA bounce was detected.campaignId, recipient, at
recipient.unsubscribedA recipient used the unsubscribe link, first time only per campaign.campaignId, recipient, at, method

Opens and clicks need tracking on for the campaign; replies and bounces come from the reply scan that follows every sent campaign.

Mail Merge sends one POST per event per subscribed endpoint, with a JSON body:

{
"id": "6f1c2d3e-…",
"event": "email.opened",
"createdAt": "2026-10-15T14:03:21.512Z",
"data": { "campaignId": "…", "recipient": "ada@example.com", "at": "2026-10-15T14:03:21.498Z" }
}

and three headers:

HeaderValue
X-MailMerge-EventThe event name, same as event in the body.
X-MailMerge-DeliveryThe delivery id, same as id. Use it to ignore a duplicate.
X-MailMerge-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the endpoint’s secret.

Answer with any 2xx within 5 seconds. On a network error or a 5xx Mail Merge retries once, after 2 seconds; a 4xx is taken as your verdict and not retried. The same delivery id is sent on the retry. After 20 failed deliveries in a row the endpoint is disabled and the row says so: fix the endpoint, then delete and add it again. Any successful delivery resets the counter.

Events are delivered at least once and in no guaranteed order. Opens in particular arrive in bursts (every mail client that renders the tracking pixel), so make your handler idempotent on X-MailMerge-Delivery or on (event, campaignId, recipient).

Always verify before trusting a payload. Compute the HMAC over the raw request body (the exact bytes, before any JSON parsing) and compare it in constant time:

// Node.js (Express with `express.raw({ type: "application/json" })`)
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyMailMergeSignature(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader ?? "")
return a.length === b.length && timingSafeEqual(a, b)
}
app.post("/mailmerge", (req, res) => {
if (!verifyMailMergeSignature(req.body, req.get("X-MailMerge-Signature"), process.env.MAILMERGE_WEBHOOK_SECRET)) {
return res.status(401).end()
}
const payload = JSON.parse(req.body)
// … handle payload.event / payload.data
res.status(204).end()
})
# Python
import hashlib, hmac
def verify_mailmerge_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")

No code needed: create a Catch Hook (Zapier Webhooks by Zapier), a Custom webhook (Make) or a Webhook trigger node (n8n), paste the URL it gives you as the endpoint, tick the events, and click Send test so the tool can sample the payload. The data fields become the variables you map into the next step (add a row to a sheet, update a CRM contact, post to Slack). Those tools do not verify signatures out of the box; the URL they give you is unguessable, which is the protection they rely on.

  • AI agents (MCP), if an assistant should drive Mail Merge for you instead of a script.
  • Campaigns, for what the statuses in the recipients endpoint mean.
  • Pricing, for what the Professional plan includes.