NEWSupero Studio — generate, control, and deploy production apps.Start free →HIRINGSee the roles →

Telehealth scheduling, with notes the platform cannot read

Consent, identity, calendars and reminders — the integrations are the easy half. The interesting half is proving who can see what.

IntegrationsBuild estimate 2 days10 min readAugust 25, 2026

The integrations are the easy half: OTP, consent, calendars and reminders are all configured, not coded. The half that takes real time is proving Clinic A cannot see Clinic B — and encrypting the notes so that Supero cannot read them either.

The brief

What we are building

A multi-clinic telehealth group needs patients to book a video consult, complete an intake form, sign a consent document, and get reminded. Clinicians need a queue of today's visits with the intake attached. Practice managers need to run their own clinic and nothing else. Billing needs a card on file.

The integration list writes itself: one-time-passcode sign-in, SMS and WhatsApp reminders, transactional email, a shared calendar with video links, a payment gateway, object storage for uploads. What does not write itself is the boundary — a practice manager at Clinic A must not be able to enumerate Clinic B's patients, and "my appointments" has to mean mine even if someone edits the request.

7
integrations, all configured not coded
2
transactional services extended
~2 days
typical build for one developer
How it fits together

Architecture at a glance

Four layers, and who owns each one
SURFACESWho sees whatPatient portalClinician queuePractice adminIn-app AI agentLIFECYCLETransactional servicesappointmentdocument_signaturepaymentattachmentEDGESConfigured integrationsauth_otpsms · whatsappemail · google_calendarstripe · s3FOUNDATIONGoverned by the platformtenant per clinicrole + owner scopingaudit logclient-side field encryption
The mechanism

What actually happens on the wire

Book a consult — passcode, availability, calendar, reminder
PatientbrowserSupero APIapi.supero.devServicesauth_otp · appointmentVendorsTwilio · GoogleWorkflowsevent-boundIDENTIFY1auth_otp.send_otp {channel, recipient, purpose}2deliver passcode (keys read from the credential store)3verify passcode → session tokenscope comes from the credential — never from a client-supplied clinic idFIND A SLOT4appointment.check_provider_availability5overlapping slots — advisory only, not a hold6appointment.schedule_appointment7guard: requested → confirmed legal?8confirmed — or refused if the slot just wentEVERYTHING DOWNSTREAM, AUTOMATICALLY9@create:clinic:appointment fires the binding10google_calendar.create_event {summary, start, end, add_meet}11email.send_email · sms.send_sms (parallel step)12stamp workflow_status = confirmed_notified
Read the body, not the transport status: /services/execute returns HTTP 200 even when the operation is refused, with success:false and status_code: 409 inside. Branch on the body. Treat that refusal as a normal race — checking availability and taking the slot are deliberately two separate decisions.
Intake — consent signed, clinical note encrypted before it leaves the device
Clinician appholds the keySDK clientenable_encryptionSupero APIstores ciphertextdocument_signatureconsent lifecycle1create consent document + signature records2signing lifecycle stateTHE CLINICAL NOTE3create note {assessment, history, plan}4AES-256 each field value · fresh nonce5POST record — values are ENC:v1:… , names plaintextSupero never receives the key and cannot decrypt the values6stored · record carries _encrypted: trueA READER WITHOUT THE KEY7read fails outright, naming the fieldsdeliberate: ciphertext never reaches a UI, log or report disguised as a value
Field names stay in plaintext so the platform can still enforce that required fields are present. The trade is real: an encrypted field cannot be filtered, sorted or matched — keep status, dates and totals outside the encrypted set.
The visit, as guarded transitions
APPOINTMENT LIFECYCLErequestedinitialconfirmedschedule_appointmentcompletedterminalcancelledterminalno_showmark_no_show
Reschedule is a field-only update, not a transition — so it never appears in the state map and nothing guards it: an already-completed or cancelled visit will take a new time just as happily as a confirmed one. Check the status yourself before you write.
Under the hood

How it works

The clinic is a tenant, not a column

Supero's hierarchy is Domain → Project → Tenant → user or API key. Model each clinic as a tenant and isolation stops being application logic: the caller's credential resolves to a scope, and every read and write is enforced against it. A practice manager cannot widen their own scope by editing a request, because the scope was never in the request.

WhoRoleSees
Group ITdomain_adminEverything, across every clinic
Regional leadproject_adminOne project, across its clinics
Practice managertenant_adminOne clinic: its users, its records
Cliniciantenant_user (or a custom role)One clinic — and only their own records once you add an owner rule per entity. Without one, a tenant_user has full CRUD across the clinic.
Patienttenant_userOnly their own appointments and documents
AuditorviewerRead-only within a clinic

At login the platform derives a ui_view — admin for the managing roles, customer for everyone else — so the same deployed app renders the clinician console or the patient portal without a single role-string check in your UI code.

Per-owner scoping is the rule you would otherwise forget

