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.
For quick browser testing you can also pass the key as a query parameter: ?api_key=mly_.... Don't do this in production — query strings leak into server logs.
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://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 |
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://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://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://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://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://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://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://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.
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