API reference

The LuneaMail API

Send transactional email from your templates, keep contacts in sync and receive signed webhooks. The overview of what you can build is on the developers page.

Get an API keyOpenAPI spec

Getting started

The base URL is https://app.luneamail.com/api/v1. Requests and responses are JSON. Authenticate every request with a workspace API key as a Bearer token: create one in Settings › API keys (admins only). A key belongs to one workspace, so keep it on your server.

Authorization: Bearer $LUNEAMAIL_API_KEY
Content-Type: application/json

Templates are designed in the visual builder or written in HTML with Handlebars: {{name}}, {{#if}}, {{#each}} and {{#if (eq plan "pro")}}. Send one with POST /send.

Errors

Errors come with an HTTP status and a body with a stable error code to branch on and a message written for people. Codes are listed under each endpoint and all together at the end of this page.

{
  "error": "MISSING_VARIABLE",
  "message": "Template rendering failed: \"licenseKey\" not defined in [object Object]"
}

Rate limits

Over a limit, you get 429 RATE_LIMITED with a Retry-After header in seconds. Request bodies can be up to about 4.5 MB, which is why attachments are capped at 3 MB.

EndpointLimit
POST /send600 requests a minute per API key
Contacts and lists endpoints300 requests a minute per API key, shared

Send an email

POST/send

Sends one transactional email (a receipt, license key, password reset or alert) from a template, filled with your data. It needs no contact and doesn't add one.

  • templateId is the id in the template's editor address: app.luneamail.com/templates/<templateId>.
  • The subject and body take the same {{variables}}. A variable used outside {{#if}} or {{#each}} is required: leave it out and the email isn't sent (MISSING_VARIABLE), so nobody gets a broken one.
  • The email goes out from your brand's sender name and address. Until that address's domain is verified, it's sent from LuneaMail's address with your name, and replies go to yours.
  • Unsubscribing from marketing doesn't stop transactional email. Addresses that bounced, complained or were dropped are refused (SUPPRESSED).
  • Each email counts toward the plan's monthly emails, like campaign email. On Pro and Team, email sent through your own SES or SMTP server doesn't.
  • On the Free plan the email carries a small "Made with LuneaMail" line.
BodyTypeDescription
templateIdrequiredstringThe template to send.
torequiredstringOne recipient email address.
dataobjectValues for the template's {{variables}}. Any JSON: strings, numbers, booleans, arrays for {{#each}}, nested objects.
subjectstringSubject line, with {{variables}}. Defaults to the template's name.
attachmentsarrayUp to 10 files, 3 MB in total after decoding. Each is { filename, content, contentType? }: content is bare base64 (no data: prefix), contentType is guessed from the file name when left out. Executables, scripts, HTML and SVG are refused.
curl "https://app.luneamail.com/api/v1/send" \
  -H "Authorization: Bearer $LUNEAMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "cm3p0y...",
    "to": "buyer@company.com",
    "subject": "Your {{product}} license",
    "data": {
      "firstName": "Ana",
      "product": "Pro",
      "licenseKey": "ABCD-1234-EFGH",
      "seats": 5
    }
  }'
StatusErrorWhen
400BAD_REQUESTThe body isn't valid JSON.
400INVALID_TEMPLATEtemplateId is missing, isn't in this workspace, or the template is empty.
400INVALID_REQUESTto isn't one valid email address, or subject isn't a string.
400VALIDATION_ERRORdata isn't a plain object.
400MISSING_VARIABLEThe template uses a variable the data doesn't have.
400INVALID_SUBJECTThe subject has a template syntax error.
400INVALID_ATTACHMENTAn attachment is malformed: no filename, bad base64, a data: prefix, a / in the name.
400TOO_MANY_ATTACHMENTSMore than 10 attachments.
400ATTACHMENTS_TOO_LARGEAttachments total more than 3 MB decoded.
400ATTACHMENT_BLOCKEDAn executable, script, HTML or SVG file, by extension or contentType.
403OWNER_UNVERIFIEDThe workspace owner hasn't confirmed their email address yet.
422CONTENT_BLOCKEDThe content screen flagged the email as abusive (phishing, spoofing). The message says why.
422SUPPRESSEDThe recipient bounced, complained or was dropped before.
429QUOTA_EXCEEDEDThe plan's monthly emails are used up.
502SEND_FAILEDThe email provider or your own server refused the email. The message has its reason.

Add or update a contact

POST/contacts

Adds a contact for marketing email, or updates one with the same email. Lists are added to, never removed from, and attributes merge into the ones already saved.

  • A new contact needs consent: { confirmed: true }, your statement that the person agreed to receive your marketing. Updating an existing contact doesn't.
  • Someone who unsubscribed, bounced or complained is added unsubscribed and stays that way.
  • The plan's contact limit applies to new contacts (CONTACT_LIMIT).
  • A new subscribed contact sends the contact.subscribed webhook.
BodyTypeDescription
emailrequiredstringThe contact's email address. Stored lowercase.
firstNamestringUp to 100 characters.
lastNamestringUp to 100 characters.
attributesobjectUp to 50 custom fields for personalisation and segments. Names start with a letter, then letters, digits, _ or -, up to 50 characters. Values: string (up to 1,000 characters), number, boolean or null.
listsstring[]Up to 20 list ids to add the contact to. GET /lists has them.
consentobject{ confirmed: true, source?, ip? }. Required for a new contact. source (up to 200 characters) and ip are kept as proof of consent.
curl "https://app.luneamail.com/api/v1/contacts" \
  -H "Authorization: Bearer $LUNEAMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ana@company.com",
    "firstName": "Ana",
    "lastName": "Silva",
    "attributes": {
      "plan": "pro",
      "seats": 5
    },
    "lists": [
      "cl8q1v..."
    ],
    "consent": {
      "confirmed": true,
      "source": "Signup form on company.com"
    }
  }'
StatusErrorWhen
400VALIDATION_ERRORA field is missing or invalid. The message names it.
400CONSENT_REQUIREDA new contact without consent.confirmed: true.
400INVALID_LISTA list id isn't in this workspace.
403CONTACT_LIMITThe plan's contact limit is reached.

Look up a contact

GET/contacts?email=

Returns one contact by email, with its attributes and list ids.

QueryTypeDescription
emailrequiredstringThe contact's email address.
curl -X GET "https://app.luneamail.com/api/v1/contacts?email=ana@company.com" \
  -H "Authorization: Bearer $LUNEAMAIL_API_KEY"
StatusErrorWhen
400VALIDATION_ERRORNo ?email= given.
404NOT_FOUNDNo contact with that email.

Erase a contact

DELETE/contacts?email=

Erases a contact and its events, for a GDPR or CCPA deletion request. An unsubscribe or bounce record for the address is kept, so it's never emailed again.

QueryTypeDescription
emailrequiredstringThe contact's email address.
curl -X DELETE "https://app.luneamail.com/api/v1/contacts?email=ana@company.com" \
  -H "Authorization: Bearer $LUNEAMAIL_API_KEY"
StatusErrorWhen
400VALIDATION_ERRORNo ?email= given.
404NOT_FOUNDNo contact with that email.

Unsubscribe an address

POST/contacts/unsubscribe

Opts an address out of all marketing email, for example when someone unsubscribes in your own app. It works for addresses that aren't contacts yet, so they can't be added later.

  • A contact that was subscribed sends the contact.unsubscribed webhook. Transactional email (POST /send) still reaches them.
BodyTypeDescription
emailrequiredstringThe address to opt out.
curl "https://app.luneamail.com/api/v1/contacts/unsubscribe" \
  -H "Authorization: Bearer $LUNEAMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ana@company.com"
  }'
StatusErrorWhen
400VALIDATION_ERRORThe body isn't { email } with a valid address.

List your lists

GET/lists

Returns the workspace's lists with their member counts, sorted by name. Use the ids with POST /contacts.

curl -X GET "https://app.luneamail.com/api/v1/lists" \
  -H "Authorization: Bearer $LUNEAMAIL_API_KEY"

Webhooks

LuneaMail tells your server when contacts and campaigns change, so your CRM and app stay in sync.

  • Add endpoints in Settings › Webhooks: up to 10 per workspace, each a public HTTPS URL with the events it wants. Each gets its own signing secret.
  • Each event is a POST with a JSON body: { id, type, createdAt, workspaceId, data }.
  • Answer with any 2xx status within 10 seconds. Redirects aren't followed and count as a failure.
  • Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts over about two days.
  • After 15 deliveries in a row fail every retry, the endpoint is turned off and your admins get an email. Turn it back on in Settings › Webhooks.
  • Send a test event (type ping) from Settings › Webhooks to check an endpoint. It isn't retried.
HeaderTypeDescription
X-LuneaMail-EventstringThe event type, such as contact.unsubscribed.
X-LuneaMail-DeliverystringThis delivery's id. The same on every retry, so you can skip duplicates.
X-LuneaMail-TimestampstringWhen this attempt was signed, in seconds since the epoch.
X-LuneaMail-Signaturestringv1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" with the endpoint's secret>.

Verifying signatures

Compute an HMAC-SHA256 of <timestamp>.<raw body> with the endpoint's secret and compare it with the signature in constant time. Use the body exactly as received, before any JSON parsing, and reject timestamps more than five minutes old so a captured request can't be replayed.

import crypto from "node:crypto";

// rawBody: the request body exactly as received
export function verify(rawBody, headers, secret) {
  const ts = headers["x-luneamail-timestamp"];
  const expected = "v1=" + crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  const got = headers["x-luneamail-signature"] ?? "";
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  return fresh && got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}

Events

contact.subscribed

A new subscribed contact was added (in the app, by the API or an agent), or someone opted back in from the preference page.

dataTypeDescription
contactIdstringThe contact.
emailstringThe address.
firstNamestring | nullWhen known.
lastNamestring | nullWhen known.
sourcestringapp, api, agent or preference-page.
{
  "id": "evt_9f2c...",
  "type": "contact.subscribed",
  "createdAt": "2026-11-07T09:14:02.511Z",
  "workspaceId": "cw1a...",
  "data": {
    "contactId": "cm4x2k9...",
    "email": "ana@company.com",
    "firstName": "Ana",
    "lastName": "Silva",
    "source": "api"
  }
}

contact.unsubscribed

Someone unsubscribed: from an email, the preference page, the API or the app.

dataTypeDescription
contactIdstring | nullThe contact. Null when the address isn't a contact (a bounce on API email).
emailstringThe address.
sourcestringWhere it happened: recipient (a link in an email or the preference page), app, api or email-provider.
campaignIdstring | nullThe campaign it came from, when the email provider reports one.
reasonstring | nullThe provider's reason, when it gives one (bounces and complaints).
{
  "id": "evt_9f2c...",
  "type": "contact.unsubscribed",
  "createdAt": "2026-11-07T09:14:02.511Z",
  "workspaceId": "cw1a...",
  "data": {
    "contactId": "cm4x2k9...",
    "email": "ana@company.com",
    "source": "recipient"
  }
}

contact.bounced

An address hard-bounced and won't be emailed again.

dataTypeDescription
contactIdstring | nullThe contact. Null when the address isn't a contact (a bounce on API email).
emailstringThe address.
sourcestringWhere it happened: recipient (a link in an email or the preference page), app, api or email-provider.
campaignIdstring | nullThe campaign it came from, when the email provider reports one.
reasonstring | nullThe provider's reason, when it gives one (bounces and complaints).
{
  "id": "evt_9f2c...",
  "type": "contact.bounced",
  "createdAt": "2026-11-07T09:14:02.511Z",
  "workspaceId": "cw1a...",
  "data": {
    "contactId": "cm4x2k9...",
    "email": "ana@company.com",
    "campaignId": "cc7d...",
    "reason": "550 5.1.1 User unknown",
    "source": "email-provider"
  }
}

contact.complained

Someone marked an email as spam. They won't be emailed again.

dataTypeDescription
contactIdstring | nullThe contact. Null when the address isn't a contact (a bounce on API email).
emailstringThe address.
sourcestringWhere it happened: recipient (a link in an email or the preference page), app, api or email-provider.
campaignIdstring | nullThe campaign it came from, when the email provider reports one.
reasonstring | nullThe provider's reason, when it gives one (bounces and complaints).
{
  "id": "evt_9f2c...",
  "type": "contact.complained",
  "createdAt": "2026-11-07T09:14:02.511Z",
  "workspaceId": "cw1a...",
  "data": {
    "contactId": "cm4x2k9...",
    "email": "ana@company.com",
    "campaignId": "cc7d...",
    "reason": null,
    "source": "email-provider"
  }
}

campaign.sent

A campaign started sending, now or on its schedule.

dataTypeDescription
campaignIdstringThe campaign.
namestring | nullIts name in LuneaMail.
subjectstring | nullIts subject line.
listIdstringThe list it's going to.
recipientsnumberHow many people it's going to.
scheduledbooleanTrue when it started on a schedule.
{
  "id": "evt_9f2c...",
  "type": "campaign.sent",
  "createdAt": "2026-11-07T09:14:02.511Z",
  "workspaceId": "cw1a...",
  "data": {
    "campaignId": "cc7d...",
    "name": "November update",
    "subject": "What's new in November",
    "listId": "cl8q1v...",
    "recipients": 1240,
    "scheduled": false
  }
}

campaign.completed

A campaign finished sending, with its totals.

dataTypeDescription
campaignIdstringThe campaign.
namestring | nullIts name.
subjectstring | nullIts subject line.
statusstringCOMPLETED, or FAILED when nothing could be sent.
sentnumberEmails handed to the provider.
failednumberEmails the provider refused.
skippednumberRecipients skipped because they unsubscribed or were suppressed while it sent.
completedAtstring | nullWhen it finished (ISO 8601).
{
  "id": "evt_9f2c...",
  "type": "campaign.completed",
  "createdAt": "2026-11-07T09:14:02.511Z",
  "workspaceId": "cw1a...",
  "data": {
    "campaignId": "cc7d...",
    "name": "November update",
    "subject": "What's new in November",
    "status": "COMPLETED",
    "sent": 1236,
    "failed": 1,
    "skipped": 3,
    "completedAt": "2026-11-07T09:31:40.022Z"
  }
}

All error codes

StatusErrorWhen
400ATTACHMENT_BLOCKEDAn executable, script, HTML or SVG file, by extension or contentType.
400ATTACHMENTS_TOO_LARGEAttachments total more than 3 MB decoded.
400BAD_REQUESTThe body isn't valid JSON.
400CONSENT_REQUIREDA new contact without consent.confirmed: true.
400INVALID_ATTACHMENTAn attachment is malformed: no filename, bad base64, a data: prefix, a / in the name.
400INVALID_LISTA list id isn't in this workspace.
400INVALID_REQUESTto isn't one valid email address, or subject isn't a string.
400INVALID_SUBJECTThe subject has a template syntax error.
400INVALID_TEMPLATEtemplateId is missing, isn't in this workspace, or the template is empty.
400MISSING_VARIABLEThe template uses a variable the data doesn't have.
400TOO_MANY_ATTACHMENTSMore than 10 attachments.
400VALIDATION_ERRORThe body isn't { email } with a valid address.
401UNAUTHORIZEDThe Authorization header is missing, malformed or holds an unknown API key.
403CONTACT_LIMITThe plan's contact limit is reached.
403OWNER_UNVERIFIEDThe workspace owner hasn't confirmed their email address yet.
404NOT_FOUNDNo contact with that email.
422CONTENT_BLOCKEDThe content screen flagged the email as abusive (phishing, spoofing). The message says why.
422SUPPRESSEDThe recipient bounced, complained or was dropped before.
429QUOTA_EXCEEDEDThe plan's monthly emails are used up.
429RATE_LIMITEDOver the per-key rate limit. Wait for the Retry-After header's seconds.
500INTERNAL_ERRORSomething failed on our side. Safe to retry.
502SEND_FAILEDThe email provider or your own server refused the email. The message has its reason.