Skip to content
Sendora Cloud
Create account
Reference

REST API reference

Sendora Cloud is JSON over HTTPS. Every example below is rendered from @sendora/shared/constants — the same source the dashboard "How to integrate" tab imports. Snippets here never drift from snippets there.

Base URL

https://api.sendoracloud.com/api/v1

Auto-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. Both pk_* + 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. 201 on resource creation; 200 elsewhere.
  • 400 VALIDATION_ERROR — body / query failed Zod validation. error.details.fieldErrors lists per-field issues. Don't retry without fixing the request.
  • 401 UNAUTHORIZED — missing / expired key. Don't retry.
  • 403 FORBIDDEN / scope_required — caller authenticated but lacks required scope or role. Don't retry.
  • 404 — resource missing or not yours. Don't retry.
  • 409 CONFLICT — idempotency collision (e.g. duplicate suppression). Treat as success.
  • 412 — precondition (BYOD not verified, Twilio not configured, APNs/FCM creds missing). Configure then retry.
  • 429 RATE_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.isActive flips to false; 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-MXes) → top-level body. Strict-only behavior available by setting only the exact tag in localizedBody and leaving body empty (sends to non-matching locales then suppress with reason locale_unsupported).
  • Quiet hours, no token timezone: skipped — send proceeds normally. Set orgDefaultTimezone in /push/settings to apply org TZ as fallback.
  • Audience evaluation, missing trait: predicate evaluates false (not match-all). To include unset users, use any: [{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:

  1. {{event.*}} — properties from the trigger event (whatever you passed in properties on the track() call). Example: {{event.orderId}}.
  2. {{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.
  3. {{trait.*}} — custom traits set via identify(userId, traits). Anything you put in the second arg lives here. Example: {{trait.plan}} for the plan trait.

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_action step returns outcome ai_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:

  • audienceId on 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 registerToken again with the new token. Old row stays until the next dispatch fails on it; then isActive=false + push.token_invalidated. No need to DELETE first.
  • User logout — call DELETE /orgs/:orgId/push/tokens/:tokenId to flipisActive=false immediately. Use this on account-switch flows.
  • tokens[] raw send — bypasses userIds / audienceId; backend does not cross-check against isActive=false. Pre-filter your raw list if you cache tokens client-side, or pass userIds instead 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=true with 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_completed with step outcome error.
  • 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.id as 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 custom track() 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>/send from 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 /events with eventType; workflow rule in dashboard fires the matching send_push / send_email / send_sms step. 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 to sendora.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 webhook step (not send_push) → your server resolves the Expo token from your user table → calls expo-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"]
      }
    }
  }
}

Authentication

End-user signup, login, anonymous mint, upgrade-in-place, OIDC + SAML SSO. Free for self-serve methods; Business+ for SSO.

Anonymous + upgrade

FREE

Mint a stable user_id before signup. Upgrade in place when the visitor signs up — same user_id preserved, all attached events / tickets / profile / workflow runs stay bound.

// Anonymous-first
await sendora.auth.signInAnonymously();

// Upgrade in place when visitor signs up
await sendora.auth.signUp({
  email: "alice@example.com",
  password: "correct-horse-battery-staple",
});

Email + password

FREE

Standard signup + login. bcrypt by default; passwords never logged.

await sendora.auth.signUp({ email, password });
await sendora.auth.signIn({ email, password });

Magic link

FREE

One-tap email link sign-in. Sendora sends the email from auth@sendoracloud.com.

// Send the link
await sendora.auth.sendMagicLink("alice@example.com");
// Later, on the link's landing page:
const user = await sendora.auth.verifyMagicLink(tokenFromUrl);

Email OTP (6-digit code)

FREE

Cross-device-friendly alternative to magic link. 5-minute TTL, 5-attempt lockout.

await sendora.auth.sendEmailOtp("alice@example.com");
const user = await sendora.auth.verifyEmailOtp("alice@example.com", "482910");

Passkeys (WebAuthn)

FREE

Face / Touch ID + Android Credential Manager + WebAuthn. Stored in the user's password manager.

// Register a passkey for the signed-in user
await sendora.auth.passkeys.register({ name: "MacBook Pro" });

// Sign in with passkey
const user = await sendora.auth.passkeys.authenticate({ email });

Social sign-in (8 providers)

FREE

