API reference

API reference

The Lumora REST API lets you send transactional email, manage domains, work with templates, and manage audiences & marketing contacts via key-authenticated endpoints. Base URL Loading....

Authentication

Authenticate every request with your secret API key. Keys are created per app in your dashboard and carry that app's permissions. Never expose a secret key in client-side code.

Authorization header
Authentication
x-api-key: lum_sk_your_api_key_here
Use x-api-key for integrations and onboarding. The API also accepts Authorization: Bearer ... for compatibility. Missing or invalid keys return 401.

Errors

Lumora uses conventional HTTP status codes. Error bodies include a stable error code and an optional message.

Example error · 403
{
  "error": "sender_domain_not_verified",
  "message": "Sender domain is not verified or does not belong to this tenant"
}
POST/v1/email/send

Send email

Send a transactional email. The from.email domain must be verified and configured with DKIM in your portal.

Body parameters
nametypedescription
fromobjectSender object: { email, name }. If omitted, uses app default sender.
tostring[]Recipients. Required.
subjectstringEmail subject. Required.
htmlstringInline HTML body. Optional if template sending exists.
textstringPlain text body. Optional.
variablesobjectFirst-class shared template variables. Available as {{token}} across subject, html, and text.
perRecipientVariablesobjectOptional object keyed by recipient email. Each entry overrides shared variables for that recipient only.
metadataobjectOptional legacy metadata. metadata.variables and top-level metadata keys still render as {{token}} placeholders for backward compatibility.

Rendering precedence is variables, then legacy metadata, then recipient-specific overrides from perRecipientVariables or inline recipient variables. System tokens {{name}}, {{email}}, and {{unsubscribe_url}} always render per recipient.

cURL
curl /v1/email/send \
  -H "x-api-key: lum_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": { "email": "hello@mail.acme.com", "name": "Acme" },
    "to": [
      {
        "email": "jordan@globex.com",
        "name": "Jordan",
        "variables": { "cta_label": "Confirm account" }
      },
      {
        "email": "avery@globex.com",
        "name": "Avery"
      }
    ],
    "subject": "Welcome to {{company}}",
    "html": "<p>Hello {{name}}</p><p><a href=\"{{link}}\">{{cta_label}}</a></p><p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></p>",
    "variables": {
      "company": "Acme",
      "link": "https://acme.com/confirm",
      "cta_label": "Get started"
    },
    "perRecipientVariables": {
      "avery@globex.com": {
        "cta_label": "Activate workspace"
      }
    },
    "metadata": {
      "campaign": "welcome-sequence"
    }
  }'
Response · 202
{
  "messageId": "msg_9f3a21",
  "status": "queued",
  "idempotentReplay": false
}
GET/v1/messages/{messageId}

Retrieve email

Fetch the current status and delivery detail for a single message.

Response · 200
{
  "message": {
    "id": "msg_9f3a21",
    "status": "delivered",
    "subject": "Welcome to Acme",
    "createdAt": "2026-04-21T14:32:08Z"
  },
  "recipients": [],
  "events": []
}
GET/v1/emails/logs

Email logs

List delivery events for the authenticated application.

Example
curl /v1/emails/logs \
  -H "x-api-key: lum_sk_..."
GET/v1/emails/stats

Email stats

Get aggregate stats for the authenticated application.

Example
curl /v1/emails/stats \
  -H "x-api-key: lum_sk_..."
POST/v1/portal/domains

Add domain (portal)

Register a sending domain from the portal (session-authenticated). This is the flow used by the Add domain wizard.

Request
{
  "domain": "mail.acme.com"
}
POST/v1/portal/domains/{id}/verify-dns

Verify DNS (portal)

Verifies SPF/DKIM/DMARC records for a portal domain. Use this after adding DNS records.

POST/v1/templates

Create template

Create a reusable email template. Reference it later by slug when sending.

Response · 201
{
  "id": "tpl_71xV",
  "slug": "welcome-email",
  "name": "Welcome email",
  "status": "active"
}
POST/v1/portal/apps/{appId}/api-keys

Create API key (portal)

Creates an API key for an app. The secret is returned once.

Request
{
  "name": "Server key",
  "rateLimitPerMinute": 60,
  "scopes": { "email:send": true }
}
GET/v1/portal/apps/{appId}/api-keys

List API keys (portal)

Lists keys for an app, including prefixes and status.

GET/v1/marketing/lists

List audiences

List all audience (campaigns) lists for the app authenticated by the API key. Returns a paginated list of lists with member counts.

Example
curl /v1/marketing/lists \
  -H "x-api-key: lum_sk_..."
Response · 200
{
  "data": [
    {
      "id": "0189fa7e-2c0a-4f01-91a9-000000000000",
      "name": "Active Customers",
      "description": "Purchased in the last 90 days",
      "member_count": 1204,
      "double_opt_in": false,
      "created_at": "2026-09-01T10:15:00Z"
    }
  ],
  "pagination": { "count": 1 }
}
GET/v1/marketing/lists/{listIdOrName}

