Using OAuth 2.0 to Access the Medevio API from a Clinic Extension
Medevio extensions — scribes, note-taking apps, clinical calculators, body maps — are apps on your own infrastructure that a doctor opens from inside Medevio and that read and write clinic data through the Medevio API. Access is granted with OAuth 2.0: the clinic consents to your app's scopes once, when it installs the extension, and every click on your button gives your backend a short-lived access token for exactly that clinic, user and patient request. No API key is ever copied by hand.
Medevio uses standard OAuth building blocks: Token Exchange (RFC 8693) to turn a one-time launch token into an access token, and JWT client authentication (RFC 7523) instead of a client secret. Any OAuth or JOSE library that can sign an ES256 JWT is enough.
Basic steps
- Register your extension. Generate a key pair, build your extension config and send it to Medevio.
- Choose your scopes. Ask only for the API operations you call; the clinic sees them when it installs your extension.
- Receive the launch. The doctor clicks your button; Medevio opens your launch URL with a one-time
launchtoken. - Exchange it for an access token on your backend, signed with your private key. The response tells you the clinic, the patient request and the user.
- Call the Medevio API with
Authorization: Bearer <access_token>. - Refresh the access token when it expires — silently, for up to 12 hours after the click.
Medevio web app your app (browser) your backend Medevio API
| doctor clicks your button | | |
|-- opens {launchUrl}?iss=…&launch=… --> | | |
| |-- GET /launch?iss&launch ->| |
| | |-- POST /connect/token ------>|
| | |<- access_token, scope, ------|
| | | context, user |
| |<- session cookie, clean URL| |
| | |-- GET/PUT … Bearer token --->|
| | |-- refresh before expiry ---->|
1. Register your extension
The extension catalog is curated: there is no self-service console. You send Medevio one registration config per environment and we publish it. You need a backend (a single serverless function is enough) — the token exchange is signed with a private key, which a browser-only app cannot protect.
Environments
Issuer (iss) |
Token endpoint | Web app | |
|---|---|---|---|
| Production | https://api.medevio.cz |
https://api.medevio.cz/external/v1/connect/token |
https://my.medevio.cz |
| Development (sandbox) | https://api.medevio.dev |
https://api.medevio.dev/external/v1/connect/token |
https://my.medevio.dev |
Register separately for each environment, with a separate key. Development holds test data only; show a "Sandbox" badge in your UI when iss is https://api.medevio.dev.
Generate a key pair
Your app authenticates to Medevio with an EC P-256 (ES256) signing key. You keep the private key; Medevio stores only the public one.
openssl ecparam -name prime256v1 -genkey -noout -out medevio-private.pem
openssl ec -in medevio-private.pem -pubout -out medevio-public.pem
Print the public key as a JWK (Node 18+):
import { createPublicKey } from 'node:crypto';
import { readFileSync } from 'node:fs';
const jwk = createPublicKey(readFileSync('medevio-public.pem')).export({ format: 'jwk' });
console.log(JSON.stringify({ ...jwk, kid: 'example-scribe-2026-10', use: 'sig', alg: 'ES256' }));
The JWK must contain exactly kty (EC), crv (P-256), x, y, kid, alg (ES256) and optionally use (sig). Any other member — a private d above all — makes Medevio reject the whole entry. Up to three keys can be registered at once, which is how you rotate: add a key with a new kid, switch your signer, remove the old one.
Build your extension config
The config has three parts: the listing (what the clinic sees in the extension store), the launchers (the buttons in Medevio) and connect (OAuth: where we send the doctor, your public keys and your scopes).
Listing
| Field | Required | Description |
|---|---|---|
slug |
yes | Stable identifier and your OAuth client_id, lowercase kebab-case (example-scribe). Never changes once published. |
name |
yes | Display name in the store and the default button label. |
shortDescription |
yes | Text on the store tile; keep it under ~75 characters. |
description |
yes | Markdown for the detail page: paragraphs, bold, italics, bullet lists. |
iconUrl |
yes | Square icon (256×256 px) on the detail page and in the install dialog. |
imageUrl |
yes | Cover image 16:9 (e.g. 1200×630 px) on the store tile. |
publisher.name, publisher.websiteUrl |
yes | Your company and homepage. |
publisher.termsUrl |
no | Your terms; linked from the install dialog. |
infobox.title, infobox.text, infobox.button |
no | Highlighted box on the detail page, e.g. "Create your account first". |
version |
no | Your app version shown on the detail page; tell us when it changes. |
Send the image files, not links to your CDN — Medevio hosts extension images itself. Keep each under 300 kB.
Launchers — one entry per button
| Field | Required | Description |
|---|---|---|
key |
yes | Stable id of the button within your extension (record-visit). |
scope |
yes | Where the button appears: PATIENT_REQUEST_DETAIL (request detail; context carries the request and its patient) or PATIENT_RECORD_DETAIL (patient card; context carries the patient). |
type |
yes | REDIRECT |
launchMode |
yes | SIGNED — the button opens your connect.launchUrl with a launch token. |
label |
no | Button tooltip; the extension name when absent. |
buttonIconUrl |
no | Square icon inside the button, ~60×60 px, transparent PNG, monochrome or high-contrast. |
connect — the OAuth client
| Field | Required | Description |
|---|---|---|
launchUrl |
yes | Your launch endpoint, https://. Every button opens it; your own query parameters are kept. |
jwks |
yes | 1–3 public keys from the previous step. |
permissions |
yes | Your scopes, see section 2. |
maxSessionSeconds |
no | How long you may refresh tokens after a click: 1800–43200, default 43200 (12 h). |
includeUserEmail |
no | true adds the user's e-mail to the token response (for account matching). |
Example config
{
"slug": "example-scribe",
"name": "Example Scribe",
"shortDescription": "AI scribe that writes the visit note into Medevio",
"description": "Turns the conversation with the patient into a structured medical record.\n\n**How it works:**\n- Start recording before the consultation.\n- Talk to your patient as usual.\n- Review the note — it lands in the request's medical record.",
"iconUrl": "icon.png",
"imageUrl": "cover.jpg",
"version": "1.0",
"publisher": {
"name": "Example Health s.r.o.",
"websiteUrl": "https://www.example.com",
"termsUrl": "https://www.example.com/terms"
},
"infobox": {
"title": "First use",
"text": "Sign in to Example Scribe with your clinic account the first time you open it.",
"button": { "label": "Create an account", "url": "https://app.example.com/signup" }
},
"launchers": [
{
"key": "record-visit",
"label": "Record visit",
"scope": "PATIENT_REQUEST_DETAIL",
"type": "REDIRECT",
"launchMode": "SIGNED",
"buttonIconUrl": "button-icon.png"
}
],
"connect": {
"launchUrl": "https://app.example.com/medevio/launch?recording=start",
"jwks": [
{
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
"kid": "example-scribe-2026-10",
"use": "sig",
"alg": "ES256"
}
],
"permissions": ["patient_request.read", "clinic_medical_record.write", "attachment.write"]
}
}
Send it with the image files to podpora@medevio.cz and name the environment. We publish the extension to your test clinic first (see Testing); once it works there we make it visible to clinics.
2. Scopes
Scopes (Medevio calls them permissions) decide which API operations your access token may call. The clinic consents to them in the install dialog — the "The extension will be able to" list — so ask only for what you use: every extra line is a reason for a clinic not to install you.
| Scope | What the clinic sees (cs) | Endpoints |
|---|---|---|
patient_request.read |
Číst otevřený požadavek včetně údajů pacienta a příloh | GET …/patientRequests/{id} — request detail with patient and attachments |
patient_request.list_for_patient |
Číst ostatní požadavky téhož pacienta | GET …/patientRequests/{id}/patient-requests — every request of the same patient in the clinic (paged limit / offset; hasMedicalRecord=true keeps those with a document) |
clinic_medical_record.write |
Zapsat do zdravotního záznamu požadavku | PUT …/patientRequests/{id}/clinic-medical-record |
attachment.write |
Nahrát přílohy k požadavku | POST …/patientRequests/{id}/attachments/upload-link, POST …/patientRequests/{id}/attachments |
patient_request.update |
Upravit štítky a poznámku požadavku | PATCH …/patientRequests/{id} — tags, note |
tag.read |
Číst seznam štítků kliniky | GET …/tags |
patient.read |
Číst kartu pacienta | GET …/patients/{patientId} — the launch's patient only (context.patientId), the internal note included, documents (medicalRecords) never |
reservation.list_for_patient |
Číst termíny tohoto pacienta v kalendářích kliniky | POST …/calendar/reservations/search — filter.patientRequestFilter.patientId is required and must be context.patientId; includeSummary answers 403; the nested request comes without its record, notes, payment, tags or patient card, the appointment without its staff note and video-call links |
calendar.read |
Číst kalendáře kliniky a jejich volné termíny | POST …/calendar/search, POST …/calendar/available-slots/search — a filter.patientId must be context.patientId |
patient_request_type.read |
Číst typy požadavků kliniky | POST …/patientRequestTypes/search |
patient_request.create |
Vytvořit tomuto pacientovi požadavek s termínem | POST …/patientRequests/create — for context.patientId only; a message or reminder only on shouldSendNotification: true / remindDaysBefore; shouldInvitePatient, awaitPayment and includeSummary answer 403 |
clinic.read |
Číst název, adresu a telefon kliniky | GET on the clinic itself — name, address, phone and opening hours, as a row of the clinic search |
All paths are under {iss}/external/v1/clinics/{clinicSlug}. Routes bound to a patient answer 403 for any other patient than the one in context, and for a launch without a patient.
A typical scribe asks for patient_request.read + clinic_medical_record.write, and attachment.write if it attaches a document (PDF, JPEG, PNG, GIF or WebP — audio is not accepted).
- Granted scopes are in every token response (
scope, space-separated). A call outside them answers403. - Changing scopes needs no code change — tell us, we update the catalog. Until the clinic confirms the new list in the extension detail, your button shows "Rozšíření vyžaduje aktualizaci" and the token exchange answers
CONNECT_INSTALLATION_INACTIVE. - Missing an endpoint? Ask; scopes are added per endpoint.
3. How it works
Receive the launch
When the doctor clicks your button, Medevio opens your launchUrl in a new tab with two parameters added:
GET https://app.example.com/medevio/launch?recording=start&iss=https%3A%2F%2Fapi.medevio.cz&launch=eyJhbGciOiJFUzI1NiIs…
iss— the Medevio environment. Check it against a hard-coded allow-list before sending anything to it.launch— opaque, single use, valid for 60 seconds. It is not an API credential and carries no data for you.
Exchange it immediately on your server, create your own session, then redirect to a URL without the parameters.
Exchange the launch token for an access token
POST {iss}/external/v1/connect/token, Content-Type: application/x-www-form-urlencoded. No API key.
| Parameter | Value |
|---|---|
grant_type |
urn:ietf:params:oauth:grant-type:token-exchange |
subject_token |
the launch value |
subject_token_type |
urn:medevio:params:oauth:token-type:launch |
client_assertion_type |
urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
client_assertion |
a JWT signed with your private key (below) |
client_id |
optional; your slug |
The client assertion is a JWT with header { "alg": "ES256", "kid": "<your kid>" } and these claims:
| Claim | Value |
|---|---|
iss, sub |
your slug |
aud |
the exact token endpoint URL, e.g. https://api.medevio.cz/external/v1/connect/token |
jti |
a fresh UUID for every request |
iat, exp |
now, and at most 5 minutes later (60 s is plenty) |
Response 200:
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 1800,
"session_expires_in": 43200,
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"scope": "patient_request.read clinic_medical_record.write attachment.write",
"context": {
"clinicSlug": "mudr-novak",
"clinicId": "857db721-bed7-48db-89bf-d17a0f48841e",
"patientRequestId": "308d14b5-0e6a-454a-9c41-12298377713b",
"patientId": "e57b194f-301e-4fda-b28a-7588d45b8e02"
},
"user": { "id": "63886cb3-…", "name": "Jana", "surname": "Nováková", "locale": "cs", "isNurse": false }
}
contextis the only trustworthy source of the clinic, request and patient. APATIENT_RECORD_DETAILlaunch has nopatientRequestId.user.idis stable; use it for single sign-on into your app.user.emailis present only withincludeUserEmail.- Treat
access_tokenas opaque, keep it on your server, and do not cache the response (Cache-Control: no-store).
Call the Medevio API
GET https://api.medevio.cz/external/v1/clinics/mudr-novak/patientRequests/308d14b5-0e6a-454a-9c41-12298377713b
Authorization: Bearer eyJ…
Calls run as the doctor who clicked: their name appears in the clinic's audit trail together with your app. The token works only for the clinic in context. Responses wrap the payload in data; errors are { "error": "...", "code": "..." }. Full reference: https://developers.medevio.cz/medevio-api.
| Answer | Cause | What to do |
|---|---|---|
401 |
token expired or invalid; the user was deactivated or removed from the clinic | refresh; if the refresh answers invalid_grant, ask the doctor to open your app again from Medevio |
403 |
scope not granted, another clinic in the URL, or the extension was uninstalled | do not retry |
Refresh the access token
An access token lives 30 minutes (expires_in). Your backend can refresh it without the doctor — for long visits or a note that is ready after the doctor has moved on — for up to session_expires_in seconds after the click. Call the same endpoint with your latest access token as the subject:
| Parameter | Value |
|---|---|
subject_token |
your latest access_token (an expired one works too) |
subject_token_type |
urn:ietf:params:oauth:token-type:access_token |
Everything else is identical, including a fresh client assertion. The refreshed token carries only the scopes the clinic still grants. Refreshing stops when the clinic uninstalls your extension or the user loses access to the clinic; after the session limit you get CONNECT_SESSION_MAX_AGE and the doctor has to open your app again.
Not every failed refresh ends the session. Only an invalid_grant answer means it is over (see Errors from the token endpoint); then ask the doctor to open your app again. Anything else is temporary: CONNECT_RATE_LIMITED, CONNECT_UNAVAILABLE, a network error, an answer that is not JSON (from a proxy on the way), or CONNECT_INVALID_CLIENT while you are rotating keys. Keep the session, go on with the current access token while it is still valid, and refresh again after a short pause; do not retry on every call, the rate limit counts all users of your extension in the clinic. Refreshing a few minutes before expires_in gives you that margin; a doctor should never have to start over because of a short outage.
4. Example: a scribe in Node.js
A complete flow: receive the launch, exchange it, read the request, write the note, attach a PDF summary. Express + jose; Python works the same way with PyJWT[crypto].
import express from 'express';
import session from 'express-session';
import { SignJWT, importPKCS8 } from 'jose';
import { randomUUID } from 'node:crypto';
const CLIENT_ID = 'example-scribe';
const KID = 'example-scribe-2026-10';
const ALLOWED_ISSUERS = new Set(['https://api.medevio.cz', 'https://api.medevio.dev']);
// openssl pkcs8 -topk8 -nocrypt -in medevio-private.pem
const privateKey = await importPKCS8(process.env.MEDEVIO_PRIVATE_KEY_PKCS8, 'ES256');
const SUBJECT_TOKEN_TYPE = {
launch: 'urn:medevio:params:oauth:token-type:launch',
access: 'urn:ietf:params:oauth:token-type:access_token',
};
// One function for both the first exchange ('launch') and every refresh ('access').
async function tokenRequest(iss, kind, subjectToken) {
if (!ALLOWED_ISSUERS.has(iss)) throw new Error('untrusted issuer');
const tokenUrl = `${iss}/external/v1/connect/token`;
const clientAssertion = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: KID })
.setIssuer(CLIENT_ID)
.setSubject(CLIENT_ID)
.setAudience(tokenUrl)
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime('60s')
.sign(privateKey);
const res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: subjectToken,
subject_token_type: SUBJECT_TOKEN_TYPE[kind],
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
}),
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error_description), { code: body.code, error: body.error });
return body;
}
// Every response, refreshes included, carries context and user; session_expires_in is the time left.
const toGrant = (iss, token) => ({
iss,
accessToken: token.access_token,
expiresAt: Date.now() + token.expires_in * 1000,
sessionExpiresAt: Date.now() + token.session_expires_in * 1000,
scope: token.scope.split(' '),
context: token.context,
user: token.user,
});
async function accessToken(grant) {
if (Date.now() < grant.expiresAt - 5 * 60_000) return grant.accessToken;
if (Date.now() >= grant.sessionExpiresAt) throw new Error('relaunch_required');
if (Date.now() < (grant.retryAfter ?? 0)) return grant.accessToken; // pausing after a failed refresh
try {
Object.assign(grant, toGrant(grant.iss, await tokenRequest(grant.iss, 'access', grant.accessToken)));
} catch (err) {
if (err.error === 'invalid_grant') throw new Error('relaunch_required');
// temporary (429, 503, the network, a proxy): go on with the current token while it lasts
if (Date.now() > grant.expiresAt - 30_000) throw err;
grant.retryAfter = Date.now() + 30_000; // a short pause, so a busy app does not keep hitting the rate limit
}
return grant.accessToken;
}
async function medevio(grant, method, path, body) {
const { clinicSlug } = grant.context;
const res = await fetch(`${grant.iss}/external/v1/clinics/${clinicSlug}${path}`, {
method,
headers: { Authorization: `Bearer ${await accessToken(grant)}`, 'Content-Type': 'application/json' },
body: body && JSON.stringify(body),
});
if (!res.ok) throw Object.assign(new Error(`Medevio ${res.status}`), { status: res.status });
return (await res.json()).data;
}
const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET, cookie: { httpOnly: true, secure: true, sameSite: 'lax' } }));
// 1. The doctor clicked the button in Medevio.
app.get('/medevio/launch', async (req, res) => {
res.set('Referrer-Policy', 'no-referrer');
try {
const iss = String(req.query.iss);
req.session.medevio = toGrant(iss, await tokenRequest(iss, 'launch', String(req.query.launch)));
res.redirect(303, `/visit?recording=${encodeURIComponent(String(req.query.recording ?? ''))}`);
} catch (err) {
res.status(401).send('Open Example Scribe again from Medevio.');
}
});
// 2. The doctor approved the note: write it into the request's medical record.
app.post('/visit/save', async (req, res) => {
const grant = req.session.medevio;
const { patientRequestId } = grant.context;
// Read first: the doctor may already have typed into the record, and a PUT replaces it.
const request = await medevio(grant, 'GET', `/patientRequests/${patientRequestId}`);
const text = [request.clinicMedicalRecord, req.body.noteHtml].filter(Boolean).join('');
await medevio(grant, 'PUT', `/patientRequests/${patientRequestId}/clinic-medical-record`, {
text,
visibleToPatient: false,
});
// Optional: attach the PDF summary (scope attachment.write).
if (req.body.pdfBase64) {
const pdf = Buffer.from(req.body.pdfBase64, 'base64');
const link = await medevio(grant, 'POST', `/patientRequests/${patientRequestId}/attachments/upload-link`, {
contentType: 'application/pdf',
});
await fetch(link.url, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, body: pdf });
await medevio(grant, 'POST', `/patientRequests/${patientRequestId}/attachments`, {
fileHash: link.fileHash,
contentType: 'application/pdf',
description: 'Visit summary',
visibleToPatient: false,
});
}
res.json({ ok: true }); // the page then calls window.close() and the doctor is back in Medevio
});
app.listen(3000);
What the example does on purpose:
- Reads before it writes.
PUT …/clinic-medical-recordreplaces the whole record (the previous text stays in history). The record is HTML:<p>,<br>,<strong>,<em>, lists, headings — no scripts or styles. visibleToPatient: false.truepublishes the note to the patient and there is no API to take it back; send it only when the doctor asked.- Never resolves the request. Closing a request is the doctor's decision.
- Closes its own tab. Medevio opens your app with
rel="opener", sowindow.close()works after a successful save. Tell the doctor what you wrote before you close. - Is idempotent per
patientRequestId. The same request can be launched twice (double click, reload, coming back later); resume the existing visit instead of creating a new one.
Errors from the token endpoint
OAuth-style body: { "error": "...", "error_description": "...", "code": "..." }
| HTTP | error |
code |
Meaning | What to do |
|---|---|---|---|---|
| 400 | invalid_request |
CONNECT_INVALID_REQUEST |
missing or malformed parameter | fix the request |
| 400 | unsupported_grant_type |
CONNECT_UNSUPPORTED_GRANT |
wrong grant_type |
fix the request |
| 401 | invalid_client |
CONNECT_INVALID_CLIENT |
unknown slug, unregistered kid, bad signature, wrong aud, expired or reused assertion |
check key, kid, aud and your clock |
| 400 | invalid_grant |
CONNECT_LAUNCH_INVALID |
malformed launch token, or one issued to another app | ask the doctor to open your app again |
| 400 | invalid_grant |
CONNECT_LAUNCH_EXPIRED |
launch older than 60 s | ask the doctor to open your app again |
| 400 | invalid_grant |
CONNECT_LAUNCH_USED |
launch already exchanged | ask the doctor to open your app again |
| 400 | invalid_grant |
CONNECT_INSTALLATION_INACTIVE |
extension uninstalled or deactivated, or the clinic has not confirmed your current scopes | tell the doctor to check the extension in Medevio |
| 400 | invalid_grant |
CONNECT_USER_INACTIVE |
user deactivated or no longer in the clinic | end your session |
| 400 | invalid_grant |
CONNECT_SESSION_MAX_AGE |
refresh after the session limit | ask the doctor to open your app again |
| 429 | slow_down |
CONNECT_RATE_LIMITED |
over 60 requests a minute for one clinic | back off |
| 503 | temporarily_unavailable |
CONNECT_UNAVAILABLE |
temporary outage | first exchange: retry with a new launch; refresh: retry later |
Security checklist
- Check
issagainst a hard-coded list before sending anything to it. - Exchange and refresh only on your server; the private key and access tokens never reach the browser.
- Take clinic, request, patient and user only from the token response — never from your own URL parameters.
- Use each launch token once and at once; never retry with the same value.
- Redirect to a clean URL after the exchange; never log
launch, the assertion or tokens. - Show the signed-in doctor and clinic in your UI.
- Keep your session server-side (HTTP-only,
Secure,SameSite=Laxcookie) with your own idle timeout and logout. - Keep the private key in a secret manager and rotate it yearly or on any suspicion.
- Send the browser only the user fields your UI shows; the token response can carry the doctor's e-mail.
- If your extension works on the launched request, refuse any other request id:
patient_request.readreaches every request of the clinic. - While you still accept API keys, keep the two apart: a page opened by a launch uses only that launch's token, never a stored key.
Testing
- Create a free test clinic at https://my.medevio.dev/registrace-lekare (it stays invisible to patients).
- Send us its slug with your development config; we install your extension there.
- Create a test patient and request, open the request, click your button.
- Check the edge cases: an expired launch (wait 2 minutes, reload), a reused launch (open the URL twice), a refresh after the token expired, the session limit, uninstalling the extension, a record the doctor typed by hand (not overwritten),
visibleToPatientonly when asked. - Check the failures that must not end the session: a refresh while the token endpoint answers
429or503, or while the network is down, keeps the doctor signed in. And a token whosescopelacks one of your scopes (the clinic stopped granting it) makes your extension do less, not fail.
Migrating from API keys
Apps that asked the doctor to paste a clinic API key (Personal Access Token with the Scribe scope) keep working until you switch. To move to OAuth:
- Register your key pair and scopes (sections 1–2); the extension keeps launching the old way meanwhile.
- Deploy a version that handles both: a request with
launchgoes through the token exchange, anything else through your existing key flow. - Tell us; we switch your catalog entry to
SIGNEDin one step (switching back is the same step in reverse), and clinics that already use you keep their consent. - Remove the "paste your API key" step from your app and ask clinics to deactivate the old keys.
Medevio does not run your app inside an iframe, and work that is not tied to a click (nightly sync, webhooks) is not covered by this flow yet.