Documentation

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 packages
Frontend
React Vue Svelte React Native Web Components Headless & theming Real-time
Backend API
Authentication Templates Trigger & status Contacts & preferences
More
Self-hosting MCP server Reference

Two 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.
Never ship the service key to the browser. The frontend only ever holds a contact token, and only for its own contact. A contact token cannot read another contact's inbox, and cannot reach any management route at all.

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.

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

Minting 404s for a contact you have not synced yet. Handle it by creating the contact and retrying, rather than by calling 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.

EndpointSustainedBurstCounted per
POST /v1/notifications60 / minute120account
POST /v1/contacts/tokens120 / minute120account
POST /v1/contacts/bulk20 / minute20account
POST /auth/login10 / minute
5 / 15 minutes
sameIP address
email address
POST /auth/register5 / hour5IP address
POST /auth/password-reset
POST /auth/resend-verification
POST /v1/auth/tenants/{id}/data-requests
5 / hour
shared across the three
5IP 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.

These are defaults, not ceilings. If a launch or a migration needs more, tell us and we can raise them for your account. A 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.