Authentication

All API requests require authentication using an API key. Include your API key in the X-API-Key header:

X-API-Key: your-api-key-here

Obtaining an API Key

API keys can be generated through the admin dashboard or via the admin API endpoints (requires Sanctum authentication).

API Key Properties

Property Description
name A descriptive name for the key
permissions Array of allowed operations (or ["*"] for full access)
expires_at Optional expiration date
rate_limit Custom rate limit per minute (default: 60)

Example Request

curl -X GET "https://mail.example.com/api/v1/subscribers" \
  -H "X-API-Key: your-api-key-here" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json"

Rate Limiting

API requests are rate-limited to prevent abuse. The default limit is 60 requests per minute per API key.

Rate Limit Headers

Every response includes rate limit information:

Header Description
X-RateLimit-Limit Maximum requests per window
X-RateLimit-Remaining Remaining requests in current window
X-RateLimit-Reset Unix timestamp when the window resets

Rate Limit Exceeded

When the rate limit is exceeded, you'll receive a 429 Too Many Requests response:

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please slow down and try again later.",
    "status": 429,
    "rate_limit": 60,
    "retry_after": 45
  }
}

The Retry-After header indicates how many seconds to wait before making another request.


Error Handling

All API responses follow a consistent format:

Success Response

{
  "success": true,
  "data": { ... },
  "message": "Operation completed successfully."
}

Error Response

{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message.",
    "status": 400,
    "details": { ... }
  }
}

Error Codes

Code HTTP Status Description
API_KEY_MISSING 401 No API key provided in request
INVALID_API_KEY 401 API key is invalid or revoked
API_KEY_EXPIRED 403 API key has expired
UNAUTHORIZED 401 Authentication required
FORBIDDEN 403 Permission denied for this operation
NOT_FOUND 404 Resource not found
VALIDATION_ERROR 422 Request validation failed
CONFLICT 409 Resource conflict (e.g., duplicate)
RATE_LIMIT_EXCEEDED 429 Too many requests
SERVER_ERROR 500 Internal server error

Validation Error Details

When validation fails, the details field contains field-specific errors:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The given data was invalid.",
    "status": 422,
    "details": {
      "email": ["The email field is required."],
      "name": ["The name must be at least 2 characters."]
    }
  }
}

Endpoints

Subscribers

Manage email subscribers in your mailing lists.

Create Subscriber

POST /api/v1/subscribers

Request Body:

{
  "email": "john@example.com",
  "first_name": "John",
  "last_name": "Doe",
  "lists": ["01HN...ULID1", "01HN...ULID2"],
  "tags": ["01HN...ULID3"],
  "custom_fields": {
    "company": "Acme Inc",
    "role": "Developer"
  }
}

Response (201 Created):

{
  "success": true,
  "data": {
    "id": "01HN...ULID",
    "email": "john@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "status": "active",
    "confirmed_at": null,
    "lists": [...],
    "tags": [...],
    "custom_fields": {...},
    "created_at": "2024-01-15T10:30:00Z"
  }
}

Get Subscriber

GET /api/v1/subscribers/{email_or_ulid}

Query Parameters:

Parameter Type Description
include string Comma-separated relations: lists,tags

Response:

{
  "data": {
    "id": "01HN...ULID",
    "email": "john@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "status": "active",
    "confirmed_at": "2024-01-15T11:00:00Z",
    "lists": [
      {"id": "01HN...ULID", "name": "Newsletter"}
    ],
    "tags": [
      {"id": "01HN...ULID", "name": "VIP"}
    ],
    "created_at": "2024-01-15T10:30:00Z"
  }
}

Update Subscriber

PUT /api/v1/subscribers/{email_or_ulid}

Request Body:

{
  "first_name": "Jane",
  "custom_fields": {
    "company": "New Company"
  }
}

Delete Subscriber

DELETE /api/v1/subscribers/{email_or_ulid}

Marks the subscriber as unsubscribed. Does not permanently delete the record.

Add Tags to Subscriber

POST /api/v1/subscribers/{email_or_ulid}/tags

Request Body:

{
  "tags": ["01HN...ULID1", "01HN...ULID2"]
}

Response:

