84 endpoints · Last updated 17 June 2026
When a clinic owner signs up, the system creates their account, sets up their workspace, and starts a 30-day free trial. Behind the scenes, this involves creating a secure login (Firebase Auth), a clinic record in the database, and a billing profile with Stripe.
Clinic owners can also invite their clinicians by email. Each invited clinician gets a password-reset link to set up their own login.
During setup, StrydeOS can auto-fill the clinic's Ava knowledge base from public sources (Google Places, Companies House, the clinic website), and self-heals a clinician's own record on first login so their data lands under the right clinic.
Clinic owners can add clinicians to their practice. The number of clinicians allowed depends on the billing tier: Solo (1), Studio (2–5), Clinic (6+). Extra seats can be purchased if you need more capacity within your tier.
Each clinician is linked to their PMS practitioner ID so their appointment data flows through automatically. Owners and admins can change a team member's role (owner / admin / clinician) at any time, with safeguards: the last owner can't be demoted, you can't change your own role, and admins can't grant owner. Clinicians can also opt in or out of the Heidi clinical scribe individually.
StrydeOS uses Stripe for all billing. Clinics choose which modules they want (Intelligence, Pulse, Ava, or Full Stack bundle), pick a tier based on clinic size, and select monthly or annual billing (20% discount for annual).
When a clinic subscribes, Stripe tells StrydeOS which modules to activate. If a payment fails, the clinic is marked "past due" but access isn't revoked immediately — giving time to update payment details via the billing portal.
Ava (voice receptionist) has a one-time £195 setup fee in addition to the recurring subscription. This setup fee applies only when subscribing to Ava as a standalone module — Full Stack does not include a setup fee.
StrydeOS connects to your existing Practice Management System (PMS) to pull in appointment data, clinician rosters, and patient records. Supported systems: WriteUpp, Cliniko, Halaxy, Zanda (Power Diary). PPS and TM3 support coming soon.
You enter your PMS API key in Settings, test the connection, and StrydeOS starts syncing. Data refreshes automatically — WriteUpp pushes updates in real-time via webhooks; other systems sync on a schedule.
For clinics without API access, you can upload CSV exports of your appointment or patient data. StrydeOS auto-detects common formats or lets you create a custom column mapping.
Home Exercise Programme (HEP) data flows in from your connected HEP provider (Physitrack, Rehab My Patient, or Wibbi) — tracking programme assignments, adherence, and completion rates per patient.
Ava is StrydeOS's AI voice receptionist, powered by ElevenLabs Conversational AI and Twilio telephony. When a patient calls, Ava answers, understands their request, and can book appointments directly into your PMS.
The booking flow: Patient calls → Ava converses → confirms booking details → n8n automation creates the appointment in your PMS → a copy is saved in StrydeOS for tracking. If the PMS write fails (e.g., the PMS is briefly offline), StrydeOS saves the booking locally and retries automatically.
Call data — duration, outcome (booked, cancelled, escalated, voicemail), transcripts, and sentiment — flows into the Receptionist dashboard so you can see how Ava is performing. A daily digest email summarises the day's calls. Owners can pause or resume Ava in one tap (which attaches or detaches the clinic's phone number from the agent), provision a UK number, sync the knowledge base, and warm-transfer a caller to reception when needed.
Pulse sends automated SMS and email messages to patients at the right moment. Six sequences run automatically:
When patients reply to a review prompt with an NPS score (0–10), StrydeOS automatically parses it and logs it to the Reputation dashboard. All messages are tracked: email opens and clicks (via Resend) and SMS delivery (via Twilio) flow back as delivery webhooks, so the Pulse dashboard shows open rates, click rates, and whether the patient rebooked.
For insured patients, StrydeOS sends a secure, single-use link asking for their insurer, policy number, and address before their first session. A daily job polls upcoming Cliniko appointments and emails the link automatically; staff can also send one on demand, or Ava can text it mid-call.
The patient form is public but token-gated — it never touches your PMS. Submissions land in a staff review queue where sensitive fields are redacted to the last four digits. Only when a staff member approves does the insurance detail get written back to the patient's PMS record. Staff can also enter pre-authorisation codes received from insurers (authorised session counts, excess, expiry).
The Intelligence module watches each clinic's KPIs and surfaces what changed and why. A daily detection pass creates insight events (drop-offs, anomalies, wins), feeds the Pulse re-engagement queue, and fires urgent alerts when something needs attention now.
Two weekly emails go out automatically: a State of the Clinic summary for owners (Sunday), and a per-clinician digest with each clinician's own stats benchmarked against the UK average — never naming or shaming, and revenue is never shown to clinicians (Monday). A separate value pass attributes outcomes back to revenue so owners can see the commercial impact of clinical work.
Clinics can connect Heidi Health as their clinical scribe. The clinic stores an encrypted API key, tests the connection, and runs a sync. Heidi issues a per-clinician token from each clinician's own email, so every team member opts in (or out) individually from their profile.
Clinics connect their Google Business profile by Place ID. StrydeOS verifies the credentials (returning the place name, rating, and review count for confirmation), then pulls reviews so rating and review-velocity sit alongside the NPS data in the Reputation dashboard. Disconnecting keeps the cached reviews so velocity history is preserved.
Every day at 6:00 AM UTC, StrydeOS runs its data pipeline. This pulls the latest data from your PMS, syncs patient and appointment records, computes the seven KPI metrics (follow-up rate, HEP compliance, programme assignment, utilisation, DNA rate, revenue per session, NPS), and triggers any due communications.
WriteUpp clinics also get near real-time updates — when an appointment is created or changed in WriteUpp, a webhook fires and StrydeOS re-computes within seconds.
For new clinics, a one-time 90-day backfill pulls historical data so you have context from day one. A second pass computes deep metrics — retention curve, cost of the empty chair, net growth, rebooking lag, discharge quality, and patient lifetime value. A daily data-health check flags any clinic whose PMS or CSV feed has gone stale, and a weekly cleanup enforces GDPR data-retention windows.
StrydeOS supports GDPR and data privacy requirements out of the box:
Users sign in with Firebase, which StrydeOS exchanges for a short-lived, HMAC-signed session cookie (8-hour clinical workday). A no-login demo session is available for the public sandbox. Health endpoints report overall platform status and per-clinic module heartbeats (Ava, Intelligence, Pulse).
The StrydeOS team (superadmins) use the rest to monitor system health, run schema migrations, diagnose integration issues, and manage clinics. The integration health dashboard shows sync success rates across all clinics — if a PMS connection is degrading, the team can see it before the clinic owner notices. A secured MCP endpoint also exposes clinic operations to approved AI assistants.
| Method | Header / Mechanism | Used By |
|---|---|---|
| Firebase Token + Session Cookie | ID token → HMAC-signed session cookie (8h) | Dashboard users — role-gated (~60 endpoints) |
| Cron Secret | Authorization: Bearer <CRON_SECRET> | Vercel Cron (7 scheduled jobs) |
| HMAC Webhook Signature | x-elevenlabs-signature / svix-signature / x-twilio-signature / x-webhook-secret | ElevenLabs, Resend, Twilio, WriteUpp, n8n |
| Stripe Signature | stripe-signature: <sig> | Stripe billing webhooks |
| Per-Clinic Ingest Token | Bearer <clinic ingest token> (bound to clinicId) | Inbound CSV email import |
| MCP Bearer / OAuth (PKCE) | Authorization: Bearer <token> (constant-time) | claude.ai MCP connector (stryde-ops) |
| Public Intake Token | Signed, single-use token in URL | Patient insurance intake form |
Roles: owner admin clinician superadmin — enforced via requireRole() in auth-guard.ts
clinicName: string ← required email: string ← required password: string ← required (min 8) profession: string ← optional clinicSize: string ← optional country: "uk"|"us"|"au" ← optional
{ uid, clinicId, email }
400 — Missing required fields 409 — Email already registered 500 — Internal server error
users doc, clinics doc (with compliance config derived from country), Stripe customeronboardingV2.stage: "signup_complete"clinicName: string ← required ownerEmail: string ← required ownerFirstName: string ← optional ownerLastName: string ← optional pmsType: string ← optional country: "uk"|"us"|"au" ← opt plan: string ← optional notionLeadId: string ← optional
x-admin-secret: <STRYDE_ADMIN_SECRET> Authorization: Bearer <token> (opt)
{ uid, clinicId, email,
passwordResetLink }
No body — clinicId from token
{ promoted: boolean,
alreadyLive?: boolean,
reason?: "no_admin_users"|"pending_logins" }
email: string ← required clinicianId: string ← optional
{ sent: true }
— or if Resend not configured —
{ sent: false, link, note }
name: string ← required role: string ← default "Physiotherapist" pmsExternalId: string ← optional (PMS practitioner ID) physitrackId: string ← optional
{ id: string,
clinician: { id, name, role, pmsExternalId,
physitrackId, active, avatar, createdAt } }
403 — Seat limit reached
{ error, currentCount, limit, canPurchaseSeat }
modules: ["intelligence"|"pulse"|"ava"|"fullstack"][] ← required tier: "solo"|"studio"|"clinic" ← default "studio" interval: "month"|"year" ← default "month" includeAvaSetup: boolean ← adds £195 one-time
{ url: string } ← Stripe Checkout URL
No body — clinicId from token{ url: string } ← Stripe Portal URLquantity: number ← 1–10, default 1
{ success: true, extraSeats: number }past_due (does NOT revoke access)provider: "writeupp"|"cliniko"|"halaxy"|"zanda" ← default "writeupp" apiKey: string ← required baseUrl: string ← optional (custom API URL)
{ ok: true }provider: string apiKey: string ← required
200: { ok: true, resolvedBase? } 400: { ok: false, error }
No body{ ok: true }{ results: [{ clinicId, ok, count?, error? }] }file: File (CSV) ← required fileType: "appointments"|"patients" ← required schemaId: string ← optional (auto-detect if omitted)
{ ok, count, imported, skipped, errors[] }file: File (CSV) ← required fileType: "appointments"|"patients" recipient: string ← extracts clinicId from email clinicId: string ← explicit override
x-inbound-secret: <CSV_INBOUND_SECRET>
name: string ← custom provider name fileType: "appointments"|"patients" ← required fieldMap: Record<csvHeader, canonicalField> ← required dateFormat: "uk"|"us"|"iso" ← default "uk" statusMap: Record<source, target> ← appointments only
date, time, endDate, endTime,
patientId, patientFirst, patientLast,
patientEmail, patientPhone, patientDob,
practitioner, practitionerId,
type, status, notes, price, duration
limit: number ← 1–100, default 30
{ ok, imports: [{ id, fileName, fileType,
importedAt, count, status, errors }] }provider: "physitrack" | "rehab_my_patient" | "wibbi" ← required apiKey: string ← required
{ ok: true }200: { ok: true } 400: { ok: false, error }
{ ok: true }No body — config pulled from clinic doc{ agent_id, message }book_appointment — create booking via PMScheck_availability — query open slotsupdate_booking — modify existing appointmenttransfer_to_reception — warm transfer to clinic reception deskNo body — knowledge pulled from
clinics/{clinicId}.ava.knowledge
{ doc_ids: string[], synced_at }
locality: string ← optional, e.g. "London" for local number
{ phone, agent_id, trunk_sid, message }
{ error: "Already provisioned", phone }
clinicId: string ← set at provisioning time
TwiML: <Dial><Sip>
sip:{agent_id}@sip.rtc.elevenlabs.io
</Sip></Dial>
X-Twilio-Signature against the reconstructed public URL (handles Vercel proxy)check_availability / book_appointment / update_booking during a live call to the correct clinic's PMS adapter.Called by ElevenLabs when Ava
invokes a tool mid-conversation
{ result: string }
// speakable text Ava reads aloud
call_logAVA_ENGINE_URLtransfer_to_reception tool. Warm-transfers the live call to the clinic's reception number via Twilio.Called by ElevenLabs when Ava's
transfer_to_reception tool fires
(complaint, escalation, human help needed)
{ result: "Call is being transferred..." }
// or graceful fallback message if transfer fails
clinicId: string ← required patientFirstName: string ← required patientLastName: string ← required patientPhone: string ← required (E.164) clinicianExternalId: string ← required dateTime: string ← required (ISO 8601) patientEmail: string ← optional durationMinutes: number ← default 45 appointmentType: string ← optional notes: string ← optional callId: string ← ElevenLabs call ID idempotencyKey: string ← dedup key
{ ok, appointmentId, pmsExternalId,
patientExternalId }
{ ok, partial: true,
pmsExternalId: null,
warning: "PMS write failed..." }
{ ok, retried, failed, skipped, results[] }Summary contains "book/appt" → booked Summary contains "cancel" → follow_up_required Summary contains "voicemail" → voicemail Summary contains "escalat" → escalated Summary contains "success" → resolved
clinicId: string ← required patientId: string ← required patientName: string ← required sequenceType: string ← required channel: "sms"|"email" ← required to: string ← required body: string ← required subject: string ← required for email templateVars: Record ← optional
hep_reminder rebooking_prompt pre_auth_collection review_prompt reactivation_90d reactivation_180d
clinicId, patientId, sequenceType, channel, logId, executionId, outcome: "booked"|"unsubscribed"|"no_action" openedAt?, clickedAt?
type: "inbound_reply" clinicId, fromPhone (E.164), replyText, receivedAt? NPS parsing: "8", "8/10", "eight" → 8 ≥9 = promoter, 7-8 = passive, <7 = detractor
x-webhook-secret: <WRITEUPP_WEBHOOK_SECRET> x-strydeos-clinic-id: ← optional direct clinic ID
clinicId?: string ← optional (all clinics if omitted for superadmin)
{ ok, result } or { results: [...] }clinicId?: string
clinicId?: string ← optional (all clinics if omitted) weeksBack?: number ← 1–12, default 6
{ clinicId, written } or { results: [...] }type: "access"|"correction"|"deletion" ← required requestedBy: string ← required patientId?: string description: string ← required
{ requests: [{
id, type, status, requestedBy,
patientId, description,
responseDeadline, ← 30 days from creation
completedAt, createdAt, updatedAt
}] }
{ success, data: {
exportedAt, patientId, clinicId,
patient: { ... },
appointments: [ ... ], ← up to 1000
comms_log: [ ... ], ← up to 1000
outcome_scores: [ ... ]← up to 1000
} }
{ success, message, gracePeriodEnd }clinicId: string ← required
{ success, baaSignedAt }days: number ← 1–90, default 30
{ clinics: [{
clinicId, clinicName, pmsProvider,
integrations: {
[provider]: {
totalSyncs, successfulSyncs,
successRate, avgDurationMs,
lastSuccessAt, lastFailureAt,
lastErrors, status: "healthy"|"degraded"|"down"
}
}
}] }clinicId: string ← required
{ ok, summary: {
dateFrom, dateTo, totalReturned,
keyShape: [{ keys, sample }],
confirmedFields, parseError?
} }None — public endpoint CORS: Access-Control-Allow-Origin: *
{
overall: "operational"|"degraded"|"down",
checkedAt: ISO 8601,
services: {
[key]: {
name, status: "operational"|"degraded"|"down",
latency: number ← ms, -1 if unreachable,
checkedAt, statusSource: "statuspage"|"ping",
uptimeHistory: number[] ← 30 entries (1=up, 0.5=degraded, 0=down)
}
}
}
Services checked (17):
Statuspage API: Vercel, Stripe, Twilio, Sentry, ElevenLabs
Google Cloud: Firebase / Firestore
HTTP ping: StrydeOS, Resend, n8n, WriteUpp,
Cliniko, Halaxy, Zanda, Physitrack, Heidi, Google PlacesclinicId: string ← optional (all active clinics if omitted)
{ ok: true, processedAt: ISO,
results: [{ clinicId, detection, pulse,
reengagement, urgentEmails }] }
Cron 06:30 daily · cron auth tried first, falls back to session+roleclinicId: string ← optional (own clinic unless superadmin)
10 / IP / 60s → 429{ ok: true, processedAt: ISO,
results: [{ clinicId, detection, summary }] }
403 — clinic mismatch (non-superadmin)No body{ ok: true, processedAt: ISO,
results: [{ clinicId, sent: bool,
error?: "already_sent_this_week" }] }
Cron Sun 07:00 UTCNo body{ ok: true, processedAt: ISO,
results: [{ clinicId, sent, skipped, errors }] }
Cron Mon 07:30 UTC · benchmarks labelled "UK avg"clinicId: string ← optional (own clinic unless superadmin) weekStart: string ← optional YYYY-MM-DD (default current Monday)
10 / IP / 60s → 429{ ok: true, weekStart, processedAt,
results: [{ clinicId, written: number, error? }] }No body
10 / IP / 60s → 429{ ok: true, checked, alerted,
results: [{ clinicId, alerted, reason? }] }
Cron 08:00 dailyNo body · cron secret required (no user fallback){ ok: true, clinicsScanned, totalDeleted,
gdprHardDeleted, billingDowngraded,
collections: { audit_logs, comms_log, … } }
Cron Sun 03:00 UTCNo body · superadmin = all clinics, owner/admin = own{ results: [{ clinicId, ok, skipped?,
candidates?, sent?, noEmail?, suppressed?, errors? }] }
Cron 09:00 daily · audit-logged only if sent > 0patientRef: string ← required appointmentId: string ← optional force: boolean ← optional (bypass anti-spam)
{ ok: true, url, emailed: bool,
texted: bool, email: string|null }patientRef: string ← required appointmentId: string ← optional
{ ok: true, linkId, token, url,
expiresAt: ISO, insurerOptions: string[] }status: "pending"|"approved"|"rejected" ← default "pending"
{ intakes: [{ id, reviewStatus, patientRef,
policyNumber (•••• last 4), insurerName, capturedAt }] }action: "approve"|"reject" ← required note: string ← optional (rejection reason)
{ ok: true, reviewStatus,
result?: { ok, usedFallback, onboardingTaskNeeded? } }
Double-approve/reject blocked · audit-loggedinsurerName: string ← required policyNumber: string ← required authorisationCode: string ← optional address: object ← optional consent: boolean ← required
GET 30/min · POST 15/min{ clinicName, insurerOptions: string[],
status: "issued"|"submitted", consentVersion }
{ ok: true } · 409 if already submittedpatientId: string ← required insurerName: string ← required preAuthCode: string ← required sessionsAuthorised: number ← required status: "confirmed"|"pending"|"rejected" ← required expiryDate: string ← optional ISO excessAmountPence: number ← optional
{ preAuthId: string,
message: "Pre-auth created"|"Pre-auth updated" }apiKey: string ← required region: "uk"|"au"|"us"|"eu" ← default "uk" testEmail: string ← optional (validates key)
{ ok: true }apiKey: string ← required email: string ← required region: "uk"|"au"|"us"|"eu" ← default "uk"
200: { ok: true } 401: { error }
No bodysyncHeidi() result · rate-limited 10/60sNo body{ ok: true }enabled: boolean ← required email: string ← required when enabled=true
{ ok: true }placeId: string ← required ([A-Za-z0-9_-]{10,}) apiKey: string ← optional (else env fallback)
{ ok: true }placeId: string ← required apiKey: string ← optional
{ ok: true, displayName,
totalReviews, avgRating }No body{ ok: true }No body{ enabled: boolean }since: ISO ← optional (default 24h ago)
{ sent: bool, summary: { booked, callbacks,
escalated, info, voicemail, total } }type: "post_call_transcription" data: { conversation_id, agent_id, transcript[], metadata, analysis }
{ ok: bool, clinicId: string|null,
conversationId }
Returns 200 on bad sig to suppress retriesNo body · rate-limited 5/60s{ agent_id, deleted: [...], new_tool_ids: [...],
voice_id, message }clinicId: string ← required svix-id, svix-timestamp, svix-signature
{ type: "email.opened"|…, data: { email_id, … } }
→ 204 No ContentclinicId: string ← required MessageSid, MessageStatus (form-encoded)
204 No Content · in-flight statuses ignoredrole: "owner"|"admin"|"clinician" ← required
{ ok: true, role: string }website: string ← optional country: string ← optional (default clinic jurisdiction / "uk") rate-limited 3 / 10min
{ ok: true, entriesCount, entries: [...],
sources: [...], resolved: { places?, companiesHouse?, website? } }No body · clinicId + uid from token{ ensured: true, clinicianId, created? }
201 if created, 200 if existedidToken: string ← required rate-limited 10/60s · 401 if invalid
{ ok: true } ← sets/clears SESSION_COOKIENo body · rate-limited 5/60s{ ok: true }No body{ status: "ok"|"degraded"|"error",
checks: { … }, timestamp: ISO }clinicId: string ← required
{ ok, clinicId, status: "ok"|"warning"|"error",
checkedAt, modules: { ava, intelligence, pulse } }clinicId: string ← optional (all clinics if omitted) rate-limited 5/60s · 404 if clinic not found
{ summary: { clinicsProcessed, totalApplied, totalErrors },
results: [{ name, version, applied[], errors[] }] }action: "add"|"remove"|"replace" ← required sender: string | string[] ← required rate-limited 20/60s · DELETE clears list (pauses ingest)
{ email, allowedSenders: [...],
provisioned: bool, domain }{ jsonrpc: "2.0", id?, method: string, params? }{ jsonrpc: "2.0", id,
result? | error: { code, message } }
Phase D: per-clinic scoping + full OAuth (deferred)token, redirect_uri, code_challenge, code_challenge_method (S256), state
302 → redirect_uri?code=…&state=…grant_type: "client_credentials"|"authorization_code" client_secret | (code + code_verifier)
{ access_token, token_type: "Bearer",
expires_in: 86400 }Auto-refreshes every 60s · Pulls live from strydeos.com/status