Developer Documentation

Transactional API

API-first transactional sends with dashboard-saved message templates.

Send one-off transactional emails (password resets, OTPs, receipts, order confirmations, and more) via the REST API. Design reusable email templates in the dashboard and integrate them into your existing application or workflow.

Your contacts remain in your own system — only the template ID and merge variables are required for each send request.

Email templates must be created and managed in Transactional → Email Templates. Once a template is created, send emails using POST /api/v1/transactional/send by providing the template ID and required parameters.

Authentication

  • Transport: Authorization: Bearer <api_key> or X-Api-Key.
  • API key permission: transactional.
  • Plan: the tenant’s active plan must have transactional_enabled.

Common errors:

Error Response

{ "status": "error", "reason": "Your current plan does not include Transactional Email." }

Error Response

{ "status": "error", "reason": "API key does not have transactional permission." }

All tenant routes are under /api/v1/transactional and use the api-v1-transactional limiter (60 requests/min per API key). Failed validation on these routes returns HTTP 422 with { "status": "error", "reason": "<first error>" }.

Templates

Open Transactional → Email Templates (/transactional/message-types; /transactional/messages redirects there). Each row is a transactional_messages record used for send content and analytics grouping.

Dashboard save stores:

  • name (required)
  • subject (required, max 998 characters) — merge tokens allowed
  • body_html (required) — HTML template; merge tokens allowed
  • body_text (optional) — if omitted, the dashboard derives plaintext from the HTML
  • enabled — disabled templates cannot be sent
  • track_opens / track_clicks — dashboard-created templates set both to true
  • source — dashboard creates manual
  • slug — dashboard creates null; send and list identify templates by numeric id
  • required_variables — dashboard create stores []. The send API still enforces this list when it is non-empty

A template is sendable only when subject is non-empty and the HTML has visible text or an <img> tag. Otherwise send returns HTTP 422: This message has no saved template.

GET /api/v1/transactional/messages returns metadata including subject. It does not return body_html or body_text.

Send

POST /api/v1/transactional/send

Queues delivery through a mailbox you own. Subject, HTML, and optional plaintext are taken from the saved template after {{token}} substitution.

Required

Field Type Rules
transactional_message_id integer Must exist for this tenant, be enabled, and have a sendable template
to.email string Recipient address. Not stored as a contact
from.email string Must match a mailbox on the tenant (case-insensitive)

Optional

Field Type Rules
to.name string Display name on the recipient
from.name string If omitted, the mailbox display_name is used
variables object Key/value map for {{token}} in subject, HTML, and plaintext
reply_to string Valid email, max 255
headers object Only names starting with X- / x- are kept; others are dropped silently. Values max 2000 characters
idempotency_key string Max 191. Client-chosen unique key per logical send (e.g. order-42-welcome or a UUID). Same tenant + key + same payload returns the original outbound (idempotent_replay: true). Same key with a different payload returns HTTP 409. Omit the field to always create a new send. Never use a human description as the key.
attachments array Base64 files. See Attachments

Prohibited (HTTP 422): subject, message, text. Content cannot be overridden on the send call.

Statuses

  • 202 — new send queued (idempotent_replay: false)
  • 200 — existing send returned for the same idempotency_key and matching payload (idempotent_replay: true); the outbound job is not dispatched again
  • 409idempotency_key was already used for a different send (different template, recipient, from, reply-to, variables, or X-* headers)

data.status reflects queue/SMTP acceptance (queuedsendingsent, or failed / bounced). Inbox confirmation is separate: check delivered_at and delivery_events (populated by provider delivery webhooks).

Request Example

curl --fail-with-body --silent --show-error \
  -X POST "$APP_URL/api/v1/transactional/send" \
  -H "Authorization: Bearer YOUR_32_CHARACTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "transactional_message_id": 1,
    "to": { "email": "user@example.com", "name": "Ada" },
    "from": { "email": "noreply@yourdomain.com" },
    "variables": { "first_name": "Ada" },
    "idempotency_key": "order-42-welcome"
  }'

Success Response

{
  "status": "success",
  "data": {
    "id": 101,
    "transactional_message_id": 1,
    "status": "queued",
    "channel": "transactional",
    "from_address": "noreply@yourdomain.com",
    "from_name": "Acme",
    "to_addresses": ["user@example.com"],
    "subject": "Hello Ada",
    "message_id": null,
    "idempotency_key": "order-42-welcome",
    "failure_reason": null,
    "queued_at": "2026-09-04T12:00:00.000000Z",
    "sent_at": null,
    "delivered_at": null,
    "last_deferred_at": null,
    "created_at": "2026-09-04T12:00:00.000000Z",
    "delivery_events": []
  },
  "idempotent_replay": false
}