Google, GitHub, Apple, Microsoft, LinkedIn, Facebook, Discord — first-class adapters with verified-email gates. Twitter intentionally rejected (OAuth 2.0 doesn't expose verified email; takeover risk).

// SDK opens the provider's consent screen
await sendora.auth.signInWithGoogle();
await sendora.auth.signInWithGitHub();
await sendora.auth.signInWithApple();
await sendora.auth.signInWithMicrosoft();
await sendora.auth.signInWithLinkedIn();
await sendora.auth.signInWithFacebook();
await sendora.auth.signInWithDiscord();

TOTP MFA

FREE

Google Authenticator-compatible RFC 6238. Users enroll from their account page. Login returns mfaRequired+mfaChallengeToken when enrolled.

// Enroll (Bearer-auth, signed-in user)
const { otpauthUrl, recoveryCodes } = await sendora.auth.enrollMfa();
await sendora.auth.confirmMfa(codeFromAuthenticator);

// Login flow when MFA is on
const r = await sendora.auth.signIn({ email, password });
if (r.mfaRequired) {
  await sendora.auth.challengeMfa(r.mfaChallengeToken, codeOrRecovery);
}

Password reset + email verification

FREE

Sendora sends the email. SDK exposes 4 helpers for the flow.

await sendora.auth.requestPasswordReset(email);
await sendora.auth.resetPassword(token, newPassword);
await sendora.auth.verifyEmail(token);
await sendora.auth.sendVerificationEmail(); // Bearer-auth, resend

Enterprise SSO (OIDC + SAML)

BUSINESS+

Per-org OIDC + SAML 2.0. Configure in Authentication → Settings → OIDC SSO. SDK kicks off the redirect flow.

// SDK opens the IdP's authorization page
await sendora.auth.signInWithSso({
  returnTo: window.location.href,
});

// On the returnTo page, consume the token from the URL fragment
const user = await sendora.auth.consumeSsoTokenFromUrl();

SCIM 2.0 provisioning

BUSINESS+

Auto-sync users + groups from your IdP (Okta, Azure AD, JumpCloud, OneLogin). Mint a bearer token in Authentication → Settings → SCIM.

# IdP-side configuration. Paste the bearer token + base URL into your IdP's SCIM connector.
# Base URL: https://api.sendoracloud.com/api/v1/scim/v2 (paste this into IdP's SCIM connector field — IdP appends /Users etc).
# Auth: Bearer scim_…

# Verify the connection from your IdP, OR test with curl:
curl https://api.sendoracloud.com/api/v1/scim/v2/Users \
  -H "Authorization: Bearer scim_…" \
  -H "Accept: application/scim+json"

curl -X POST https://api.sendoracloud.com/api/v1/scim/v2/Users \
  -H "Authorization: Bearer scim_…" \
  -H "Content-Type: application/scim+json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "alice@example.com",
    "name": { "givenName": "Alice", "familyName": "Example" },
    "active": true,
    "emails": [{ "value": "alice@example.com", "primary": true }]
  }'

JWT verification (your backend)

FREE

Verify access tokens server-side using Sendora's per-org JWKS endpoint. RS256, key-rotation safe via kid.

// Node.js / any JWKS-aware verifier (e.g. jose)
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(
  new URL("https://api.sendoracloud.com/api/v1/auth-service/<ORG_ID>/.well-known/jwks.json"),
);

const { payload } = await jwtVerify(accessToken, jwks);
// payload.sub, payload.email, payload.is_anonymous, ...your custom claims

Email

Transactional + templated send, BYOD setup, inbound routing + threading, suppression, delivery webhooks.

Send transactional email

FREE

Server-side send (sk_* secret key). Per-org rate-limited (60/min default, 10/min on probation). BYOD required for production volume; shared default for first 50/day. Returns sendId for tracking.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/send \
  -H "x-api-key: pk_prod_…" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "alice@example.com",
    "subject": "Welcome",
    "category": "transactional",
    "html": "<p>Welcome to Acme</p>"
  }'

Send a templated email

FREE

Reference a saved template by id with mustache-style variable substitution. Templates manage in dashboard or via /email/templates CRUD.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/send \
  -H "x-api-key: pk_prod_…" \
  -d '{ "to": "alice@example.com", "templateId": "<TEMPLATE_UUID>", "variables": { "firstName": "Alice" } }'

Templates CRUD

FREE

Create / list / get / update / delete saved templates. Subject + html + text + category. Use templateId on send.

# Create
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/templates \
  -H "x-api-key: pk_prod_…" \
  -d '{ "name": "welcome", "subject": "Welcome {{firstName}}", "html": "<p>Hi {{firstName}}</p>", "category": "transactional" }'

# List
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/templates -H "x-api-key: pk_prod_…"

# Preview render
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/templates/<TEMPLATE_UUID>/preview \
  -H "x-api-key: pk_prod_…" \
  -d '{ "variables": { "firstName": "Alice" } }'

Bring your own domain (BYOD)

FREE

Add custom sending domain in Dashboard → Email → Custom domains. Returns DKIM + SPF + DMARC records to add to DNS. Real Resend-backed verification. Required before sending from your own domain.

# BYOD setup is dashboard-only — visual editor for DNS records + verification status.
# Once verified, sends use the BYOD From address automatically.

Query send history

FREE

List recent sends + per-send detail. Filter by status / recipient / category. Status enum: queued | sent | delivered | bounced | complained | failed | suppressed.

