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.

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.com

Use mail-api.centerleap.com, not api.centerleap.com — they resolve to different services.

The complete surface

MethodPathRequired scopePurpose
POST/v1/emailssend_only / full_accessSend an email
GET/v1/emails/_pinganyIdentify the key + scope + rate limit
GET/v1/inbox/messagesread_only / full_accessList recent inbound mail (cursor-paginated)
GET/v1/inbox/messages/:idread_only / full_accessFetch one message with full body
PATCH/v1/inbox/messages/:idfull_accessMark read / star / move folder
GET/v1/inbox/_pinganySame 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

  1. Go to Dashboard → Mail → Settings
  2. Open the domain and select the mailbox the key should send/read as
  3. Click the "API Keys" tab
  4. Pick a scope: Send only (default), Read only, or Full access
  5. Pick an expiration: Never (default), 30 / 60 / 90 days, 1 year, or a custom date
  6. 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_abc123…def456

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

FieldTypeNotes
from*stringMust exactly match the key's mailbox (case-insensitive).
to*string | string[]One or more recipients.
subject*stringMax 998 characters (RFC 5322).
htmlstringHTML body. Provide html, text, or both.
textstringPlain-text body.
ccstring | string[]CC recipients.
bccstring | string[]BCC recipients.
reply_tostring | string[]Reply-To addresses.
headersobjectAdditional headers, e.g. { 'X-Entity-Ref-Id': 'abc' }.
attachmentsarrayUp 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=50

Query parameters:

  • folder — message folder. Defaults to inbox. 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 with received_atstrictly less than this. Pass the previous page's next_cursor to 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/:id

Returns 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

FieldTypeNotes
is_readbooleanMark read or unread.
is_starredbooleanStar or un-star.
folderstringFree-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/_ping
GET https://mail-api.centerleap.com/v1/inbox/_ping

cURL

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.

401
unauthorized

Missing or unrecognized Bearer key. Check Authorization header.

403
forbidden_sender

On POST /v1/emails: 'from' didn't match the key's mailbox.

403
forbidden_scope

Key scope insufficient for this endpoint (e.g. send_only key calling PATCH inbox).

409
mailbox_not_api_readable

Inbox endpoints require encryption_tier='plaintext'. Switch the mailbox tier in settings.

400
validation_error

Request body failed schema validation. See the issues array for the failing field.

429
rate_limited

Hourly send quota exceeded. Response includes the limit. Resets at the next hour boundary.

404
not_found

Inbox message id doesn't exist or isn't in this mailbox.

502
storage_unavailable

R2 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.