data.subject is the rendered subject after variable substitution.

Send-time error reasons (examples)

Status Reason Cause
422 Do not send subject; it is taken from the saved message template. subject present
422 The transactional message was not found. Wrong ID or another tenant
422 The transactional message is disabled. enabled is false
422 This message has no saved template. Missing subject/HTML
422 The from email must belong to one of your mailboxes. from.email is not a tenant mailbox
422 Missing required variables: otp. Message required_variables not satisfied
422 Unregistered variables: first_name. Create them in Variable Manager first. Tenant has a variable registry and the template uses an unregistered key
409 This idempotency_key was already used for a different send. Use a unique key per logical send, or omit idempotency_key. Same key, different payload

Transactional HTML does not get List-Unsubscribe headers. Open-pixel and click rewriting follow the template’s track_opens / track_clicks flags.

Attachments

Example

"attachments": [
  {
    "filename": "invoice.pdf",
    "content_type": "application/pdf",
    "content": "<base64>"
  }
]

Limits come from config/transactional.php (env overrides in parentheses):

  • count: TRANSACTIONAL_ATTACHMENTS_MAX_COUNT (default 5)
  • per file: TRANSACTIONAL_ATTACHMENTS_MAX_BYTES (default 5 MiB)
  • total: TRANSACTIONAL_ATTACHMENTS_MAX_TOTAL_BYTES (default 15 MiB)
  • MIME allowlist: PDF, JSON, plain text, CSV, PNG, JPEG, GIF, WebP, Word, Excel

Invalid base64 or a disallowed content_type returns HTTP 422.

Variables

Use {{variable_name}} in the template subject and body.

If the tenant has any rows in Variable Manager (/transactional/variables):

  • every {{token}} in the template (and every required_variables key) must exist in the registry
  • send values must match the registered data_type
  • registry default_value is applied when the send payload omits the key or sends empty

If the registry is empty, only required_variables on the message (when set) are enforced; other tokens are substituted as provided.

Registered types: string, integer, date, url, boolean, email, currency.

POST /api/v1/transactional/variables

Creates a typed registry entry. Duplicate key returns HTTP 409 unless overwrite=true (then HTTP 200). New keys return HTTP 201. source on the record is api.

Field Required Rules
key yes Max 64; letters, numbers, _, ., -
data_type yes One of the types listed above
default_value no String, max 2000
overwrite no Boolean

Request Example

curl --fail-with-body --silent --show-error \
  -X POST "$APP_URL/api/v1/transactional/variables" \
  -H "Authorization: Bearer YOUR_32_CHARACTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "otp",
    "data_type": "integer",
    "overwrite": false
  }'

Success Response

{
  "status": "success",
  "data": {
    "id": 1,
    "key": "otp",
    "data_type": "integer",
    "default_value": null,
    "source": "api"
  },
  "overwritten": false
}

Error Response

{
  "status": "error",
  "reason": "A variable with this key already exists. Pass overwrite=true to replace it.",
  "data": { "key": "otp", "id": 1 }
}

List templates

GET /api/v1/transactional/messages

Paginated list for the tenant (enabled and disabled). Ordered by name.

Query: per_page — integer 1–100, default 50.

Response is a Laravel resource collection (data, links, meta). Each item:

Request Example

curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer YOUR_32_CHARACTER_API_KEY" \
  "$APP_URL/api/v1/transactional/messages"

Success Response

{
  "data": [
    {
      "id": 1,
      "name": "Password Reset",
      "slug": null,
      "description": null,
      "subject": "Reset your password",
      "enabled": true,
      "required_variables": [],
      "track_opens": true,
      "track_clicks": true,
      "source": "manual",
      "created_at": "2026-09-04T12:00:00.000000Z",
      "updated_at": "2026-09-04T12:00:00.000000Z"
    }
  ]
}

HTML/plaintext bodies are omitted.

Get a send

GET /api/v1/transactional/sends/{id}

Returns one outbound row if it belongs to the tenant and channel is transactional. Otherwise HTTP 404.

Uses the same send resource as POST /send.

Request Example

curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer YOUR_32_CHARACTER_API_KEY" \
  "$APP_URL/api/v1/transactional/sends/101"

Success Response