# List
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/sends?status=bounced&pageSize=20' \
  -H "x-api-key: pk_prod_…"

# Aggregate stats (delivery rate / open rate / click rate)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/sends/stats \
  -H "x-api-key: pk_prod_…"

# Reputation (bounce + complaint thresholds + probation status)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/email/reputation \
  -H "x-api-key: pk_prod_…"

Inbound email + threading

STARTER+

Cloudflare Email Routing forwards your domain's mail to Sendora. Replies threaded into originating Support ticket via plus-addressing (`+T<ticketId>`). Webhooks at `/email/webhooks/ses` + `/email/webhooks/resend` for provider receipts.

# Configure inbound in dashboard → Email → Inbound. No SDK call needed.
# Test by replying to a ticket-originated email — reply lands as a new ticket comment.

One-click unsubscribe (RFC 8058)

FREE

Sendora auto-stamps List-Unsubscribe + List-Unsubscribe-Post headers + appends footer link. POST `/unsubscribe/:token` applies suppression — handled by Sendora; your app does nothing.

# Both endpoints are public + token-scoped; the SDK never calls them.
# Token is signed; tampering returns 400.
curl https://api.sendoracloud.com/api/v1/unsubscribe/<TOKEN>          # GET → confirmation page
curl -X POST https://api.sendoracloud.com/api/v1/unsubscribe/<TOKEN>  # applies suppression

Delivery + bounce webhooks

FREE

Subscribe to email.delivered / email.bounced / email.complained / email.opened / email.clicked. Signed via HMAC-SHA256 in `X-Sendora-Signature` header.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/webhooks/endpoints \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "url": "https://your-app.com/hooks/sendora",
    "events": ["email.delivered","email.bounced","email.complained","email.opened","email.clicked"]
  }'

Push notifications

APNs + FCM. Token registration, send-by-user, audience broadcast, provider config.

Register a device token

FREE

Call after the OS grants notification permission. APNs (iOS) + FCM (Android) + Web Push (RFC 8030 / VAPID) supported. Token bound to current Auth user (or anonymous user). Note: Sendora does not speak Expo Push Service — Expo managed-workflow apps must eject to bare or use a webhook bridge to expo-server-sdk.

// Service worker registered at /sw.js handles "push" + "notificationclick".
await sendora.webPush.subscribe("/sw.js");

Send a push notification (direct)

FREE

Server-side direct send. Org-scoped path. Audience-resolution + per-recipient TZ-aware quiet hours + frequency cap + A/B variant + locale resolution all applied. Pass `userIds`, `audienceId`, OR raw `tokens` (mutually exclusive). Failed-delivery tokens auto-pruned.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "userIds": ["u_abc123"],
    "title": "Order shipped",
    "body": "Your package is on the way",
    "data": { "orderId": "ord_42", "url": "/orders/42" }
  }'

Broadcast to an audience

STARTER+

Same endpoint as direct send — pass `audienceId` instead of `userIds`. Audience evaluation happens at send time. Quiet hours + frequency caps + localization apply per recipient.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "audienceId": "<AUDIENCE_UUID>",
    "title": "Sale starts now",
    "body": "20% off everything",
    "localizedBody": { "en": "20% off everything", "fr": "20% sur tout", "es-MX": "20% en todo" }
  }'

Schedule a send

STARTER+

Pass `scheduledFor` (ISO timestamp). 30s poller claims due rows. Cancel via DELETE on the returned send id while still `status=scheduled`.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "audienceId": "<AUDIENCE_UUID>",
    "title": "Daily digest",
    "body": "Today\u2019s top stories",
    "scheduledFor": "2026-05-09T08:00:00Z"
  }'

Track a notification open / click

FREE

SDKs auto-fire this on tap. Stamps `pushSends.openedAt + clickedAt + clickAction` and emits `push.opened` / `push.clicked` analytics events for workflow triggers.

curl -X POST https://api.sendoracloud.com/api/v1/push/track-open \
  -H "x-api-key: pk_prod_…" \
  -d '{ "sendId": "<SEND_UUID>", "clickAction": "save" }'

Per-org delivery policies (quiet hours + frequency cap)

FREE

GET / PATCH org-level push policies. Quiet hours evaluated per-recipient against `pushTokens.timezone` (IANA, DST-correct, wraps midnight). Frequency cap counts non-suppressed sends per user in rolling window. ADMIN role on PATCH.

# Read
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/settings \
  -H "x-api-key: pk_prod_…"

# Update
curl -X PATCH https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/settings \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "quietHoursStartLocal": 22,
    "quietHoursEndLocal": 7,
    "deferInQuietHours": true,
    "freqCapPerUserPerDay": 5,
    "freqCapPerUserPerHour": 2
  }'

Query send history + delivery health

FREE