A PolicyRule can pin rows to their owner. The platform stamps the caller onto the record; the client never supplies the owner, so "my appointments" cannot be forged by changing an id in a request. filter_match accepts exactly three values — $user.email, $user.uuid, $user.name — and filter_field must be a scalar field on the record holding that value, not a reference name. Get either wrong and the rule resolves to nothing: no filter is applied, and every patient sees every record. This is the one sample in this guide worth testing as a non-admin before you trust it.

python
POLICIES = [
    PolicyDef(role="tenant_admin", default_access="full", rules=[]),

    # A patient: their own visits and documents, and nothing else at all.
    PolicyDef(role="tenant_user", default_access="none", rules=[
        PolicyRule(entity="appointment", can_read=True, can_create=True,
                   filter_field="owner_username", filter_match="$user.name"),
        PolicyRule(entity="consent_document", can_read=True,
                   filter_field="owner_username", filter_match="$user.name"),
        PolicyRule(entity="provider", can_read=True),      # to pick one
    ]),

    PolicyDef(role="viewer", default_access="none", rules=[
        PolicyRule(entity="appointment", can_read=True),
    ]),
]

Encryption, precisely

There is no "encrypted": true schema flag, and that is the honest design. Protection for clinical free-text is client-side, and the guarantee is the strong one: you generate a 32-byte AES-256 key and hold it in your own secrets manager, Supero stores ENC:v1:<base64> values, and Supero never sees the key. Not "we promise not to look" — structurally cannot. That is a sentence worth saying out loud to a buyer, and it is the reason this architecture answers the PHI question rather than working around it.

python
from py_api_lib import ApiLib

# You generate and hold this. Supero never sees it and cannot decrypt without it.
encryption_key = my_secrets_manager.get("clinical-notes-key")

client = ApiLib(
    api_key="ak_live_...",
    enable_encryption=True,
    encryption_key=encryption_key,
)
  • Encryption is a property of the client, not the field. Everything an encryption-enabled client writes is encrypted, bar a fixed skip list. To keep metadata filterable you need two clients — one with the key for notes, one without it for everything else — and to route each write through the right one.
  • Nine fields are never encrypted: uuid, fq_name, parent_uuid, parent_type, obj_type, name, topic, plus the two encryption meta fields. Timestamps survive because the server stamps them itself. display_name and description are not on that list, so a record title written through an encrypted client renders as ciphertext.
  • A client without the key that reads a single record fails outright, naming the fields it could not decrypt. List and bulk reads behave differently: encrypted records are silently omitted. A short list, not an error, is the failure you will actually hit in a queue view — test both paths.
  • Encrypted fields cannot be filtered, sorted or matched. Keep status, start_time/end_time and any amount outside the encrypted set.
  • Lose the key and the data is gone. There is no support-assisted recovery.
Surface area

Integration points and data sources

Needservice_idNotes
Passcode sign-in / step-upauth_otpIssue and verify one-time passcodes over a configured channel
Reminderssms, whatsappTwilio-backed; free trial test credentials exercise the API without sending
Confirmations, summariesemail or email_smtpSendGrid, or your own SMTP host
Video visit + invitegoogle_calendarcreate_event {summary, start_time, end_time, add_meet}
Card on file / copaystripe_checkout, paymentHosted session, mirrored onto a payment record
Uploads (referrals, images)amazon_s3 (integration) + attachment (transactional)Your bucket; the attachment service owns the upload/scan/archive lifecycle
Practice-management systemDatabase connectorPostgreSQL/MySQL/SQL Server in live_read — nothing copied into Supero
Anything not in the catalogPlugin registryRegister your own HTTP endpoint with auth, retry and circuit-breaker settings
Step by step

How to build it on Supero

Describe the app, then fix the tenancy shape first

Generate from a description, then immediately answer one question before anything else: what is a tenant? Here it is a clinic. Get that wrong and every policy afterwards is compensating for it. Register the clinics in config.py's tenants list, default-tenant first.

⏱ ~30 minutes

Extend the two lifecycle services

Appointments and consent are not yours to model. Inherit them.

python
Appointment = {"schema_type": "object", "name": "Appointment", "namespace": "clinic",
               "parent_type": "tenant", "extends": "appointment:base_appointment",
               "fields": [{"name": "visit_reason", "type": "text"},
                          {"name": "modality", "type": "string",
                           "values": ["video", "in_person"], "default-value": "video"}],
               "references": [{"name": "Provider", "cardinality": "one",
                               "back_ref_name": "appointments"},
                              # the reminder binding reads patient.email through this
                              {"name": "Patient", "cardinality": "one",
                               "back_ref_name": "appointments"}]}

# document_signature is a two-schema service: a document and its signatures
ConsentDoc = {"schema_type": "object", "name": "ConsentDocument", "namespace": "clinic",
              "parent_type": "tenant", "extends": "document_signature:base_document",
              "fields": []}
