Skip to content

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
}
  • 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.

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_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.

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_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.

TypeRaised when
visit.booked.v1The visit is booked
visit.rescheduled.v1It is moved to another slot
visit.cancelled.v1It is cancelled
visit.started.v1The call is observed to begin
visit.completed.v1The clinician completes it
visit.no_show.v1The 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}/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.