List sends with status filter, aggregate stats, last-7d delivery-health (top failure / suppression reasons + recent raw failures). Use this instead of guessing whether your sends actually delivered.

# List (paginated)
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends?status=failed&pageSize=20' \
  -H "x-api-key: pk_prod_…"

# Aggregate counts (total/sent/delivered/failed/scheduled/suppressed/opened/clicked + rates)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends/stats \
  -H "x-api-key: pk_prod_…"

# Delivery health — top failure / suppression reasons + last-10 failures
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends/delivery-health \
  -H "x-api-key: pk_prod_…"

Templates + A/B tests

STARTER+

Saved drafts (templates) + sticky-by-user variant tests (A/B). Reference at send-time via `templateId` or `abTestId`. Per-variant counts via `/ab-tests/:id/stats`.

# Templates CRUD
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/templates \
  -H "x-api-key: pk_prod_…" \
  -d '{ "name": "welcome", "title": "Welcome!", "body": "Tap to start", "actions": [{"id":"start","title":"Start"}] }'

curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/templates \
  -H "x-api-key: pk_prod_…"

# A/B tests — variants stored as jsonb, weights sum to 100
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/ab-tests \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "name": "subject-line-test",
    "status": "active",
    "variants": [
      { "key": "A", "title": "20% off everything", "body": "Today only", "weight": 50 },
      { "key": "B", "title": "Last chance — 20% off", "body": "Ends midnight", "weight": 50 }
    ]
  }'

# Per-variant stats
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/ab-tests/<TEST_UUID>/stats \
  -H "x-api-key: pk_prod_…"

Live Activities (iOS) / Live Updates (Android)

GROWTH+

ActivityKit on iOS (push-type=liveactivity), data-only FCM push routed to NotificationCompat on Android. Host app starts the activity locally; SDK registers per-activity token; server-side push updates the visual state.

# Server-side update (existing activity from POST /push/live-activities/start-token)
curl -X PATCH https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities/<ACTIVITY_UUID>/update \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "contentState": { "status": "delivered", "minutesAway": 0 },
    "alert": { "title": "Order delivered", "body": "Enjoy!" }
  }'

# List active activities for org
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities \
  -H "x-api-key: pk_prod_…"

# End
curl -X DELETE https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities/<ACTIVITY_UUID> \
  -H "x-api-key: pk_prod_…"

Geofences

GROWTH+

Server-managed circular regions. CRUD by operator; SDK auto-fetches + arms (iOS cap 20 via CLLocationManager, Android cap 100 via GeofencingClient). Enter/exit/dwell transitions emit `geofence.entered` / `.exited` / `.dwelled` events.

# Create
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/geofences \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "name": "Downtown SF",
    "latitude": 37.7749,
    "longitude": -122.4194,
    "radiusMeters": 500,
    "triggers": ["enter", "exit"],
    "priority": 10,
    "enabled": true
  }'

# List
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/geofences -H "x-api-key: pk_prod_…"

# Per-geofence trigger counts (last 30d)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/geofences/<GEOFENCE_UUID>/stats \
  -H "x-api-key: pk_prod_…"

Configure APNs / FCM / VAPID

FREE

Upload APNs `.p8` auth key + key id + team id; paste FCM v1 service-account JSON; VAPID keypair lazy-generated per org on first /push/vapid call. Per-org credentials stored AES-256-GCM at rest.

# Configure in dashboard → Push → Settings → Providers. Encrypted server-side.
# Required before any send will deliver.
# Web Push: GET /orgs/:orgId/push/vapid returns the public key for browser subscribe().

SMS

Twilio with TCPA + CTIA Tier 1 STOP/START handling, manual suppression, signed inbound webhooks.

Send an SMS

STARTER+

Server-side send via Twilio (sk_* secret key). Per-org rate-limited. STOP-list checked before delivery — suppressed numbers silently dropped + logged.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/sms/send \
  -H "x-api-key: pk_prod_…" \
  -d '{ "to": "+15551234567", "body": "Your code is 482910", "category": "transactional" }'

STOP / START keyword handling

STARTER+

TCPA + CTIA Tier 1 keywords (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) auto-suppress. Twilio inbound webhook signed via HMAC-SHA1.

# Inbound webhook URL to set in Twilio console (org id baked in path):
# https://api.sendoracloud.com/api/v1/sms/webhooks/twilio/<ORG_UUID>
# Twilio signs with auth token — Sendora verifies signature before processing.

Query send history

STARTER+

List recent sends + per-send detail. Filter by status / phone / category. Aggregate counters via /stats.

# List
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/sms/sends?status=failed' \
  -H "x-api-key: pk_prod_…"

# Aggregate
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/sms/stats \
  -H "x-api-key: pk_prod_…"

Manual suppression

STARTER+

Manually suppress a number (opt-out form / customer support request). Returns 409 if already suppressed. ADMIN role required.

