Build with Maily
In a nutshell
Drive AI-powered newsletters and audience sync from any backend. JSON over HTTPS with a single Bearer-token auth header.
Overview
The Maily REST API lets you build a complete newsletter feature into your own product — without ever showing the Maily interface to your end users. Two headline use cases:
- Subscriber sync — capture leads from your signup forms and land them in a Maily list automatically.
- AI newsletter sends — call a single endpoint with a topic, and Maily writes the email in your brand voice, wraps it in your default template, and sends to your list.
Tip
The API is JSON over HTTPS. All requests must authenticate with an API key — see the next section.
Authentication
Authenticate every request with a Bearer token in the Authorization header. Generate an API key from your account at Settings → API.
The plaintext key is shown once at creation time and never again — copy it somewhere safe. Lost keys can be revoked and replaced, but never recovered.
Treat API keys like passwords: never commit them to source control, never embed them in client-side code, and never put them in a URL query string (they leak into logs, browser history, and referer headers). Use environment variables or a secrets manager, and rotate on any suspected exposure.
Authorization: Bearer mly_xxxxxxxxxxxxxxxx
Base URL & versioning
All endpoints live under a versioned prefix. The v1 prefix is the current version — breaking changes get a new version (v2, v3) and existing versions stay live for at least 12 months after deprecation notice. Non-breaking additions ship within the current version without notice.
https://www.usemaily.xyz/api/v1
Rate limits
Throttling is per API key, not per IP. Exceeded requests return 429 with a Retry-After header telling you when to retry.
| Endpoint | Limit |
|---|---|
| All reads + default | 60 / minute |
| POST /campaigns (AI cost) | 10 / minute |
| POST /campaigns/{id}/send | 5 / minute |
| POST /subscribers/batch | 20 / minute |
| POST /emails | 600 / minute |
| POST /emails/batch | 60 / minute (up to 100 per call) |
Plan limits
Your subscription plan applies to the API the same way it does to the dashboard. The two share one set of counters — subscribers added via API count toward your plan's subscriber cap, AI generations via API count toward your monthly AI quota, and so on.
Plan-limit errors return 402 Payment Required (a quota that needs upgrading) or 429 (an ephemeral monthly limit):
| Code | Meaning |
|---|---|
| subscriber_limit_reached | List cap hit — upgrade to add more contacts. |
| campaign_limit_reached | Total campaign cap hit on free plan. |
| ai_not_available | Your plan doesn't include AI generation. |
| ai_quota_exceeded | Monthly AI generation quota used up. |
| quota_exceeded | Monthly email sending cap hit. |
Errors
All errors return a consistent envelope. The errors object is only present on validation failures.
Status codes
| Status | Meaning |
|---|---|
| 200 | Success — resource returned in data. |
| 201 | Resource created. |
| 204 | Success, no content (e.g. unsubscribe). |
| 401 | Missing or invalid API key. |
| 402 | Plan limit reached — upgrade required. |
| 403 | Resource exists but doesn't belong to this key. |
| 404 | Resource not found. |
| 409 | Conflict (e.g. already-sent campaign). |
| 422 | Validation error — see errors. |
| 429 | Rate or quota limit. Retry after a backoff. |
| 502 | Upstream failure (e.g. AI provider). |
{
"error": {
"code": "validation_failed",
"message": "One or more fields are invalid.",
"errors": {
"email": ["The email field is required."]
}
}
}
List all lists
Return all email lists owned by the API key's user.
curl https://www.usemaily.xyz/api/v1/lists \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"id": 1,
"name": "Newsletter",
"description": null,
"total_subscribers": 2156,
"active_subscribers": 2104,
"created_at": "2026-01-12T10:14:23+00:00"
}
]
}
List subscribers
Paginated subscribers across your lists. Filterable by list, status, or free-text search.
Query parameters
list_idintegerOptionalScope to a single list.
statusstringOptionalOne of active, unsubscribed, bounced, complained.
searchstringOptionalMatch against email, first_name, last_name.
per_pageintegerOptional1 to 100, default 50.
curl "https://www.usemaily.xyz/api/v1/subscribers?list_id=1" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"id": 42,
"list_id": 1,
"email": "alice@example.com",
"first_name": "Alice",
"last_name": null,
"status": "active",
"subscribed_at": "2026-05-01T08:13:00+00:00",
"unsubscribed_at": null
}
],
"meta": {
"current_page": 1,
"per_page": 50,
"last_page": 1,
"total": 1
}
}
Create a subscriber
Add a subscriber to a list. Idempotent — re-posting the same (list_id, email) returns the existing record, and previously-unsubscribed contacts are reactivated.
Body parameters
emailstringRequiredA valid email address.
list_idintegerRequiredMust be one of your lists.
first_namestringOptionallast_namestringOptionalReturns 201 for net-new, 200 for idempotent re-add. The body matches a single subscriber from the index endpoint.
curl -X POST https://www.usemaily.xyz/api/v1/subscribers \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"list_id": 1,
"first_name": "Alice"
}'
Batch upsert subscribers
Bulk upsert up to 1,000 subscribers into one list in a single request — built for migrating an existing user base or batched ongoing sync. Each row follows the same idempotent rules as the single endpoint. A malformed email in one row is skipped and reported, never failing the whole batch.
Heads up
Unlike the single endpoint, batch does not emit a subscriber.created webhook per row (a 1,000-row import would flood your endpoint). Use POST /subscribers when you need a webhook per signup.
Body parameters
list_idintegerRequiredMust be one of your lists.
subscribersarrayRequiredArray of 1–1,000 objects. Each may include email (required), first_name, last_name, and tags[].
curl -X POST https://www.usemaily.xyz/api/v1/subscribers/batch \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"list_id": 1,
"subscribers": [
{ "email": "alice@example.com", "first_name": "Alice", "tags": ["imported"] },
{ "email": "bob@example.com", "first_name": "Bob" }
]
}'
{
"data": {
"list_id": 1,
"created": 842,
"updated": 153,
"reactivated": 4,
"invalid": 5,
"skipped_over_cap": 0,
"invalid_examples": ["bad@", "not-an-email"]
},
"meta": { "received": 1000, "processed": 995 }
}
Unsubscribe
Unsubscribe by email. With ?list_id=, scopes to one list; without it, unsubscribes the email from every list you own. Returns 204 (idempotent).
curl -X DELETE "https://www.usemaily.xyz/api/v1/subscribers/alice@example.com?list_id=1" \
-H "Authorization: Bearer YOUR_API_KEY"
List templates
Templates available to your account — both system templates and any custom templates you've built. The is_default flag marks the one used automatically when you create a campaign without template_id.
{
"data": [
{
"id": 1,
"name": "Minimal Digest",
"category": "newsletter",
"is_system": true,
"is_default": true
},
{
"id": 4,
"name": "Company Announcement",
"category": "announcement",
"is_system": true,
"is_default": false
}
]
}
List campaigns
Paginated campaigns. Filterable by status (draft, scheduled, sending, sent, failed) or list_id. Default page size 20, max 100.
Retrieve a campaign
One campaign, with stats and full content.
{
"data": {
"id": 7,
"subject": "Our new pricing tiers",
"preheader": "Plus a free 14-day trial — start anytime.",
"status": "sent",
"list_id": 1,
"template_id": 1,
"creation_method": "ai",
"recipient_count": 2104,
"emails_sent": 2099,
"emails_failed": 5,
"opens": 837,
"clicks": 142,
"scheduled_at": null,
"sent_at": "2026-05-15T09:00:12+00:00",
"created_at": "2026-05-15T08:59:47+00:00",
"content": "<p>...</p>"
}
}
Create a campaign
The headline endpoint. Create a campaign from either an AI prompt or raw HTML. Lands in draft by default — pass send: true to queue immediately, or scheduled_at to schedule.
How it renders
Your body — AI-generated or raw html — is rendered into your template (logo, header, footer, social links) and a compliant unsubscribe footer is added automatically, exactly like the dashboard. Personalisation tokens like {{first_name}} are preserved and filled per recipient at send time.
Body parameters
list_idintegerRequirednamestringOptionalInternal campaign name; defaults to the subject.
subjectstringOptionalAI generates one if omitted and you're using AI mode.
preheaderstringOptionalInbox preview line (≤150 chars). Overrides the AI-generated one.
template_idintegerOptionalFalls back to your default template. The body is rendered into it.
ai.promptstringConditional5–2000 chars. Required if you don't pass html. Mutually exclusive with html.
htmlstringConditionalRaw HTML body (becomes the template's content). Required if you don't pass ai.
sendbooleanOptionalDefault false. When true, queues the send immediately.
scheduled_atstringOptionalISO-8601 in the future. When set, the campaign is scheduled instead of queued.
curl -X POST https://www.usemaily.xyz/api/v1/campaigns \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"list_id": 1,
"ai": {
"prompt": "Announce our new pricing tiers and free 14-day trial."
},
"send": true
}'
curl -X POST https://www.usemaily.xyz/api/v1/campaigns \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"list_id": 1,
"subject": "Friday Roundup",
"html": "<p>Hello! Here are this week’s updates...</p>",
"scheduled_at": "2026-05-22T13:00:00Z"
}'
Send a campaign
Queue a draft for sending. Pass scheduled_at to schedule instead of sending now. Returns the updated campaign. Already-sent campaigns return 409 already_sent.
Delete a draft
Delete a draft. Sent campaigns are immutable history and return 409 cannot_delete if you try.
Transactional emails
Send one-off, user-triggered emails — password resets, receipts, magic links, order confirmations — through the same delivery pipeline as your campaigns, but on a surface built for high-frequency, per-request sends.
Transactional sends are deliberately different from campaigns:
- Recipients are just email addresses — no subscriber row is created and lists are never touched.
- No tracking pixel, no link rewriting, no unsubscribe footer is injected (CAN-SPAM doesn't require one for transactional).
- Bounces mark the send row, but they don't suppress anyone from your marketing lists.
- Rate limits are an order of magnitude higher because signup/reset flows legitimately spike.
Before you start
The domain in from must be verified for your account under Settings → Email. Unverified senders return 422. If your API key has scope restrictions, it needs the transactional scope — keys with no scope restrictions pass every check.
Delivery lifecycle
Each send moves through the states below. Bounce, complaint, and delivery events arrive asynchronously from the mail provider, so a status you read immediately after POST /emails may still be queued or sending — poll GET /emails/{id} to see it settle.
| Status | Meaning |
|---|---|
| queued | Accepted and waiting to be processed. |
| sending | Being handed off for delivery. |
| sent | Handed off successfully. Not yet a delivery confirmation. |
| delivered | The recipient's mail server accepted the message. |
| bounced | The recipient's mail server rejected the message. See error_message. |
| complained | The recipient marked the message as spam. |
| failed | The send couldn't be attempted (validation, rendering, or transport error). See error_message. |
Send a transactional email
Queue a single transactional email. Returns 201 for a new send, 200 if idempotency_key matched a previous request.
Content modes
Every request picks one of two mutually exclusive modes:
- Inline — pass raw
html(and optionallytext). - Template — pass
template_idand avariablesobject. Placeholders of the form{{key}}in the template'shtml_contentare substituted before send. The same substitution runs onsubject.
Passing both html and template_id is a 422.
Body parameters
tostringRequiredRecipient email address.
to_namestringOptionalRecipient display name.
fromstringRequiredSender email address. The domain must be verified.
from_namestringOptionalSender display name. If omitted and you have a saved sender for this address, its name is used automatically.
reply_tostringOptionalSet the Reply-To header.
subjectstringRequired≤255 characters. Merge tags allowed.
htmlstringConditionalHTML body. Required unless template_id is supplied.
textstringOptionalPlain-text alternative. Only meaningful with html mode.
template_idintegerConditionalOne of your templates or a system template. Required unless html is supplied.
variablesobjectOptionalFlat key/value map substituted into subject, html, and text. Values are cast to string; nested objects are JSON-encoded.
idempotency_keystringOptional≤64 characters. Same key + same account returns the existing send instead of re-queuing.
curl -X POST https://www.usemaily.xyz/api/v1/emails \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "alice@example.com",
"from": "noreply@yourbrand.com",
"from_name": "Your Brand",
"subject": "Reset your password",
"html": "<p>Click <a href=\"https://app/reset?t=abc\">here</a> to reset.</p>",
"idempotency_key": "reset-42-1721390000"
}'
curl -X POST https://www.usemaily.xyz/api/v1/emails \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "alice@example.com",
"from": "receipts@yourbrand.com",
"subject": "Receipt for order #{{order_id}}",
"template_id": 12,
"variables": {
"first_name": "Alice",
"order_id": "1042",
"total": "$29.00"
}
}'
{
"id": "k2nQ4pVL8j",
"status": "queued",
"to": "alice@example.com",
"from": "noreply@yourbrand.com",
"subject": "Reset your password",
"sent_at": null,
"delivered_at": null,
"bounced_at": null,
"complained_at": null,
"error_message": null,
"created_at": "2026-07-27T12:00:00+00:00"
}
Send transactional emails in batch
Queue up to 100 transactional emails in one request. Each item follows the same shape as POST /emails.
Heads up
Validation is all-or-nothing — one bad item rejects the whole batch with 422 and nothing is queued. Idempotency is honoured per item: a repeat idempotency_key inside a batch returns the existing send in the response array without re-dispatching.
Body parameters
emailsarrayRequired1–100 items. Each item accepts the same fields as POST /emails.
curl -X POST https://www.usemaily.xyz/api/v1/emails/batch \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emails": [
{
"to": "alice@example.com",
"from": "receipts@yourbrand.com",
"subject": "Receipt #1042",
"template_id": 12,
"variables": { "first_name": "Alice", "order_id": "1042" },
"idempotency_key": "receipt-1042"
},
{
"to": "bob@example.com",
"from": "receipts@yourbrand.com",
"subject": "Receipt #1043",
"template_id": 12,
"variables": { "first_name": "Bob", "order_id": "1043" },
"idempotency_key": "receipt-1043"
}
]
}'
{
"emails": [
{ "id": "k2nQ4pVL8j", "status": "queued", "to": "alice@example.com", "..." : "..." },
{ "id": "hM7xZq2Wt4", "status": "queued", "to": "bob@example.com", "..." : "..." }
]
}
Retrieve a transactional send
Fetch the current state of a single send by its opaque id (the value returned as id from POST /emails). Useful for polling delivery status or surfacing failure reasons in your own UI. The response shape matches the object returned by POST /emails.
curl "https://www.usemaily.xyz/api/v1/emails/k2nQ4pVL8j" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"id": "k2nQ4pVL8j",
"status": "delivered",
"to": "alice@example.com",
"from": "noreply@yourbrand.com",
"subject": "Reset your password",
"sent_at": "2026-07-27T12:00:01+00:00",
"delivered_at": "2026-07-27T12:00:04+00:00",
"bounced_at": null,
"complained_at": null,
"error_message": null,
"created_at": "2026-07-27T12:00:00+00:00"
}
Webhooks
Register one or more URLs at Settings → API to receive event notifications. Maily POSTs a signed JSON payload to your endpoint whenever a subscribed event fires.
Request headers
X-Maily-EventstringThe event name (e.g. campaign.sent).
X-Maily-Delivery-IdstringThe evt_... id — useful for idempotency on your side.
X-Maily-SignaturestringHMAC-SHA256 of the raw body, signed with your endpoint secret.
Delivery & retries
Each event fires once per endpoint with an 8-second timeout. Return any 2xx status to acknowledge receipt — anything else marks the delivery as failed and bumps your endpoint's consecutive-failure counter (visible in the dashboard). There is no automatic retry; if you miss an event, refetch via the API.
{
"id": "evt_xxxxxxxxxxxxxxxxxxxxxxxx",
"event": "campaign.sent",
"created_at": "2026-05-17T10:00:00+00:00",
"data": {
"campaign": {
"id": 7,
"subject": "...",
"emails_sent": 2099
}
}
}
Event catalog
| Event | When it fires |
|---|---|
| campaign.sent | A campaign finished sending with at least one successful delivery. |
| campaign.failed | A campaign attempted to send but 100% of recipients failed. |
| subscriber.created | A new subscriber was added via the API. |
| subscriber.unsubscribed | A subscriber opted out (via API or public unsubscribe link). |
| subscriber.bounced | Hard bounce — subscriber automatically suppressed. |
| subscriber.complained | Spam complaint — subscriber automatically suppressed. |
Verifying signatures
Always verify the signature before trusting a webhook body. Compute HMAC-SHA256 of the raw request body using your endpoint secret, then constant-time compare it against the X-Maily-Signature header.
<?php
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_MAILY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, 'whsec_your_endpoint_secret');
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$payload = json_decode($body, true);
// ... handle $payload['event'] and $payload['data']
import crypto from 'crypto';
app.post('/maily/webhook',
express.raw({type: 'application/json'}),
(req, res) => {
const expected = crypto
.createHmac('sha256', process.env.MAILY_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (expected !== req.header('X-Maily-Signature')) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
// ... handle event
res.status(200).end();
});
Need help? Email support@usemaily.xyz or open a ticket from your dashboard.
Maily API · v1