Maily docs
API Reference

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.

Header
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.

Base URL
https://mail.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.

EndpointLimit
All reads + default60 / minute
POST /campaigns (AI cost)10 / minute
POST /campaigns/{id}/send5 / minute
POST /subscribers/batch20 / minute
POST /emails600 / minute
POST /emails/batch60 / 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):

CodeMeaning
subscriber_limit_reachedList cap hit — upgrade to add more contacts.
campaign_limit_reachedTotal campaign cap hit on free plan.
ai_not_availableYour plan doesn't include AI generation.
ai_quota_exceededMonthly AI generation quota used up.
quota_exceededMonthly email sending cap hit.

Errors

All errors return a consistent envelope. The errors object is only present on validation failures.

Status codes

StatusMeaning
200Success — resource returned in data.
201Resource created.
204Success, no content (e.g. unsubscribe).
401Missing or invalid API key.
402Plan limit reached — upgrade required.
403Resource exists but doesn't belong to this key.
404Resource not found.
409Conflict (e.g. already-sent campaign).
422Validation error — see errors.
429Rate or quota limit. Retry after a backoff.
502Upstream failure (e.g. AI provider).
Error envelope
{
  "error": {
    "code": "validation_failed",
    "message": "One or more fields are invalid.",
    "errors": {
      "email": ["The email field is required."]
    }
  }
}
GET /lists

List all lists

Return all email lists owned by the API key's user.

cURL
curl https://mail.usemaily.xyz/api/v1/lists \
  -H "Authorization: Bearer YOUR_API_KEY"
200 Response
{
  "data": [
    {
      "id": 1,
      "name": "Newsletter",
      "description": null,
      "total_subscribers": 2156,
      "active_subscribers": 2104,
      "created_at": "2026-01-12T10:14:23+00:00"
    }
  ]
}
GET /subscribers

List subscribers

Paginated subscribers across your lists. Filterable by list, status, or free-text search.

Query parameters

list_idintegerOptional

Scope to a single list.

statusstringOptional

One of active, unsubscribed, bounced, complained.

searchstringOptional

Match against email, first_name, last_name.

per_pageintegerOptional

1 to 100, default 50.

cURL
curl "https://mail.usemaily.xyz/api/v1/subscribers?list_id=1" \
  -H "Authorization: Bearer YOUR_API_KEY"
200 Response
{
  "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
  }
}
POST /subscribers

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

emailstringRequired

A valid email address.

list_idintegerRequired

Must be one of your lists.

first_namestringOptional
last_namestringOptional

Returns 201 for net-new, 200 for idempotent re-add. The body matches a single subscriber from the index endpoint.

cURL
curl -X POST https://mail.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"
  }'
POST /subscribers/batch

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_idintegerRequired

Must be one of your lists.

subscribersarrayRequired

Array of 1–1,000 objects. Each may include email (required), first_name, last_name, and tags[].

cURL
curl -X POST https://mail.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" }
    ]
  }'
200 Response
{
  "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 }
}
DELETE /subscribers/{email}

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
curl -X DELETE "https://mail.usemaily.xyz/api/v1/subscribers/alice@example.com?list_id=1" \
  -H "Authorization: Bearer YOUR_API_KEY"
GET /templates

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.

200 Response
{
  "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
    }
  ]
}
GET /campaigns

List campaigns

Paginated campaigns. Filterable by status (draft, scheduled, sending, sent, failed) or list_id. Default page size 20, max 100.

GET /campaigns/{id}

Retrieve a campaign

One campaign, with stats and full content.

200 Response
{
  "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>"
  }
}
POST /campaigns

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_idintegerRequired
namestringOptional

Internal campaign name; defaults to the subject.

subjectstringOptional

AI generates one if omitted and you're using AI mode.

preheaderstringOptional

Inbox preview line (≤150 chars). Overrides the AI-generated one.

template_idintegerOptional

Falls back to your default template. The body is rendered into it.

ai.promptstringConditional

5–2000 chars. Required if you don't pass html. Mutually exclusive with html.

htmlstringConditional

Raw HTML body (becomes the template's content). Required if you don't pass ai.

sendbooleanOptional

Default false. When true, queues the send immediately.

scheduled_atstringOptional

ISO-8601 in the future. When set, the campaign is scheduled instead of queued.

AI · Send now
curl -X POST https://mail.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
  }'
HTML · Schedule
curl -X POST https://mail.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"
  }'
POST /campaigns/{id}/send

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 /campaigns/{id}

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.

StatusMeaning
queuedAccepted and waiting to be processed.
sendingBeing handed off for delivery.
sentHanded off successfully. Not yet a delivery confirmation.
deliveredThe recipient's mail server accepted the message.
bouncedThe recipient's mail server rejected the message. See error_message.
complainedThe recipient marked the message as spam.
failedThe send couldn't be attempted (validation, rendering, or transport error). See error_message.
POST /emails

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 optionally text).
  • Template — pass template_id and a variables object. Placeholders of the form {{key}} in the template's html_content are substituted before send. The same substitution runs on subject.

Passing both html and template_id is a 422.

Body parameters

tostringRequired

Recipient email address.

to_namestringOptional

Recipient display name.

fromstringRequired

Sender email address. The domain must be verified.

from_namestringOptional

Sender display name. If omitted and you have a saved sender for this address, its name is used automatically.

reply_tostringOptional

Set the Reply-To header.

subjectstringRequired

≤255 characters. Merge tags allowed.

htmlstringConditional

HTML body. Required unless template_id is supplied.

textstringOptional

Plain-text alternative. Only meaningful with html mode.

template_idintegerConditional

One of your templates or a system template. Required unless html is supplied.

variablesobjectOptional

Flat 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.

Inline HTML
curl -X POST https://mail.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"
  }'
Template + variables
curl -X POST https://mail.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"
    }
  }'
201 Response
{
  "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"
}
POST /emails/batch

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

emailsarrayRequired

1–100 items. Each item accepts the same fields as POST /emails.

cURL
curl -X POST https://mail.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"
      }
    ]
  }'
201 Response
{
  "emails": [
    { "id": "k2nQ4pVL8j", "status": "queued", "to": "alice@example.com", "..." : "..." },
    { "id": "hM7xZq2Wt4", "status": "queued", "to": "bob@example.com",   "..." : "..." }
  ]
}
GET /emails/{id}

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
curl "https://mail.usemaily.xyz/api/v1/emails/k2nQ4pVL8j" \
  -H "Authorization: Bearer YOUR_API_KEY"
200 Response
{
  "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-Eventstring

The event name (e.g. campaign.sent).

X-Maily-Delivery-Idstring

The evt_... id — useful for idempotency on your side.

X-Maily-Signaturestring

HMAC-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.

Payload
{
  "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

EventWhen it fires
campaign.sentA campaign finished sending with at least one successful delivery.
campaign.failedA campaign attempted to send but 100% of recipients failed.
subscriber.createdA new subscriber was added via the API.
subscriber.unsubscribedA subscriber opted out (via API or public unsubscribe link).
subscriber.bouncedHard bounce — subscriber automatically suppressed.
subscriber.complainedSpam 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
<?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']
Node.js
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