{
  "success": true,
  "message": "Tags processed successfully.",
  "data": {
    "subscriber": "01HN...ULID",
    "attached": ["01HN...ULID1"],
    "skipped": ["01HN...ULID2"]
  }
}

Remove Tag from Subscriber

DELETE /api/v1/subscribers/{email_or_ulid}/tags/{tag_ulid}

Add Subscriber to Lists

POST /api/v1/subscribers/{email_or_ulid}/lists

Request Body:

{
  "lists": ["01HN...ULID1", "01HN...ULID2"]
}

Remove Subscriber from List

DELETE /api/v1/subscribers/{email_or_ulid}/lists/{list_ulid}

Import Subscribers from CSV

POST /api/v1/subscribers/import

Multipart upload. The file is queued and processed in the background in chunks of 500 rows; the subscriber limit of the plan is enforced per chunk, so a file that does not fit is imported up to the limit and the remaining rows are reported as failed.

Field Type Description
file file CSV with an email column and optional first_name, last_name, name columns
list string List ULID to add the subscribers to
tags array Tag ULIDs to attach
update_existing boolean Update subscribers that already exist (default false)

Responds 202 Accepted with the import record:

{
  "success": true,
  "message": "Import queued. It runs in the background.",
  "data": {
    "ulid": "01HN...ULID",
    "status": "queued",
    "original_name": "people.csv",
    "imported": 0,
    "updated": 0,
    "skipped": 0,
    "failed": 0,
    "errors": [],
    "started_at": null,
    "finished_at": null,
    "created_at": "2026-10-06T14:17:48+00:00"
  }
}

Get Import Status

GET /api/v1/subscribers/imports/{ulid}

Returns the same record. status is queued, processing, completed or failed; the counters update after each chunk. errors lists the first 100 row problems, followed by a "Further errors omitted." entry when there were more. An import of another organization answers 404.


Lists

Manage mailing lists.

List All Mailing Lists

GET /api/v1/lists

Query Parameters:

Parameter Type Description
search string Search by name
is_double_optin boolean Filter by opt-in type
with_count boolean Include subscriber counts
per_page integer Items per page (max: 100)
sort string Sort field: name, created_at
direction string Sort direction: asc, desc

Create List

POST /api/v1/lists

Request Body:

{
  "name": "Weekly Newsletter",
  "description": "Our weekly updates and tips",
  "default_from_name": "Acme News",
  "default_from_email": "news@example.com",
  "is_double_optin": true,
  "welcome_sequence": "01HN...ULID"
}

name must be unique among the organization's lists. welcome_sequence is the ULID of one of the organization's sequences; an unknown or foreign ULID answers 422.

Get List

GET /api/v1/lists/{ulid}

Update List

PATCH /api/v1/lists/{ulid}

Only the submitted fields change.

Delete List

DELETE /api/v1/lists/{ulid}

Get List Subscribers

GET /api/v1/lists/{ulid}/subscribers

Query Parameters:

Parameter Type Description
status string Filter by status: active, unsubscribed, bounced
confirmed boolean Filter by confirmation status
email string Search by email
per_page integer Items per page (max: 100)

Tags

Manage subscriber tags for segmentation.

List All Tags

GET /api/v1/tags

Query Parameters:

Parameter Type Description
search string Search by name or slug
with_count boolean Include subscriber counts
per_page integer Items per page (max: 100)

Create Tag

POST /api/v1/tags

Request Body:

{
  "name": "VIP Customer",
  "color": "#FF5733"
}

Get Tag

GET /api/v1/tags/{ulid}

Update Tag

PUT /api/v1/tags/{ulid}

Delete Tag

DELETE /api/v1/tags/{ulid}

Campaigns

Manage and send email campaigns.

List Campaigns

GET /api/v1/campaigns

Query Parameters:

Parameter Type Description
status string Filter: draft, scheduled, sending, sent, cancelled
list string Filter by list ULID
search string Search by name or subject
from datetime Created after date
to datetime Created before date
per_page integer Items per page (max: 100)

Create Campaign

POST /api/v1/campaigns

Request Body:

{
  "name": "January Newsletter",
  "subject": "New Year, New Features!",
  "from_name": "Your Company",
  "from_email": "news@example.com",
  "reply_to": "support@example.com",
  "list_ulid": "01HN...ULID",
  "template_ulid": "01HN...ULID",
  "content_html": "<html>...</html>",
  "content_text": "Plain text version..."
}

