Authentication
Two credential kinds, and one rule: the browser never holds anything that can act for more than one contact.
All documentation
Getting started
Overview Quickstart SDK packagesFrontend
React Vue Svelte React Native Web Components Headless & theming Real-timeBackend API
Authentication Templates Trigger & status Contacts & preferencesMore
Self-hosting MCP server ReferenceTwo credential kinds
Every request carries Authorization: Bearer <token>. The account is derived from the verified token, never from a header you send.
- Service key (
sk_…): held by your backend. Authorizes management calls, triggering events, and minting contact tokens. - Contact token: short-lived, scoped to a single contact. Minted by your backend and handed to the browser; the SDK uses it for the inbox, preferences, push tokens, and the stream.
Minting a contact token
Your backend exchanges a contact's external_id (your own user id) for a short-lived token plus Elaan's internal contact id.
POST /v1/contacts/tokens Authorization: Bearer sk_… { "external_id": "crm-12345" }Response
{
"token": "eyJ…",
"token_type": "bearer",
"expires_in": 900,
"contact_id": "01J…"
}
Return token and contact_id to the browser from your own endpoint; the SDK refreshes automatically when the token expires by calling your tokenProvider again.
POST /v1/contacts first: that route creates only and returns 409 once the external_id exists, so an endpoint that always calls it works on a contact's first page load and fails on every one after. See Making sure a contact exists.The tokenProvider contract
The SDK never talks to /v1/contacts/tokens itself. It calls the function you supply, which is expected to hit your endpoint, where your own session tells you which user is asking:
async function tokenProvider() { const res = await fetch("/api/elaan-token", { credentials: "include" }); const { token, contact_id } = await res.json(); return { token, contactId: contact_id }; }
That indirection is the whole security model: the contact identity comes from your session on your server, never from anything the browser claims. The SDK calls this again on expiry, so keep it cheap and idempotent.
Rate limits
Limits are a token bucket rather than a fixed window. The bucket refills steadily at the sustained rate and holds up to the burst, so a nightly batch firing a hundred triggers at once goes through while sustained abuse is still throttled. Rejections are a 429 with a Retry-After header in seconds.
| Endpoint | Sustained | Burst | Counted per |
|---|---|---|---|
POST /v1/notifications | 60 / minute | 120 | account |
POST /v1/contacts/tokens | 120 / minute | 120 | account |
POST /v1/contacts/bulk | 20 / minute | 20 | account |
POST /auth/login | 10 / minute 5 / 15 minutes | same | IP address email address |
POST /auth/register | 5 / hour | 5 | IP address |
POST /auth/password-resetPOST /auth/resend-verificationPOST /v1/auth/tenants/{id}/data-requests | 5 / hour shared across the three | 5 | IP address |
Those are the endpoints with a published limit. Any endpoint may be limited if traffic makes it necessary, and the signal is always the same, so a client that honours Retry-After keeps working either way. The account-counted limits are counted for the account, not the key, so issuing more service keys does not raise them.
Three notes on the numbers that matter more than the numbers themselves. POST /v1/contacts/bulk takes up to 500 contacts per call, so 20 a minute is 10,000 contacts a minute: a one-off migration fits comfortably. POST /auth/login is counted two ways at once, by IP and by the email address being attempted, because either one alone misses half of what credential stuffing looks like. And the last row is a single shared budget: those three endpoints all cause us to send mail to an address the caller chose, so five an hour is five in total across them, not five each.
429 always means "slow down and retry"; it never means you have exceeded your plan. Plan limits are separate and are never enforced by rejecting a send.