API reference
The base URL for the REST API is https://api.practicl.io. Requests are JSON in, and responses are JSON — or, for the chat endpoint, a Server-Sent Events stream. All paths are under /api/v1, except the /health and /ready checks.
Authentication
Send your key in the X-API-Key header on every request.
X-API-Key: pk_live_3f9a...c21bA key is the string pk_live_ followed by 32 hexadecimal characters. It is shown once, when you create it, and only a short prefix is visible afterwards — so store it when you see it. Keys are created and revoked in the dashboard under API Keys.
This page covers the endpoints a key can call. Uploading documents, managing keys, inviting colleagues and editing settings all require a signed-in dashboard session instead, and are done in the dashboard rather than over the API. See Security model for why the line falls there.
Errors
Every error returns JSON of the shape { "detail": "..." }. Two of them mean something more specific than the status code suggests.
401—Authentication required.No key was sent.403—Invalid or expired API key.The key was not recognised, or it has been revoked or has expired.403—Access denied.Your key is valid; this document is not in its scope. This is the one that looks like a broken credential and is not — the same key will be working everywhere else. Check the key’s scope under API Keys in the dashboard, or use a key scoped to this document.404—Manual(s) not found: [...]No document with that id exists.422— a field is missing or the wrong type. The body is an array naming each one.429— a rate limit or quota has been reached.402— a limit has been reached that waiting will not clear: the monthly usage cap or the document allowance on the plan. The body carrieserror(usage_limit_reachedordocument_limit_reached), a readablemessage, andused/limit. Somebody has to raise the limit.
Health
GET/health
no credential · not under /api/v1
A liveness check. It reports that the process is up and answers without touching the database or Redis, so it stays 200 even while a dependency is down.
GET https://api.practicl.io/health
200 OK
{
"status": "ok",
"service": "api",
"version": "1.0.0"
}GET/ready
no credential · not under /api/v1
A readiness check. This one does reach the database and Redis. It returns 503 when the database is unavailable, and reports degraded with a 200 when Redis is down but the API can still answer. Use /health for liveness and /ready for dependency checks.
Chat
POST/api/v1/chat
API key or dashboard session
The core endpoint. It answers a question from your documents and streams the response as Server-Sent Events. Pass manual_id to search one document; add manual_ids to search several at once; pass session_id to continue an existing conversation. Answers are drawn only from the provided documents and cite the page in sources[].
Headers:
X-API-Key: pk_live_...
Content-Type: application/json
Accept: text/event-streamRequest body:
{
"manual_id": "UUID",
"manual_ids": ["UUID", "..."],
"question": "...",
"session_id": "optional-UUID"
}manual_id is required. manual_ids is optional and searches several documents at once. session_id is optional and continues a conversation.
The response is an SSE stream of JSON events. There are three event shapes:
data: {"type": "text", "text": "The Ocean HR"}
data: {"type": "text", "text": " Series supports"}
data: {
"type": "done",
"session_id": "a1d644c5-8db3-4467-a14c-6f5daf87a873",
"tier": 1,
"sources": [
{
"chunk_id": "ocean-hr-status-leds",
"title": "Ocean HR Status Indicator LEDs",
"manual_id": "8add9714-a68f-41fe-8bab-2716f5e614de",
"page_numbers": [10],
"similarity": 0.3985,
"content_type": "ui_description"
}
],
"model": "claude-haiku-4-5-20251001",
"input_tokens": 4883,
"output_tokens": 58,
"latency_ms": 1351,
"stop_reason": "end_turn"
}
data: {"type": "error", "message": "..."}Every line is a bare data: line — there is no event: field, so parse on the type inside the JSON rather than on an SSE event name.
A text event carries a streamed portion of the answer in text. The done event closes the stream. An error event reports a failure mid-stream and is the last thing you will receive.
Each entry in sources[] carries six fields:
chunk_id— the passage that was cited, stable across requeststitle— the heading that passage sits undermanual_id— which document it came from, which matters when you searched several withmanual_idspage_numbers— the pages of the original PDF, for a “see page 10” linksimilarity— the retrieval score for that passage, between 0 and 1. A lower score is not necessarily a worse citation: a precise answer often comes from a short passage sharing few words with the question.content_type— what kind of passage it is, such asui_descriptionorprocedure
Example request:
curl -N https://api.practicl.io/api/v1/chat \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"manual_id":"5f8c...","question":"What is the wavelength stability of the SL-940?"}'Documents
GET/api/v1/manuals/{manual_id}
API key or dashboard session
Metadata for one document — its title, the product it belongs to, and its processing state. Use it to label a conversation with the document it is about, rather than storing the title on your side and letting the two drift.
GET /api/v1/manuals/{manual_id}
200 OK
{
"id": "8add9714-a68f-41fe-8bab-2716f5e614de",
"title": "Ocean Optics Ocean HR Spectrometer (v3)",
"product_id": "1f54d6ee-70e9-4939-a684-2883c27d1da2",
"processing_status": "completed"
}Returns 403 Access denied if your key does not open this document, and 404 if no such document exists.
GET/api/v1/products/{product_id}/manuals
API key or dashboard session
Every document in a product, filtered to what your key opens. A key scoped to two documents in a ten-document product sees two.
This is what to call when you are building your own document picker: the list you get back is the list that key can actually answer from, so nothing in it can 403 afterwards.
GET /api/v1/products/{product_id}/manuals
200 OK
[
{
"id": "8add9714-a68f-41fe-8bab-2716f5e614de",
"title": "Ocean Optics Ocean HR Spectrometer (v3)",
"productId": "1f54d6ee-70e9-4939-a684-2883c27d1da2",
"processingStatus": "completed",
"signedUrl": "https://<project>.supabase.co/storage/v1/object/sign/...",
"signedUrlExpiresAt": 1789934364
}
]productId and processingStatus here; product_id and processing_status everywhere else. Reading the snake_case names off these rows gives you undefined rather than an error, so it fails quietly. It also returns a bare array rather than an object.In exchange it saves you a call per document: each row already carries a signedUrl and the unix timestamp signedUrlExpiresAt, so you do not need /pdf-url as well unless the URLs have expired.
GET/api/v1/manuals/{manual_id}/pdf-url
API key, dashboard session, or transfer token
A time-limited signed URL to the original PDF. Use it to show the document beside your own chat interface, so a reader can check a citation against the page it came from.
GET /api/v1/manuals/{manual_id}/pdf-url
200 OK
{
"url": "https://<project>.supabase.co/storage/v1/object/sign/manuals/...?token=...",
"expires_in": 3600
}expires_in is seconds from now, not a timestamp — one hour. Note that /products/{id}/manuals expresses the same thing as signedUrlExpiresAt, a unix timestamp. Two representations of expiry on two endpoints that return the same resource.
Request a new URL rather than storing one — a stored URL becomes a broken one, and re-requesting costs a single call.
Manual status
GET/api/v1/manuals/{manual_id}/status
API key or dashboard session
Read the processing state of a document. processing_status is one of pending, processing, completed, needs_review or failed.
GET /api/v1/manuals/{manual_id}/status
200 OK
{
"manual_id": "8add9714-a68f-41fe-8bab-2716f5e614de",
"processing_status": "completed",
"chunk_count": 30,
"validation_score": 0.4,
"error": null,
"title": "Ocean Optics Ocean HR Spectrometer (v3)"
}validation_score is an automated quality estimate between 0 and 1, and a low score on a completed document is not a contradiction: a document that scored below the threshold is held as needs_review until someone approves it, and approving it does not rewrite the score. Treat it as the reading taken at ingestion, not as the current state.
error carries the failure reason when processing_status is failed, and is null otherwise.
What the widget calls
These four are how the embedded widget talks to us. They are documented so you can see what the script on your page does, not as endpoints to build against — if you are writing your own interface, the five above are the ones you want.
GET/api/v1/config
API key or dashboard session
Your organisation’s widget configuration — accent colour, greeting, company name and corner position. The widget calls it on load so it matches your site. The key identifies the organisation; there is no organisation parameter.
GET/api/v1/sessions/{session_id}
API key, dashboard session, or transfer token
Session metadata and the full turn history — each question and answer in the conversation. This is what lets a conversation survive being carried from the widget into the full workspace.
POST/api/v1/events
API key or dashboard session
Records a widget interaction, so you can see how visitors use it.
POST/api/v1/transfer
API key or dashboard session
Mints the short-lived token that carries one conversation from the widget into the full workspace. You never call this yourself — the widget’s hand-off button does, and the token is stripped from the URL once used, so the share key itself is never placed in a URL.
Rate limits
100 requests per minute per IP across the API, plus a per-organisation daily quota and an optional per-key hourly limit you can set when you want one integration capped independently of the rest. Exceeding any of them returns 429.