# Add
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/sms/suppressions \
  -H "x-api-key: pk_prod_…" \
  -d '{ "phoneNumber": "+15551234567", "reason": "user_request" }'

# List
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/sms/suppressions \
  -H "x-api-key: pk_prod_…"

# Remove (URL-encode the phone — '+' becomes '%2B')
curl -X DELETE https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/sms/suppressions/<PHONE_E164_URLENCODED> \
  -H "x-api-key: pk_prod_…"

Configure Twilio

STARTER+

Per-org Twilio credentials (Account SID + Auth Token + From number). Stored AES-256-GCM at rest. Configure in Dashboard → SMS → Provider; sends return 412 until configured.

# Dashboard-only — visual editor for Twilio creds + From-number selection.

In-app messages

Modals, banners, slide-outs, tooltips. Audience targeting + frequency caps + impression/click tracking.

Fetch in-app messages

FREE

Returns active messages targeted to the current user. Targeting evaluated server-side against the user's profile + audience memberships.

const { messages } = await sendora.inApp.fetch();
messages.forEach(m => console.log(m.title, m.body, m.cta));

Track impression / click

FREE

Fires `in_app.impression` and `in_app.clicked` events into the analytics stream. Used for conversion + frequency-cap evaluation.

await sendora.inApp.trackImpression(messageId);
await sendora.inApp.trackClick(messageId);

Author a message (dashboard)

STARTER+

Visual editor in the dashboard: title, body, optional CTA url, audience targeting, schedule + frequency cap. No SDK call — created server-side.

# Author in dashboard → In-app messaging → New message.
# Or POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/in-app-messages with the same payload.

Profiles

Identify, fetch profile, GDPR export + delete.

Identify a user

FREE

Bind the current anonymous identity to a known user_id + email + traits. Future events attach to that profile.

await sendora.identify({
  userId: "u_abc123",
  email: "alice@example.com",
  traits: { plan: "growth", company: "Acme" },
});

Fetch a profile

FREE

Returns the canonical profile + traits + audience memberships + lifecycle stage. Use server-side for personalization.

curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/profiles/<PROFILE_UUID> \
  -H "x-api-key: pk_prod_…"

Build audiences

STARTER+

Nested AND/OR rules over traits + behavioral events. Evaluated lazily at send time. Visual builder in the dashboard. CRUD: GET/POST /orgs/:orgId/audiences, GET/PATCH/DELETE /orgs/:orgId/audiences/:audienceId.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/audiences \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "name": "Active Pro users",
    "rules": { "all": [
      { "trait": "plan", "op": "eq", "value": "pro" },
      { "event": "feature_used", "op": "occurred_in_last", "value": "30d" }
    ]}
  }'

GDPR export + delete

FREE

Customer-initiated data export (JSON) + right-to-be-forgotten (cascade delete across all modules). Both endpoints idempotent.

# Profile data export — uses Data-IO module (per-profile + bulk)
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/data-io/export \
  -H "x-api-key: pk_prod_…" \
  -d '{ "module": "profiles", "filter": { "profileId": "<PROFILE_UUID>" } }'

# Right-to-be-forgotten — cascade delete via consent module deletion-request
curl -X POST https://api.sendoracloud.com/api/v1/consent/deletion-request \
  -H "x-api-key: pk_prod_…" \
  -d '{ "userId": "u_abc123", "scope": "all" }'

# Direct profile delete (admin scope)
curl -X DELETE https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/profiles/<PROFILE_UUID> \
  -H "x-api-key: pk_prod_…"

Audiences

Nested AND/OR rules over traits + behavioral events. Org-scoped CRUD; lazy evaluation at send time. Use audienceId on push / email / SMS sends to fan out automatically.

List audiences

STARTER+

All audiences in the org (optionally filtered by project). Each row includes the resolved rule tree. Visual builder in dashboard mirrors this shape. Auth: requires sk_* secret key. The Web SDK refuses sk_* keys at init time (browser safety) — call audiences API server-side via @sendoracloud/sdk-web-ssr or a custom server proxy. Browser-side audiences.* calls return 403 scope_required.

// SERVER-SIDE only. The Web SDK refuses sk_* keys; pass via SSR client
// or your own server proxy (browser-side calls return 403).
const list = await sendora.audiences.list();

Create an audience

STARTER+

Nested AND/OR rules over traits + behavioral events. `all` = AND, `any` = OR; either can recurse. Evaluated lazily at send time — no precompute.

const aud = await sendora.audiences.create({
  name: "Active Pro users — last 30d",
  description: "Pro plan + recent feature usage",
  rules: {
    all: [
      { trait: "plan", op: "eq", value: "pro" },
      { event: "feature_used", op: "occurred_in_last", value: "30d" },
      {
        any: [
          { trait: "country", op: "eq", value: "US" },
          { trait: "country", op: "eq", value: "CA" },
        ],
      },
    ],
  },
});

