Reference
Slot booking
Book video-call (vKYC) appointments over /v3 — available time intervals for a date, meeting requests, status, reschedule and cancel — drop-in compatible with the meeting-time / meeting-request slot-booking contract.
The slot-booking endpoints let a partner app book a video-call verification appointment for an applicant: list the free time intervals on a date, save a meeting request for one of them, then read, move or cancel it. Bookings land on the same scheduling engine the hosted flow uses, so agents see them under Video Calls → Upcoming bookings, the applicant is reminded when joining opens, and the call runs in the verification session's Video Call step.
The two core calls keep the widely used slot-booking contract — the same
paths, field names, DD/MM/YYYY dates, 24-hour preferredTime,
"hh:mm AM/PM" slot times, the flat [{id, time}] list and the
{message, status, data: {meetingRequestId}} booking response — so an app that
already integrates a backend with this contract switches by changing its base
URL and adding the API key header — even the numeric purposeId it already sends
keeps working once the workflow is given that code (see
Purpose codes).
Info
These endpoints are on the /v3 surface: https://<your-domain>/v3, with the
tenant API key in x-api-key. Every path answers with and without a
trailing slash.
Endpoints
| Endpoint | Purpose |
|---|---|
GET /v3/meeting-purpose | The purposeId values you can book |
POST /v3/meeting-time/get-available | Free time intervals on a date → a bare [{id, time}] array |
POST /v3/meeting-request/save | Book an interval → 201 {message, status: "CREATED", data} |
GET /v3/meeting-request/{meeting_request} | Status of a booking |
POST /v3/meeting-request/{meeting_request}/reschedule | Move a booking to another interval |
POST /v3/meeting-request/{meeting_request}/cancel | Cancel a booking and free its seat |
{meeting_request} is either the booking's booking_id (uuid) or its
meetingRequestId (000000324 — zero padding optional).
How the contract maps onto ThirdFactor
| Contract field | In ThirdFactor |
|---|---|
purposeId | A workflow whose Video Call step has Scheduling switched on (Console → Workflows → Video Call). The workflow decides what the call covers; its availability (Console → Video calls → Availability) decides when agents take calls. Sent as the workflow's 9-digit numeric code (000000001, 000000011, …) — or its UUID. List them with GET /v3/meeting-purpose. |
timeIntervalId | A bookable slot — generated from the agents' weekly availability windows, or published by hand as a one-off. The id is stable: the one you list is the one you book, and it survives an admin adding more availability. As in the contract, an interval is a time of day — sent with another preferredDate, the same interval on that date is booked. |
date, preferredDate, preferredTime | Read in the purpose's schedule timezone (the Availability page's zone, e.g. Asia/Kathmandu), returned as timezone everywhere. |
meetingRequestId | A per-tenant sequence, zero-padded to nine digits (000000001, 000000002, …). |
email, phone, fullname, title, existingCustomer | The applicant, as you send them. Stored on the booking and shown to agents. |
ThirdFactor adds, alongside the contract's keys: booking_id, session_id
and session_url (the verification session hosting the call and the link the
applicant joins from), booking_status, queue_state (where the call stands
for agents), workflow_id, and the slot's starts_at / ends_at as ISO 8601
instants. None of the contract's keys change shape.
Purpose codes
Every workflow of your tenant is numbered once, in creation order, and keeps
its number — that 9-digit code is its purposeId. GET /v3/meeting-purpose
lists the bookable ones; Console → Video calls → Availability → Booking API
purposes shows every workflow's code with a copy button, and an admin with
Manage KYC can renumber a workflow — e.g. to 000000011, the purposeId an
existing integration already hard-codes — so that integration needs no
change. Codes are unique per tenant and never reused.
No session needed — or bring your own
A booking needs no verification session up front. When you send applicant
details without session_id, the booking creates the session that will host
the call:
- It runs the purpose workflow, with the applicant's name, email and phone filled in, and stays open until a day after the booked slot.
- It is charged like
POST /v3/session/— theSESSION_CREATErate (1 credit unless your platform manager set another). A booking the balance can't cover is refused with402and nothing is booked. - The applicant gets a confirmation with the verification link — by email
(with an add-to-calendar link) when you sent one, else by SMS — and a
reminder 10 minutes before the slot, when joining opens. Send
notify: falseif you message the applicant yourself. - Open
session_urlfor the applicant at the booked time (in a web view, or send it on). The hosted flow runs the workflow's steps and puts them in the call; a booked applicant is served ahead of walk-ups once the slot is due. An applicant who opens the link early can join straight away: they wait in the agents' queue — shown as waiting for their booked time — and an agent may take them before it (queue_state: "waiting").
Already created a session with POST /v3/session/? Book its Video Call step by
sending session_id instead — purposeId and the applicant details become
optional, nothing is charged, and the slot must start before that session
expires.
Switching an existing integration
| Before | With ThirdFactor | |
|---|---|---|
| Base URL | your current backend | https://<your-domain>/v3 |
| Authentication | — | x-api-key: <tenant_api_key> on every call |
purposeId | your current purpose code | unchanged — give the workflow that code (Booking API purposes), or use its id from GET /v3/meeting-purpose |
| Request bodies, field names, date and time formats | — | unchanged |
| Responses | — | the same keys, with ThirdFactor's added alongside |
Add an Idempotency-Key header to save while you're there — a fresh value
per booking attempt makes a retried booking safe (see
Idempotency).
Try it in the API Explorer
The API Explorer sends real requests to your tenant — put your
API key in x-api-key, then:
GET /v3/meeting-purpose— copy a purpose'sid(e.g.000000001).POST /v3/meeting-time/get-available— setpurposeIdto that id anddateto a future working day within the next 14 days that your agents have availability on (DD/MM/YYYY). The example's date is only a sample: a past date, or a day nobody works, lists[]. Copy an interval'sidandtime.POST /v3/meeting-request/save— samepurposeId, the date aspreferredDate, the interval'sidastimeIntervalIdand its time in 24-hour form aspreferredTime(01:00 PM→13:00). Leave Idempotency-Key empty, or give every attempt a fresh value — reusing one for a different booking is refused with422 idempotency_key_reused.
List booking purposes
GET /v3/meeting-purpose
x-api-key: <tenant_api_key>[
{
"id": "000000001",
"workflow_id": "b2c1e9f0-4a3d-4c1b-9e2a-8f7d6c5b4a30",
"name": "Video KYC",
"timezone": "Asia/Kathmandu",
"schedule_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"schedule_name": "Branch hours"
}
]Only workflows whose Video Call step allows appointment booking are listed
(archived workflows never are). id is the numeric purposeId to send;
workflow_id (the same purpose's UUID) is accepted too. schedule_id is null when no availability
schedule governs the workflow — then only hand-published one-off slots are
offered.
Get available time intervals
POST /v3/meeting-time/get-available
x-api-key: <tenant_api_key>
Content-Type: application/jsondatestringbodyrequiredThe day to list, DD/MM/YYYY, in the purpose's timezone (ISO YYYY-MM-DD
is also read) — a future working day within the next 14 days.
purposeIdstringbodyrequiredAn id from GET /v3/meeting-purpose: the 9-digit code (000000001;
unpadded digits or a JSON number are read too) or the purpose's
workflow_id. Optional when session_id is sent.
detailsbooleanbodyDefault: falseThirdFactor extension: add time_24h, date, starts_at, ends_at,
seats_left and timezone to every interval.
session_idstringbodyThirdFactor extension: list what this verification session can book. Its own expiry then caps the list.
{
"date": "05/10/2026",
"purposeId": "000000001"
}[
{ "id": "fe65740f-f776-4eec-a607-22c59fb3b1db", "time": "12:30 PM" },
{ "id": "374b53f3-d7ed-464c-ac0f-06e4167f2cf6", "time": "01:00 PM" },
{ "id": "5f4f786c-520f-4668-afd3-64087e80eaad", "time": "01:30 PM" }
][
{
"id": "374b53f3-d7ed-464c-ac0f-06e4167f2cf6",
"time": "01:00 PM",
"time_24h": "13:00",
"date": "05/10/2026",
"starts_at": "2026-10-05T13:00:00+05:45",
"ends_at": "2026-10-05T13:30:00+05:45",
"seats_left": 2,
"timezone": "Asia/Kathmandu"
}
]The response is a bare JSON array of exactly {id, time}, soonest first —
[] when nothing is free, exactly as the contract returns it. Send the chosen
time to save as 24-hour preferredTime (01:00 PM → 13:00); with
details: true, time_24h and date are those values ready-made.
An interval is listed only when it can really be booked:
- Inside the agents' availability — generated from the purpose schedule's weekly windows, or published by hand as a one-off. Switching a schedule off stops its hours being offered.
- Not started yet — past intervals, and intervals already under way today, are never listed.
- With a seat left — capacity comes from the availability window. Several
agents available at the same time appear once, with their seats summed
in
seats_left; booking that id takes whichever of them is free. - Within the booking horizon — slots are offered up to 14 days ahead. A
date in the past or beyond the horizon returns
[].
Availability an admin saves is bookable immediately — listed here, in the hosted flow's picker and on the console's slot list at once, with no refresh.
Book a time interval
POST /v3/meeting-request/save
x-api-key: <tenant_api_key>
Idempotency-Key: <a new UUID per booking attempt — optional>
Content-Type: application/jsonfullnamestringbodyrequiredThe applicant's full name, shown to agents. Optional with session_id, or
when title carries the name (the contract sends it in both).
phonestringbodyThe applicant's mobile number, 7–15 digits (+, spaces and dashes allowed).
A phone or an email is required unless session_id is sent.
emailstringbodyThe applicant's email. The confirmation and reminder go here when given, else
by SMS to phone.
preferredDatestringbodyrequiredThe date to book, DD/MM/YYYY, in the purpose's timezone — the date you listed.
preferredTimestringbodyrequiredThe interval's start time, 24-hour HH:mm (13:00). The list's
"01:00 PM" form is read too.
timeIntervalIdstringbodyrequiredAn id from get-available.
purposeIdstringbodyrequiredThe purpose's 9-digit code (or workflow_id) the interval was listed for.
Optional with session_id; if sent with it, it must be that session's
workflow.
titlestringbodyA label for the booking — in the contract, a copy of fullname. Defaults to
fullname. Optional string fields may also be sent as null.
existingCustomerbooleanbodyDefault: falseWhether the applicant is already your customer — recorded on the booking and the session.
session_idstringbodyThirdFactor extension: book the Video Call step of this existing verification
session instead of creating one. sessionId is accepted too.
vendor_datastringbodyThirdFactor extension: your user id for the session the booking creates —
the same field as on POST /v3/session/.
notifybooleanbodyDefault: trueThirdFactor extension: false sends no confirmation, reschedule notice or
reminder — you message the applicant yourself.
{
"email": "[email protected]",
"existingCustomer": false,
"fullname": "Sita Sharma",
"phone": "9800000001",
"preferredDate": "05/10/2026",
"preferredTime": "13:00",
"purposeId": "000000001",
"timeIntervalId": "374b53f3-d7ed-464c-ac0f-06e4167f2cf6",
"title": "Sita Sharma"
}{
"message": "Saved the Meeting Request data.",
"status": "CREATED",
"data": {
"meetingRequestId": "000000324",
"booking_id": "7f1c2a90-4b6d-4e15-9a02-3d8c1b7e5f44",
"booking_status": "CONFIRMED",
"queue_state": "scheduled",
"applicant_waiting": false,
"applicant_joined_at": null,
"purposeId": "000000001",
"workflow_id": "b2c1e9f0-4a3d-4c1b-9e2a-8f7d6c5b4a30",
"purpose_name": "Video KYC",
"timeIntervalId": "374b53f3-d7ed-464c-ac0f-06e4167f2cf6",
"preferredDate": "05/10/2026",
"preferredTime": "13:00",
"time": "01:00 PM",
"starts_at": "2026-10-05T13:00:00+05:45",
"ends_at": "2026-10-05T13:30:00+05:45",
"timezone": "Asia/Kathmandu",
"session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"session_url": "https://acme.v3.thirdfactor.ai/verify/Qm9va2luZy10b2tlbg",
"applicant": {
"fullname": "Sita Sharma",
"email": "[email protected]",
"phone": "9800000001",
"title": "Sita Sharma",
"existingCustomer": false
},
"video_call": {
"id": "0d9c8b7a-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
"status": "waiting",
"verdict": null
},
"cancelled_at": null,
"cancel_reason": "",
"created_at": "2026-09-24T08:15:02.118000+00:00",
"updated_at": "2026-09-24T08:15:02.301000+00:00"
}
}status names the HTTP status — CREATED here, OK on the other calls; the
booking's own state is data.booking_status. Treat any 2xx as success.
The time must be the interval's. preferredTime is checked against
timeIntervalId in the purpose's timezone; a different time is
422 slot_mismatch, naming the interval's own start time in message and
errors, so a mixed-up pick never books the wrong hour. An interval is a time
of day: the same timeIntervalId with another preferredDate books that
interval on that date (422 slot_mismatch on preferredDate when there is no
such interval then). An id that is no longer offered — availability was edited
since you listed — books the free interval at preferredDate +
preferredTime instead; data.timeIntervalId always names the interval
actually booked.
Seats are claimed atomically. Two requests racing for the last seat can't
both win: the second gets 409 slot_full, and nothing — no booking, no
session, no charge — is left behind. If the id you sent filled up but another
agent is free at the very same time, that seat is booked instead
(data.timeIntervalId tells you which).
Booking status
GET /v3/meeting-request/{meeting_request}
x-api-key: <tenant_api_key>Returns {message: "Fetched the Meeting Request data.", status: "OK", data}
with the same data as save. booking_status follows the call itself, so it
also reflects what happened outside this API — the applicant moving or
cancelling the appointment from their verification link, the agent taking the
call, its outcome:
booking_status | Meaning |
|---|---|
CONFIRMED | Booked; the seat is held. |
RESCHEDULED | Moved to another interval — by you, or by the applicant from their link — and upcoming again. |
IN_PROGRESS | An agent has picked the call up. |
COMPLETED | The agent gave a verdict: video_call.verdict is pass or fail. |
NO_SHOW | No agent picked the call up by 30 minutes after the interval ended, or the call ended without a verdict. |
CANCELLED | Cancelled — by you (cancel_reason is yours) or by the applicant from their link (cancelled_in_hosted_flow). |
CONFIRMED and RESCHEDULED are upcoming; IN_PROGRESS is live;
COMPLETED, NO_SHOW and CANCELLED are final — except that a NO_SHOW
whose call is still waiting can be rescheduled or cancelled.
queue_state says where the call stands for agents meanwhile:
queue_state | Meaning |
|---|---|
scheduled | Booked; the applicant isn't in the agents' queue yet. |
waiting | In the agents' waiting queue — the applicant joined from their link (even before the slot: applicant_waiting: true, applicant_joined_at), or the slot is due. The booking itself stays CONFIRMED / RESCHEDULED. |
in_call | An agent has the call (IN_PROGRESS). |
completed / cancelled / no_show | Final, as booking_status. |
An applicant still waiting in the queue after their slot ended is not a
NO_SHOW — they came; the booking stays upcoming until an agent takes them.
The full verification outcome (documents, liveness, the agent's notes and
recording) is on the session: GET /v3/session/{session_id}/decision/.
Reschedule
POST /v3/meeting-request/{meeting_request}/reschedule
x-api-key: <tenant_api_key>
Idempotency-Key: <unique-per-move>
Content-Type: application/json{
"preferredDate": "05/10/2026",
"preferredTime": "14:30",
"timeIntervalId": "2a99dc6d-cc5f-403d-b97a-0bcf81d79e39"
}Pick the new interval from get-available and send it exactly as for save
(optionally with notify, which then applies from here on). The old seat is
released and the new one taken in one step — a refused move leaves the booking
where it was. booking_status becomes RESCHEDULED and the applicant is told
the new time. A session the booking created stays open until a day after the
new slot.
Refused with 409: booking_not_modifiable when the booking is
IN_PROGRESS, COMPLETED or CANCELLED (the body carries booking_status),
slot_unchanged when it already holds that time, and slot_full /
slot_in_past as for save.
Cancel
POST /v3/meeting-request/{meeting_request}/cancel
x-api-key: <tenant_api_key>
Content-Type: application/json{ "reason": "Customer asked to cancel" }Frees the seat and sets booking_status to CANCELLED; reason (optional,
up to 255 characters) comes back as cancel_reason. If the booking created its
own verification session and the applicant never opened it, that session is
retired too — its link stops working and it expires like any unused session.
A session you created yourself is left alone.
Status reads and cancellations keep working even if Video Call is later
switched off for your tenant, so existing bookings can always be released.
IN_PROGRESS, COMPLETED and already-CANCELLED bookings answer
409 booking_not_modifiable.
Idempotency
Send an Idempotency-Key header (any string up to 128 characters) on save,
reschedule and cancel — a new value for every attempt, e.g. a UUID from
uuidgen or crypto.randomUUID(). It is optional: leave it out (or empty in
the API Explorer) when you don't need retry safety.
| Retry | Result |
|---|---|
| Same key, same body | The original response, verbatim — same status, same body — with Idempotent-Replayed: true. Nothing is booked, moved or charged again. |
| Same key, different body (or another endpoint) | 422 idempotency_key_reused — use a new key for a new operation. The usual cause when a second, different booking is refused right after a successful one. |
Same key after a refusal (409 slot_full, 400, …) | Runs again: only a successful request consumes a key. |
| Two concurrent requests, same key | One books; the other waits for it and replays its response. |
Keys are per tenant and remembered for the life of the booking. A replay
returns the response as it was when first made; read
GET /v3/meeting-request/{meeting_request} for the booking's current state.
Tip
Without a key, a save whose response you never received is not safe to
repeat blindly — it can book a second seat and create a second session. With a
key, retry as often as you like.
Errors
Every error — validation, business rules, authentication, throttling and malformed JSON alike — uses one envelope:
{
"message": "That time interval is fully booked — fetch fresh slots and pick another.",
"status": "CONFLICT",
"detail": "slot_full",
"data": null
}Branch on the HTTP status first, then on detail — always a stable machine
code. status names the HTTP status; message is a sentence you can log or
show an operator. Validation errors add errors, a per-field map of messages;
some refusals add context keys (features, booking_status, booking_id,
meetingRequestId, retry_after).
| HTTP | detail | When |
|---|---|---|
400 | validation_error | A missing field (fullname — or title — without session_id), a date that isn't DD/MM/YYYY, a time that isn't HH:mm, an invalid email or phone, a malformed id. See errors. |
400 | invalid_json | The body isn't valid JSON. |
401 | not_authenticated / authentication_failed | No x-api-key header / an unknown, revoked or suspended key. |
402 | insufficient_credits | The balance can't cover the session the booking creates. |
403 | feature_not_enabled | Video Call is off for your tenant (features: ["VIDEO_CALL"]). |
403 | api_key_tenant_mismatch / unknown_tenant_host | The key was used on another tenant's base URL, or on a host that belongs to no tenant. |
404 | purpose_not_found | purposeId isn't one of your purposes (the message echoes it). |
404 | slot_not_found | timeIntervalId isn't a bookable interval of this purpose, and none starts at preferredDate + preferredTime. |
405 | method_not_allowed | Wrong HTTP method (the booking calls are POSTs). |
404 | session_not_found | session_id isn't one of your sessions. |
404 | booking_not_found | No booking with that booking_id / meetingRequestId. |
409 | slot_full | Every seat at that time is taken. |
409 | slot_in_past | The interval has already started. |
409 | session_already_booked | session_id already holds a booking — reschedule or cancel it (its booking_id / meetingRequestId come back when it was booked here). |
409 | session_closed | The session has finished or expired. |
409 | call_in_progress | An agent is already on the session's call. |
409 | booking_not_modifiable | The booking is in progress, completed or cancelled. |
409 | slot_unchanged | A reschedule to the time the booking already holds. |
409 | idempotency_request_in_progress | A request with the same key is still being processed — retry shortly. |
422 | slot_mismatch | preferredTime isn't the start time of timeIntervalId, or there is no such interval on preferredDate. |
422 | purpose_not_bookable / scheduling_not_enabled | The workflow's Video Call step doesn't allow appointment booking. |
422 | purpose_session_mismatch | purposeId isn't the workflow of session_id. |
422 | slot_after_session_expiry | The interval starts after session_id expires. |
422 | applicant_not_eligible | The applicant is blocklisted (by vendor_data), so no session can be created for them. |
422 | idempotency_key_reused | The key was already used for a different request — send a new key per booking. |
429 | throttled | Rate limited — wait retry_after seconds (also the Retry-After header) and retry. |
End to end
Find your purposeId
GET /v3/meeting-purpose once, and store the id (9-digit code) of the
workflow you book for — or renumber the workflow to the code you already use.
Show the free times
POST /v3/meeting-time/get-available with the date the applicant picks; show
each entry's time.
Book
POST /v3/meeting-request/save with a fresh Idempotency-Key, the
applicant's details, the chosen entry's id, the date and its time in 24-hour
form. Keep meetingRequestId and booking_id.
Send the applicant in at the booked time
Open session_url (or rely on the confirmation and reminder we send). Poll
GET /v3/meeting-request/{booking_id} — or the session's decision — for the
outcome.
curl -X POST "https://<your-domain>/v3/meeting-time/get-available" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"date": "05/10/2026", "purposeId": "000000001"}'
curl -X POST "https://<your-domain>/v3/meeting-request/save" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"email": "[email protected]", "existingCustomer": false,
"fullname": "Sita Sharma", "phone": "9800000001",
"preferredDate": "05/10/2026", "preferredTime": "13:00",
"purposeId": "000000001",
"timeIntervalId": "374b53f3-d7ed-464c-ac0f-06e4167f2cf6",
"title": "Sita Sharma"}'Try every call live in the API Explorer under Slot Booking.