# Cuvo Integrations API > The public telehealth consult platform. A partner creates patients, records consents, files cases for a licensed clinician to decide, and follows prescriptions and orders through events and webhooks. Test mode runs the whole loop against a sandbox. Every guide from https://developers.cuvo.co/docs, in reading order. --- # Getting started > Mint a test key, make your first call, and drive a whole consult against the sandbox. The Cuvo Integrations API is one REST surface at `https://api.cuvo.co`. Every request carries a secret key as a bearer token, names the organization it acts for, and answers JSON. Start in test mode: a test key reaches a sandbox that holds only the data you invented for it, so you can drive a case from intake to delivery before a real patient exists. ## Four things to do first 1. Create an account at [developers.cuvo.co](https://developers.cuvo.co) and verify your email. 2. Create an integration. It is the thing that holds credentials, grants, webhook endpoints and billing. 3. Mint a test key. It is shown once, starts with `cuvo_sk_test_`, and carries the scopes you tick. 4. Ask a clinic for access. You send its `org_…` id and the scopes you need; the clinic approves the grant. In test mode you are given a sandbox organization straight away. Live mode needs two more things: an accepted business associate agreement, and a `cuvo_sk_live_` key. See [Authentication](/docs/authentication). ## Your first call `GET /v1/organization` tells you which organization the credential acts for, in which mode, with which scopes. It needs `organization:read`. ```sh curl https://api.cuvo.co/v1/organization \ -H "Authorization: Bearer cuvo_sk_test_…" ``` ```json { "id": "org_c8k2q1w9", "name": "Northgate Health", "slug": "northgate", "kind": "DIRECT", "status": "active", "mode": "test", "scopes": ["organization:read", "patients:write", "cases:write", "events:read"] } ``` The `scopes` list is what this credential can actually do here: your key's scopes intersected with what the organization granted. If something is missing from it, the call that needs it answers 403. ## The loop The API is one shape repeated. You create a patient, record the consents they accepted, file a case, and then follow what the clinician and the pharmacy do with it through events. 1. `POST /v1/patients` with a name, date of birth, sex at birth, email, an E.164 phone number and a US address. You get back a `pat_…` id. 2. `POST /v1/patients/{id}/consents` once for `telehealth` and once for `privacy`, each with the version of the document the patient saw. A case filed without both current consents is refused with `consent_required`. 3. `POST /v1/cases` with the patient, at least one requested medication from [`GET /v1/medications`](/docs/reference), the intake answers, and the consent ids. The case arrives at `queued`, or at `received` if you set `hold` so you can attach files first. 4. Read `GET /v1/cases/{id}/events`, or subscribe a webhook endpoint, and watch the case move through `in_review` to `approved` or `declined`. An approval produces prescriptions, and a prescription produces an order. ```sh curl https://api.cuvo.co/v1/cases \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: case-2026-09-08-0001" \ -d '{ "patient": "pat_9f2b7c4a", "consent_ids": ["cons_1a2b3c4d", "cons_5e6f7a8b"], "requested_medications": [ { "medication_id": "semaglutide-0-25mg", "quantity": 4, "refills": 0, "days_supply": 28, "directions": "Inject 0.25 mg subcutaneously once weekly." } ], "answers": [ { "id": "goal", "question": "What are you hoping to achieve?", "answer": "Weight loss", "type": "string" } ] }' ``` ## Moving the sandbox Nothing decides a sandbox case on its own unless you ask it to. `POST /v1/test/cases/{id}/decision` plays the clinician, `/ship` and `/deliver` play the pharmacy, and `PATCH /v1/test/autopilot` sets timers so a demo runs untouched. See [Test mode](/docs/test-mode). ## Where to go next [Authentication](/docs/authentication) for credentials, [Scopes and organizations](/docs/scopes-and-organizations) for what a grant decides, [Cases](/docs/cases) for the resource everything else hangs off, [Visits](/docs/visits) when the consult is a live video call instead, and [Webhooks](/docs/webhooks) for the delivery loop. The full operation list is in the [API reference](/docs/reference). --- # Authentication > API keys and OAuth clients, how each is minted and rotated, and what live mode asks for first. Every request to `https://api.cuvo.co` carries `Authorization: Bearer `. Two kinds of credential open the same door, and they resolve to the same principal: one integration, one mode, one organization, one set of scopes. ## API keys An API key is what an integration uses for its own traffic. Mint it in the developer portal, behind a fresh multi-factor challenge. The plaintext is returned once and stored only as a hash, so a key you did not write down is gone rather than recoverable. ``` cuvo_sk_test_… the sandbox cuvo_sk_live_… production ``` The mode is in the prefix, so you can tell the two apart before anything reads them. A key expires 90 days after it is minted. Rotating one mints a replacement and leaves the old key working for 24 hours, which is the window you have to deploy. Revoking one takes effect at once. Never put a live key in a browser, a mobile binary or a repository. It is a bearer credential for protected health information. ```sh curl https://api.cuvo.co/v1/organization \ -H "Authorization: Bearer cuvo_sk_test_…" ``` ## OAuth clients An OAuth client is what a platform uses when it acts for the organizations that granted it access, and it is how an agent reaches the [MCP server](/docs/mcp). Create one at `/portal//oauth-clients`, under the same guard keys use. You get a `client_id` and a secret shown once. The authorization server is `https://developers.cuvo.co`. Its metadata is published at `https://developers.cuvo.co/.well-known/oauth-authorization-server`. Exchange the client credentials for an access token: ```sh curl https://developers.cuvo.co/api/auth/oauth2/token \ -u "$CUVO_CLIENT_ID:$CUVO_CLIENT_SECRET" \ -d grant_type=client_credentials \ -d 'scope=patients:write cases:write events:read' \ -d resource=https://api.cuvo.co ``` `client_secret_basic`, as above, is the only accepted method. Every client is registered for exactly one token-endpoint auth method, and that method is Basic; the same credentials sent in the form body are refused. **`resource` is required.** It is the RFC 8707 parameter, and it is the only thing the authorization server sets the token's audience from. A token minted without it names no audience, and the API refuses it at the door. Send `resource=https://api.cuvo.co` for the REST API, or `resource=https://mcp.cuvo.co` for the [MCP server](/docs/mcp). The response is a signed JWT that lives for one hour. Present it as you would a key: ```sh curl https://api.cuvo.co/v1/cases \ -H "Authorization: Bearer eyJhbGciOi…" \ -H "Cuvo-Organization: org_c8k2q1w9" ``` Those two are the only resources, and every client is linked to both. A token naming either is accepted at either door, because the two hosts are one deployment. A token naming anything else is refused, and so is a portal session token, which never names a client. Rotating a client secret replaces it on the same `client_id` and takes effect immediately: the old secret stops exchanging at once, while tokens already issued run out their hour. Revoking a client stops issuance and closes the door inside the same request. Each resource server says what it is at `/.well-known/oauth-protected-resource`, and a 401 from the MCP door names that URL in its `WWW-Authenticate` header, so a client that has never seen Cuvo can find its way in. ## The device grant A client on a machine with no browser starts at `POST https://developers.cuvo.co/api/auth/device/code`, form-encoded, carrying `client_id`, `scope` and `resource`. The answer holds a `device_code`, a short `user_code` for the person to confirm, the verification page to confirm it on, and an `interval` in seconds. The client then polls the same token endpoint as every other grant, with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, the `device_code`, its `client_id` and the same `resource`. Name the same resource at both steps: one the person did not authorize is refused as `invalid_target`. Poll no faster than `interval`. Until the person confirms, the token endpoint answers `authorization_pending`; a poll that came too soon answers `slow_down`, and the client adds five seconds to its interval; `expired_token` and `access_denied` mean there is nothing left to wait for. The client must be registered for that grant type, which is checked both when the code is issued and when it is redeemed, so a client registered for `authorization_code` alone is refused at both. This is the flow `cuvo login --device` runs; see [the CLI](/docs/cli). ## Agents and user consent An agent that signs in as a portal member, rather than holding a client secret, picks the integration and the scopes it may use at the consent screen. That token is always a **test mode** principal. Reaching live data needs a live key or a live client, through the MCP server exactly as through the API. ## What live mode asks for A live credential is refused until the integration has accepted the business associate agreement that is currently in force. The refusal is its own code, `baa_required`, because the answer is to sign something in the portal rather than to change the request. Accepting a superseded version does not count: when counsel publishes a new agreement, live access closes until someone signs it. Test mode is untouched. A live credential is also refused while the integration is pending activation, suspended or closed. ## Failed attempts A request with a missing, malformed, expired or revoked credential answers `401 unauthorized`. Failed attempts are rate limited per source address, 20 per minute, so guessing a key is throttled. Successful calls are never limited by that counter; see [Rate limits and idempotency](/docs/rate-limits-and-idempotency). --- # Scopes and organizations > What a credential may do, who it may do it for, and how a clinic grants you access. Two things decide whether a call is allowed: the scopes on the credential, and the scopes the organization granted your integration. The API uses the intersection of the two, and never more. ## The scope vocabulary Scopes are `resource:verb`. There are twenty-one. | Scope | What it opens | | --- | --- | | `organization:read` | `GET /v1/organization`, `GET /v1/organizations` | | `catalog:read` | Medications, pharmacies, states | | `patients:read` | Reading patients, exports, case lists by patient | | `patients:write` | Creating and updating patients, deletion requests | | `files:read` | Reading file metadata | | `files:write` | Reserving, confirming and deleting files | | `consents:read` | Reading a patient's consents | | `consents:write` | Recording a consent | | `cases:read` | Cases, their events, status history and prescriptions | | `cases:write` | Filing, cancelling and releasing a case | | `messages:read` | The message thread on a case | | `messages:write` | Posting to that thread | | `orders:read` | Orders raised from an approved case | | `labs:read` | Lab orders and their released reports, PDF links included | | `visits:read` | Reading visits, their slots and a join link | | `visits:write` | Booking, cancelling and rescheduling a visit | | `webhooks:manage` | Endpoints, deliveries, redelivery | | `events:read` | The event feed | | `realtime:subscribe` | Minting a realtime token | | `billing:read` | Usage and invoices | | `test:manage` | The sandbox controls, test mode only | Every operation in the [reference](/docs/reference) names the one scope it requires. ## The intersection rule A credential carries the scopes you ticked when you minted it. A grant carries the scopes the organization approved. The effective set is what both agree on. A key with `cases:write` acting for an organization that granted only `cases:read` may read cases and nothing more. `GET /v1/organization` returns the effective set, so you can see what this credential can actually do here rather than infer it from a refusal. A call outside the effective set answers `403 forbidden`. ## Naming the organization An integration can hold approved grants to several organizations. The credential is the same; the organization is chosen per request. - One approved grant: it is used automatically and the header is optional. - More than one: send `Cuvo-Organization: org_…`. Without it the request answers `400 invalid_request`, because guessing would be worse than refusing. - Naming an organization you hold no approved grant to answers `403 forbidden`, which is the same answer as naming one that does not exist. - No approved grant at all answers `403 forbidden`. ```sh curl https://api.cuvo.co/v1/organizations \ -H "Authorization: Bearer cuvo_sk_test_…" ``` ```json { "data": [ { "id": "org_c8k2q1w9", "name": "Northgate Health", "slug": "northgate", "kind": "DIRECT", "status": "active", "scopes": ["organization:read", "patients:write", "cases:write"] } ], "has_more": false } ``` `kind` is how the organization reaches Cuvo: `DIRECT`, `RESELLER`, `RESELLER_CLIENT`, or `INTEGRATION` for a headless brand that exists only behind an API. It does not change what you can call. `status` is `active` or `suspended`. ## Asking a clinic for access A grant is requested in the portal, by an integration member with the ADMIN role or above. You need three things: 1. The organization's public `org_…` id, which the clinic gives you. It is not guessable and cannot be looked up, so an id naming no organization is refused with a clear message rather than turned into a directory. 2. The scopes you are asking for. Ask for what you need; a request for everything is a slower conversation. 3. A note saying who you are and what you are building. The person approving it reads this. The request appears as a pending grant. Once it is approved, the organization's name appears on your grant and the credential can act for it. Until then the clinic is a bare id on your side, so the form can never become an id-to-name lookup. None of this blocks you from starting. Every new integration is created with an approved grant on the shared sandbox organization, so the whole loop is reachable before a clinic has heard of you. --- # Test mode > The sandbox, the simulated clinician and pharmacy, and how to drive a whole consult without one. A `cuvo_sk_test_` key reaches a sandbox. It holds only the data you invented for it, it is scoped to your integration, and every row is deleted 30 days after it is written. Do not send real patient information to test mode. Test mode is not a mock. The same routes, the same validation, the same problem bodies, the same events, the same signed webhook deliveries. What differs is that behind the seam there is no chart and no pharmacy, so this API gives you the controls to play both. ## The sandbox controls Everything under `/v1/test` requires the `test:manage` scope and is refused outright to a live credential with `403 forbidden`. There is no way to reach these from production, which is what keeps a simulated approval out of a real chart. | Operation | What it plays | | --- | --- | | `POST /v1/test/cases/{id}/decision` | The clinician approving, declining, or asking a question | | `POST /v1/test/cases/{id}/clinician_message` | The clinician replying on the case thread | | `POST /v1/test/cases/{id}/ship` | The pharmacy shipping, with optional tracking | | `POST /v1/test/cases/{id}/deliver` | The parcel arriving | | `POST /v1/test/cases/{id}/block` | The order stopping, with a reason | | `POST /v1/test/cases/{id}/fail` | The order failing for good | | `POST /v1/test/visits/{id}/start` | The [visit](/docs/visits) call beginning | | `POST /v1/test/visits/{id}/complete` | The clinician completing the visit | | `POST /v1/test/visits/{id}/no_show` | The patient never arriving | | `POST /v1/test/webhooks/trigger` | Any event type, against a fabricated resource | | `PATCH /v1/test/autopilot` | The timers below | | `POST /v1/test/reset` | Wiping the sandbox | ## Deciding a case An approval carries the plan the clinician settled on, which does not have to match what was requested. A line with `dispensable: false` is recorded for the record and will never be filled. ```sh curl https://api.cuvo.co/v1/test/cases/case_7d1e4b2a/decision \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: decide-case-ada-2026-09-08" \ -d '{ "decision": "approve", "plan": [ { "medication_id": "semaglutide-0-25mg", "quantity": 4, "refills": 0, "days_supply": 28, "directions": "Inject 0.25 mg subcutaneously once weekly." } ] }' ``` A decline takes a `reason`, which is written to the case and is safe to show the patient. A `needs_info` decision takes a `target` of `patient` or `integration` and the question being asked, and moves the case to `waiting_on_patient` or `waiting_on_integration`. ## Autopilot Autopilot is off by default. Turn it on and the sandbox approves a case on its own after `approve_after_s` seconds, then ships and delivers its order after `ship_after_s` each. Both are seconds, 0 to 3600, and `null` turns that timer off. A background tick runs every minute, so a timer fires on the next tick after it comes due. ```sh curl -X PATCH https://api.cuvo.co/v1/test/autopilot \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: autopilot-demo-2026-09-08" \ -d '{"approve_after_s": 30, "ship_after_s": 60}' ``` Use it for a demo that runs untouched. Turn it off when you are testing a branch that depends on a case sitting still. Nothing advances a live case on a timer. ## Proving a receiver `POST /v1/test/webhooks/trigger` emits a real event of a type you choose and schedules real, signed deliveries for it, so you can prove your receiver before a case exists. Name an `endpoint` to send to one endpoint only. ## Starting over `POST /v1/test/reset` removes this integration's sandbox rows and tells you how many went. ```json { "reset": true, "resources_deleted": 47 } ``` It does not touch your integration, your credentials or your webhook endpoints. ## What test mode cannot tell you `DELETE /v1/files` exists in test mode and has no live counterpart, so a live delete answers `501 live_not_available`. It is the one operation left that does: a document filed against a chart is part of a clinical record, and removing one is a decision the clinic makes in its own console. The honest answer rather than a permission error, and the [reference](/docs/reference) lists every operation with the modes it serves. Labs have no sandbox. It places no lab orders, so under a test key `GET /v1/lab_orders` answers an empty list and any lab order or result id answers `404 not_found`. Lab events still reach a receiver in test mode through `POST /v1/test/webhooks/trigger`, about a fabricated resource. See [Labs](/docs/labs). A sandbox export is worth calling out the other way. It assembles a real bundle out of the fixtures you made, at the same `/v1/exports/{id}/download` link live mode hands out, so the code that collects it is the code you ship. The one difference is that a sandbox document carries no link to its bytes: the sandbox accepts uploads and serves none back. --- # Cases > Filing an asynchronous consult, the statuses it moves through, and what falls out of a decision. A case is one asynchronous consult: what was requested, what the patient answered, and where a licensed clinician has taken it. It is the centre of the API. Everything else either feeds a case (patients, consents, files) or falls out of one (messages, prescriptions, orders, events). ## Filing one `POST /v1/cases` needs `cases:write` and four things: - `patient`, a `pat_…` id. - `requested_medications`, one to ten lines, each naming a `medication_id` from `GET /v1/medications` with a quantity, refills, days supply and label directions of at most 140 characters. You may name a `pharmacy_id`, add `pharmacy_notes`, or set `no_substitutions`. - `answers`, one to three hundred intake answers. Each carries a stable `id`, the `question` as the patient saw it, the `answer`, and a `type` of `boolean`, `number`, `string` or `choice`. Set `important` or `critical` to raise an answer in the clinician's view. - `consent_ids`, the consents this case is filed under. A case filed without a current telehealth and privacy consent answers `409 consent_required`. `external_id` is your own key for the case, unique within the organization. `metadata` is up to sixteen string keys of your own state. Set `hold: true` to park the case at `received` instead of queueing it, which is what you want when you still have files to attach. `POST /v1/cases/{id}/release_hold` queues it. ## The statuses ``` received -> queued -> in_review -> approved -> declined waiting_on_patient <-> in_review waiting_on_integration <-> in_review ``` `received` is where a held case waits. `waiting_on_patient` and `waiting_on_integration` mean the clinician asked a question and named who should answer it. `cancelled` is yours to cause; `withdrawn` is an approval later voided. Both `approved` and `declined` set `decided_at`, and a decline sets `decline_reason`, which is written for the patient to read. `GET /v1/cases/{id}/status_history` returns every status the case has held, oldest first, each with the status it came from and when it changed. That is an audit you can serve yourself rather than reconstruct from events. ## Cancelling `POST /v1/cases/{id}/cancel` withdraws a case that has not been decided, with an optional reason. A case that has already been decided answers `409 conflict`. ## What an approval produces An approved case produces prescriptions, and a prescription that is dispensable produces an order. Both are read-only over this API, because both are decisions made downstream of you. - `GET /v1/cases/{id}/prescriptions` needs `cases:read`. A line carries `dispensable`. When it is false the clinician recorded the line for the record and no pharmacy will fill it, so do not bill or ship against it. `days_supply` is null on a live prescription, because a clinician's plan item has no such field. - `GET /v1/cases/{id}/orders` and `GET /v1/orders/{id}` need `orders:read`. An order moves through `preparing`, `at_pharmacy`, `shipped`, `delivered`, and carries tracking once the pharmacy sets it. `blocked` always names a reason of `address`, `prescriber`, `payment` or `attestation`, each saying what has to be fixed. `failed` is terminal. ## Talking on a case `GET /v1/cases/{id}/messages` reads the thread and `POST` writes to it. Reading needs `messages:read`, writing needs `messages:write`. You post as `patient` or as `integration`; the clinician's replies arrive on the same thread and are never writable by you. ## Following one Every transition emits an event. Poll `GET /v1/cases/{id}/events` for one case, poll `GET /v1/events` for all of them, or subscribe a webhook endpoint. Every event carries the resource's status after the transition, so a delivery that arrives late is still safe to apply. See [Webhooks](/docs/webhooks) and [Events and realtime](/docs/events-and-realtime). To drive all of this without a clinician, see [Test mode](/docs/test-mode). --- # Patients and consents > Creating the identity a case is filed under, recording what the patient accepted, and honouring a deletion request. A patient is the identity a case is filed under. The clinical chart lives on the other side of the seam and never crosses into this contract, so what you read here is what you sent plus the ids Cuvo minted. ## Creating a patient `POST /v1/patients` needs `patients:write`. ```sh curl https://api.cuvo.co/v1/patients \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: patient-ada-lovelace-1" \ -d '{ "first_name": "Ada", "last_name": "Lovelace", "date_of_birth": "1990-04-14", "sex_at_birth": "female", "email": "ada@example.com", "phone": "+14155550123", "address": { "line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105" }, "external_id": "crm-88213" }' ``` Three fields have a shape worth knowing before you send them. `phone` is E.164, so one format reaches the pharmacy, the clinician and the SMS rail. `state` is a two-letter USPS code, and states are the licensure unit for the formulary, so check `GET /v1/states` and the `states` list on a medication before you build a funnel around one. `sex_at_birth` is `male` or `female`, recorded because prescribing and licensure both depend on it. `external_id` is your own key, unique for your integration within the organization. Look a patient up by it with `GET /v1/patients?external_id=crm-88213` rather than keeping your own mapping. `PATCH /v1/patients/{id}` takes any subset of the same fields. An empty body is refused, so a PATCH always means something. ## Consents Consent is required before a case can be filed, and it is append-only: a new acceptance is a new row, never an edit. Record each one with `POST /v1/patients/{id}/consents`, which needs `consents:write`. ```sh curl https://api.cuvo.co/v1/patients/pat_9f2b7c4a/consents \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: consent-ada-telehealth-1" \ -d '{ "kind": "telehealth", "version": "2026-04-01", "accepted_at": "2026-09-08T14:02:11Z" }' ``` `kind` is `telehealth` or `privacy`, and a case needs a current one of each. `version` is the version of the document the patient actually saw, so the clinician reviewing the case knows what was agreed to. `accepted_at` defaults to the moment the request arrives; send it when you are recording an acceptance that happened earlier in your own flow. `GET /v1/patients/{id}/consents` needs `consents:read` and returns every consent recorded for the patient, which is what you keep for your own records. ## Reading back `GET /v1/patients/{id}` and `GET /v1/patients` need `patients:read`. `GET /v1/patients/{id}/cases` lists every case filed for a patient and needs `cases:read`, since it is case data behind a patient path. ## Exports and deletion `POST /v1/patients/{id}/exports` starts an export of everything held for a patient and returns a job. The job comes back already `completed`, in both modes: a chart is bounded, so there is nothing to wait for. Its `url` is `/v1/exports/{id}/download`, fetched with the same credential and good for 24 hours, which is the life of the document links inside the bundle. Read the job again at `GET /v1/patients/{id}/exports/{job}` or at `GET /v1/exports/{id}`, whichever id you kept. All of them need `patients:read`. The bundle holds the patient, their cases, consents, files, prescriptions and orders in the same shapes the rest of this API publishes, plus a download link per document. Collect it as often as you need inside the window: the link is not one-shot, and a retry after a dropped connection is the case it exists for. After `expires_at` the bundle and those links are deleted together and the download answers `410 gone`; start another export. `POST /v1/patients/{id}/deletion_requests` needs `patients:write` and is a request, not an erasure. Contact data is removed and the chart is restricted, while clinical records are retained for the period the patient's state requires. The response carries a `retention` sentence saying so, because you have to be able to tell the patient what actually happens. --- # Files > Reserving an upload, PUTting the bytes, confirming, and the one id that changes on the way. A file belongs to a patient. The chart files a document against one, so a file reserved without a patient would have nowhere to land, and naming the patient at reservation is what opened live mode for this resource. Files never cross the 1 MB request cap. Uploading one is three steps: reserve, PUT, confirm. ## 1. Reserve `POST /v1/files` needs `files:write` and takes the patient, the kind, the filename, the MIME type and the exact byte length. ```sh curl https://api.cuvo.co/v1/files \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: file-a1c-2026-09-1" \ -d '{ "patient": "pat_9f2b7c4a", "kind": "lab_result", "filename": "a1c-2026-09.pdf", "mime": "application/pdf", "size": 184320 }' ``` `kind` is `photo`, `id_document`, `lab_result` or `other`. The chart has no photo type, so a `photo` filed in live mode reads back as `other`. `mime` is one of `application/pdf`, `image/jpeg`, `image/png` or `image/heic`: live storage signs for exactly one of these, so the sandbox publishes the same list rather than letting a type through that would fail the day it mattered. `size` is at most 25 MB. The response is the only one that carries the upload URL. It is not readable again. `size` comes back as `0` here rather than as the size you declared: nothing has arrived yet, and this service reports the bytes it has counted, never the number you told it to expect. ```json { "id": "file_3c9a71d2", "patient": "pat_9f2b7c4a", "kind": "lab_result", "filename": "a1c-2026-09.pdf", "mime": "application/pdf", "size": 0, "status": "pending", "upload_url": "https://uploads.example.com/…", "upload_headers": { "content-type": "application/pdf" }, "upload_expires_at": "2026-09-08T15:02:11Z", "created_at": "2026-09-08T14:02:11Z" } ``` ## 2. PUT the bytes Send the bytes to `upload_url` with every header in `upload_headers`, and nothing else. The URL is signed for those headers, so a PUT without them is refused by storage rather than by this service. Do not send your API key: the signed URL is the credential. ```sh curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: application/pdf" \ --data-binary @a1c-2026-09.pdf ``` ## 3. Confirm `POST /v1/files/{id}/confirm` makes the file attachable. It repeats the patient, the kind and the filename, because storage addresses the object by all three and this service keeps no copy to look them up in. A filename that differs from the one the upload was signed for addresses nothing, so the sandbox refuses a mismatch exactly as live storage does. ```sh curl https://api.cuvo.co/v1/files/file_3c9a71d2/confirm \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: file-a1c-2026-09-confirm" \ -d '{ "patient": "pat_9f2b7c4a", "kind": "lab_result", "filename": "a1c-2026-09.pdf" }' ``` **The id can change here.** What the reservation answered with identifies the reservation; what the confirmation answers with identifies the filed document. In live mode those are two different things and their ids differ. In the sandbox they are one row and the id happens to be the same. Use the id from the response you last received and both modes behave identically. A reservation is not readable through `GET /v1/files/{id}` once it has been confirmed. ## Reading and deleting `GET /v1/files/{id}` needs `files:read` and returns the metadata, never the bytes. `DELETE /v1/files/{id}` needs `files:write` and removes a file that is not attached to a case. It is test mode only. In live mode it answers `501 live_not_available`, because the internal clinical contract has no delete and a 501 is the honest answer rather than a permission error. ## Attaching to a case `POST /v1/cases` takes up to twenty confirmed `files` in test mode. A live case is refused if that array is not empty: a live file is already filed on the patient's chart, and the clinical contract takes no file ids on a case. Reserve and confirm the file against the patient, and the clinician sees it on the chart. --- # Labs > Lab orders placed through Cuvo's lab network, the reports that come back, corrections, and the short-lived PDF link. A lab order is one panel a patient is tested for through Cuvo's lab network. It was asked for by the clinic, by a clinician reviewing a [case](/docs/cases), or by the patient. A lab result is a report that came back for an order. Both are read-only here: the lab rail owns every transition, and you hear about them as [events](/docs/events-and-realtime). Every operation on this page needs `labs:read`. A clinic decides that scope separately from the rest, because lab values are the most sensitive read the API offers. ## Lab orders `GET /v1/lab_orders` lists the organization's orders, newest first. ```sh curl "https://api.cuvo.co/v1/lab_orders?patient=pat_9f2c1a7b&updated_after=2026-10-01T00:00:00Z" \ -H "Authorization: Bearer cuvo_sk_live_…" ``` ```json { "data": [ { "id": "lbo_3c9e1f20", "patient": "pat_9f2c1a7b", "case": "case_7d1e4b2a", "panel": { "slug": "testosterone", "name": "Testosterone, total and free" }, "status": "at_lab", "collection_method": "at_home_kit", "lab": "quest", "requested_by": "clinician", "cancelled_at": null, "created_at": "2026-10-12T15:02:11Z", "updated_at": "2026-10-14T09:40:03Z" } ], "has_more": false } ``` - `patient`, `status` and `updated_after` filter the list. `updated_after` returns orders changed strictly after that moment, so pass back the newest `updated_at` you have seen. - `limit` and `starting_after` page it. See [Pagination](/docs/pagination). - `GET /v1/lab_orders/{id}` reads one. `status` is one of `awaiting_patient`, `placed`, `collecting`, `at_lab`, `final`, `cancelled` or `failed`. `awaiting_patient` waits for the patient to choose how they are collected; `final` means the report is in or on its way. `patient` is null only while an order a patient placed for themselves is not linked to a chart yet. `case` names the case whose clinician asked for the order, when one did. `collection_method` and `lab` are null until the patient chooses. Nothing here names the lab network's own ids, a price, or the person who placed the order beyond `requested_by`. ## Results `GET /v1/lab_orders/{id}/results` lists an order's reports, highest version first. `GET /v1/lab_results/{id}` reads one. ```json { "id": "lbr_8a41d7c2", "lab_order": "lbo_3c9e1f20", "patient": "pat_9f2c1a7b", "version": 1, "flag": "abnormal", "collected_at": "2026-10-13T08:15:00Z", "reported_at": "2026-10-14T09:38:51Z", "released_at": "2026-10-14T09:40:03Z", "values": [ { "biomarker": "2986-8", "name": "Testosterone, total", "value_numeric": 212, "value_text": "212", "unit": "ng/dL", "reference_range": { "low": 264, "high": 916, "text": "264-916" }, "flag": "abnormal", "observed_at": "2026-10-13T08:15:00Z" } ], "pdf_url": "https://…", "pdf_url_expires_at": "2026-10-14T09:45:03Z", "created_at": "2026-10-14T09:40:03Z" } ``` - `biomarker` is the stable key to trend a marker by: its LOINC code, else the lab's own marker id. - `value_text` is always what the lab printed. `value_numeric` is the same value as a number, when it is one. - `flag` is `normal`, `abnormal`, `critical` or `unknown`. A report's flag is the worst of its values. ### Only released reports A report appears here once it is released, the moment the patient can see it, and never before. A report a clinic filed by hand from an outside lab is part of the clinic's own record and is never served, whether or not it was shared with the patient. ### Corrections When the lab corrects a report, the correction is a new result with its own `lbr_` id and the next `version`, and it fires its own `lab_result.final.v1`. Earlier versions stay readable. The highest version is current. ### The PDF link `pdf_url` is minted for the response that carries it and expires five minutes later, at `pdf_url_expires_at`. Follow it right away and never store it: read the result again for a fresh link. It is null when no PDF is on file. ## Events `lab_order.placed.v1` and `lab_order.status_changed.v1` carry the order's status when the event was written; read the order for the current one. An order is first announced when it is placed, so one still waiting on the patient shows up only in the list. An order a clinician requested during a case review also carries `case`. `lab_result.final.v1` fires once per released version and carries no status. No lab event carries `patient`: read it from the order or the result. See [Events and realtime](/docs/events-and-realtime) for the shape. Lab events reach an integration only when the organization's grant includes `labs:read`. A grant without it hears nothing about lab work, by webhook, poll or realtime, and a grant that gains the scope hears from that point on. ## Test mode The sandbox places no lab orders. Under a test key `GET /v1/lab_orders` answers an empty list, and any lab order or result id answers `404 not_found`. To build a receiver for lab events, `POST /v1/test/webhooks/trigger` sends any of the three types about a fabricated resource. See [Test mode](/docs/test-mode). --- # Visits > Booking a video consult with a licensed clinician, moving or cancelling it, and the short-lived link the patient joins through. A visit is one live video consult between your patient and a licensed clinician. It is the second thing you can ask a clinician for, beside the asynchronous [case](/docs/cases), and the two are related but not nested: a visit names the case it belongs to when there is one, and stands on its own when the call comes first. Reading a visit and minting its join link need `visits:read`. Booking, cancelling and rescheduling need `visits:write`. ## Finding a slot `GET /v1/visits/slots` returns the slot starts a patient in one state can be booked into. ```sh curl "https://api.cuvo.co/v1/visits/slots?state=CA&treatment=weight_loss&from=2026-09-12T00:00:00Z&to=2026-09-15T00:00:00Z" \ -H "Authorization: Bearer cuvo_sk_test_…" ``` ```json { "data": [ { "start": "2026-09-12T16:00:00Z", "capacity": 2 }, { "start": "2026-09-12T16:15:00Z", "capacity": 1 } ], "has_more": false } ``` - `state` is the patient's state, because licensure is what decides who can take the call. - `treatment` is optional and narrows availability to the clinicians who can treat it. - `from` and `to` are both required, `to` must be after `from`, and the window may cover at most fourteen days. Ask for the days you are about to show, not a quarter of a calendar. `capacity` is how many clinicians are free at that start, never which ones. Booking is pooled and provider-blind: who is on shift in a state is the clinical network's business rather than yours. ## Booking `POST /v1/visits` claims one of those slot starts. Like every write, it requires an `Idempotency-Key` header; see [Rate limits and idempotency](/docs/rate-limits-and-idempotency). ```sh curl -X POST https://api.cuvo.co/v1/visits \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Idempotency-Key: booking-88213-attempt-1" \ -H "Content-Type: application/json" \ -d '{ "patient": "pat_9f2c1a7b", "case": "case_7d1e4b2a", "slot_start": "2026-09-12T16:00:00Z", "treatment": "weight_loss" }' ``` ```json { "id": "vis_4b81c0de", "patient": "pat_9f2c1a7b", "case": "case_7d1e4b2a", "status": "scheduled", "scheduled_start": "2026-09-12T16:00:00Z", "scheduled_end": "2026-09-12T16:15:00Z", "audio_only": false, "cancel_reason": null, "started_at": null, "ended_at": null, "created_at": "2026-09-11T09:31:02Z" } ``` - `slot_start` must be a start `GET /v1/visits/slots` offered. A time that is not on the grid, or one too soon to book, is refused rather than rounded to the nearest slot. - `case` is optional: a visit can precede a case, and a first consult has no case to name yet. - `audio_only` defaults to `false`. - `treatment` is carried to the clinician as what the call is about. The slot is claimed, so two callers asking for the same start race and the loser is told the slot is gone with `409 conflict`, and test mode refuses a second booking of a held slot, or a reschedule onto one, the same way. That is why the idempotency key matters here: retry a booking under the same key and you get your first booking back rather than a second appointment. A retry under a NEW key is a new booking attempt, and it will be one if a slot is still free. Nothing about a visit is charged to the patient's card. An integration's visit is billed to the integration on its rate card; see [Billing](/docs/billing). ## The statuses ``` scheduled -> started -> completed -> cancelled -> no_show ``` `scheduled` is where a visit waits. `started` is the call observed to begin, so `started_at` is when someone actually joined rather than when the slot opened. `completed` and `no_show` are the clinician's wrap-up, and `cancelled` is yours or the clinic's to cause. A visit is never read as `rescheduled`. Moving one emits `visit.rescheduled.v1` and leaves it `scheduled`, because the new slot is the same appointment rather than a new state to be in. There is no status for a call that failed technically either: a read here never says something no event says. `GET /v1/visits/{id}` reads one back. ## Moving and cancelling `POST /v1/visits/{id}/reschedule` takes a new `slot_start` and keeps the visit's id, so your own reference to the appointment survives the move. `POST /v1/visits/{id}/cancel` takes an optional `reason`, which is what the clinician and the patient are shown; without one the cancellation is recorded as yours. Both are refused once the visit has left `scheduled`. A call in progress is not one anybody moves, and a finished or already cancelled visit has nothing left to change. ## The join link `GET /v1/visits/{id}/join` mints the URL the patient opens to join the call. ```json { "url": "https://clinic.example.com/visits/join/…", "expires_at": "2026-09-12T16:04:11Z" } ``` What it is: a signed, patient-bound, single-visit link onto the clinic's own storefront, where the call is rendered. It is never a room id and never a meeting token, so a link that leaks is a link that has already expired rather than a way into somebody's consult. Four things follow from that, and all four are the contract rather than advice: - **It lives fifteen minutes.** `expires_at` is fifteen minutes after you asked. - **Fetch it at join time.** It is minted per request, so ask for it at the moment the patient presses the button, and ask again if they come back later. - **Never store it and never email it.** Storing a link that dies in fifteen minutes leaves you serving a dead one; the link is not an appointment record, the `vis_` id is. - **It opens an hour before the visit.** Asked for earlier than sixty minutes before `scheduled_start`, the call is refused. A visit that is `completed`, `cancelled` or `no_show` is refused too: only a `scheduled` or `started` visit has a call to join. ## The events Six, every one of them naming the visit as its resource and carrying the status the visit holds after the change. Ask for `include_resource` and the visit DTO rides along, which is where the patient and the case are. See [Events and realtime](/docs/events-and-realtime). | Type | Raised when | | --- | --- | | `visit.booked.v1` | The visit is booked | | `visit.rescheduled.v1` | It is moved to another slot | | `visit.cancelled.v1` | It is cancelled | | `visit.started.v1` | The call is observed to begin | | `visit.completed.v1` | The clinician completes it | | `visit.no_show.v1` | The patient never arrived | `visit.completed.v1` is the one that is priced. A booking, a move and a cancellation cost no clinical time, and a no-show is unpriced in this release. ## In test mode A `cuvo_sk_test_` key drives the whole flow without a clinician. See [Test mode](/docs/test-mode). Sandbox availability is generated from one fixed weekday template, 09:00 to 17:00 UTC on the quarter hour, with a single simulated clinician, so `capacity` is always `1` and you always find a slot to book. The thirty-minute booking lead is the live one, so a slot inside the next half hour is refused there too. Three routes play the clinician, all needing `test:manage` and all refused to a live credential: - `POST /v1/test/visits/{id}/start` begins the call, as the meeting itself would. - `POST /v1/test/visits/{id}/complete` completes it, which is the event live mode prices. - `POST /v1/test/visits/{id}/no_show` marks the patient as never arrived. Each takes an empty body and emits the same event live mode emits. Nothing in test mode is charged, so a completed sandbox visit writes no charge and shows up in no invoice. The sandbox join link is a placeholder: it opens a page on this developer portal that names the visit and explains itself, rather than a clinic storefront, and it joins no meeting, because a sandbox visit has no room and no clinician. What it is for is the shape of the flow. It expires in fifteen minutes like a live one and is refused in the same two cases a live one is, so your join flow meets those refusals here rather than in production. --- # Webhooks > Registering an endpoint, verifying a signature, and what happens when your receiver is down. A webhook endpoint belongs to one integration and one mode, filters the event types it wants, and signs with its own secret. Everything under `/v1/webhook_endpoints` needs `webhooks:manage`. ## Registering one ```sh curl https://api.cuvo.co/v1/webhook_endpoints \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: endpoint-hooks-example-1" \ -d '{ "url": "https://hooks.example.com/cuvo", "event_types": ["case.approved.v1", "case.declined.v1", "order.shipped.v1"], "include_resource": true, "description": "Production receiver" }' ``` The URL must be `https` and must not carry credentials. It is also checked against private, loopback and link-local address ranges, both when you register it and again immediately before every single attempt, because a hostname that resolved publicly yesterday can resolve to a metadata address today. Redirects are not followed. `event_types` is a filter and must name at least one type. `include_resource` embeds the resource DTO in the delivery as `data`, which saves you a read; leave it off and refetch by id. The response is one of exactly two places the signing secret appears. It starts with `whsec_`, it is sealed at rest, and it is never readable again. The other place is the rotation below. ## What arrives A delivery is a `POST` of the event body with four headers. ``` Cuvo-Signature: t=1757340131,v1=6f8a… Cuvo-Event-Id: evt_2b7c9d10 Cuvo-Event-Type: case.approved.v1 Cuvo-Delivery-Id: dlv_44f1c0a8 ``` ## Verifying the signature The digest is HMAC-SHA256 of `` `${t}.${rawBody}` ``, hex encoded. It covers the timestamp, so a captured delivery cannot be replayed under a fresh one, and it covers the **raw** body, so re-serializing on either side breaks it. Verify before you parse. ```js const [t, ...sigs] = header.split(",").map((part) => part.split("=")[1]); const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; const ok = fresh && sigs.some((sig) => crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))); ``` Both SDKs ship this as `verifySignature`. See [SDKs](/docs/sdks). ## Rotating the secret `POST /v1/webhook_endpoints/{id}/rotate_secret` returns a new secret and keeps the previous one valid for 24 hours. During the grace the header carries two `v1=` values, the new secret first. A receiver that accepts a delivery when **any** `v1=` matches can move to the new secret whenever it likes inside that window, which is the point of the grace. ## Retries and failure Delivery is at-least-once, so **dedupe on `Cuvo-Event-Id`**. A worker that dies after sending and before recording sends again. An attempt succeeds on any 2xx. Anything else, including a timeout at 15 seconds, is a failure and is retried on this ladder: ``` immediately, then +1m, +5m, +30m, +2h, +6h, +12h, +24h, +24h ``` That is up to nine attempts across about 2.9 days, after which the delivery is failed for good. Twenty consecutive failed events in a row disable the endpoint. Nothing is delivered to a disabled endpoint, and the disabling itself emits `webhook_endpoint.disabled.v1`, so subscribe to that on a second endpoint if you want to be told. ## Debugging a receiver `GET /v1/webhook_endpoints/{id}/deliveries` lists every attempt, filterable by `status` of `pending`, `delivered` or `failed`. Each row carries the attempt number, the HTTP status, the latency, `next_attempt_at`, and the first kilobyte of what your endpoint answered, which is usually enough to see the stack trace it returned. To replay one, `POST /v1/events/{id}/redeliver`, optionally naming a single `endpoint`. To prove a brand new receiver before any case exists, `POST /v1/test/webhooks/trigger` emits a real signed delivery of any event type you choose. --- # Events and realtime > The event catalog, polling as the backstop, and a subscribe-only channel for the moments you want live. Every state change writes an event. Events are the record of what happened and the payload every webhook carries, and they are the same rows whether you receive them by webhook, read them back, or watch them arrive on a channel. ## The shape ```json { "id": "evt_2b7c9d10", "type": "case.approved.v1", "created_at": "2026-09-08T14:07:44Z", "organization": "org_c8k2q1w9", "mode": "test", "resource": { "type": "case", "id": "case_7d1e4b2a" }, "status": "approved", "previous_status": "in_review" } ``` Events are thin by design: ids, type, status and timestamps. `status` is the resource's status **after** the transition, so a delivery that arrives out of order is still safe to apply. `data` carries the full resource DTO only when the endpoint has `include_resource` on, or when you asked for it on the query. ## The catalog Each type is versioned. A payload change ships as a new type rather than a silent break. | Group | Types | | --- | --- | | Patient | `patient.created.v1`, `patient.updated.v1`, `patient.deleted.v1` | | Case | `case.received.v1`, `case.queued.v1`, `case.in_review.v1`, `case.waiting_on_patient.v1`, `case.waiting_on_integration.v1`, `case.approved.v1`, `case.declined.v1`, `case.cancelled.v1`, `case.withdrawn.v1` | | Thread | `case.question_asked.v1`, `case.message_created.v1` | | Prescription | `prescription.created.v1`, `prescription.updated.v1` | | Order | `order.created.v1`, `order.at_pharmacy.v1`, `order.shipped.v1`, `order.delivered.v1`, `order.blocked.v1`, `order.failed.v1` | | Visit | `visit.booked.v1`, `visit.rescheduled.v1`, `visit.cancelled.v1`, `visit.started.v1`, `visit.completed.v1`, `visit.no_show.v1` | | Lab | `lab_order.placed.v1`, `lab_order.status_changed.v1`, `lab_result.final.v1` | | Other | `consent.recorded.v1`, `file.attached.v1`, `charge.created.v1`, `webhook_endpoint.disabled.v1` | A visit event's `resource` is `{ "type": "visit", "id": "vis_…" }` and its `status` is the status the visit holds afterwards. `visit.rescheduled.v1` carries `scheduled` on both `status` and `previous_status`, because a move changes the hour rather than the state. See [Visits](/docs/visits). A lab order event's `status` is the order's status when the event was written (`placed`, `collecting`, `at_lab`, `final`, `cancelled`, `failed`, or `awaiting_patient` after a re-order); `GET /v1/lab_orders/{id}` is the current truth. An order is first announced when it is placed: one still waiting on the patient is not, so list `GET /v1/lab_orders?updated_after=` to see those. An order a clinician requested during a case review also carries `case`, so it is listed by `GET /v1/cases/{id}/events`. Lab events never carry `patient`: read it from the order. `lab_result.final.v1` fires once per released version of a report, the moment the patient can see it, and carries no status, case or patient. A correction is a new result with a higher `version` on the same lab order, announced with its own `lab_result.final.v1`, and supersedes the earlier one; `GET /v1/lab_orders/{id}/results` lists them newest first. Read `GET /v1/lab_results/{id}` with `labs:read` for its values, its lab order and a PDF link. Lab events reach an integration only when the organization's grant includes `labs:read`. See [Labs](/docs/labs). ## Polling `GET /v1/events` needs `events:read` and is the backstop for a webhook you missed. It is the one call you should be able to run after an outage and catch up completely. ```sh curl "https://api.cuvo.co/v1/events?since=2026-09-08T00:00:00Z&type=case.approved.v1&limit=100" \ -H "Authorization: Bearer cuvo_sk_test_…" ``` - `since` returns events created strictly after that moment. Keep the `created_at` of the last event you processed and pass it back. - `type` filters to one type. - `include_resource=true` embeds the resource, saving you a read per event. - `limit` and `starting_after` page it. See [Pagination](/docs/pagination). `GET /v1/events/{id}` reads one. `GET /v1/cases/{id}/events` reads a single case's events and needs `cases:read` instead. `POST /v1/events/{id}/redeliver` queues an event for delivery again, to every subscribed endpoint or to one you name. It needs `webhooks:manage`. ## Realtime Realtime is a notification, not a delivery guarantee. Everything on the channel was persisted as an event first, so a client that misses a message loses nothing it cannot poll for. Use it to make a dashboard move, and use webhooks or polling for anything you act on. `POST /v1/realtime/tokens` needs `realtime:subscribe` and takes no inputs: the credential and its organization already name the channel. ```json { "token": "…", "channel": "integration:int_5a1c:org_c8k2q1w9:test", "capability": "subscribe", "mode": "test", "expires_at": "2026-09-08T15:07:44Z" } ``` The token is Ably's, lives one hour, and grants `subscribe` on that one literal channel. Publish is never granted, so nothing on the channel can be invented by a client. Test mode is a separate channel with a `:test` suffix rather than a flag inside the message, so a sandbox event can never reach a listener that only asked for live traffic. Each message is named after the event type and carries the same event body the webhook would. Subscribe with any Ably client, minting a fresh token when the last one expires. --- # Billing > What gets charged, how to read the usage ledger, and how an invoice reconciles to it. Three read-only endpoints, all `billing:read`, all answering in either mode. Usage and the invoice list give a test credential an honest empty answer rather than a refusal, because nothing is charged for a sandbox and nothing is invoiced to one. Reading one invoice by id gives a test credential a `404`, for the same reason one resource at a time: there is no invoice of any id for a sandbox to hold. ## What is billable Charges are raised per event, not per API call. Reading the API is free; a clinician's time is not. Today four kinds are recorded: | Kind | Raised when | | --- | --- | | `case_approved` | A clinician approves a case | | `case_declined` | A clinician declines a case | | `case_cancelled_after_review` | A case is cancelled after review began | | `video_visit` | A [visit](/docs/visits) is completed | A visit is charged when the call happens, which is `visit.completed.v1` and nothing else. Booking one costs no clinical time, moving it costs none either, and a no-show is unpriced in this release: `visit.no_show.v1` raises no charge at all. `kind` is a string rather than an enum on purpose. A new kind arrives with the feature that produces it, and you must not have to upgrade an SDK to keep reading your own usage. Every charge also emits `charge.created.v1`, so you can mirror the ledger as it is written rather than poll for it. ## Usage `GET /v1/usage` aggregates the charge ledger over a window. ```sh curl "https://api.cuvo.co/v1/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z" \ -H "Authorization: Bearer cuvo_sk_live_…" ``` ```json { "period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z", "currency": "usd", "data": [ { "kind": "case_approved", "count": 412, "priced_cents": 823000, "unpriced_count": 0 }, { "kind": "case_declined", "count": 18, "priced_cents": 0, "unpriced_count": 18 } ], "totals": { "count": 430, "priced_cents": 823000, "unpriced_count": 18 } } ``` The window is half open: a charge at or after `period_start` is counted, a charge at `period_end` is not. `from` defaults to the first instant of the current calendar month in UTC, and `to` to the first instant of the next. `unpriced_count` is the number that matters when a total looks too small. A charge with no rate card for its kind is usage that happened and will never reach an invoice. It is counted rather than hidden, so you can see it instead of inferring it. ## Invoices `GET /v1/invoices` lists the invoices raised against your integration, newest first. This is a mirror of what the billing system holds, so `id` is the payment processor's own invoice id and carries no Cuvo prefix. ```json { "data": [ { "id": "in_1QpX…", "number": "C4A2B1-0007", "status": "open", "amount_due_cents": 823000, "amount_paid_cents": 0, "hosted_invoice_url": "https://invoice.stripe.com/…", "period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z", "due_at": "2026-10-15T00:00:00Z", "paid_at": null, "created_at": "2026-10-01T02:14:09Z" } ], "has_more": false } ``` `status` is `draft`, `open`, `paid`, `uncollectible` or `void`. `number` is null on a draft that was never finalized. `hosted_invoice_url` is where the invoice can be read and paid. ## Reconciling an invoice `GET /v1/invoices/{id}` returns the same mirror row plus `charges`: one line per billable event, oldest first. The hosted invoice shows a handful of aggregated lines, which is what the processor was told to bill. This is the ledger underneath it, which is the level an invoice is actually argued at. ```json { "id": "in_1QpX…", "number": "C4A2B1-0007", "status": "open", "amount_due_cents": 823000, "amount_paid_cents": 0, "hosted_invoice_url": "https://invoice.stripe.com/…", "period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z", "due_at": "2026-10-15T00:00:00Z", "paid_at": null, "created_at": "2026-10-01T02:14:09Z", "charges": [ { "id": "chg_9d2f11ab", "kind": "case_approved", "resource_id": "case_7d1e4b2a", "amount_cents": 2000, "currency": "usd", "created_at": "2026-09-08T14:07:44Z", "reversal_of": null } ] } ``` `resource_id` is the same public id the `charge.created.v1` event carries, so a line traces back to the case that caused it without a second lookup. `amount_cents` is null when no rate card priced the kind, and negative on a reversal. A correction is always a new row with `reversal_of` set, never an edit to the row it corrects. An account billed outside the processor, or one on a comped agreement, has no invoices to list here. Its usage still reads normally. --- # Errors > The problem body every failure answers with, the machine codes to switch on, and what each one means you should do. Every failure answers RFC 9457 problem details with `Content-Type: application/problem+json`. ```json { "type": "about:blank", "title": "Consent Required", "status": 409, "code": "consent_required", "detail": "The patient has no current telehealth consent.", "request_id": "iad1::abc123-1757340131234-9f2b7c4a" } ``` Switch on `code`. It is the stable machine string; `detail` is human text that can be reworded at any time, and `title` is fixed per code. `request_id` identifies the one request that failed and is the first thing to quote when you ask for help. ## The codes | `code` | Status | What it means | | --- | --- | --- | | `invalid_request` | 400 | The request is malformed, or a required header is missing | | `unauthorized` | 401 | Missing, malformed, expired or revoked credential | | `forbidden` | 403 | The credential or the grant does not carry the scope, or a test-only route was called with a live credential | | `baa_required` | 403 | The integration has not accepted the agreement currently in force | | `not_found` | 404 | No such resource for this integration and organization | | `conflict` | 409 | The resource is in a state that will not take this | | `consent_required` | 409 | A case was filed without a current telehealth and privacy consent | | `idempotency_conflict` | 409 | The same `Idempotency-Key` was reused with a different request | | `gone` | 410 | The export expired; its bundle and its file links were deleted together | | `payload_too_large` | 413 | The body exceeded 1 MB | | `rate_limited` | 429 | Too many requests; a `Retry-After` header names the wait | | `internal` | 500 | A fault on our side | | `live_not_available` | 501 | This resource has no live path yet | | `unavailable` | 503 | A dependency was unreachable; always safe to retry | ## Validation failures A `400 invalid_request` from schema validation carries `issues`, one per field, each addressed by the dotted path that produced it. ```json { "code": "invalid_request", "detail": "The request body is invalid.", "issues": [ { "path": "phone", "message": "Must be an E.164 phone number." }, { "path": "address.postal_code", "message": "Must be a US postal code." } ] } ``` ## The four worth handling separately **`baa_required`** is not a permission problem. Somebody signs the agreement in the developer portal and the same credential starts working. Do not retry it. **`consent_required`** is answered by going back to the patient, not by changing the request. Record the missing consent and file the case again. **`live_not_available`** means the operation is published and sandboxed but has no live path yet. It is a 501 rather than a 403 so your client does not treat it as something a scope would fix. **`idempotency_conflict`** means you reused a key with a different body. See [Rate limits and idempotency](/docs/rate-limits-and-idempotency). ## What to retry | Answer | Retry | | --- | --- | | 429 | Yes, after `Retry-After` | | 500, 502, 503, 504 | Yes, with backoff | | 400, 401, 403, 404, 409, 413 | No; the same request will fail the same way | Retry a write **only** with the same `Idempotency-Key` you sent the first time. A write with no key must not be retried, because you cannot tell a timeout from a success. Both SDKs implement exactly this ladder. See [SDKs](/docs/sdks). ## Errors that carry no body A request to a host that does not serve the path answers `404` with no problem body. `api.cuvo.co` serves `/v1`, `developers.cuvo.co` serves the portal and these docs, and `mcp.cuvo.co` serves `/mcp`. Calling `/v1` on the docs host is a 404 rather than a quiet cross-serve. --- # Rate limits and idempotency > The per-credential limit, and the header every write requires so a retry never creates a second row. ## Rate limits Limits are per credential, per rolling minute. | Credential | Requests per minute | | --- | --- | | Test | 1200 | | Live | 600 | Exceeding one answers `429 rate_limited` with `Retry-After: 60`. Wait the named number of seconds and try again. Both SDKs honour the header for you. Failed authentication is limited separately, 20 attempts per minute per source address, so guessing a key is throttled without the limit touching a working integration. If your workload needs more, ask before you build around it. Batching a backfill behind a queue is usually the answer, and `starting_after` paging is cheaper than parallel requests. ## Idempotency **Every write requires an `Idempotency-Key` header.** That is every `POST`, `PATCH` and `DELETE` except `POST /v1/realtime/tokens`, which mints a short-lived secret rather than a resource and so has nothing to replay. A write without one answers `400 invalid_request`. The key is yours to choose. It must be 16 to 128 printable ASCII characters and it must be unguessable enough not to collide with another attempt of your own. ```sh curl https://api.cuvo.co/v1/patients \ -H "Authorization: Bearer cuvo_sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: patient-crm-88213-attempt-1" \ -d '{ … }' ``` ### What the key buys you Retry a request that timed out, with the same key and the same body, and you get the first attempt's answer back rather than a second patient, case or message. The replay carries `Idempotent-Replayed: true`. This is the only safe way to retry a write. A write with no key cannot be retried, because a timeout and a success are indistinguishable from your side. ### The rules - **Same key, same request: replay.** The stored status and body are returned verbatim. - **Same key, different request: `409 idempotency_conflict`.** The method and the path are part of the comparison, so one key reused against another route is caught too. - **The key is claimed before validation.** A retry of a request that failed validation replays that same `400` rather than being judged fresh each time. - **A stored answer lives 24 hours.** After that the key is free and the request is treated as new. - **401, 403 and 429 are never stored.** Those are decided before the handler runs, and replaying one would pin you to a credential problem you have since fixed. - **5xx is never stored.** A retry after a fault has to actually retry. - **A concurrent retry does not race.** Whichever request records its answer first wins, and the other replays it. ### Choosing keys Derive the key from the thing you are creating, not from the moment you tried. ``` patient: case::v1 message:: ``` A key like `uuid()` generated inside the retry loop defeats the whole mechanism: every attempt is a new key, so every attempt is a new row. Generate it once, alongside the job, and reuse it for every attempt of that job. Both SDKs mint a key per call and reuse it across their own retries. Pass your own when the retry comes from your queue rather than from theirs, which is the case that matters. ## Body size Request bodies are capped at 1 MB, answered with `413 payload_too_large`. Files never go through a request body; see [Files](/docs/files). ## Being a good client - Page with `starting_after` rather than issuing parallel requests for a range. - Poll `GET /v1/events?since=…` on a sensible interval instead of polling each case. - Prefer a webhook endpoint to polling at all. It costs you nothing against the limit. - Back off on 429 and on 5xx, with jitter, so a fleet of your workers does not retry in lockstep. --- # Pagination > One cursor envelope for every list, why there is no total, and how to page a backfill. Every list in the API answers the same envelope. ```json { "data": [ … ], "has_more": true } ``` Two query parameters drive it. | Parameter | Default | Meaning | | --- | --- | --- | | `limit` | 20 | How many items to return. 1 to 100. | | `starting_after` | none | The id of the last item you saw. | ## Paging forward Read a page, take the id of the **last** item in `data`, and pass it as `starting_after` on the next call. Stop when `has_more` is false. ```sh curl "https://api.cuvo.co/v1/cases?limit=100" \ -H "Authorization: Bearer cuvo_sk_test_…" curl "https://api.cuvo.co/v1/cases?limit=100&starting_after=case_7d1e4b2a" \ -H "Authorization: Bearer cuvo_sk_test_…" ``` ```js let cursor; do { const url = new URL("https://api.cuvo.co/v1/cases"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("starting_after", cursor); const page = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }).then((r) => r.json()); for (const item of page.data) await handle(item); cursor = page.data.at(-1)?.id; var more = page.has_more; } while (more); ``` Both SDKs expose this as an async iterator, so you write a `for await` loop and never touch the cursor yourself. See [SDKs](/docs/sdks). ## Why there is no total Counting a tenant-scoped table on every page is exactly the cost a cursor exists to avoid, and `has_more` is what a client actually needs to decide whether to ask again. If you need a count of something, count what you have written on your own side, or aggregate it from [events](/docs/events-and-realtime). ## Ordering and stability Lists are ordered by id. A cursor is an id, not an offset, so rows created while you are paging never shift the page under you and never cause a duplicate or a skip. An id you did not get from this API is not a valid cursor. ## Filters Filters compose with the cursor; keep them identical across every call in one pass, since changing one mid-run makes the cursor mean something else. | List | Filters | | --- | --- | | `GET /v1/cases` | `status`, `patient`, `created_after` | | `GET /v1/patients` | `external_id` | | `GET /v1/events` | `since`, `type`, `include_resource` | | `GET /v1/webhook_endpoints/{id}/deliveries` | `status` | ## Backfilling For a first import, page `GET /v1/cases` with `limit=100` and no filter, then keep up with `GET /v1/events?since=…` carrying the `created_at` of the last event you processed. That pair is the whole synchronization story: one bounded pass to get level, then an incremental feed to stay level. Do not run pages in parallel. A cursor is sequential by construction, and parallel requests only spend your [rate limit](/docs/rate-limits-and-idempotency) faster than one loop does. --- # Versioning > What we may change without telling you, what we may not, and how long a deprecation runs. The version is in the path. Every operation lives under `/v1`, and `/v1` is a promise about shape, not a release train. ## What can change without notice These are additive, and your client must tolerate them: - **New endpoints.** - **New optional request fields.** Anything required today stays required, and nothing new becomes required. - **New response fields.** Parse leniently. Ignore what you do not recognise rather than failing on it. - **New enum values.** New case statuses, order statuses, order block reasons, event types, problem codes and billing kinds all arrive this way. Handle an unknown value by falling through to a default rather than throwing. This is why `kind` on a billing charge is a string rather than an enum. - **New event types.** A webhook endpoint only receives the types it subscribed to, so a new type reaches you when you ask for it. - **Reworded `detail` and `title` on a problem.** Switch on `code`, never on the prose. ## What will not change inside v1 - A field is never removed or renamed. - A field never changes type, and an optional field never becomes required. - An endpoint is never removed and its path never changes. - The meaning of an existing enum value never changes. - The `code` on a problem never changes what it means. - An identifier prefix never changes. A `case_…` is always a case. ## Event payload versions Event types carry their own version, `case.approved.v1`. A payload change ships as a new type rather than a silent break, so `case.approved.v2` would arrive beside `v1` and you would subscribe to it when you were ready. That is why the suffix exists on every type even though none has needed a second version yet. ## Breaking changes A breaking change only ever ships under a new path version. `/v1` keeps behaving as `/v1`. When an operation or a field is deprecated: 1. It is announced in the [changelog](/docs/changelog) with a date and a replacement. 2. Responses carry a `Deprecation` header naming the date it stops working. 3. It keeps working for **twelve months** from the announcement. Twelve months is a floor, not a target. Nothing in `/v1` is deprecated today. ## Pinning The contract this API validates against is published as an OpenAPI 3.1 document at [`/docs/openapi.yaml`](/docs/openapi.yaml). It is generated from the same schemas the server uses, and a build fails if the two drift, so the document is not a description of the API written alongside it. It is the API. Both SDKs generate their types from that document, so upgrading an SDK is how you pick up an additive change, and neither can compile against a shape the server does not serve. See [SDKs](/docs/sdks). ## Status and incidents Current service state is on the [status page](/docs/status). --- # MCP server > The Model Context Protocol door at mcp.cuvo.co, the tools behind it, and how an agent authenticates. The MCP server is the same API, addressed the way an agent addresses things. It is a thin client of `/v1`: every tool is one published operation, made with your own credential, subject to the same scopes, modes, grants and audit. ``` https://mcp.cuvo.co/mcp ``` Streamable HTTP, stateless, on `GET`, `POST` and `DELETE`. There is no separate SSE endpoint. ## Authenticating The door takes the same bearer credential as the REST API: an API key, or an OAuth access token. See [Authentication](/docs/authentication). ```json { "mcpServers": { "cuvo": { "url": "https://mcp.cuvo.co/mcp", "headers": { "Authorization": "Bearer cuvo_sk_test_…" } } } } ``` An MCP client that has never seen Cuvo can discover the rest. An unauthenticated request answers 401 with `WWW-Authenticate: Bearer resource_metadata="https://mcp.cuvo.co/.well-known/oauth-protected-resource"`, which points at the authorization server. Dynamic client registration is open and unauthenticated, with PKCE required, because an MCP client has no other way in. A registered client holds nothing until a portal member consents to it. **A user-consented token is always a test mode principal.** An agent that signed a person in picks the integration and the scopes at the consent screen and reaches the sandbox. Live data needs a live key or a live client, exactly as it does through the API. **Every credential used here must hold `organization:read`.** That is the scope that names which organization the principal acts for, the door resolves one before any tool runs, and a credential without it cannot open a session at all. Everything beyond that is admitted per tool: each tool's own scope is checked against the credential's effective scopes, and its modes against the credential's mode. ## The tools Forty-six, in three groups. **build**, for writing the integration. `docs_search` searches these guides and `spec_lookup` reads the published contract; both answer from this service, spend no credential and need no scope. The other fourteen are the simulated clinician and pharmacy: `test_approve_case`, `test_decline_case`, `test_request_info`, `test_clinician_message`, `test_ship_order`, `test_deliver_order`, `test_block_order`, `test_fail_order`, `test_start_visit`, `test_complete_visit`, `test_no_show_visit`, `test_trigger_webhook`, `test_set_autopilot`, `test_reset`. All need `test:manage` and are refused to a live credential. **operate**, for running it: `usage_get`, `invoices_list` (`billing:read`), `webhook_endpoints_list`, `webhook_deliveries_list`, `events_redeliver` (`webhooks:manage`), `events_list` (`events:read`). **runtime**, the consult itself: `organization_get`, `catalog_medications`, `catalog_pharmacies`, `catalog_states`, `patients_create`, `patients_list`, `patients_get`, `consents_create`, `consents_list`, `cases_create`, `cases_list`, `cases_get`, `cases_cancel`, `cases_release_hold`, `messages_list`, `messages_send`, `orders_list`, `orders_get`, `visits_slots`, `visits_create`, `visits_get`, `visits_cancel`, `visits_reschedule`, `visits_join_link`. Each carries the scope its operation carries, and all of them work in both modes. `tools/list` returns only the tools your credential's scopes and mode admit, so an agent is never offered something it will be refused. A call outside scope is a JSON-RPC error, and it is audited like any other refusal. Every tool carries MCP annotations, derived from the operation it names rather than written beside it. `readOnlyHint` is true for a tool that only reads, `idempotentHint` is true when calling a tool twice is calling it once, `destructiveHint` is true for a tool that cancels, decides, deletes or redelivers, and `openWorldHint` is false on all of them, because every tool talks to this one API. They are hints, for a client deciding how much confirmation to ask a person for. What a credential may actually do is still its scopes and its mode, checked here and again at the API door. A list tool answers the same envelope the REST list does: `data` plus `has_more`, with `limit` and `starting_after` to page it. See [Pagination](/docs/pagination). ## The resources The guides and the contract are also MCP resources, so a client can attach one to a conversation rather than spend a tool call on it. - `cuvo://docs/` is one guide, as markdown, under the title and summary the developer portal gives it. This page is `cuvo://docs/mcp`. - `cuvo://openapi/v1.yaml` is the published contract, the same bytes [`/docs/openapi.yaml`](/docs/openapi.yaml) serves. `resources/list` returns every one of them to every session. They need no scope of their own: the `organization:read` your credential already holds to open the door is the whole requirement. A read is audited the way a tool call is, with the URI where the tool name goes, and a URI nothing is published at is a JSON-RPC error and an audited refusal. `docs_search` stays. A resource list is a table of contents, and an agent still has to find the paragraph. ## What is deliberately absent No tool mints, rotates or reveals a credential. No tool creates or deletes a webhook endpoint or rotates a signing secret. Those stay in the developer portal behind a fresh multi-factor challenge, because an agent holding a bearer token should not be able to mint a second one. `test_decide_case` is three tools rather than one, because the decision is a discriminated union and a single tool would have meant a nested `decision.decision`. ## Audit Every request through the door writes the same audit row a REST call writes. A tool call writes a second row naming the tool, and a resource read writes one naming the URI. Nothing an agent does is invisible to the integration that owns the credential; the portal's audit log shows both. ## A first session Ask the agent to call `spec_lookup` with no arguments for the operation index, `docs_search` for the guide it needs, then `organization_get` to confirm which organization and mode it is in. From there `patients_create`, `consents_create`, `cases_create` and `test_approve_case` drive a whole consult. The published skill at [`/skills/cuvo-api/SKILL.md`](/skills/cuvo-api/SKILL.md) says the same thing in the form an agent loads directly. --- # SDKs > The TypeScript and Python clients, what each adds over raw fetch, and how their types stay honest. Two official clients. Both generate their types from [`/docs/openapi.yaml`](/docs/openapi.yaml), which is itself generated from the schemas the server validates with, and both fail their build if the generated tree drifts from the published document. If it compiles, it speaks the deployed contract. **Both are published, both are prerelease.** Install them with the commands below. They stay prerelease while the developer product settles, which means `0.1.x` can still move under you: pin the version you tested against, and read the [changelog](/docs/changelog) before you take a new one. ## TypeScript Published as `@cuvo-health-us/api` under the `next` tag: ```sh pnpm add @cuvo-health-us/api@next ``` Node 20 or newer. `verifySignature` uses `node:crypto`; the client runs anywhere there is a `fetch`. ```ts import { createCuvoClient, unwrap } from "@cuvo-health-us/api"; const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY!, // organization: "org_…", // only when the credential holds more than one grant }); const patient = unwrap( await cuvo.POST("/v1/patients", { body: { first_name: "Ada", /* … */ } }), ); ``` `createCuvoClient` takes an API key or an OAuth access token in the same option, since both arrive as a bearer. The exports are small on purpose: | Export | What it does | | --- | --- | | `createCuvoClient` | The typed client | | `unwrap`, `CuvoApiError` | Turn a problem body into a thrown error carrying `code` and `issues` | | `verifySignature`, `parseEvent` | The webhook recipe, constant time, with a timestamp tolerance | | `paginate` | An async iterator over a list, so you never touch a cursor | | `typedEvent`, `expandEvent` | Narrow an event by its `type`, then read the resource it names | | `paths`, `components`, `operations` | The generated contract types | `typedEvent` is the piece worth knowing about. It pairs an event's `type` with the resource type that event can name, so `case.approved.v1` narrows to a `Case` and nothing else, and a mismatch is caught at runtime rather than becoming an undefined field three lines later. ## Python Published as `cuvo`, at version `0.1.0a1`. It is a prerelease, so pip needs to be told to take one: ```sh pip install --pre cuvo ``` Python 3.11 or newer. `httpx` under the hood. ```python import os from cuvo import CuvoClient cuvo = CuvoClient(os.environ["CUVO_API_KEY"]) ``` The same helpers by the same names: `verify_signature`, `parse_event`, `paginate`, `expand_event`, `CuvoApiError`. Every generated endpoint has an `asyncio` twin, and it carries the same guarantees as the sync one: the same headers, the same minted idempotency keys, the same backoff. Await `cuvo.aclose()` when you are done with an async client. ## The command line `@cuvo-health-us/cli` is the same contract as a `cuvo` command, generated from the same document and carried over the TypeScript client, so it inherits the headers, the minted idempotency keys and the retries described below. `cuvo login` signs a person in through the browser and `cuvo login --client-id` signs a server in with its secret. Output is JSON on stdout by default, which is what makes it usable from a script or an agent as well as by hand. See [CLI](/docs/cli). ## What both add over raw fetch Three things, and they are most of why the clients exist. 1. **Headers.** `Authorization`, and `Cuvo-Organization` when you named one. 2. **An `Idempotency-Key` on every write.** Required by the API, minted per call, and reused across the client's own retries. Pass your own when the retry comes from your job queue rather than from theirs. See [Rate limits and idempotency](/docs/rate-limits-and-idempotency). 3. **Retries that are safe.** On 429 and on the server faults a second attempt can clear, with exponential backoff, jitter, and `Retry-After` honoured when the server names a delay. A write with no idempotency key is never retried, because a timeout and a success are indistinguishable. ## Verifying a webhook ```ts import { parseEvent } from "@cuvo-health-us/api"; const raw = await request.text(); const event = parseEvent(raw, request.headers.get("cuvo-signature"), process.env.CUVO_WEBHOOK_SECRET!); ``` It verifies before it parses, accepts any of the signatures in the header so a [rotation](/docs/webhooks) grace works without a code change, and throws rather than returning a half-trusted object. ## No SDK for your language The contract is an OpenAPI 3.1 document, so a generator gets you most of the way. Read [Errors](/docs/errors), [Pagination](/docs/pagination) and [Rate limits and idempotency](/docs/rate-limits-and-idempotency) first: those three are what a generated client will not give you, and they are what makes a client correct rather than merely typed. A [Postman collection](/docs/postman.json) is generated from the same document if you would rather click through it first. --- # CLI > The cuvo command, how it signs you in, and how an operation becomes a command. `@cuvo-health-us/cli` puts the whole v1 contract on the command line. Every command is generated from the same [OpenAPI document](/docs/openapi.yaml) the API validates against, so it cannot offer an operation or a flag the contract does not have. Published under the `next` tag, prerelease while the developer product settles, the same as the [SDKs](/docs/sdks): ```sh pnpm add -g @cuvo-health-us/cli@next ``` Node 20 or newer. ## Signing in ```sh cuvo login ``` This opens your browser, signs you in as a portal member, and keeps the tokens in `~/.config/cuvo/credentials.json` at mode 0600. The CLI registers itself once as a native public client through [dynamic registration](/docs/authentication), holds no secret, and catches the authorization code on a listener bound to `127.0.0.1`. It asks for `offline_access` alongside the scopes its commands need, so it holds a refresh token and you sign in once. A member's token is always a **test mode** principal; reaching live data needs a live client or a live API key. On a machine with no browser of its own, a server over SSH or a container, sign in with the device flow instead: ```sh cuvo login --device ``` The CLI prints a short code and the page to type it into, opens that page here when there is a browser to open, and polls the authorization server until you have confirmed it on whichever machine does have one. It writes the same profile `cuvo login` writes, so nothing downstream knows which door you used. `--no-open` keeps it from opening anything locally. The code you confirm is the only one you ever see: the code the CLI redeems for the token never reaches the screen. For a server, or for live mode, sign in as a confidential client instead: ```sh CUVO_CLIENT_SECRET=… cuvo login --client-id oac_… ``` The secret is never a flag. It comes from `CUVO_CLIENT_SECRET` or a prompt that does not echo, so `ps` and your shell history never see it. `cuvo whoami` says which organization and mode the profile reaches, and `cuvo logout` revokes the refresh token and forgets the profile. `--profile ` keeps several logins side by side, which is how you hold a test member and a live client at once. ## Calling an operation ``` cuvo [arguments] [flags] ``` The group is the first path segment after `/v1/`. The verb is the operation's own name with the group's noun removed, so `GET /v1/cases` is `cuvo cases list` and `POST /v1/cases/{id}/release_hold` is `cuvo cases release-hold`. Path parameters are positional, in the order the path names them; query parameters and flat body fields are flags. ```sh cuvo cases list --status in_review --limit 100 cuvo cases release-hold case_abc cuvo patients create --first-name Ada --last-name Lovelace --body-file address.json cuvo webhook-endpoints create --url https://example.com/hooks --event-types case.approved.v1 cuvo visits list-slots --state CA --from 2026-09-12T00:00:00Z --to 2026-09-15T00:00:00Z ``` Sixty-five commands across seventeen groups, every one of them generated. Seven more are written by hand: `login`, `logout`, `whoami`, `completion`, `init`, `events tail` and `webhooks listen`. `cuvo --help` lists the groups and the scope each one spends, `cuvo --help` its verbs, and `cuvo --help` the flags, the scope, the modes, and the fields only JSON can carry. ### Bodies, arrays and booleans A nested object, an array of objects, or a body that is one of several shapes cannot be written as flags. Pass it whole with `--body-file ` or `--body -` for standard input; flags then override single fields on top of it. An array of scalars is a repeatable flag. A boolean is `--flag` and `--no-flag`, so a field can be turned off as well as on. ```sh echo '{"decision":"approve","note":"looks fine"}' | cuvo test decide-case case_abc --body - cuvo webhook-endpoints update whe_1 --no-include-resource ``` ## Following the feed ```sh cuvo events tail --type order.shipped.v1 ``` polls [`GET /v1/events`](/docs/events-and-realtime) and prints one event per line as JSON until you stop it. Without `--since` it starts from the moment you ran it. ## Listening locally ```sh cuvo webhooks listen --forward-to http://127.0.0.1:4242/webhook ``` subscribes to this integration's [realtime channel](/docs/events-and-realtime) and POSTs every event to that URL with a `Cuvo-Signature` header, signed the way a delivery is signed, so the handler you have already written verifies it with `verifySignature` unchanged. The signing secret is minted for the run and printed once, on stderr, when the listener starts: put that in `CUVO_WEBHOOK_SECRET` while you develop. `--events case.approved.v1` narrows what is forwarded and repeats. One line of JSON per event on stdout, carrying the event id, its type, the status the handler answered and how long it took. `--forward-to` must be https, or http on `127.0.0.1` or `localhost`. **It is a mirror of the event stream, not a delivery.** Nothing is retried, nothing is acknowledged, and no delivery row is written anywhere: a handler that answers 500 gets a line on stderr and the event is gone. The retry ladder, the delivery log and a secret that survives the process belong to a real [webhook endpoint](/docs/webhooks), and that is what a retry path has to be tested against. ## Completions ```sh cuvo completion zsh > ~/.zsh/completions/_cuvo cuvo completion bash > ~/.local/share/bash-completion/completions/cuvo cuvo completion fish > ~/.config/fish/completions/cuvo.fish ``` The script is printed from the command tree this build carries, so it completes groups, verbs and each verb's flags. Nothing is checked in anywhere, which means nothing goes stale: print it again after upgrading the CLI and the operations the contract gained complete too. ## Starting a project ```sh cuvo init my-integration --language typescript ``` writes five files into that directory: a smoke script that creates a patient, records both consents, files a case, plays the clinician approving it through [test mode](/docs/test-mode) and prints the events that came out; a webhook handler that verifies `Cuvo-Signature` before it parses anything; a README; and a `.env.example`. `--language python` writes the same five against the Python SDK, and with neither flag the CLI asks when there is a terminal to ask. It scaffolds and stops: nothing is installed, and the commands to run next are printed on stderr. A directory that already holds something is refused unless you pass `--force`. ## Output JSON on stdout, always, unless `--table` asks for aligned columns. Progress goes to stderr and `--quiet` silences it, so a pipe carries the answer and nothing else. A failure is the same [problem document](/docs/errors) on stderr whether the API refused the request or the CLI never sent it, so an agent switches on `code` and never parses two shapes. | Exit | What happened | | --- | --- | | 0 | The command ran | | 1 | The request was refused, or could not be made | | 2 | The command line did not make sense | The transport underneath is [`@cuvo-health-us/api`](/docs/sdks), so the CLI inherits what the SDK already guarantees: an `Idempotency-Key` minted for every write, retries with backoff on 429 and on the server faults a second attempt can clear, `Cuvo-Organization` when you name an organization, and an access token refreshed a minute before it runs out.