Update an audience

STARTER+

PATCH semantics — only fields you pass are touched. Pass a full new `rules` tree to replace the rule set; partial rule edits aren't supported.

await sendora.audiences.update("<AUDIENCE_UUID>", {
  name: "Active Pro users — extended to 60d",
  rules: {
    all: [
      { trait: "plan", op: "eq", value: "pro" },
      { event: "feature_used", op: "occurred_in_last", value: "60d" },
    ],
  },
});

Delete an audience

STARTER+

Hard delete. Workflows + sends that reference the deleted audience by id continue to run — they simply resolve to zero recipients on next eval. Audit-log entry stamped.

await sendora.audiences.delete("<AUDIENCE_UUID>");

Use an audience in a send

STARTER+

Pass `audienceId` (instead of `userIds`/`tokens`) to push / email / SMS send endpoints. Backend resolves at dispatch time — late-joining members of the audience get the send if dispatch hasn't started; suppressed by quiet hours / freq cap as usual.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "audienceId": "<AUDIENCE_UUID>",
    "title": "Sale starts now",
    "body": "20% off everything"
  }'

Automation / Workflows

Event-triggered DAGs across email / push / SMS / webhook / branch / wait / ai_action / update_profile. Bundle export+import for IaC. Visual builder in dashboard mirrors API shape 1:1.

Trigger a workflow from any SDK event

FREE

Workflows subscribe to event names. Fire the matching `track()` call from any SDK and the workflow runs server-side — no extra API needed. Workflows are per-project (ADR-013): event's projectId scopes which workflows fire.

// Workflow trigger: { eventType: "checkout_completed" }
sendora.track("checkout_completed", { amount: 49.99 });

Create a workflow

STARTER+

Define triggers + steps via API instead of the visual builder. Step types: `send_email`, `send_push`, `send_sms`, `update_profile`, `webhook`, `branch`, `wait`, `ai_action`. Each step's `config` shape is type-specific.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "name": "Welcome series",
    "trigger": { "eventType": "user.signed_up" },
    "isActive": true,
    "steps": [
      { "type": "send_email", "config": { "templateId": "<TEMPLATE_UUID>", "to": "{{event.userEmail}}" } },
      { "type": "wait", "config": { "reason": "let user finish onboarding" }, "delayMinutes": 1440 },
      { "type": "send_push", "config": { "title": "Tips for day 1", "body": "{{user.firstName}}, here are 3 tips..." } }
    ]
  }'

Step `config` shapes

STARTER+

Per-step-type config schema. Templates use `{{handlebars}}` substitution against trigger context (`{{event.*}}`, `{{user.*}}`, `{{trait.*}}`).

# send_email   { templateId, to } OR { subject, bodyHtml, to }   — BYOD verified domain required
# send_push    { title, body }                                          — fans out to ctx.userId's tokens
# send_sms     { to, body }                                              — Twilio configured required
# update_profile { traits: { ... } }                                    — merges into ctx.user profile
# webhook      { url, method?, headers?, body? }                        — HMAC-signed; SSRF-guarded
# branch       { condition: { trait/event, op, value }, ifTrue, ifFalse } — jumps to step label
# wait         { reason? } + delayMinutes                               — uses workflow_runs.next_step_at
# ai_action    { flavor: "generate"|"decide"|"extract", prompt, saveTo? } — Ollama Cloud, jsonMode optional

Dry-run a workflow

STARTER+

Send a sample event through the engine without writing rows or firing side effects — returns a step-by-step trace for debugging. EDITOR role required.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/<WORKFLOW_UUID>/test-run \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "eventType": "checkout_completed",
    "properties": { "amount": 49.99 }
  }'

Query workflow runs

STARTER+

Inspect each run's step trace + outcome. Per-step outcomes: `sent | suppressed | ai_unavailable | ai_pending | error | skipped-missing-config | byod_required | rate_limited`.

# List
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/runs?workflowId=<WORKFLOW_UUID>&pageSize=20' \
  -H "x-api-key: pk_prod_…"

# Detail (full step trace)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/runs/<RUN_UUID> \
  -H "x-api-key: pk_prod_…"

Export / import workflow bundles (IaC)

STARTER+

Versioned bundle format for git-tracking workflows + CI promotion (dev → staging → prod). Importer is idempotent by name. Bundle includes `$schema` URL for VS Code autocomplete + validation.

# Export single workflow
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/<WORKFLOW_UUID>/export \
  -H "x-api-key: pk_prod_…" > welcome-series.workflow.json

# Export every workflow in a project
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/export?projectId=<PROJECT_UUID>' \
  -H "x-api-key: pk_prod_…" > all.workflows.json

# Import (idempotent — same name updates, new name creates)
curl -X POST 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/import?projectId=<PROJECT_UUID>' \
  -H "x-api-key: pk_prod_…" \
  -H "Content-Type: application/json" \
  -d @welcome-series.workflow.json