ConsentSig = {"schema_type": "object", "name": "ConsentSignature", "namespace": "clinic",
              "parent_type": "tenant", "extends": "document_signature:base_signature",
              "fields": []}

⏱ ~30 minutes

Write the access policy before the UI

Policies are the load-bearing part of this app. Start every non-admin role at default_access="none" and add back exactly what it needs, with owner scoping on anything patient-specific. It is far easier to open a door later than to discover one was never closed.

⏱ ~45 minutes

Import the integrations with test credentials

Add auth_otp, sms, whatsapp, email, google_calendar, stripe_checkout, amazon_s3 — and workflows last. Every one of these has a free sandbox: Twilio test credentials, SendGrid sandbox_mode: true, Stripe test keys, a Google service account shared onto one calendar.

⏱ ~45 minutes

Bind the reminders to data events

One binding on @create:clinic:appointment covers confirmation; use reference hydration to pull the patient's contact details from the linked record in the same dotted path.

json
{"event": "@create:clinic:appointment",
 "workflow_id": "appointment_confirmed",
 "input_map": {"appointment_uuid": "uuid",
               "start_time":       "start_time",
               "patient_email":    "patient.email",
               "patient_phone":    "patient.phone",
               "provider_name":    "provider.full_name"}}

A path that does not resolve is simply omitted rather than failing the trigger — so mark those fields optional in the workflow's input_schema and let on_error: "continue" keep one dead channel from blocking the rest.

⏱ ~30 minutes

Split the clients, then turn on encryption

Two build notes first: the key must be raw 32 bytes, not the string your secrets manager hands back, and encryption is enabled on the low-level ApiLib client — the high-level supero client does not forward these arguments, so confirm your SDK build ships the encryption module before you design on it. Then split the note schema deliberately — encrypted free-text on one side, filterable metadata on the other — and route each through its own client, because encryption is per-client not per-field. Then run the real test twice: read a single record from a client without the key and confirm it fails, and list through that same client and confirm you notice the rows that quietly disappeared.

⏱ ~1 hour, mostly key management

Prove the boundary before you ship

The claim worth testing is not "the booking flow works" — it is "Clinic A cannot see Clinic B". Sign in as a tenant_admin of one clinic and try to read the other's appointments by uuid. Then do the same as a patient against another patient's record. Run the platform's quality gates on the bundle, then deploy — managed URL, your own AWS/GCP (only the UI container moves; auth and data stay on the Supero backend), or self-hosted for strict residency. Check the last one carefully: on a self-hosted install the integrations catalog this app depends on is a separately licensed add-on.

⏱ ~2 hours for the cross-clinic tests — then most of a second day on key handling, the clinician queue and the intake form itself, which is the part no generator gets right first time.

Know the edges

What this build does not give you

  • Encrypted fields cannot be filtered, sorted or matched, and a client without the key silently drops them from list reads. Every clinical query you want has to run on unencrypted metadata — design the split before you write the schema, not after.
  • Key custody is an operational problem you now own. Browser-held keys mean thirty clinicians hold copies and no recovery path; a server-side reader means one custodian and a service to run. There is no third option.
  • Vendor credentials are per project, so every clinic in a project shares one Twilio and one SendGrid account. A clinic that needs its own needs its own project.
  • No clinical content of any kind. No coding systems, no drug database, no e-prescribing, no HL7 or FHIR interface. This is scheduling and intake around a clinical record you model yourself.
  • Controls are not a certification. Tenant isolation, role enforcement and audit logging are engineering; the compliance conversation is separate and it is yours.
The economics

Time to deliver

PhaseOn SuperoA scheduling SaaS you outgrow, then rebuild
Data model, CRUD APIs, clinician + patient screens~1 h2–3 weeks
Appointment + consent lifecycles0 — inherited via extends2–3 weeks
OTP, SMS, WhatsApp, email, calendar, payments~45 min of configuration3–4 weeks
Multi-clinic isolation + role model + owner scoping~2 h of policy authoring3–6 weeks
Client-side field encryption~1 h1–2 weeks
Audit logging0 — platform default1 week
Total~2 days~3 months
The takeaway

Spend your time on the boundary

Half a day of this build is integrations, and integrations turned out to be configuration. The rest is the part that actually deserves a developer's attention: deciding what a clinic is, which role sees which record, and which fields are so sensitive that not even the platform should be able to read them.

So budget it backwards from a normal project: an afternoon for everything the vendors do, and the rest for the two decisions nobody can make for you — where the tenant boundary falls, and who holds the encryption key. Both are cheap to get right before there is patient data behind them, and expensive in a way no refactor fixes afterwards.

Delivery estimates in this guide are for the scope described, built by one experienced developer with any vendor sandbox credentials already in hand. Treat them as a realistic starting point, not a commitment — and check the documentation for the authoritative behaviour of any API named here.

Build this on Supero

Describe it and get schemas, APIs, SDKs and a governed multi-tenant backend — or point your own AI editor at Supero over MCP and build it there.