{
  "status": "success",
  "data": {
    "id": 101,
    "transactional_message_id": 1,
    "status": "sent",
    "channel": "transactional",
    "from_address": "noreply@yourdomain.com",
    "from_name": "Acme",
    "to_addresses": ["user@example.com"],
    "subject": "Hello Ada",
    "message_id": "<abc@mail.example>",
    "idempotency_key": "order-42-welcome",
    "failure_reason": null,
    "queued_at": "2026-09-04T12:00:00.000000Z",
    "sent_at": "2026-09-04T12:00:03.000000Z",
    "delivered_at": "2026-09-04T12:00:05.000000Z",
    "last_deferred_at": null,
    "created_at": "2026-09-04T12:00:00.000000Z",
    "delivery_events": [
      {
        "event_type": "delivered",
        "provider": "mailcow",
        "occurred_at": "2026-09-04T12:00:05.000000Z",
        "diagnostic": null
      }
    ]
  }
}

status: sent means the provider accepted the message. delivered_at / delivery_events confirm downstream delivery when webhooks are configured.

Dashboard

  • Email Templates — design subject/HTML used by send
  • Variables — typed registry; duplicate keys need overwrite confirmation (API: 409 unless overwrite=true)
  • Sends — outbound history
  • Analytics — funnel metrics (including Delivered / Deferred when provider events exist); CSV / Excel / JSON export
  • API Logs — redacted request/response logs; purge with transactional:purge-api-logs (retention: TRANSACTIONAL_API_LOGS_RETENTION_DAYS, default 30)

Delivery events

Provider callbacks are ingested at POST /api/webhooks/outbound-delivery (not tenant API keys).

Authenticate with X-Outbound-Delivery-Secret or Authorization: Bearer matching OUTBOUND_DELIVERY_WEBHOOK_SECRET.

Payload fields: provider, provider_event_id (idempotent), event_type (delivered | deferred | rejected | failed | complaint), plus message_id and/or outbound_email_id.

Matched delivered events set outbound_emails.delivered_at once; deferred updates last_deferred_at. Unknown message ids are logged and ignored. These fields and a summarized delivery_events list are exposed on GET /api/v1/transactional/sends/{id} and on the send response from POST /send.

Admin analytics

Admins can open /admin/transactional/analytics for cross-tenant funnel, tenant breakdown, templates, recent sends, and CSV/JSON export.

MCP tools

On the MailAPI MCP server (transactional permission + plan entitlement):

Tool Notes
list_transactional_messages Enabled templates only. Returns id, name, slug, subject, required_variables, track_opens, track_clicks, enabled, source
send_transactional_email Same validation as REST send. Also requires write. Args: transactional_message_id, to_email, from_email, optional to_name, from_name, variables, reply_to, headers, idempotency_key (unique per logical send; omit to always create a new outbound). Returns idempotent_replay. Same key with a different payload returns an error. Does not accept attachments. Do not pass subject, message, or text

SMTP vs REST

The SMTP submission gateway authenticates with the same API key but sends the MIME subject and body as submitted. It does not load dashboard templates. Use REST when you want template IDs and variables; use SMTP when the client already builds the full message.

Language examples

PHP (Laravel HTTP client)

Request Example

Http::withToken($apiKey)
    ->post(config('app.url').'/api/v1/transactional/send', [
        'transactional_message_id' => 1,
        'to' => ['email' => 'user@example.com'],
        'from' => ['email' => 'noreply@yourdomain.com'],
        'variables' => ['first_name' => 'Ada'],
    ]);

Node.js

Request Example

await fetch(`${process.env.APP_URL}/api/v1/transactional/send`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    transactional_message_id: 1,
    to: { email: 'user@example.com' },
    from: { email: 'noreply@yourdomain.com' },
    variables: { first_name: 'Ada' },
  }),
});

Python

Request Example

import requests

requests.post(
    f"{APP_URL}/api/v1/transactional/send",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "transactional_message_id": 1,
        "to": {"email": "user@example.com"},
        "from": {"email": "noreply@yourdomain.com"},
        "variables": {"first_name": "Ada"},
    },
)

Best practices

  • Create the template in Email Templates, then copy its numeric id into transactional_message_id.
  • Use idempotency_key for retries from your application.
  • Register merge keys in Variable Manager before sending if you use the registry.
  • Keep transactional content free of marketing unsubscribe requirements.
  • Monitor Transactional → Analytics for sent/failed/bounced/opens/clicks.
  • Respect mailbox and plan sending limits; deferred sends are retried by the outbound queue.