Webhooks

Outbound events with HMAC-SHA256 signing, exponential backoff retries, replay + test-fire.

Create a webhook

FREE

POSTs the event JSON to your URL on every matching event. Returns a `secret` (one-time reveal) used to sign the `X-Sendora-Signature` header.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/webhooks/endpoints \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "url": "https://your-app.com/hooks/sendora",
    "events": ["email.delivered", "email.bounced", "auth.user_signed_up"]
  }'

# Response includes "secret" — store it to verify signatures.

Verify the signature

FREE

HMAC-SHA256 of the raw request body using your webhook secret. Compare in constant time to defend against timing attacks.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, signature: string, secret: string) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Retry + backoff

FREE

Failed deliveries (non-2xx, timeout, network error) retried in-process with exponential backoff over ~2 minutes (5 attempts including the initial fire). Final failure logged + visible in the Recent deliveries panel; Retry button on the dashboard re-fires a one-off attempt.

# No SDK call — automatic.
# Backoff schedule: initial fire, then 2s, 8s, 30s, 90s. Endpoints
# returning 2xx within ~2min won't see a final-failure log.

Fire a test event

FREE

Send a synthetic event to verify your handler. Same signature flow as production events.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/dev-tools/webhook-test \
  -H "x-api-key: pk_prod_…" \
  -d '{ "url": "https://your-app.com/hooks/sendora", "event": "email.delivered" }'

Deep links

Universal links + deferred attribution + per-app AASA / assetlinks generation.

Surveys

NPS / CSAT / CES / custom. Audience-targeted, frequency-capped, response analytics.

Fetch active survey for the user

FREE

Returns surveys eligible to show the current user (audience match, frequency cap satisfied, not already responded).

const { surveys } = await sendora.surveys.fetch();
if (surveys.length > 0) showSurveyModal(surveys[0]);

Submit a response

FREE

POSTs answers + optional NPS score. Triggers `survey.responded` event into the analytics stream.

await sendora.surveys.submit(surveyId, {
  answers: { q1: "Excellent", q2: 9 },
  npsScore: 9,
});

Aggregated results

FREE

Per-question breakdown, NPS score, response count over time. Dashboard renders charts; same data via API for export.

# Per-survey responses + stats are org-scoped admin routes.
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/surveys/<SURVEY_UUID>/responses \
  -H "x-api-key: pk_prod_…"

curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/surveys/<SURVEY_UUID>/stats \
  -H "x-api-key: pk_prod_…"

Feature flags

Boolean / string / number flags. Percentage rollout, audience targeting, per-user overrides.

Evaluate a single flag

FREE

Returns boolean / string / number per the flag's rules. Cached client-side; falls back to default if SDK offline.

const isEnabled = await sendora.flags.evaluate("new-checkout", false);
if (isEnabled) showNewCheckout();

Bootstrap all flags

FREE

One round-trip on app boot. Returns the full evaluated map for the current user. Recommended over per-flag calls in hot paths.

const flags = await sendora.flags.evaluateAll();
// flags = { "new-checkout": true, "max-items": 50, ... }

Create a flag

FREE

Boolean, string, or number type. Rules: percentage rollout, audience targeting, per-user overrides. Authored in the dashboard or via API.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/feature-flags \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "key": "new-checkout",
    "type": "boolean",
    "default": false,
    "rules": [{ "audience": "<audience_uuid>", "value": true }]
  }'

Analytics

Event tracking, page/screen views, funnels, retention cohorts. Same events drive Audiences + Workflows.

Track an event

FREE

Universal `track()` for any user action. Auto-attaches device id + session id + current Auth user. Buffered + flushed in batches.

sendora.track("checkout_completed", {
  amount: 49.99,
  currency: "USD",
  items: 3,
});

Page / screen views

FREE

Convenience method emitting `page_viewed` (web) or `screen_viewed` (mobile). Auto-fires on Next/React-Router nav when `autotrack` enabled.

sendora.page("Checkout", { plan: "growth" });

Funnel queries

STARTER+

Server-side aggregation: ordered events with optional time window. Returns conversion + drop-off per step.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/analytics/funnels \
  -H "x-api-key: pk_prod_…" \
  -d '{
    "steps": ["signup", "onboarding_completed", "first_action"],
    "window": "7d"
  }'

Retention cohorts

STARTER+

Cohort-by-week retention chart. First-event grouping → return-event match.

curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/analytics/retention \
  -H "x-api-key: pk_prod_…" \
  -d '{ "firstEvent": "signup", "returnEvent": "session_started", "windowWeeks": 12 }'

Enterprise SSO (Business+)

Per-org OIDC + SAML 2.0. SP metadata XML is served at /auth-service/sso/saml/:orgId/metadata for one-click IdP import. SDK starts the flow with signInWithSso({ returnTo }); the callback redirects back with the refresh token in the URL fragment.