list_ulid and template_ulid must belong to the organization of the API key; a reference to another organization's list or template answers 422. Numeric database ids are not accepted.

Get Campaign

GET /api/v1/campaigns/{ulid}

Get Campaign Statistics

GET /api/v1/campaigns/{ulid}/stats

Response:

{
  "data": {
    "campaign_id": "01HN...ULID",
    "sent": 10000,
    "delivered": 9850,
    "bounced": 150,
    "opened": 4500,
    "clicked": 1200,
    "unsubscribed": 25,
    "complained": 2,
    "open_rate": 45.69,
    "click_rate": 12.18,
    "bounce_rate": 1.5,
    "unsubscribe_rate": 0.25
  }
}

Schedule Campaign

POST /api/v1/admin/campaigns/{ulid}/schedule

Schedules a draft campaign. Requires a signed-in session or token, not an API key.

Field Type Description
scheduled_at string Required. A time without an offset (2026-11-10 09:00:00) is wall-clock time in the organization's timezone (Settings). A time with an offset (2026-11-10T09:00:00+01:00, ...Z) keeps that offset. Stored and returned in UTC.

A send time that is not in the future once converted, or an organization whose timezone is not set, answers 422 with the error under error.details.scheduled_at.

Trigger Campaign Send

POST /api/v1/campaigns/{ulid}/send

Immediately queues the campaign for sending. Only works for draft or scheduled campaigns.

Response:

{
  "success": true,
  "message": "Campaign has been queued for sending.",
  "data": {
    "id": "01HN...ULID",
    "status": "sending",
    "sent_at": "2024-01-15T14:30:00Z"
  }
}

Sequences

Manage automated email sequences (drip campaigns).

List Sequences

GET /api/v1/sequences

Query Parameters:

Parameter Type Description
active boolean Filter by active status
trigger_type string Filter by trigger type
list string Filter by list ULID
search string Search by name
with_count boolean Include subscriber/step counts
per_page integer Items per page (max: 100)

Get Sequence

GET /api/v1/sequences/{ulid}

Query Parameters:

Parameter Type Description
include string Include relations: steps,list,subscribers
with_count boolean Include counts

Enroll Subscriber in Sequence

POST /api/v1/sequences/{ulid}/subscribers

Request Body:

{
  "subscriber": "01HN...SUBSCRIBER_ULID"
}

or

{
  "email": "john@example.com"
}

Response:

{
  "success": true,
  "message": "Subscriber has been enrolled in the sequence.",
  "data": {
    "sequence": "01HN...ULID",
    "subscriber": "01HN...ULID"
  }
}

Remove Subscriber from Sequence

DELETE /api/v1/sequences/{ulid}/subscribers/{subscriber_email_or_ulid}

Events

Record custom events to trigger automations.

Record Custom Event

POST /api/v1/events

This endpoint allows you to track custom events that can trigger automations, sequences, or be used for segmentation.

Request Body:

{
  "subscriber_email": "john@example.com",
  "event_name": "purchase_completed",
  "properties": {
    "product_id": "SKU-12345",
    "product_name": "Premium Plan",
    "amount": 99.99,
    "currency": "USD"
  }
}

Event Name Format:

  • Must be in snake_case format
  • Only lowercase letters, numbers, and underscores allowed
  • Maximum 100 characters

Response (201 Created):

{
  "success": true,
  "message": "Event recorded successfully.",
  "data": {
    "id": "01HN...ULID",
    "event_name": "purchase_completed",
    "properties": {
      "product_id": "SKU-12345",
      "product_name": "Premium Plan",
      "amount": 99.99,
      "currency": "USD"
    },
    "source": "api",
    "occurred_at": "2024-01-15T14:30:00Z",
    "created_at": "2024-01-15T14:30:00Z"
  }
}

Common Event Examples:

Event Name Description
purchase_completed Customer completed a purchase
trial_started User started a free trial
trial_ended Trial period ended
subscription_upgraded Customer upgraded their plan
subscription_cancelled Customer cancelled subscription
webinar_registered User registered for webinar
webinar_attended User attended webinar
form_submitted User submitted a form
product_viewed User viewed a product
cart_abandoned User abandoned their cart

Webhook Events

