API Keys Guide
Send and read email programmatically with a single REST API. Onecl_live_…key, six endpoints, Bearer authentication. SMTP is also supported for legacy clients.
On This Page
Overview
CenterLeap exposes a small, focused REST API for transactional email. Each API key is bound to exactly one mailbox and authenticates with a standard Authorization: Bearer cl_live_… header — the same pattern as Resend, Stripe, OpenAI, etc.
Base URL
https://mail-api.centerleap.comUse mail-api.centerleap.com, not api.centerleap.com — they resolve to different services.
The complete surface
| Method | Path | Required scope | Purpose |
|---|---|---|---|
POST | /v1/emails | send_only / full_access | Send an email |
GET | /v1/emails/_ping | any | Identify the key + scope + rate limit |
GET | /v1/inbox/messages | read_only / full_access | List recent inbound mail (cursor-paginated) |
GET | /v1/inbox/messages/:id | read_only / full_access | Fetch one message with full body |
PATCH | /v1/inbox/messages/:id | full_access | Mark read / star / move folder |
GET | /v1/inbox/_ping | any | Same shape as /v1/emails/_ping |
Scopes
Pick the narrowest scope that does the job. Changing a key's scope means rotating it, so it's worth getting right the first time.
Send only
scope: send_only
POST /v1/emails only. Cannot read or modify the inbox. Default — the right pick for transactional senders, password resets, and notifications.
Read only
scope: read_only
GET /v1/inbox/* only. Cannot send or modify. Right for analytics dashboards and audit-log readers.
Full access
scope: full_access
Send, read, and mark mail. For embedded webmail experiences and AI inbox agents that need to act on the user's behalf.
Inbox endpoints require plaintext encryption tier
Mailboxes in the default standard tier store mail PGP-encrypted at rest — the server cannot return plaintext bodies to the API. To enable /v1/inbox/* for a mailbox, set its encryption tier to plaintext in CenterLeap settings before creating a read_only or full_access key. Sending (/v1/emails) works on any tier.
Creating an API Key
- Go to Dashboard → Mail → Settings
- Open the domain and select the mailbox the key should send/read as
- Click the "API Keys" tab
- Pick a scope: Send only (default), Read only, or Full access
- Pick an expiration: Never (default), 30 / 60 / 90 days, 1 year, or a custom date
- Click Create — copy the key now, or reveal it again later from the row
You can copy the key again later
Unlike most API providers, CenterLeap stores the key AES-256-GCM-encrypted at rest and lets you reveal the plaintext again. Click Copy key, re-enter your account password, and you'll see the full cl_live_… value. Lost it entirely? Rotate — that issues a new value and revokes the old one in one operation.
Key format
cl_live_ prefix + 48 hex characters. The first 12 hex characters are also stored unencrypted as a lookup prefix — do not paste the prefix anywhere public.
Sending Email
POST https://mail-api.centerleap.com/v1/emails
The send endpoint is Resend-compatible — most SDKs that target Resend will work by swapping the base URL.
cURL
cURL
curl -X POST 'https://mail-api.centerleap.com/v1/emails' \
-H 'Authorization: Bearer cl_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"from": "hello@yourdomain.com",
"to": "recipient@example.com",
"subject": "Hello from CenterLeap",
"html": "<p>Hello!</p>"
}'Node.js (fetch)
JavaScript / TypeScript
const res = await fetch('https://mail-api.centerleap.com/v1/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CENTERLEAP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'hello@yourdomain.com',
to: 'recipient@example.com',
subject: 'Hello from CenterLeap',
html: '<p>This email was sent via REST API.</p>',
}),
});
const { id } = await res.json();
console.log('queued', id);Python (requests)
Python
import os, requests
res = requests.post(
'https://mail-api.centerleap.com/v1/emails',
headers={
'Authorization': f"Bearer {os.environ['CENTERLEAP_API_KEY']}",
'Content-Type': 'application/json',
},
json={
'from': 'hello@yourdomain.com',
'to': 'recipient@example.com',
'subject': 'Hello from CenterLeap',
'html': '<p>Hello!</p>',
},
)
res.raise_for_status()
print('queued', res.json()['id'])Request body
| Field | Type | Notes |
|---|---|---|
from* | string | Must exactly match the key's mailbox (case-insensitive). |
to* | string | string[] | One or more recipients. |
subject* | string | Max 998 characters (RFC 5322). |
html | string | HTML body. Provide html, text, or both. |
text | string | Plain-text body. |
cc | string | string[] | CC recipients. |
bcc | string | string[] | BCC recipients. |
reply_to | string | string[] | Reply-To addresses. |
headers | object | Additional headers, e.g. { 'X-Entity-Ref-Id': 'abc' }. |
attachments | array | Up to 10 attachments: { filename, content (base64), content_type? }. |
Response (200)
Success
{ "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" }The id is the internal message UUID. The message is queued for outbound delivery — the response returns as soon as the message hits the queue, before the SMTP relay.
Attachments
With one PDF attachment
{
"from": "hello@yourdomain.com",
"to": "recipient@example.com",
"subject": "Your invoice",
"html": "<p>Invoice attached.</p>",
"attachments": [
{
"filename": "invoice-2026-05.pdf",
"content": "JVBERi0xLjQKJ…",
"content_type": "application/pdf"
}
]
}content is the base64-encoded file bytes (no data: URI prefix). If content_type is omitted, the server defaults to application/octet-stream.
Reading the Inbox
GET https://mail-api.centerleap.com/v1/inbox/messages
Plaintext-tier mailboxes only
Standard-tier mailboxes return 409 mailbox_not_api_readable. Switch the mailbox to plaintext tier in settings before reading inbox via the API. (Sending is unaffected.)
List messages
GET https://mail-api.centerleap.com/v1/inbox/messages?folder=inbox&limit=50Query parameters:
folder— message folder. Defaults toinbox. Conventional values:inbox,archive,trash,sent,spam. Free-form text — whatever value you PATCH to a message becomes a valid folder filter.limit— page size, 1–100. Defaults to 50.before— ISO-8601 timestamp. Returns messages withreceived_atstrictly less than this. Pass the previous page'snext_cursorto paginate.
cURL — list latest 20 in inbox
curl 'https://mail-api.centerleap.com/v1/inbox/messages?folder=inbox&limit=20' \
-H 'Authorization: Bearer cl_live_xxx'Response shape
{
"messages": [
{
"id": "9c2…", // internal UUID
"message_id": "<…@example.com>", // RFC 5322 Message-ID
"from": "sender@example.com",
"to": "you@yourdomain.com",
"subject": "Hello",
"received_at": "2026-05-16T00:48:46Z",
"folder": "inbox",
"is_read": false,
"is_starred": false,
"has_attachments": false,
"size_bytes": 4821
}
// …
],
"next_cursor": "2026-05-15T22:11:03Z" // null on the last page
}Fetch one message
GET https://mail-api.centerleap.com/v1/inbox/messages/:idReturns the full parsed headers plus text and html bodies. Use the id (internal UUID) from the list response.
Response shape
{
"id": "9c2…",
"message_id": "<…@example.com>",
"headers": { "from": "…", "to": "…", "subject": "…", "date": "…" },
"text": "Plain-text body…",
"html": "<p>HTML body…</p>",
"received_at": "2026-05-16T00:48:46Z",
"folder": "inbox",
"is_read": false,
"is_starred": false,
"has_attachments": false,
"size_bytes": 4821
}Marking Mail
PATCH https://mail-api.centerleap.com/v1/inbox/messages/:id
Requires full_access scope. Send any subset of the three writable fields — omitted fields are left unchanged. Returns { ok: true, no_op: true } when the body has no fields.
cURL — mark read
curl -X PATCH 'https://mail-api.centerleap.com/v1/inbox/messages/9c2…' \
-H 'Authorization: Bearer cl_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "is_read": true }'cURL — star + move
curl -X PATCH 'https://mail-api.centerleap.com/v1/inbox/messages/9c2…' \
-H 'Authorization: Bearer cl_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "is_starred": true, "folder": "archive" }'Body
| Field | Type | Notes |
|---|---|---|
is_read | boolean | Mark read or unread. |
is_starred | boolean | Star or un-star. |
folder | string | Free-form. 1–64 characters. Use 'trash' for soft delete. |
Unlike GET, PATCH does not require the mailbox to be on plaintext tier — it only flips DB columns, never reads ciphertext.
Diagnostics
Two cheap health probes that identify a key without sending anything. Use them to verify a key works during integration or from a deploy-time smoke test.
GET https://mail-api.centerleap.com/v1/emails/_pingGET https://mail-api.centerleap.com/v1/inbox/_pingcURL
curl 'https://mail-api.centerleap.com/v1/emails/_ping' \
-H 'Authorization: Bearer cl_live_xxx'Response
{
"ok": true,
"mailbox": "noreply@yourdomain.com",
"scope": "send_only",
"rate_limit_per_hour": 1000
}Errors
Every error response carries a name machine code and a human-readable message. Validation errors include a Zod issues array.
unauthorizedMissing or unrecognized Bearer key. Check Authorization header.
forbidden_senderOn POST /v1/emails: 'from' didn't match the key's mailbox.
forbidden_scopeKey scope insufficient for this endpoint (e.g. send_only key calling PATCH inbox).
mailbox_not_api_readableInbox endpoints require encryption_tier='plaintext'. Switch the mailbox tier in settings.
validation_errorRequest body failed schema validation. See the issues array for the failing field.
rate_limitedHourly send quota exceeded. Response includes the limit. Resets at the next hour boundary.
not_foundInbox message id doesn't exist or isn't in this mailbox.
storage_unavailableR2 was unreachable during send. Retry; if it persists, check our status page.
Rate Limits
POST /v1/emails is rate-limited per-key, per-hour. The default is 1000 sends/hour; you can set a different value when creating a key. The current limit is returned in /v1/emails/_ping as rate_limit_per_hour.
When you hit the limit you get 429 rate_limited with the limit value in the body:
429 response
{
"name": "rate_limited",
"message": "Hourly send quota exceeded",
"limit": 1000
}The bucket resets at the wall-clock hour boundary, not 60 minutes from your first send. The /v1/inbox/* reads and PATCHes are not currently rate-limited per-key (the nginx vhost has a global rate limit as a backstop).
SMTP (Legacy Clients)
Use the REST API above if you can. SMTP exists for software that can't speak HTTP — WordPress plugins, older CRMs, printer/scanner-to-email.
The CenterLeap SMTP shim accepts SMTP AUTH where the password is your cl_live_* key. It parses the message, then forwards it to POST /v1/emails internally — the same code path, just over SMTP. No separate SMTP password.
Host
mail.centerleap.com
Port (STARTTLS)
587
Port (implicit TLS)
465
Username
centerleap
Password
Your cl_live_… API key
Python (smtplib)
import os, smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg['From'] = 'hello@yourdomain.com' # must match the key's mailbox
msg['To'] = 'recipient@example.com'
msg['Subject'] = 'Hello from CenterLeap'
msg.set_content('Sent via SMTP shim.')
with smtplib.SMTP('mail.centerleap.com', 587) as smtp:
smtp.starttls()
smtp.login('centerleap', os.environ['CENTERLEAP_API_KEY'])
smtp.send_message(msg)SMTP-shim sends are subject to the same scope and rate-limit rules as REST sends. The Fromheader must match the key's mailbox exactly.
Security
Server-side only
Never embed cl_live_* keys in browser bundles, mobile apps, or anything the end user can decompile. Keys are bearer credentials — possession is authorization.
Pick the narrowest scope
Send-only for transactional senders. Read-only for analytics. Full access only when the integration genuinely needs to mutate inbox state.
Rotate after personnel changes
When a contractor or service leaves your environment, rotate every key they had access to. Rotation issues a new value and revokes the old one in one operation.
Set expirations on bot keys
For keys used by one-off automations, set an expiry. Never-expire keys are best reserved for long-lived production services with proper secret management.
Audit log
Every authenticated request writes to api_key_audit (per-key history) and log_email_activity (org-wide feed). Suspicious activity surfaces in the dashboard's Mail Activity view.
Key storage
Keys are encrypted at rest with AES-256-GCM. The encryption key is derived per-row via HKDF-SHA256(server-secret, row-salt, info, 32), so compromise of one row doesn't cascade. Authentication does a prefix lookup, decrypts, and constant-time compares — no plaintext is ever logged.
Attribution
Optional X-CenterLeap-Actor request header lets you attribute a send to a downstream end-user in your own system (e.g. {"user_id":"u_123","email":"buyer@example.com"}). The header is stored opaquely on the audit row — useful when one of your customers reports an unexpected email.