API reference
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.
x-api-key: lum_sk_your_api_key_herex-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.
{
"error": "sender_domain_not_verified",
"message": "Sender domain is not verified or does not belong to this tenant"
}Send email
Send a transactional email. The from.email domain must be verified and configured with DKIM in your portal.
| name | type | description |
|---|---|---|
| from | object | Sender object: { email, name }. If omitted, uses app default sender. |
| to | string[] | Recipients. Required. |
| subject | string | Email subject. Required. |
| html | string | Inline HTML body. Optional if template sending exists. |
| text | string | Plain text body. Optional. |
| variables | object | First-class shared template variables. Available as {{token}} across subject, html, and text. |
| perRecipientVariables | object | Optional object keyed by recipient email. Each entry overrides shared variables for that recipient only. |
| metadata | object | Optional 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 /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"
}
}'{
"messageId": "msg_9f3a21",
"status": "queued",
"idempotentReplay": false
}Retrieve email
Fetch the current status and delivery detail for a single message.
{
"message": {
"id": "msg_9f3a21",
"status": "delivered",
"subject": "Welcome to Acme",
"createdAt": "2026-04-21T14:32:08Z"
},
"recipients": [],
"events": []
}Email logs
List delivery events for the authenticated application.
curl /v1/emails/logs \
-H "x-api-key: lum_sk_..."Email stats
Get aggregate stats for the authenticated application.
curl /v1/emails/stats \
-H "x-api-key: lum_sk_..."Add domain (portal)
Register a sending domain from the portal (session-authenticated). This is the flow used by the Add domain wizard.
{
"domain": "mail.acme.com"
}Verify DNS (portal)
Verifies SPF/DKIM/DMARC records for a portal domain. Use this after adding DNS records.
Create template
Create a reusable email template. Reference it later by slug when sending.
{
"id": "tpl_71xV",
"slug": "welcome-email",
"name": "Welcome email",
"status": "active"
}Create API key (portal)
Creates an API key for an app. The secret is returned once.
{
"name": "Server key",
"rateLimitPerMinute": 60,
"scopes": { "email:send": true }
}List API keys (portal)
Lists keys for an app, including prefixes and status.
List audiences
List all audience (campaigns) lists for the app authenticated by the API key. Returns a paginated list of lists with member counts.
curl /v1/marketing/lists \
-H "x-api-key: lum_sk_..."{
"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 }
}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.
curl /v1/marketing/lists/Active%20Customers \
-H "x-api-key: lum_sk_..."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.
| name | type | description |
|---|---|---|
| name | string (optional) | New list name. 1–128 chars. Must be unique within the app; duplicates return HTTP 409. |
| description | string|null (optional) | Public description shown in the Audiences portal. Max 1000 chars. Pass null to clear. |
| company_name | string|null (optional) | Legal company name used on CAN-SPAM / CASL footers in campaign emails. Pass null to clear. |
| company_address | string|null (optional) | Physical company address included in compliance footers. Pass null to clear. |
| require_double_opt_in | boolean (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. |
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
}'{
"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
}
}/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.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.
| name | type | description |
|---|---|---|
| name | string (required) | Display name of the audience. 1–128 chars; case-sensitive matching. |
| description | string|null (optional) | Internal description shown in Audiences portal. Max 1000 chars. |
| company_name | string|null (optional) | Legal company name used on CAN-SPAM / CASL footers in campaign emails. |
| company_address | string|null (optional) | Physical company address included in compliance footers. |
| require_double_opt_in | boolean (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). |
| contacts | Contact[] (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. |
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" }
}
]
}'{
"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-…"
}
]
}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.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.).
{
"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 }
}Retrieve contact
Look up a single marketing contact by email. The email query parameter is required.
| name | type | description |
|---|---|---|
| string | Email of the contact to look up. (query, required) |
curl "/v1/marketing/contacts?email=chika%40example.ng" \
-H "x-api-key: lum_sk_..."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.
| name | type | description |
|---|---|---|
| string | Contact email. Required. | |
| first_name | string (optional) | Given name. Max 128 chars. |
| last_name | string (optional) | Family name. Max 128 chars. |
| phone | string (optional) | E.164 phone number, e.g. +2348030000000. Max 64 chars. Available as the {{phone}} merge tag in campaign emails. |
| tags | string[] (optional) | String tag array, e.g. ["vip", "2026-signup"]. |
| custom_fields | object (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_id | string (optional) | UUID of an existing audience to attach this contact to. Mutually exclusive with list_name. |
| list_name | string (optional) | Exact name of an existing audience to attach this contact to. Mutually exclusive with list_id. |
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"
}'{
"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"
}
}email,first_name,last_name,phone (phone column supported alongside the legacy alias set: phone_number / phonenumber / mobile).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.
| name | type | description |
|---|---|---|
| contacts | Contact[] (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. |
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" }
}
]
}'{
"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-…"
}
]
}