The mailing system sends webhooks for various events. Configure webhook endpoints in the admin dashboard.

Webhook Payload Structure

{
  "event": "subscriber.created",
  "timestamp": "2024-01-15T14:30:00Z",
  "data": {
    // Event-specific data
  },
  "signature": "sha256=..."
}

Verifying Webhook Signatures

Webhooks are signed using HMAC-SHA256. Verify the signature by:

  1. Get the X-Webhook-Signature header
  2. Compute HMAC-SHA256 of the raw body using your webhook secret
  3. Compare with the signature header
$signature = hash_hmac('sha256', $rawBody, $webhookSecret);
$isValid = hash_equals($signature, $headerSignature);

Available Webhook Events

Event Description
subscriber.created New subscriber added
subscriber.updated Subscriber data updated
subscriber.unsubscribed Subscriber unsubscribed
subscriber.confirmed Subscriber confirmed (double opt-in)
campaign.sent Campaign finished sending
campaign.opened Email opened
campaign.clicked Link clicked
bounce.received Email bounced
complaint.received Spam complaint received
sequence.completed Subscriber completed sequence
event.recorded Custom event recorded

Webhook Retry Policy

Failed webhook deliveries are retried with exponential backoff:

  • Attempt 1: Immediate
  • Attempt 2: 5 minutes
  • Attempt 3: 30 minutes
  • Attempt 4: 2 hours
  • Attempt 5: 24 hours

After 5 failed attempts, the webhook is marked as failed and no further retries are made.

Inbound Delivery Callbacks

The sending provider reports bounces, complaints and deliveries to POST /api/v1/webhooks/bounce, /complaint and /delivery. Each request is signed with hash_hmac('sha256', $rawBody, $secret) in the X-Webhook-Signature header, using MAIL_WEBHOOK_BOUNCE_SECRET, MAIL_WEBHOOK_COMPLAINT_SECRET and MAIL_WEBHOOK_DELIVERY_SECRET. Outside local and testing environments an unset secret rejects every callback.

Subscriber addresses are unique per organization only, so an address cannot say which tenant a callback belongs to. Every outgoing message therefore carries the headers X-Mailwell-Subscriber (the subscriber ULID) and either X-Mailwell-Campaign (campaign ULID) or X-Mailwell-Step (sequence step ULID). The same values travel in the message metadata as subscriber_ulid, campaign_ulid and step_ulid. The callback payload must return them, top level or under metadata:

Field Bounce Complaint Delivery
email required, must equal the subscriber's address required required
subscriber_ulid required required required
campaign_ulid / step_ulid optional, at most one optional, at most one campaign_ulid required
bounce_type required: hard (or permanent) / soft (or transient)
bounce_code, message optional

A callback that fails these rules is answered with 422 and changes nothing: a missing or malformed identifier, an unknown subscriber, an address that does not match the subscriber, a campaign or step belonging to another organization, an unknown bounce_type, or a delivery for a send that does not exist. A hard bounce, or the third soft bounce within 30 days, sets the subscriber to bounced; a complaint sets it to complained. Every accepted callback is also stored in webhook_logs.


Pagination

List endpoints support pagination using the following query parameters:

Parameter Description Default
page Page number 1
per_page Items per page 15 (max: 100)

Pagination Response

{
  "data": [...],
  "links": {
    "first": "https://mail.example.com/api/v1/subscribers?page=1",
    "last": "https://mail.example.com/api/v1/subscribers?page=10",
    "prev": null,
    "next": "https://mail.example.com/api/v1/subscribers?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 10,
    "per_page": 15,
    "to": 15,
    "total": 150
  }
}

Filtering and Sorting

Most list endpoints support filtering and sorting:

Common Filter Parameters

Parameter Type Description
status string Filter by status
search string Search by name/email
created_from datetime Created after date
created_to datetime Created before date

Sorting

Parameter Type Description
sort string Field to sort by
direction string asc or desc

Example

GET /api/v1/subscribers?status=active&sort=created_at&direction=desc&per_page=25

SDKs and Libraries

Official SDKs are available for popular programming languages:

  • PHP: composer require expirator/mailing-php
  • JavaScript/Node.js: npm install @expirator/mailing-js
  • Python: pip install expirator-mailing

Support

For API support, contact us at: