Trigger a notification
One call records the event and returns 202. Channel resolution, template rendering and delivery all happen after you have gone back to work.
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 ReferenceOne call, every channel
The single front door. Record an event for one or more recipients (e.g. everyone subscribed to a resource) in one call; Elaan resolves the enabled channels, renders templates, and delivers to each asynchronously. Returns 202 immediately with one event_id per recipient.
POST /v1/notifications Authorization: Bearer sk_… { "notification_type_key": "order_shipped", "external_ids": ["crm-12345", "crm-67890"], // 1–100 recipients "branding_key": "acme", // optional; falls back to the account default "variables": { "order_id": "A-1042" } }Response · 202 Accepted
{
"events": [
{ "external_id": "crm-12345", "event_id": "01J…" },
{ "external_id": "crm-67890", "event_id": "01K…" }
]
}
- notification_type_key: the type you created in the console.
- external_ids: one to 100 recipients (your own user ids); one event is recorded per recipient, each fanning out and reporting status independently. Duplicates are de-duplicated.
- branding_key: optional. An unknown or omitted brand falls back to the account default, and naming one that does not exist is accepted unless you send strict.
- variables: values merged into the template alongside brand values and contact attributes.
- secret_variables: values that are sent in full but masked in your activity log, for one-time codes and reset links.
Strict mode
By default a branding_key that does not exist is not an error: the send falls back to your default brand and goes out. That is on purpose, so a typo in one field cannot stop a customer's mail.
If you would rather send nothing than send under the wrong brand, set strict:
POST /v1/notifications Authorization: Bearer sk_… { "notification_type_key": "order_shipped", "external_ids": ["crm-12345"], "branding_key": "acme-typo", "strict": true } // → 422 Unprocessable Content { "detail": "No branding with key 'acme-typo'. Sent with strict, so the trigger was rejected rather than falling back to the account's default brand." }
- Nothing is queued when it rejects. The check runs before any event is recorded, so a rejected trigger leaves no partly-accepted batch behind, and an idempotency key sent with it is not spent. Fix the key and send the same request again.
- A request that was already accepted still replays. If you resend with the same idempotency key, you get the original
202and event ids even understrict, and even if the brand has been deleted since. Those events exist and will be delivered either way, so reporting them as rejected would be untrue and would leave you nowhere to go. - It does not change what gets sent, only whether a substitution is allowed, so it is not part of the idempotency fingerprint. Adding the flag to a retry of an accepted request will not make it look like a different request, and will not un-accept it.
- It is a moment-in-time check. A brand deleted between the call and delivery still falls back at send time. Strict means "not knowingly substituted", not a guarantee no substitution can ever happen.
- It covers the branding key only. It is not a general validation mode: an unknown
notification_type_keyis still accepted here and reported on the event.
Leave it off unless you mean it. It is most useful on a send whose whole point is the brand, such as a customer-facing campaign for one of your own tenants.
Idempotency
Most backends call this endpoint from something that already delivers at least once: a queue consumer, a webhook handler, a retried background job. Send an Idempotency-Key header and a repeat of the same request replays the original response instead of sending again.
POST /v1/notifications Authorization: Bearer sk_… Idempotency-Key: sqs-9f3c1b2a { "notification_type_key": "order_shipped", "external_ids": ["crm-12345"], "variables": { "order_id": "A-1042" } }
The second call returns the same 202 with the same event_id values, and nothing is queued twice. Use any token that identifies the work: the queue message id, your own job id, or a hash of the domain event.
- Scoped to your account, so your tokens can never collide with another customer's. At most 255 characters.
- Recipient order does not matter. A request is identified by the set of recipients it named, so a list that comes back in a different order on a retry still replays.
- Reusing a token for a different payload is a
409, not a silent replay. If the second request would send something different, you need to know that rather than have it quietly dropped. - Two identical requests sent at the exact same moment resolve to one accepted and one
409, because neither can see the other yet. Nothing is sent twice.
Both cases above return 409, and you can always tell which one you are in, because you know whether you changed the payload:
- You resent the request unchanged: the conflict is the race, and it is transient. Retry, and you get the replay.
- You changed the payload: the conflict is permanent, and retrying will never clear it. Use a new token.
- The header is optional. Without it, every call records a new event, which is the behaviour if you have not used it before.
Tokens last as long as the events they name. There is no expiry window to schedule your retries around.
Template variables
A template's {{ slots }} are filled from three places, and the slot itself names which: {{ contact.first_name }} for the recipient, {{ brand.logo }} for the resolved brand, and {{ order_id }} for the variables you send here. contact and brand are reserved, so a variable cannot take either name. A slot nobody filled renders as empty rather than failing the send, so adding a slot to a template does not break callers that have not been updated yet.
A variable can be any JSON: a string, a number, a nested object, or a list of rows for invoice lines and order items, rendered with a loop in the template:
{
"notification_type_key": "invoice_ready",
"external_ids": ["crm-12345"],
"variables": {
"total": "£420.00",
"lines": [
{ "item": "Seat licence", "amount": "£400.00" },
{ "item": "Support", "amount": "£20.00" }
]
}
}
In the template, {% for line in lines %} reads the rows and {{ line.item }} reads a field off the current one. Rows may themselves carry lists, so a loop can sit inside a loop. See Templates.
Secret variables
Some values have to reach the recipient and nobody else: a one-time code, a password-reset link, a signed download URL. Send those in secret_variables instead of variables. They fill {{ slots }} exactly the same way and the message goes out in full, but your activity log shows them replaced by a placeholder.
POST /v1/notifications Authorization: Bearer sk_… { "notification_type_key": "login_code", "external_ids": ["crm-12345"], "variables": { "name": "Ada" }, "secret_variables": { "code": "550913" } }
A template writes {{ code }} either way, so moving a value between the two maps needs no template change. What changes is what the log shows: the raw payload lists code with its value masked, and the sent message shown against the delivery is a second copy rendered with the placeholder in place of the value. The recipient's own copy always carries the real value: the email that arrives, and their entry in the in-app inbox.
Three rules worth knowing:
- A name may appear in
variablesor insecret_variables, never both. Sending the same name in both is a 422 rather than a silent choice between them. - One recipient per request. Sending
secret_variableswith more than oneexternal_idis a 422: one payload is rendered for every recipient of a batch, so all of them would receive the same code. Batching is right for a shared order id and never for a credential. - Values must be plain strings. Repeat-block lists are not accepted here.
- Use a fresh idempotency key for every re-issued secret. Two triggers that differ only in a secret value are different requests, so reusing one key across a new code is a
409and nothing is sent — it will not replay the old code, but it will not send the new one either. If your key is the queue message id, derive it per issuance instead (for examplereset-{user_id}-{token_id}).
This governs what the log shows, not how long anything is kept. The values you send are stored with the event, and the sent message is retained like every other delivery, until you delete the contact. So treat it as protection against a code being read off a dashboard, not as a guarantee the value was never written down.
Delivery status
Because delivery is asynchronous, you can query an event's outcome with the event_id from the trigger response:
GET /v1/notifications/{event_id}
Authorization: Bearer sk_…
// → pending | processed | failed, with any per-channel error summary
Per-channel failures are isolated: a misconfigured email template never blocks the in-app or push delivery of the same event. Each channel records its own outcome on the event, so a partial success is visible as exactly that rather than as a blanket failure.
Retries
Transient send failures are retried with exponential backoff and jitter before the row is dead-lettered, so a provider having a bad ten minutes does not cost you the message. Only failures that cannot be repaired by waiting are terminal immediately.