Verifying JWTs on your backend

Sendora access tokens are RS256 JWTs with the per-org issuer URL embedded in the iss claim: https://api.sendoracloud.com/api/v1/auth-service/<orgId>. Standard libraries auto-discover the JWKS from <iss>/.well-known/jwks.json — no out-of-band orgId env var on your server. Matches Auth0 / Clerk / Firebase / Supabase / Cognito / Okta / WorkOS.

// Node — using jose (recommended)
import { createRemoteJWKSet, jwtVerify, decodeProtectedHeader, decodeJwt } from "jose";

const JWKS_BY_ISS = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function getJwks(iss: string) {
  if (!JWKS_BY_ISS.has(iss)) {
    JWKS_BY_ISS.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`)));
  }
  return JWKS_BY_ISS.get(iss)!;
}

export async function verifySendoraJwt(token: string) {
  const { iss } = decodeJwt(token);
  if (typeof iss !== "string" || !iss.startsWith("https://api.sendoracloud.com/")) {
    throw new Error("Untrusted issuer");
  }
  const { payload } = await jwtVerify(token, getJwks(iss), { issuer: iss });
  return payload; // { sub, org, email, ... }
}

For OIDC-style discovery, hit <iss>/.well-known/openid-configuration. Returns { issuer, jwks_uri, ... } so libraries like openid-client resolve everything in one round-trip.

Workflow export / import (IaC)

Automation workflows are defined visually in the dashboard but export to a portable bundle for git + CI. Bundle format is versioned (version: 1) and strips org / project IDs + per-instance metadata so the same bundle deploys to dev / staging / prod. Importer is idempotent by name — re-importing updates the workflow in place.

  • GET /orgs/:orgId/automation/workflows/:workflowId/export — single workflow.
  • GET /orgs/:orgId/automation/workflows/export?projectId=<uuid> — every workflow in a project.
  • POST /orgs/:orgId/automation/workflows/import?projectId=<uuid> — apply a bundle. Returns { created, updated, ids }.

Bundles include a $schema URL so editors auto-fetch JSON Schema for autocomplete + validation in committed .workflows.json files:

{
  "$schema": "https://sendoracloud.com/schemas/workflow-bundle.v1.json",
  "version": 1,
  "exportedAt": "2026-05-08T12:00:00Z",
  "workflows": [
    {
      "name": "Welcome series",
      "description": "Two-step onboarding for new signups",
      "trigger": { "eventType": "user.signed_up" },
      "steps": [
        { "type": "send_email", "config": { "templateId": "welcome-1" } },
        { "type": "delay", "config": {}, "delayMinutes": 1440 },
        { "type": "send_email", "config": { "templateId": "welcome-2" } }
      ],
      "isActive": true
    }
  ]
}

SSR `identify()` flow

On Next.js / Remix / SvelteKit — identify is split: client-side SendoraCloud.identify(...) establishes the user-id cookie (HttpOnly + Secure + SameSite=Lax) on first sign-in or page hydrate; server-side reads it via createSendoraServerClient(cookies(), ...).

// server-action / route-handler
import { cookies } from "next/headers";
import { createSendoraServerClient } from "@sendoracloud/sdk-web-ssr/server";

const sendora = createSendoraServerClient(cookies(), {
  publicKey: process.env.NEXT_PUBLIC_SENDORA_KEY!,
});
const { userId, traits } = sendora.getSession() ?? {};
// Server-side track that auto-binds to the same userId:
await sendora.track("order.placed", { amount: 49.99 });

HttpOnly-cookie SSR sessions

For Next.js / Remix / SvelteKit / SolidStart apps, install @sendoracloud/sdk-web-ssr alongside @sendoracloud/sdk-web. Backend issues HttpOnly + Secure + SameSite cookies (sd_at access, sd_rt path-scoped refresh, sd_csrf double-submit token) on every token-issuing endpoint. Refresh- token rotation with reuse detection — replay of a stolen cookie triggers a family-wide revoke. Edge middleware gates protected routes; createSendoraServerClient(cookies(), ...) reads the session in RSC + route handlers + server actions. Industry-standard posture (Clerk / Auth0 / NextAuth pattern).

SCIM 2.0 provisioning (Business+)

Mint a bearer token in dashboard → Authentication → SCIM, paste it into your IdP's connector along with the base URL https://api.sendoracloud.com/api/v1/scim/v2. RFC 7643 schemas + RFC 7644 protocol. Supports User + Group CRUD, PATCH ops (active, displayName, externalId, name, member add/remove), filter by userName eq / externalId eq.

OpenAPI spec

Full spec at https://api.sendoracloud.com/api/v1/docs/openapi.json — OpenAPI 3.1, version 1.2.0. Import it into Postman, Insomnia, or your code generator of choice.