Retrieve audience

Fetch a single audience by UUID id or exact name. If the path segment looks like a UUID we try the direct id, then always fall back to name match within the same app.

Example (by name)
curl /v1/marketing/lists/Active%20Customers \
  -H "x-api-key: lum_sk_..."
PATCH/v1/marketing/lists/{listIdOrName}

Update audience

Update the fields of an existing audience (marketing list). The URL accepts the list's UUID or its current exact name (same resolution order as the GET route). Any subset of the mutable fields can be sent — unspecified fields are left unchanged. Pass null for text fields like description, company_name, or company_address to clear them.

Body parameters (all optional)
nametypedescription
namestring (optional)New list name. 1–128 chars. Must be unique within the app; duplicates return HTTP 409.
descriptionstring|null (optional)Public description shown in the Audiences portal. Max 1000 chars. Pass null to clear.
company_namestring|null (optional)Legal company name used on CAN-SPAM / CASL footers in campaign emails. Pass null to clear.
company_addressstring|null (optional)Physical company address included in compliance footers. Pass null to clear.
require_double_opt_inboolean (optional)When true, new members added to this list (via API import or CSV) start as 'pending_opt_in' until they click the confirmation link.
Rename + enable double opt-in
curl -X PATCH /v1/marketing/lists/Active%20Customers \
  -H "x-api-key: lum_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Paid Customers",
    "description": "Anyone who purchased in the last 90 days",
    "require_double_opt_in": true
  }'
Response · 200
{
  "ok": true,
  "data": {
    "id": "0189fa7e-2c0a-4f01-91a9-000000000000",
    "name": "Paid Customers",
    "description": "Anyone who purchased in the last 90 days",
    "company_name": null,
    "company_address": null,
    "require_double_opt_in": true,
    "created_at": "2026-09-01T10:15:00Z",
    "updated_at": "2026-09-21T14:02:09Z",
    "contact_count": 1204
  }
}
If you rename a list, the new name becomes immediately available for /v1/marketing/lists/<NewName>/contacts style imports (the list-by-name resolver uses the updated name). Renaming to a name already taken by another list in the same app returns 409 list_name_taken.
POST/v1/marketing/lists

Create audience + import contacts (atomic)

For users who never onboarded or created an audience yet. Atomically create a marketing list (audience) by name if it does not already exist in the app, then optionally upsert up to 500 contacts into it — all in a single HTTP call. If a list with the same name already exists for your app, no new list is created and the existing list is reused; the contacts are imported into it and the response returns HTTP 200 with list_created: false.

Body parameters
nametypedescription
namestring (required)Display name of the audience. 1–128 chars; case-sensitive matching.
descriptionstring|null (optional)Internal description shown in Audiences portal. Max 1000 chars.
company_namestring|null (optional)Legal company name used on CAN-SPAM / CASL footers in campaign emails.
company_addressstring|null (optional)Physical company address included in compliance footers.
require_double_opt_inboolean (optional)When true, all members imported via this call will be created with membership_status = 'pending_opt_in' until they click the opt-in email link. Defaults to false (single opt-in / subscribed).
contactsContact[] (optional, 1–500)Array of contact objects. Each contact supports the same fields as POST /v1/marketing/contacts: email (required), first_name, last_name, phone, tags[], custom_fields{}, status. Omit entirely to create an empty audience and return the list id for later batch additions.
Create list named Onboarding + import 2 contacts
curl -X POST /v1/marketing/lists \
  -H "x-api-key: lum_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Onboarding",
    "company_name": "Lumora Billing Inc.",
    "company_address": "12 Acme St, Lagos",
    "require_double_opt_in": false,
    "contacts": [
      {
        "email": "chika@example.ng",
        "first_name": "Chika",
        "last_name": "Okafor",
        "phone": "+2348030000000",
        "tags": ["free-trial","onboarding-email-1"]
      },
      {
        "email": "tunde@example.ng",
        "first_name": "Tunde",
        "custom_fields": { "sign_up_channel": "twitter" }
      }
    ]
  }'
Response · 201 (list was created + imported)
{
  "ok": true,
  "list_created": true,
  "list": {
    "id": "0189fa7e-2c0a-4f01-91a9-000000000000",
    "name": "Onboarding",
    "description": null,
    "company_name": "Lumora Billing Inc.",
    "company_address": "12 Acme St, Lagos",
    "require_double_opt_in": false,
    "created_at": "2026-09-22T11:00:00Z",
    "updated_at": "2026-09-22T11:00:00Z"
  },
  "imported_count": 2,
  "imported": [
    {
      "id": "0189fa80-…",
      "email": "chika@example.ng",
      "updated": false,
      "list_membership_id": "0189fa81-…"
    },
    {
      "id": "0189fa92-…",
      "email": "tunde@example.ng",
      "updated": false,
      "list_membership_id": "0189fa93-…"
    }
  ]
}
Repeat this call with the same name to keep topping up the same list with new contacts without getting a 409 conflict. The first call returns 201 list_created:true; subsequent calls return 200 list_created:false and still import every new/updated contact in the batch. When you need bulk-only imports against a known existing list, the lighter-weight endpoint POST /v1/marketing/lists/{listIdOrName}/contacts is still preferred.
GET/v1/marketing/lists/{listIdOrName}/contacts

