Base URL
https://api.sendoracloud.com/api/v1Auto-scoped vs explicit paths. Every route accepts both shapes: /api/v1/<resource> (auto-scoped — the org + project are derived from your API key) and /api/v1/orgs/:orgId/<resource> (explicit). Examples on this page use the auto-scoped form for clarity. Reach for the explicit form only when a single key needs to act across multiple orgs (rare — most installations use one key per project).
Authentication
API key in the x-api-key header. Use secret keys (sk_prod_…) server-side, public keys (pk_prod_…) in browsers or mobile apps. The key's prefix encodes its environment; events are forcibly tagged with it server-side (see ADR-014).
x-api-key: pk_prod_...Public keys are safe to commit + ship in client bundles. They can identify(), track(), and register push tokens — they cannot send pushes, mutate audiences, or read other users. If pre-commit secret-scanners flag pk_prod_*, add the prefix to your scanner allowlist (e.g. gitleaks.toml, trufflehog exclusions). Secret keys (sk_prod_*) must stay server-side.
Path conventions — auto-scoped vs explicit
Every customer-facing CRUD route accepts two shapes. The auto-scoped form /api/v1/<module>/<action> derives org + project from your API key — preferred for single-project keys. The explicit form /api/v1/orgs/:orgId/<module>/<action> is the original shape and is kept for back-compat + multi-org keys. Three routes ignore both conventions, all intentional:
POST /api/v1/events,POST /api/v1/identify,POST /api/v1/push/tokens,POST /api/v1/push/track-open,POST /api/v1/push/geofences/event— public SDK ingest. Org is derived from the API key, no path param needed.POST /api/v1/sms/webhooks/twilio/:orgId,POST /api/v1/email/webhooks/{ses,resend}— provider-callback receivers (Twilio / SES / Resend post here).GET/POST /api/v1/unsubscribe/:token— RFC 8058 one-click unsubscribe, token-scoped + public.
Everything else (send, list, stats, settings, templates, audiences, workflows, sends history) is reachable under both /api/v1/<module>/... (auto-scoped) and /api/v1/orgs/:orgId/<module>/... (explicit).
Auth scopes per endpoint
Public keys (pk_prod_*) carry a default ingest + read scope. Secret keys (sk_prod_*) carry the full scope set. Custom scoped keys can be minted via Settings → API Keys → Mint with custom scopes.
events.write— POST/events,/identify. Bothpk_*+sk_*.push:send,email:send,sms:send— required on/push/send,/email/send,/sms/send.sk_*only by default.audiences:read,profiles:read,profiles:write— audience + profile CRUD.sk_*only.
If you see a 403 with scope_required, you're using a public key for a secret-only endpoint. Pulling an audienceId out of a public bundle and POSTing to /push/send from a browser fails on missing push:send scope, not on path access.
Standard response envelope
Every JSON response carries { success: boolean, data?: <T>, pagination?: ..., error?: ... }. On success the resource lives under data. Send endpoints (/push/send, /email/send, /sms/send) return the created send row including id (the sendId you pass to /push/track-open + use in subsequent GETs):
{
"success": true,
"data": {
"id": "01HX...send-uuid",
"status": "queued",
"tokenCount": 4,
"scheduledFor": null
}
}Fetch a single send via GET /orgs/:orgId/push/sends/:id. Cancel a still- scheduled send via DELETE /orgs/:orgId/push/sends/:id — the row flips to status="cancelled" if claim hasn't started, or 409 if it already dispatched.
HTTP status codes + retry semantics
200 / 201— success.201on resource creation;200elsewhere.400VALIDATION_ERROR— body / query failed Zod validation.error.details.fieldErrorslists per-field issues. Don't retry without fixing the request.401UNAUTHORIZED— missing / expired key. Don't retry.403FORBIDDEN/scope_required— caller authenticated but lacks required scope or role. Don't retry.404— resource missing or not yours. Don't retry.409CONFLICT— idempotency collision (e.g. duplicate suppression). Treat as success.412— precondition (BYOD not verified, Twilio not configured, APNs/FCM creds missing). Configure then retry.429RATE_LIMIT— bucket empty. Retry with exponential backoff (start 1s, max 60s, jitter ±20%).500/502/503— transient. Retry up to 3× with backoff. Sendora's own outbound webhooks already use this scheme.
Push / email / SMS dispatch failures (APNs 410 Unregistered, FCM NOT_FOUND, etc.) do not bubble up as HTTP errors — the send accepts (200), then the row stamps status=failed with error filled in. Auto-prune marks the token inactive so subsequent sends short-circuit. Query /orgs/:orgId/push/sends/delivery-health for the rolling failure aggregate.
Webhook event catalog
Subscribe via POST /orgs/:orgId/webhooks with events: [...]. Outbound is HMAC-SHA256-signed in X-Sendora-Signature. Full enum:
- Push lifecycle:
push.send_requested,push.scheduled,push.sent,push.delivered,push.failed,push.suppressed,push.cancelled,push.opened,push.clicked. - Push token:
push.token_invalidated— emitted when APNs / FCM returns Unregistered / BadDeviceToken.pushTokens.isActiveflips tofalse; subscribe to reconcile your own user-token table. - Live Activities:
push.live_activity_started,_updated,_ended,_invalidated. - Geofences:
geofence.entered,.exited,.dwelled. - Email:
email.delivered,email.bounced,email.complained,email.opened,email.clicked. - SMS:
sms.delivered,sms.failed,sms.inbound,sms.stop_received. - Auth:
user.signed_up,user.signed_in,user.identified,user.session_expired,user.password_reset. - Workflow:
workflow.run_started,workflow.run_completed,workflow.step_executed.
Locale + null-state resolution
Default behaviors when the recipient's profile / token is missing fields used by quiet hours, locale, audiences:
- Locale fallback chain:
localizedBody[pushTokens.locale]→ 2-letter language match (e.g.es-MX→es) → top-levelbody. Strict-only behavior available by setting only the exact tag inlocalizedBodyand leavingbodyempty (sends to non-matching locales then suppress with reasonlocale_unsupported). - Quiet hours, no token timezone: skipped — send proceeds normally. Set
orgDefaultTimezonein/push/settingsto apply org TZ as fallback. - Audience evaluation, missing trait: predicate evaluates
false(not match-all). To include unset users, useany: [{trait, op:"eq", value:"X"}, {trait, op:"not_set"}]. - Frequency cap, no userId on token: counted per token id (less aggressive — anonymous fan-outs aren't deduped across devices).
iOS — `push.trackOpen(sendId)` payload extraction
Sendora stamps every APNs payload with data.sendoraSendId. Read it from userInfo in your userNotificationCenter(_:didReceive:) delegate:
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
if let sendId = userInfo["sendoraSendId"] as? String {
// Body tap: response.actionIdentifier == UNNotificationDefaultActionIdentifier
// Action tap: response.actionIdentifier matches one of your action ids
let action = response.actionIdentifier == UNNotificationDefaultActionIdentifier
? nil
: response.actionIdentifier
SendoraCloud.push?.trackOpen(sendId: sendId, clickAction: action)
}
completionHandler()
}
}Same for Android — sendoraSendId arrives in RemoteMessage.data; pass it to SendoraCloud.push?.trackOpen(sendId).
iOS Critical Alerts
Bypass DND / Focus / silent. Pass criticalAlert on send body or template. Backend stamps APNs payload with aps.sound = { critical: 1, name, volume } + interruption-level = critical.
curl -X POST {{BASE_URL}}/api/v1/push/send \
-H "x-api-key: sk_prod_..." \
-d '{
"userIds": ["u_1"],
"title": "EMERGENCY",
"body": "Building evac in 3 min",
"criticalAlert": { "soundName": "siren.caf", "volume": 1.0 }
}'Two prerequisites or APNs silently downgrades to regular alert: Apple-issued com.apple.developer.usernotifications.critical-alerts entitlement on the host iOS app (1-3 wk approval, request via developer.apple.com → Contact Us → App Capabilities) + runtime user permission via .criticalAlert UNAuthorizationOption.
SDK helper signatures (Swift):
// Request runtime permission. Closure is (Bool) -> Void on the main queue.
SendoraCloudCriticalAlerts.requestPermission { granted in
// granted = true only if entitlement is present AND user accepted.
}
// Read current setting (without prompting).
SendoraCloudCriticalAlerts.currentSetting { enabled in
// .authorized | .denied | .notDetermined
}Without the entitlement, the request prompt won't even include the "Critical Alerts" option — user sees a regular notification permission dialog. Apple rejects entitlement requests that aren't safety / health / on-call critical (general news + marketing don't qualify).
Live Activities — tier matrix
All Live Activity routes (POST start-token, PATCH update, DELETE end, GET list) gate on Growth+. Lower tiers receive 402 PAYMENT_REQUIRED tier_required with requiredPlan: "growth". Activities can't be created on Free / Starter so the "orphaned activities accumulate forever" case doesn't arise.
Live Activity per-activity token registration
iOS-only. Host app starts an ActivityKit Activity locally; SDK reports the per-activity push token to Sendora so server updates can target it. Different endpoint from regular push tokens because each Activity has its own rotating token.
POST /api/v1/push/live-activities/start-token
{
"userId": "u_1",
"activityType": "OrderAttributes",
"externalId": "order-1234",
"pushToken": "<per-activity-token-hex>",
"platform": "ios"
}SDK helpers (SendoraCloud.liveActivities?.track(...)) handle this automatically. See Live Activities deep-dive.
Template variable interpolation
Handlebars-style {{path.to.value}}. Resolution order — three scopes, all read-only at template render time:
{{event.*}}— properties from the trigger event (whatever you passed inpropertieson thetrack()call). Example:{{event.orderId}}.{{user.*}}— built-in profile fields the backend always sets:userId,email,createdAt,lastSeenAt,locale,timezone. Don't come from your code — derived from auth + ingest signals.{{trait.*}}— custom traits set viaidentify(userId, traits). Anything you put in the second arg lives here. Example:{{trait.plan}}for theplantrait.
Why split user.* + trait.*: built-in identity fields (email) sit on the same row as your custom traits in the database, but the namespaces are separate to prevent name collisions (you can have a custom trait {{trait.email}} that's different from the canonical {{user.email}}).
Unresolved paths render empty. No HTML escaping in push title / body (text channel); HTML emails apply auto-escape unless field is named bodyHtml.
`ai_action` workflow step — quota economics
ai_action steps route through Ollama Cloud. Quota ownership depends on plan:
- Free / Starter — disabled.
ai_actionstep returns outcomeai_unavailable. - Growth+ — Sendora-paid Ollama Cloud quota, shared across all orgs on the plan, soft cap 100K tokens / org / day.
- BYOK (any plan) — paste your own Ollama Cloud API key in Settings → AI → BYOK. Steps then route through your account, no shared cap. Key encrypted AES-256-GCM at rest.
Quota overrun: when a Growth+ org exceeds 100K tokens/day on Sendora-paid quota, subsequent ai_action steps return outcome ai_unavailable (same code as Free/Starter). Workflow run continues to next step. No 429, no overage billing — soft fail. Switch to BYOK to remove the cap.
Model selection: per-flavor defaults — generate uses gpt-oss:120b (highest quality); decide + extract use ministral:8b (fast + structured). Override per-step via config.model: "gemma3:27b", "ministral:14b", etc. Full Ollama Cloud model catalog accepted.
Tier-gating error codes
Plan-gated features return 402 PAYMENT_REQUIRED with error.code: "tier_required" + error.details.requiredPlan:
audienceIdon send (push / email / SMS),scheduledFor, A/B tests, templates — Starter+.- Geofences, Live Activities (POST + PATCH + DELETE), workflow IaC export/import, custom JWT claims — Growth+.
- OIDC + SAML SSO, SCIM provisioning, custom rate limits — Business+.
Token registration — `projectId` derivation
POST /push/tokens body does not require projectId. It's optional in the body; the backend derives it from the API key if omitted (each pk_prod_* is bound to a single project at mint time). Pass it explicitly only when using a multi-project secret key.
Same rule for orgId on all SDK ingest paths (events, tokens, identify, track-open) — derived from the key.
iOS push permission prompt
SDK doesn't hide the OS prompt — host app must trigger it via Apple's standard UNUserNotificationCenter API. Sendora then registers the token returned to didRegisterForRemoteNotificationsWithDeviceToken:.
import UIKit
import UserNotifications
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
// In your AppDelegate:
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
SendoraCloud.push?.registerToken(token, platform: .ios) { _ in }
}For Critical Alerts, also request .criticalAlert (requires Apple entitlement) via SendoraCloudCriticalAlerts.requestPermission(_:).
A/B tests — two-call flow
abTestId on a send body references a test you pre-create via POST /orgs/:orgId/push/ab-tests. The test row holds the variants jsonb + weights (sum to 100). Send-time assignment is sticky-by-user (sha256(testId + userId) % 100 → bucket).
# 1. Create test (once)
TEST_ID=$(curl -s -X POST https://api.sendoracloud.com/api/v1/push/ab-tests \
-H "x-api-key: sk_prod_..." \
-d '{
"name": "subject-line-test",
"status": "active",
"variants": [
{ "key": "A", "title": "20% off", "body": "Today only", "weight": 50 },
{ "key": "B", "title": "Last chance — 20% off", "body": "Ends midnight", "weight": 50 }
]
}' | jq -r .data.id)
# 2. Send referencing it
curl -X POST https://api.sendoracloud.com/api/v1/push/send \
-H "x-api-key: sk_prod_..." \
-d '{
"audienceId": "<AUD>",
"abTestId": "'"$TEST_ID"'",
"title": "fallback-if-no-variants-defined",
"body": "fallback"
}'
# 3. Per-variant stats
curl https://api.sendoracloud.com/api/v1/push/ab-tests/$TEST_ID/stats \
-H "x-api-key: sk_prod_..."If abTestId references a test in status paused or completed, send falls through to the top-level title / body (no variant assignment).
iOS geofences — no `handleBroadcast`
SendoraCloud.geofences?.start() wires CLLocationManager's region-monitoring delegate internally — no equivalent to Android's handleBroadcast(intent) needed. iOS dispatches transitions directly via the system to the SDK's background delegate, even when your app is suspended (.authorizedAlways required). Just call start() and the SDK handles the rest.
On Android, GeofencingClient uses PendingIntent + a host-app BroadcastReceiver, which is why handleBroadcast(intent) exists — host owns the receiver, SDK reads the intent.
SDK auto-tracked events
Every SDK auto-fires lifecycle events when autoTrack is enabled (default true). These count against your event quota:
- Web:
page.viewed(history-state push),session.started,link.clicked(auto on first-party links),app.foregrounded/app.backgrounded(visibilitychange). - iOS / Android (RN, native):
screen.viewed(route-change hook),session.started,app.opened/app.backgrounded.
Disable per-instance via autoTrack: false on init. page.viewed + screen.viewed still fire if you call them manually with custom properties — auto-firing is just the default cadence.
Template + action button shape
Push templates store reusable send payloads. Action buttons (max 4 — APNs hard limit) carry an id, title, optional launch URL, and optional icon name (iOS only):
{
"name": "order-shipped",
"title": "Order shipped",
"body": "Tracking #{{event.tracking}}",
"data": { "url": "/orders/{{event.orderId}}" },
"actions": [
{ "id": "track", "title": "Track", "url": "https://app.com/orders/{{event.orderId}}" },
{ "id": "share", "title": "Share", "icon": "square.and.arrow.up" }
],
"localizedBody": {
"en": "Tracking #{{event.tracking}}",
"fr": "Suivi #{{event.tracking}}"
},
"criticalAlert": null
}actions[].id is what arrives in POST /push/track-open clickAction when the user taps that button. actions[].url opens in the system browser on tap.
Token registration semantics
POST /push/tokens is upsert by (orgId, token). Re-registering the same token (e.g. after app reinstall mints a new APNs token with the same string somehow, or app foreground re-fires registration) is idempotent — no duplicate rows. Updates userId / locale / timezone / appVersion if changed; preserves isActive.
- Token rotated client-side — call
registerTokenagain with the new token. Old row stays until the next dispatch fails on it; thenisActive=false+push.token_invalidated. No need to DELETE first. - User logout — call
DELETE /orgs/:orgId/push/tokens/:tokenIdto flipisActive=falseimmediately. Use this on account-switch flows. - tokens[] raw send — bypasses
userIds/audienceId; backend does not cross-check againstisActive=false. Pre-filter your raw list if you cache tokens client-side, or passuserIdsinstead and let backend resolve.
`412` — what state are you in?
Returned when the org hasn't configured the channel's provider creds (APNs .p8, FCM JSON, Twilio Account SID + Auth Token, Resend BYOD verified domain). Send returns 412 before any pre-flight work:
- Rate-limit quota: not consumed.
- Pricing event: not counted (the request never reaches dispatch).
- Token state: untouched. Tokens stay
isActive=truewith no error stamp. - What to do: configure creds in dashboard + retry. The request body wasn't persisted, so re-fire with the same payload.
Outbound webhook retry policy
Sendora's outbound webhooks (e.g. the Expo bridge step in workflow) follow exponential backoff:
- Success: 2xx response within 10s.
- Retry: 408 / 429 / 5xx / network-error. Attempts at 30s, 2m, 10m, 1h, 6h (5 retries total). After 6h, marked permanently failed.
workflow.run_completedwith step outcomeerror. - No retry: 400 / 401 / 403 / 404 / 410 / 422. Permanent failure — fix the receiver and re-fire.
- Replay: dashboard → Webhooks → run detail → Replay button. Idempotency: bridges should treat the body's
event.idas the dedup key.
Web Push service-worker sample
Browser push needs a service worker to render notifications. Save this at the path you pass to sendora.webPush.subscribe(swPath) (e.g. /sw.js):
// /sw.js — Sendora Web Push handler.
self.addEventListener("push", (event) => {
const payload = event.data ? event.data.json() : {};
const { title, body, icon, image, data, actions } = payload;
event.waitUntil(
self.registration.showNotification(title, {
body, icon, image, data,
actions: actions || [],
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
const sendId = event.notification.data && event.notification.data.sendoraSendId;
// Track open back to Sendora.
if (sendId) {
fetch("https://api.sendoracloud.com/api/v1/push/track-open", {
method: "POST",
headers: { "x-api-key": "pk_prod_...", "Content-Type": "application/json" },
body: JSON.stringify({
sendId,
clickAction: event.action || "body",
}),
});
}
event.waitUntil(clients.openWindow(url));
});Custom scopes — minting + revocation
Mint scoped keys via Settings → API Keys → Mint with custom scopes. Dashboard-only as of 2026-05; programmatic scope-mint API is on the roadmap. Scope strings are stable (e.g. push:send, email:send, audiences:read), so you can grep-audit existing keys via GET /orgs/:orgId/api-keys (returns scope arrays per row).
The `module` field on events + pricing counting
The event envelope requires module (free-form string ≤ 64 chars) so events partition by product surface. Standard values shipped with the SDKs:
analytics— your customtrack()calls.profiles— identify + trait updates.auth— sign-in, sign-up, MFA, session events.push,email,sms— message-lifecycle (sent / opened / clicked / bounced).links— deep-link clicks + deferred-attribution.billing— Stripe webhook ingest.support,chatbot,knowledge-base,surveys,feature-flags,in-app,consent,geofence,automation,data-io,observability.
Pricing event-counting — explicit:
- Every accepted row in
events= 1 billing tick. - Customer events (your
track()/identify()calls): always counted. - System lifecycle events (
push.send_requested,push.sent,push.delivered,email.delivered, etc.): NOT counted against your event quota. These are free observability signals (you see them in the dashboard + outbound webhooks but don't pay for them). - So a 1,000-user broadcast = 0 quota events. You only burn quota when the broadcast triggers your own
track()calls (e.g. server-side analytics). - Webhook deliveries (Sendora → your bridge endpoint) are also free.
Free tier 10K events/mo is the cap on track() + identify() ingest only.
Rate limits
Token-bucket per API key. Defaults: 1,000 events / sec steady-state, 5,000 burst capacity (per key). Hit the ceiling and the response is 429 RATE_LIMIT. Per-event payload caps: 256 KB body, 100 properties / event, 1,000 array items / property. Events per month counted against your plan's included quota — Free tier is 10K/mo. Email send paths have their own limiter (60/min default, 10/min on probation per acceptable-use policy).
Direct send vs workflow trigger
Two valid paths to deliver a push / email / SMS. Pick by where the decision lives:
- Direct send —
POST /orgs/:orgId/<module>/sendfrom your server. Use when your code knows the user(s) + content already. Transactional flows (order shipped, password reset, OTP). Fast-path; no event hop. - Event → workflow —
POST /eventswitheventType; workflow rule in dashboard fires the matchingsend_push/send_email/send_smsstep. Use when non-engineers tune cadence / copy / audience in dashboard, or when one event triggers multiple channels (e.g. cart-abandon → email at T+1h, push at T+24h).
Both routes apply quiet hours, frequency caps, A/B variants, and localization. Both stamp pushSends / emailSends / smsSends for delivery tracking. Use direct for "always send this exact message right now," workflow for "route based on dashboard config."
React Native — Expo managed workflow
Sendora dispatches via APNs HTTP/2 + FCM v1 + Web Push (RFC 8030). Sendora does NOT speak Expo Push Service (exp.host/--/api/v2/push/send). If you ship Expo managed and only have ExponentPushToken[xxx], those tokens cannot be dispatched directly. Two options:
- Eject to bare workflow — install
@react-native-firebase/messaging+ native iOS push capability.FirebaseMessaging.getToken()returns a real FCM token you can pass tosendora.push.registerToken(...). Full Sendora feature set unlocks (Live Activities, Critical Alerts, Geofences, server quiet-hours / freq-cap / A/B / templates / localization). - Webhook bridge (managed-friendly) — keep Expo tokens; have your workflow rule fire a
webhookstep (notsend_push) → your server resolves the Expo token from your user table → callsexpo-server-sdk. You lose Sendora's dispatch-side features but keep events + audiences + workflow orchestration.
Errors
Every response carries success: boolean. On failure the error object has a code, a human message, and optional details.fieldErrors for validation failures. The request ID travels in the X-Request-ID response header.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"fieldErrors": {
"orgId": ["Required"]
}
}
}
}