Maily docs

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.

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://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

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://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://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://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://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://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://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://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.

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