Webinars (F17)
Status: specified 2026-10-14, not built. Implementation plan and checkboxes: features.md → F17. Owner decisions are recorded below; open items are listed at the end and are the owner's to settle.
What it is
A clinic connects a webinars provider on its Integrations page. Once it is connected, the clinic links that provider's webinars into the platform, decides who may watch each one, and places them in the patient catalog. Patients join from the Portal. The provider streams and runs the chat; the platform owns the patient, the price and the access decision.
The first provider is Proofcast (working name, may change), which is the restartix-webinars repository. A webinar there is typically a recurring class series sold monthly: one webinar, one occurrence per class.
This is the first Cat B integration that adds a product surface to the Clinic and Portal rather than feeding an existing one (the shop connectors feed F14). The framework changes it needs are written to be reused by every later integration kind — see Integration kinds.
Owner decisions (2026-10-14)
| Topic | Decision |
|---|---|
| Shape | Cat B connected account. A clinic pastes an API key issued by its own Proofcast org. Available to any clinic; RestartiX creates the Proofcast orgs for now. |
| Delivery | Broadcast only. The instructor never sees patients. A new YouTube Live per class. |
| Content | Live classes and recordings. Replays are available for a limited time set by the provider. |
| Patient UX | Proofcast opens in a new tab. No iframe in the first version. |
| Chat identity | First name only. |
| What leaves the platform | A stable pseudonymous ID and a first name. Never email or phone. |
| Access | Free, OR included in a patient plan, OR a paid month. A patient may hold both a plan that includes it and paid months; nothing reconciles the two (option (a)). |
| Replay access | Checked when the patient asks to watch (each join ticket), the same as live. Once a ticket is exchanged, Proofcast keeps that one class open for the patient until its replay closes, even if their access lapses meanwhile (simpler; accepted 2026-10-14). |
| Display name | The platform sends its best guess of the first name with every ticket, or "Participant" when the profile has no name — the same word in every language, never "Pacient"/"Patient", because on an open webinar the chat is shared with the public and a name saying "clinic patient" discloses health data by inference (owner, 2026-10-15). Proofcast applies it on every join until the attendee sets their own chat name there; from then on their choice wins. |
| Subject key | The HMAC key is rotated only if compromised. |
| Payment | Not part of this integration. Monthly recurring billing is a separate platform capability (EuPlatesc); until it exists, staff add paid months by hand. |
| Sub-processors | Proofcast's own sub-processors (Firebase, Railway, YouTube) are not on the platform's list. Disclosure lives with the integration — see Data protection. |
| Migrations | Everything F17 changes goes in one new migration, including the alterations to older tables. |
Public webinars (owner, 2026-10-15)
Clinics may also link and promote a provider's public webinars, not only integration-only ones. A public webinar is always free on the platform — nobody can sell access to something anyone can watch on the provider — so the platform stores the provider's mode (webinars.provider_access: integration | open), links an open webinar as free, refuses "paid" for it (public_webinar_is_free), and a refresh that finds a webinar turned public makes it free. Members-only and private webinars stay out: the provider sells members-only access itself. A clinic only ever sees webinars of the Proofcast org its API key belongs to. A clinic patient joins an open webinar exactly as an integration one — pseudonymous contact, first name only, no registration form, no emails, no funnel — and shares the chat with the public who registered there. Approved by Proofcast 2026-10-15; being built on their side.
Responsibilities
| Platform | Provider (Proofcast) | |
|---|---|---|
| Patient identity | Owns it. Sends a pseudonymous ID and first name per join. | Holds a pseudonymous contact. No email, no phone, no marketing sync. |
| What a webinar is | A webinars row: link to the provider's webinar, access settings, catalog placement. | Title, description, cover, schedule, stream URLs, replay window. |
| Schedule | Reads occurrences from the provider; stores none. | Creates occurrences (by hand today, recurrence later). |
| Access | Decides, every time, before asking for a join ticket. | Trusts a valid single-use ticket for one occurrence. |
| Price and billing | Owns both (later track). | None. |
| Chat, polls, attendance | None. | All of it. |
Integration kinds
Tables follow the kind of integration, never the provider. Installing an integration is a row in organization_integrations, never a schema change.
integration_services.kind(new,NOT NULL):commercefor the two shops,webinarsfor Proofcast. Each kind has one Go interface that its providers implement and one set of feature tables that every provider of that kind shares.- One availability check per kind: "does this organization have a
connectedintegration of kind X?" Every Clinic and Portal option that depends on an integration asks this, and nothing compares slugs. A broken connection (expired,error,revoked) therefore hides its options automatically. - Patients never read
organization_integrations(its RLS is staff-only and it holds credentials). Patient-facing reads resolve availability on the server and expose only the boolean. - Credentials at call time. Pull-style connectors need the decrypted credentials of one connection for one call.
Service.Credentials(ctx, id)is referenced inintegrations/model.gobut was never written; F17 adds it, following the rules the comment already states (admin pool, plaintext never outlives the connector call). - Generic Clinic Integrations page. Today it is hard-coded to the two shops. It becomes: the library grouped by kind, a connect form generated from
config_schema, connection status with test and revoke, and the shop tabs (offers, SKU bindings, orders, campaigns, fulfillments) shown only when acommerceintegration is connected.
Provider interface
A Proofcast connector implements the existing integrations.Connector (validate config and credentials, healthcheck) and the kind's interface:
// package webinars — the capability every webinars provider implements.
type Provider interface {
ListWebinars(ctx context.Context, conn Connection, q string, page Page) ([]ExternalWebinar, error)
GetWebinar(ctx context.Context, conn Connection, ref string) (ExternalWebinar, error)
ListOccurrences(ctx context.Context, conn Connection, ref string, from, to time.Time) ([]Occurrence, error)
CreateJoinTicket(ctx context.Context, conn Connection, occurrenceRef string, attendee Attendee) (JoinTicket, error)
EraseAttendee(ctx context.Context, conn Connection, subject string) error
}Connection carries the decrypted credentials and config for one call. Attendee is {Subject, DisplayName} and nothing else. Field names are settled by the HTTP contract below.
Vocabulary. On the platform, one class is an occurrence. Never call it a "session": sessions is the exercise-session table. Proofcast keeps its own word on its side.
Data model
All of it lands in migration 000060.
New table webinars — one row per provider webinar a clinic has linked.
| Column | Notes |
|---|---|
id, organization_id | Standard. RLS on organization_id. |
organization_integration_id | FK → organization_integrations. No cascade: connections are revoked, never deleted. |
external_webinar_ref | The provider's ID. Unique per (organization, connection). |
title, description, cover_url | Cached from the provider at link time and refreshed on demand. The provider stays the source of truth. |
provider_synced_at | When that cache was last read from the provider. |
access_mode | free | paid. Defaults to paid (fails closed). |
included_in_plans | Only meaningful when paid; CHECK forces false when free. Defaults to false. |
status | draft | published | archived. Defaults to draft. |
sessions_per_month | The clinic's own number for "8 sesiuni live în fiecare lună", 1–31, NULL shows no line. Set in the access dialog. Never counted from the provider's schedule: it is filled a few weeks ahead and a holiday month runs short, so a count read as the offer changing (owner, 2026-11-05; folded into 000060, catch-up infra/scripts/000060-webinar-sessions-per-month.sql). |
created_by_principal_id, timestamps | Standard. |
No deleted_at and no DELETE policy: a webinar is retired by archiving it, and grants (purchase records) keep pointing at the row. Published rows are readable by everyone in the org, patients included (the catalog_entries shape); webinars.view sees drafts and archived ones.
Why access lives on the webinar and not on catalog_entries. For programs, access is set per catalog placement and the least restrictive placement wins. A webinar needs a single answer in places the catalog never reaches: the join, and later the monthly price. So the webinar row is the source, and a catalog entry of type webinar carries required_entitlement IS NULL (enforced by CHECK).
Alterations to existing tables (same migration):
integration_services.kind+ backfill (commerceforwoocommerceandmerchantpro) + the Proofcast row (webinars).catalog_entries.content_typeandpatient_content_grants.content_typeaccept'webinar'.
Seeds (same migration): entitlement content.webinars; permissions webinars.view (admin, specialist, customer support) and webinars.manage (admin). Granting months reuses the existing content.grant.
Every new column gets its data-classification entry in the same PR.
Access
The decision reuses access.EvalCatalogContent(set, required, granted) unchanged:
| Webinar | Rule | Who gets in | Lock reason |
|---|---|---|---|
free | — | Every patient of the clinic. | — |
paid, included in plans | EvalCatalogContent(plan, "content.webinars", granted) | A patient whose live plan carries content.webinars, or who holds a grant covering now. | requires_upgrade, or subscription_scheduled with the start date |
paid, not included | grant only | Only a grant covering now. | requires_purchase — a better plan would not help, so it is not requires_upgrade |
A paid month is a grant: a patient_content_grants row with content_type = 'webinar', granted_from / granted_until. That table already allows several rows per patient and item, so a plan and paid months coexist with no workaround, and each can lapse without affecting the other. The interim staff action is "add a month": a new grant starting where the latest one ends.
When EuPlatesc arrives, a separate billing record (first payment, renewals, cancellation) writes the same grants. The gate does not change. The webinar gains a monthly price then, and the "sold monthly" option appears only while the clinic has a recurring-payment capability connected.
A month paid by a subscription is stopped, not ended (2026-10-21). The recurring payment shipped on Stripe Connect (000061). A grant that a provider subscription pays for refuses the staff revoke (409 grant_paid_by_subscription): ending the grant alone left the subscription charging, and the next paid renewal extended the grant again. Staff press "Stop renewal" instead (POST …/content-grants/{id}/stop-renewal, content.grant), which cancels the subscription at the end of the period already paid, as the patient's own cancel does. Access runs to that date and nothing more is charged. Whether a month renews is mirrored onto access_offer_checkouts.subscription_ends_at from every subscription change.
A refund of a subscription's payment asks what follows: stop the subscription now (the default; it is cancelled at once and the access it pays for ends today) or keep it (a goodwill refund, a wrong charge on an older month). A refunded one-time purchase still withdraws its access with no question.
The plan entitlement content.webinars appears in the patient-plan editor only while a webinars integration is connected.
Free trial (owner, 2026-11-03)
A clinic may give one session of a paid webinar free to anyone who has not paid for it, so they can try it before buying — or, its choice, every live session for a week or a calendar month (owner, 2026-11-03, folded into 000066, catch-up infra/scripts/000066-webinar-trial-periods.sql). Built in migration 000066.
A week or a month (
webinars.free_trial_length:session|week|month, fixed choices). Claiming writes the same pass with an end (webinar_passes.ends_at): every session starting before it may be joined live — the room open early included — never a replay. It is deliberately not an access grant: a grant opens replays and counts as a purchase. The pass still names its first session (the one a one-session trial would open), so a period is offered only when a session falls inside it. While it runs, the patient's page reads "Acces gratuit până pe …" with the buy band; after, it is the lapsed state. Two days before the end the worker sendswebinar_trial_ending(email + bell, own opt-out category, once per pass, not after leaving or buying). Changing the length affects new claims only. Once per person per webinar still holds: a person has a session trial or a period of a webinar, never both.Per webinar, off by default.
webinars.free_trial, set in the access dialog beside "included in plans". Only a paid webinar can offer it (chk_webinars_trial_is_paid); making a webinar free, or the provider making it public, switches it off in the same statement.Which session: the one live now if at least half of it is still ahead, else the next. A trial spent on a session's last minutes is the worst sample of it, and the patient cannot try again. Half is measured on the provider's start and end; with no end, an hour after the start. A session whose lobby is open counts as ahead. A replay is never the trial.
Live only. The join opens the trial's session like any other, early into the waiting room whenever the provider lets anyone in (owner, 2026-11-03); its replay answers 403
webinar_trial_live_only. (A ticket the provider has already exchanged keeps the class open until its replay closes, per its own contract; the platform decides at join time only.)Once per person per webinar at the clinic. Keyed on the person (
patient_profile_id) like grants, so leaving the clinic and coming back gives no second trial (uq_webinar_passes_trial). A patient who leaves the webinar before their session can claim again and gets the same session; once it is over the trial is used (409webinar_trial_used).Claiming enrolls.
POST /v1/me/webinars/{id}/trialwrites the pass and the enrollment in one transaction, and the confirmation email names the free session. The reminder an hour before goes out for every session while they stay enrolled (owner, 2026-11-03): the free one as a normal reminder, the others saying they are available once they subscribe. Leaving (Renunțați la înscriere) stops them. Both rows are audited against the patient, so the claim shows on the patient's Activity tab.My Programs. Before the session: the main card leads with it (Sesiune gratuită), only its row carries Join, and the side card says the other sessions are available once they subscribe, with the clinic's offers (monthly first). After it: Sesiunea gratuită s-a încheiat, and the same offers, until the patient leaves (Renunțați la înscriere; named so it does not read as the opposite of Abonați-vă, which is the payment). A patient who buys and later lapses reads as a lapsed payer (Accesul dumneavoastră s-a încheiat), not as on a trial: the old pass no longer counts.
Library page. Under the price: sau, an outlined Încercați gratuit button, and which session it opens.
webinar_passes is one class a patient may join without paying: (organization_id, webinar_id, patient_id, patient_profile_id, source, occurrence_ref, live_starts_at, created_by_principal_id, created_at), immutable, no UPDATE or DELETE policy. live_starts_at is the session's start at claim time, so a used trial is still recognised once the provider no longer lists the session. The patient inserts their own trial only on a published webinar with the switch on (RLS); webinars.view reads every pass.
Gifts (planned, not built). A clinic giving a patient a session, even after their trial, is a second source (gift) on the same table: widen the CHECK and add a staff insert arm. The once-only index covers trial only, so it never limits gifts.
Attendance. The platform knows a patient opened a session when it hands them a join ticket (audited as WEBINAR_JOIN). Two columns keep the facts the Clinic filters on, so no list scans the audit log: webinar_passes.joined_at (the free session opened, first time) and webinar_enrollments.last_joined_at (the latest session opened). What happens inside the provider's room is the provider's.
Clinic → Webinars (webinars.view) is a tabbed section laid out as Sales:
- Webinars: one card per linked webinar (search, status), with who may watch it, how many current patients are registered, and how its trial went (claimed, bought since; a
purchasegrant created after the claim — a plan that includes it is not a purchase). Each count opens the Registrants tab already filtered. Access, publish, archive and refresh stay on the card forwebinars.manage. - Registrants: one row per patient and webinar (their latest enrollment), across every webinar (
GET /v1/organizations/{id}/webinar-registrants). Search by name or email; filters on webinar (typeahead), access now (paid,grantedby the clinic,trial,free,none), the free trial (claimed, joined, missed, bought after) and state (registered now, unsubscribed). The list does not resolve plans:noneon a webinar included in plans says the patient may still have access through one. Resolving it would need the plan rule in SQL beside the Go resolver — two copies of one rule — so it waits for the resolver itself to move to one place (owner, 2026-11-03).
The patient's Abonament tab has a card of the webinars they tried (GET /v1/patients/{patientId}/webinar-passes), read off the person so a withdraw-and-return still shows it, and the claim and the enrollment are on the Activity tab.
A cancelled free session moves. If the provider cancels the trial's session while it is still ahead and unopened, the reminder worker's next round (every five minutes, before it queues reminders) moves the pass to the session a claim would get now, audited as the system, so the reminder goes out for the new one. Left alone, the pass would count as used once the cancelled date passed. A failed schedule read moves nothing; a webinar with no session ahead keeps the pass where it is.
Portal, on a trial: the free session's row says Sesiune gratuită, and every other session (replays included) says Disponibilă după abonare with a lock, so a row with no Join reads as locked rather than broken.
Patient Activity names the webinar. The feed's rows carry an optional subject (a name, never content), filled per entity type by the domain that owns it and resolved under the reader's own RLS; webinars name the join, the enrollment and the pass by the webinar's title.
Attendance per session. webinar_attendances records the first time a patient opens each session, live or as its replay — one row per patient and session, append-only, monthly-partitioned on the session's start (P41), so a replay watched days later counts against its own session. Written by the join route inside a savepoint: a failure there (a missing partition) is logged and never costs the patient the join. The Registrants table shows "3 din 8" over the last 30 days and filters active / inactive; "held" is the distinct sessions anyone at the clinic opened, because the provider keeps no history of past sessions the platform can ask. Joins before this shipped are only in the audit log (WEBINAR_JOIN, without the session's start or mode).
GDPR archive. /me/export carries webinar_enrollments, webinar_free_sessions and webinar_attendance, each named by the webinar's title, and a note that what the person wrote in the provider's chat is held by the provider.
The worker visits a clinic when it has a live enrollment OR an unopened free session still ahead, so a holder who left the webinar meaning to come back is moved too.
Not built yet: gifts (source = 'gift').
Enrollment and reminders (owner, 2026-10-15)
A patient enrolls in a webinar before they may join, free or paid, the same as a guided program. One enrollment covers every class.
- Enroll from the presentation page (
/library/webinars/[id]) when they may watch it; it lands them on the webinar in My Programs (/my-programs/webinars/[id]), where the Join buttons are. My Programs lists enrolled webinars in their own section with the next class. - A paid month enrolls. Staff recording a month (a grant) enrolls the patient on the spot (
source = 'grant'): paying is registering. - Leave ends the enrollment (
left) and calls off a reminder already queued. Leaving the clinic ends it in the same trigger that queues the provider erasure (left_clinic). - A lapsed payment stays in My Programs, locked (owner, 2026-11-03). Once the patient may no longer watch (a month ran out, a subscription was stopped or refunded, a plan that included webinars ended), the webinar stays listed and its page says access has ended, with every class locked ("Disponibilă după abonare") and the side card selling it again, until they leave. The same as a free trial that is over: being enrolled is what keeps it there, not access. Paying again opens it with no second enrollment. The library page sells it to them too, with "Vezi înscrierea ta" under the price. A trial whose person bought the webinar since is no trial any more: locked again, they read as a lapsed payer. Replaces the 2026-10-21 rule that a lapsed month left My Programs (which had itself replaced the 2026-10-15 rule this restores).
- Notifications are the platform's, by email and in the Portal bell; the provider sends nothing to clinic patients and holds no email (owner, 2026-10-15).
webinar_enrolledconfirms an enrollment;webinar_startinggoes an hour before each class to everyone enrolled; to one who may not join that class (a free trial whose session is another, a lapsed payment) it says so (locked: "available after payment" in place of "sign in to join").webinar_livefollows at the start of each class (owner, 2026-11-03), only to whoever may join it, so somebody who missed the hour-before notice still hears it has begun; its own opt-out category, so a patient can keep one and stop the other. All point at the webinar's page in My Programs — a one-tap sign-in link (single use, one day, as in the programme email; 2026-11-03), never at a join ticket: a ticket is single-use and lasts two minutes, and an inbox is not where access is decided. No replay-available notice. - The reminder is derived from the provider's schedule as it is now, by an in-process worker (the API already holds the provider connectors), with an idempotency key per enrollment and class.
- When the reminder goes is the clinic's (owner, 2026-11-05): 15 or 30 minutes, 1 hour (default), 2 hours or 1 day before, set on the notice's row in Clinic → Notificări (
organization_notification_policy.config .before_minutesonwebinar_starting; no migration). The email says „astăzi", „mâine" or the date from its own send time.webinar_liveis queued only in the last 15 minutes before the start, never with the reminder, so a class cancelled in between does not announce itself. - Every webinar email's card names the session's own title under the webinar's (a provider label that is only the date is dropped) and shows the webinar's cover beside it (notify's
panel_imageblock, https only). - Taking a webinar off (published → draft or archived) calls off every notice already queued for its enrollments and frees their keys (
RevokeScheduledForRequeue), so putting it back reminds of the next class. The enrollments stay. - One provider call per clinic per five-minute round, whatever the number of webinars:
GET /sessions?from=&to=returns every class of the connection starting in the window. Each round, one API instance claims a clinic with a lease row (webinar_reminder_leases); the others skip it. A clinic with nobody enrolled costs no call. Later, provider → platform webhooks would let the platform keep its own copy of the schedule and stop polling altogether (F17.6).
Join flow
- Patients reach a webinar through the catalog, wherever the clinic placed it, enroll on
/library/webinars/{id}, and join from the webinar in My Programs,/my-programs/webinars/{id}. Its join button is a plain link withtarget="_blank"to a Portal route,/webinars/{id}/join/{occurrenceRef}. A plain<a>, notnext/link, so nothing prefetches it. - That route calls
POST /v1/me/webinars/{id}/occurrences/{occurrenceRef}/join. - The API checks, in order: the webinar is published, its connection is
connected, the access decision allows, the patient is enrolled, and the occurrence belongs to it and is live or still replayable. - The API asks the provider for a join ticket with the pseudonymous subject and the display name, and returns the ticket URL.
- The route answers
303to that URL. The patient lands in Proofcast, signed in, in the right class.
Opening the tab from the link itself, rather than from script after an await, is deliberate: Safari blocks window.open that is not directly triggered by the click.
Pseudonymous subject. crypto.Pseudonym("webinar-subject", organization_id | patient_profile_id): an HMAC keyed from the platform's own KMS-backed keyring (no new secret, no key in an env var), 43 base64url characters. It is stable for a person at one clinic, different at every other clinic, and meaningless without the key. It is keyed by the oldest keyring version, so an ordinary key rotation leaves it unchanged; only retiring that key (after a compromise) changes it — the owner's rule.
Refused under impersonation. Staff acting as a patient must not create that patient's attendance. Audited as WEBINAR_JOIN against the patient (the class reference only, never the name sent).
Data protection
- What is sent: the subject and a first name, at the patient's own click. The registry is a build-time contract (nothing reads it at runtime), so the domain declares what it discloses in
webinars.DisclosedToProvider, andcmd/check-classificationfails the build unless each column permits the new egress targetwebinar_provider— todaypatient_profiles.nameonly. - It is special-category data by inference. The name column is
pii_basic, but what leaves with it — that this person is a patient of this clinic attending a class — is health data under GDPR Art. 9, even under a pseudonym. Thewebinar_providertarget's entry in the classification taxonomy says so, and the Proofcast DPA addendum and DPIA treat it as Art. 9 (raised by therestartix-webinarssession 2026-10-14). - Who is responsible: the clinic runs the class and charges for it, so Proofcast processes data on the clinic's behalf. That needs a processor agreement between the clinic and Proofcast's operator — see Processor agreement. Chat conduct rules shown to patients cover conduct, not the legal basis.
- Where it is disclosed: the Proofcast library entry carries its data-protection details (operator, what it receives, link to its privacy notice and sub-processors), shown on connect. The clinic's privacy notice gets a Proofcast section that applies only while the integration is connected.
- Platform sub-processor list: unchanged. The platform never sends data to Firebase, Railway or YouTube; Proofcast does.
- Erasure on leaving the clinic (owner decision 2026-10-15). Leaving a clinic — on their own or removed by staff — removes the patient from every webinars provider the clinic has connected. A trigger on the
patientssoft-delete records awebinar_attendee_erasuresrow per connected integration in the same transaction as the leave; the API's erasure worker sends the provider the same subject a join would, and retries with backoff (a minute doubling to six hours, twelve attempts) until the provider confirms. The row is the record of the erasure. A revoked connection is skipped — there are no usable credentials to ask with.
Medical device
A live class delivered through the platform changes the workflow the IFU describes. That needs the owner's Rev. 1 decision; it is a flag, not a blocker for building.
Out of scope for the first version
An embedded (iframe) mode, provider recurrence, provider → platform webhooks, an entry on Today, SMS. Monthly payment shipped (2026-10-20): a webinar is an access-offer item, sold one-time or monthly through Stripe — see access offers, 000060 and 000061.
Proofcast API contract
Approved by the owner 2026-10-14. Received verbatim from the restartix-webinars session. Not yet built on the Proofcast side.
Proofcast-side decisions
- Pseudonymous contacts reuse Proofcast's identity key: an
emailHashderived assha256("ext\0" + org_id + "\0" + external_subject), pluscontacts.kind = 'external'andexternal_subject. They are excluded from every marketing path and never get behavioral tracking. - New webinar access mode
integration: hidden from the public site, open registration refused, entry only by ticket. A webinar has exactly one access mode. - A ticket, once exchanged, grants that session until it is archived, i.e. through the replay window (
closes_at). A reload or a return visit during that time needs no new ticket. The platform checks access when it requests the ticket. - No consent checkbox and no data-processing consent for integration attendees: the legal basis is the clinic's processor agreement. The attendee app shows the chat conduct rules as text.
- Tickets are accepted for scheduled sessions; the attendee sees a countdown.
- Integration attendees appear in the Proofcast dashboard's contacts and registrations views, labelled as clinic patients, first name only.
- Key prefix:
rxw_live_.
Contract
Corrected 2026-10-15 (owner-approved, from the restartix-webinars session): ended is no longer a status — it behaves like live and is reported as live; every session moves to replay once live_ends_at passes; closes_at: null means the replay stays open until staff archive the session (the first version wrongly said "no replay"); joinable is false only in the few minutes between closes_at and archiving. Field names and shapes are unchanged.
Base URL: https://{api-host}/v1/integration
Auth: Authorization: Bearer rxw_live_<43 base62 chars>
- Keys are per org; an org can have several named keys. Proofcast stores
sha256(key)plus a display prefix, and the plaintext is shown once. - Admins create and revoke keys in Dashboard → Settings → API keys. Creating and revoking are audited.
- Every call is scoped to the key's org.
- Rate limit: 600 req/min per key.
Errors: {"error":"<snake_code>","message":"<optional human text>"}
401 invalid_api_key: missing, unknown or revoked. These cases are deliberately not told apart.429 rate_limited, withRetry-After.400 invalid_request:messagenames the field.
Times are RFC 3339 UTC. IDs are opaque strings.
GET /account
200 {"org_id": "...", "org_name": "...", "key_prefix": "rxw_live_ab12"}Use it as the healthcheck: on save and on "Test".
GET /webinars?q=&limit=&cursor=
limit: 1–50, default 20. q: substring match on title ignoring case and Romanian diacritics ("sedinta" finds "Ședință"). Sorted by title. Returns webinars whose access is integration or open. open means neither members-only nor clinic-platform, with visibility public or unlisted. Members-only and private webinars are excluded. Only the key's own org is ever listed.
200 {
"items": [{
"id": "...",
"title": "...",
"description": "..." | null, // plain text, max 280 chars
"cover_url": "https://..." | null, // square rendition first, then HD, then OG
"slug": "...",
"visibility": "public" | "unlisted" | "private",
"access": "integration" | "open",
"updated_at": "..."
}],
"next_cursor": "..." | null
}GET /webinars/{webinar_id}
200: the item object above. 404 webinar_not_found, also for a members-only or private webinar.
GET /webinars/{webinar_id}/sessions?from=&to=
Defaults: from=now, to=now+30d. Maximum window: 90 days. Selection: live_starts_at <= to AND (closes_at >= from OR closes_at is null), status != archived. Ordered by live_starts_at.
200 {
"items": [{
"id": "...",
"label": "..." | null,
"status": "scheduled" | "open" | "live" | "replay",
"opens_at": "..." | null, // lobby opens
"live_starts_at": "...",
"live_ends_at": "..." | null,
"closes_at": "..." | null, // replay closes; null = replay open until staff archive
"joinable": true // false only between closes_at and archiving (~5 min)
}]
}404 webinar_not_found
GET /sessions?from=&to=&limit=&cursor= (requested 2026-10-16, for the class reminders)
Every session of the key's org whose live_starts_at falls in [from, to), across all webinars the integration may see (the same rule as GET /webinars; archived excluded). from and to are required; the window is at most 48 h (400 invalid_request otherwise). limit 1–500, default 100; cursor pagination. Ordered by live_starts_at, then id. Each item is a session object as above plus webinar_id.
200 { "items": [{ "id": "...", "webinar_id": "...", "label": ..., "status": ...,
"opens_at": ..., "live_starts_at": "...", "live_ends_at": ...,
"closes_at": ..., "joinable": true }],
"next_cursor": "..." | null }POST /sessions/{session_id}/join-tickets
body {
"external_subject": "<1–128 chars, [A-Za-z0-9_-]>",
"display_name": "<1–40 chars>" // first name, or "Participant" when unknown
}
201 {
"url": "https://live.{org-domain}/{session_id}/enter#t=<ticket>",
"expires_at": "...", // now + 120 s
"ticket_id": "tk_..." // for logs; not the secret
}display_nameis applied on each join until the attendee sets their own chat name in Proofcast; after that their choice wins and it is no longer applied.- Sessions of both
integrationandopenwebinars are accepted. - The ticket is single-use, and Proofcast stores only its hash.
- It travels in the URL fragment, so it never reaches server logs, the CDN or the Referer header.
- Open the URL in a new tab, straight from the user's click.
Errors:
404 session_not_found: missing, or in a members-only or private webinar.409 session_not_joinable: the session is archived, or itscloses_athas passed.400 invalid_request
Proofcast does not write webinar_memberships rows. A per-session grant authorizes the attendee until the session is archived.
DELETE /contacts/{external_subject}
204, idempotent: also returned when the subject is unknown. Erases the contact, their registrations and grants, their Firestore PII, and their chat author name.
Platform-side consequences
- The connector's
Slug()isproofcast; its healthcheck callsGET /accountand storesorg_idasexternal_account_id. - The platform's "occurrence" is Proofcast's "session". The Portal shows the Join button for
joinablesessions only, and treats409as "this class is no longer available". api-hostand the attendee domain are Proofcast's; the Portal never builds a Proofcast URL itself, it only follows the ticketurl.
Processor agreement (settled 2026-10-14)
The platform's legal position already treats a Cat B provider as the clinic's own processor, not the platform's (sub-processors), and the DPA counts enabled integrations as the clinic's documented instructions (DPA §2.2).
- Parties. Each clinic is the controller. Proofcast is operated by RestartiX SRL, which is the processor. Every clinic accepts a Proofcast DPA with RestartiX SRL, separate from its platform DPA. It lists Proofcast's own sub-processors (Firebase, Railway, YouTube), so they stay off the platform's list and Proofcast can be sold or renamed on its own.
- RestartiX's own clinic is both controller and processor; it follows the platform's inner DPA pattern.
- Where it is accepted. On the Proofcast side, when the clinic's Proofcast org is created. Writing the Proofcast DPA and privacy notice is a
restartix-webinarstask. - What the platform records. A library entry may carry a data-processing-terms URL. When it does, the connect form links it and requires a checkbox ("We have a data processing agreement with this provider"), and the connection stores who ticked it and when. Entries without a URL (the shops) need no acknowledgement. Generic for every Cat B integration; columns land in
000060.