List audience members

Fetch the full list of members for an audience, including phone, tags, custom_fields, and the membership status (subscribed, pending_opt_in, etc.).

Response · 200
{
  "data": [
    {
      "id": "0189fa80-...",
      "email": "chika@example.ng",
      "first_name": "Chika",
      "last_name": "Okafor",
      "phone": "+2348030000000",
      "tags": ["vip"],
      "custom_fields": { "source": "website" },
      "status": "subscribed",
      "membership_status": "subscribed",
      "opted_in_at": "2026-09-01T10:20:00Z"
    }
  ],
  "pagination": { "count": 1 }
}
GET/v1/marketing/contacts

Retrieve contact

Look up a single marketing contact by email. The email query parameter is required.

nametypedescription
emailstringEmail of the contact to look up. (query, required)
Example
curl "/v1/marketing/contacts?email=chika%40example.ng" \
  -H "x-api-key: lum_sk_..."
POST/v1/marketing/contacts

Create / update contact

Create or import a contact once — idempotent upsert keyed by the combination of (app_id, email). Repeat the request with the same email, and the existing row is refreshed. Optionally attach the contact to a list in the same request by supplying list_id or list_name.

Body parameters
nametypedescription
emailstringContact email. Required.
first_namestring (optional)Given name. Max 128 chars.
last_namestring (optional)Family name. Max 128 chars.
phonestring (optional)E.164 phone number, e.g. +2348030000000. Max 64 chars. Available as the {{phone}} merge tag in campaign emails.
tagsstring[] (optional)String tag array, e.g. ["vip", "2026-signup"].
custom_fieldsobject (optional)Flat string/number/boolean values. Stored as JSON on the contact row.
status'subscribed' | 'pending_opt_in' (optional)Contact-level status. When attaching to a double-opt-in list the membership still starts as pending_opt_in.
list_idstring (optional)UUID of an existing audience to attach this contact to. Mutually exclusive with list_name.
list_namestring (optional)Exact name of an existing audience to attach this contact to. Mutually exclusive with list_id.
Single contact + attach to a list
curl -X POST /v1/marketing/contacts \
  -H "x-api-key: lum_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "chika@example.ng",
    "first_name": "Chika",
    "last_name": "Okafor",
    "phone": "+2348030000000",
    "tags": ["vip", "2026-signup"],
    "custom_fields": { "source": "website", "plan": "premium" },
    "status": "subscribed",
    "list_name": "Active Customers"
  }'
Response · 201 (new contact)
{
  "ok": true,
  "contact": {
    "id": "0189fa80-8ab0-4aa0-b991-000000000000",
    "email": "chika@example.ng",
    "updated": false
  },
  "list": {
    "id": "0189fa7e-2c0a-4f01-91a9-000000000000",
    "name": "Active Customers",
    "membership_id": "0189fa82-0000-4000-8000-000000000000"
  }
}
CSV import in the Audiences portal screen now also accepts email,first_name,last_name,phone (phone column supported alongside the legacy alias set: phone_number / phonenumber / mobile).
POST/v1/marketing/lists/{listIdOrName}/contacts

Batch import contacts

Upsert up to 500 contacts in a single request and attach them all to the specified audience. The endpoint accepts either a { contacts: [...] } wrapper body or a single contact object (which is auto-wrapped), so the same URL is convenient for both ETL batch jobs and one-off additions.

Body parameters
nametypedescription
contactsContact[] (required, 1–500)Array of contact objects. Each contact supports the same fields as POST /v1/marketing/contacts: email, first_name, last_name, phone, tags, custom_fields, status.
Batch upsert 2 contacts
curl -X POST /v1/marketing/lists/Active%20Customers/contacts \
  -H "x-api-key: lum_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {
        "email": "tunde@example.ng",
        "first_name": "Tunde",
        "last_name": "Ade",
        "phone": "+2348030000001",
        "tags": ["vip"]
      },
      {
        "email": "nnenna@example.ng",
        "phone": "+2348030000002",
        "custom_fields": { "referrer": "partner" }
      }
    ]
  }'
Response · 201
{
  "ok": true,
  "imported_count": 2,
  "imported": [
    {
      "id": "0189fa90-…",
      "email": "tunde@example.ng",
      "updated": false,
      "list_membership_id": "0189fa91-…"
    },
    {
      "id": "0189fa92-…",
      "email": "nnenna@example.ng",
      "updated": false,
      "list_membership_id": "0189fa93-…"
    }
  ]
}