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, 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.
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_…"
{
"data": [
{ "start": "2026-09-12T16:00:00Z", "capacity": 2 },
{ "start": "2026-09-12T16:15:00Z", "capacity": 1 }
],
"has_more": false
}
stateis the patient's state, because licensure is what decides who can take the call.treatmentis optional and narrows availability to the clinicians who can treat it.fromandtoare both required,tomust be afterfrom, 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.
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"
}'
{
"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_startmust be a startGET /v1/visits/slotsoffered. A time that is not on the grid, or one too soon to book, is refused rather than rounded to the nearest slot.caseis optional: a visit can precede a case, and a first consult has no case to name yet.audio_onlydefaults tofalse.treatmentis 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.
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.
{
"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_atis 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 iscompleted,cancelledorno_showis refused too: only ascheduledorstartedvisit 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.
| 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.
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}/startbegins the call, as the meeting itself would.POST /v1/test/visits/{id}/completecompletes it, which is the event live mode prices.POST /v1/test/visits/{id}/no_showmarks 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.