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.
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/jsonTemplates 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.
| Endpoint | Limit |
|---|---|
| POST /send | 600 requests a minute per API key |
| Contacts and lists endpoints | 300 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.
| Body | Type | Description |
|---|---|---|
templateIdrequired | string | The template to send. |
torequired | string | One recipient email address. |
data | object | Values for the template's {{variables}}. Any JSON: strings, numbers, booleans, arrays for {{#each}}, nested objects. |
subject | string | Subject line, with {{variables}}. Defaults to the template's name. |
attachments | array | Up 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
}
}'| Status | Error | When |
|---|---|---|
| 400 | BAD_REQUEST | The body isn't valid JSON. |
| 400 | INVALID_TEMPLATE | templateId is missing, isn't in this workspace, or the template is empty. |
| 400 | INVALID_REQUEST | to isn't one valid email address, or subject isn't a string. |
| 400 | VALIDATION_ERROR | data isn't a plain object. |
| 400 | MISSING_VARIABLE | The template uses a variable the data doesn't have. |
| 400 | INVALID_SUBJECT | The subject has a template syntax error. |
| 400 | INVALID_ATTACHMENT | An attachment is malformed: no filename, bad base64, a data: prefix, a / in the name. |
| 400 | TOO_MANY_ATTACHMENTS | More than 10 attachments. |
| 400 | ATTACHMENTS_TOO_LARGE | Attachments total more than 3 MB decoded. |
| 400 | ATTACHMENT_BLOCKED | An executable, script, HTML or SVG file, by extension or contentType. |
| 403 | OWNER_UNVERIFIED | The workspace owner hasn't confirmed their email address yet. |
| 422 | CONTENT_BLOCKED | The content screen flagged the email as abusive (phishing, spoofing). The message says why. |
| 422 | SUPPRESSED | The recipient bounced, complained or was dropped before. |
| 429 | QUOTA_EXCEEDED | The plan's monthly emails are used up. |
| 502 | SEND_FAILED | The 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.
| Body | Type | Description |
|---|---|---|
emailrequired | string | The contact's email address. Stored lowercase. |
firstName | string | Up to 100 characters. |
lastName | string | Up to 100 characters. |
attributes | object | Up 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. |
lists | string[] | Up to 20 list ids to add the contact to. GET /lists has them. |
consent | object | { 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"
}
}'| Status | Error | When |
|---|---|---|
| 400 | VALIDATION_ERROR | A field is missing or invalid. The message names it. |
| 400 | CONSENT_REQUIRED | A new contact without consent.confirmed: true. |
| 400 | INVALID_LIST | A list id isn't in this workspace. |
| 403 | CONTACT_LIMIT | The plan's contact limit is reached. |
Look up a contact
GET/contacts?email=
Returns one contact by email, with its attributes and list ids.
| Query | Type | Description |
|---|---|---|
emailrequired | string | The contact's email address. |
curl -X GET "https://app.luneamail.com/api/v1/contacts?email=ana@company.com" \
-H "Authorization: Bearer $LUNEAMAIL_API_KEY"| Status | Error | When |
|---|---|---|
| 400 | VALIDATION_ERROR | No ?email= given. |
| 404 | NOT_FOUND | No 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.
| Query | Type | Description |
|---|---|---|
emailrequired | string | The contact's email address. |
curl -X DELETE "https://app.luneamail.com/api/v1/contacts?email=ana@company.com" \
-H "Authorization: Bearer $LUNEAMAIL_API_KEY"| Status | Error | When |
|---|---|---|
| 400 | VALIDATION_ERROR | No ?email= given. |
| 404 | NOT_FOUND | No 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.
| Body | Type | Description |
|---|---|---|
emailrequired | string | The 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"
}'| Status | Error | When |
|---|---|---|
| 400 | VALIDATION_ERROR | The 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.
| Header | Type | Description |
|---|---|---|
X-LuneaMail-Event | string | The event type, such as contact.unsubscribed. |
X-LuneaMail-Delivery | string | This delivery's id. The same on every retry, so you can skip duplicates. |
X-LuneaMail-Timestamp | string | When this attempt was signed, in seconds since the epoch. |
X-LuneaMail-Signature | string | v1=<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.
| data | Type | Description |
|---|---|---|
contactId | string | The contact. |
email | string | The address. |
firstName | string | null | When known. |
lastName | string | null | When known. |
source | string | app, 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.
| data | Type | Description |
|---|---|---|
contactId | string | null | The contact. Null when the address isn't a contact (a bounce on API email). |
email | string | The address. |
source | string | Where it happened: recipient (a link in an email or the preference page), app, api or email-provider. |
campaignId | string | null | The campaign it came from, when the email provider reports one. |
reason | string | null | The 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.
| data | Type | Description |
|---|---|---|
contactId | string | null | The contact. Null when the address isn't a contact (a bounce on API email). |
email | string | The address. |
source | string | Where it happened: recipient (a link in an email or the preference page), app, api or email-provider. |
campaignId | string | null | The campaign it came from, when the email provider reports one. |
reason | string | null | The 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.
| data | Type | Description |
|---|---|---|
contactId | string | null | The contact. Null when the address isn't a contact (a bounce on API email). |
email | string | The address. |
source | string | Where it happened: recipient (a link in an email or the preference page), app, api or email-provider. |
campaignId | string | null | The campaign it came from, when the email provider reports one. |
reason | string | null | The 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.
| data | Type | Description |
|---|---|---|
campaignId | string | The campaign. |
name | string | null | Its name in LuneaMail. |
subject | string | null | Its subject line. |
listId | string | The list it's going to. |
recipients | number | How many people it's going to. |
scheduled | boolean | True 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.
| data | Type | Description |
|---|---|---|
campaignId | string | The campaign. |
name | string | null | Its name. |
subject | string | null | Its subject line. |
status | string | COMPLETED, or FAILED when nothing could be sent. |
sent | number | Emails handed to the provider. |
failed | number | Emails the provider refused. |
skipped | number | Recipients skipped because they unsubscribed or were suppressed while it sent. |
completedAt | string | null | When 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
| Status | Error | When |
|---|---|---|
| 400 | ATTACHMENT_BLOCKED | An executable, script, HTML or SVG file, by extension or contentType. |
| 400 | ATTACHMENTS_TOO_LARGE | Attachments total more than 3 MB decoded. |
| 400 | BAD_REQUEST | The body isn't valid JSON. |
| 400 | CONSENT_REQUIRED | A new contact without consent.confirmed: true. |
| 400 | INVALID_ATTACHMENT | An attachment is malformed: no filename, bad base64, a data: prefix, a / in the name. |
| 400 | INVALID_LIST | A list id isn't in this workspace. |
| 400 | INVALID_REQUEST | to isn't one valid email address, or subject isn't a string. |
| 400 | INVALID_SUBJECT | The subject has a template syntax error. |
| 400 | INVALID_TEMPLATE | templateId is missing, isn't in this workspace, or the template is empty. |
| 400 | MISSING_VARIABLE | The template uses a variable the data doesn't have. |
| 400 | TOO_MANY_ATTACHMENTS | More than 10 attachments. |
| 400 | VALIDATION_ERROR | The body isn't { email } with a valid address. |
| 401 | UNAUTHORIZED | The Authorization header is missing, malformed or holds an unknown API key. |
| 403 | CONTACT_LIMIT | The plan's contact limit is reached. |
| 403 | OWNER_UNVERIFIED | The workspace owner hasn't confirmed their email address yet. |
| 404 | NOT_FOUND | No contact with that email. |
| 422 | CONTENT_BLOCKED | The content screen flagged the email as abusive (phishing, spoofing). The message says why. |
| 422 | SUPPRESSED | The recipient bounced, complained or was dropped before. |
| 429 | QUOTA_EXCEEDED | The plan's monthly emails are used up. |
| 429 | RATE_LIMITED | Over the per-key rate limit. Wait for the Retry-After header's seconds. |
| 500 | INTERNAL_ERROR | Something failed on our side. Safe to retry. |
| 502 | SEND_FAILED | The email provider or your own server refused the email. The message has its reason. |