4handed vision API
Version m3cdt-v1.3-2026-10-08 · Base URL https://api.4handed.ai · Machine-readable spec: openapi.json
One endpoint for dental radiograph models. This page is the whole integration guide; it is served by the API itself, so it always matches the version that is answering. The reference below the guide describes every field.
The call
POST /v1/analyze, multipart form, header x-api-key: <your key> (keys are per customer).
| field | value |
|---|---|
model | m3cdt-1 (follows the current third-molar model; recommended) or the exact id from GET /v1/models |
image | the panoramic X-ray: JPEG, PNG, TIFF or WebP, up to 25 MB. One image per call: call once per X-ray file |
teeth | Universal tooth numbers, comma-separated, e.g. 1,16,17,32,30. Third molars are graded; other teeth come back under skipped |
min_bony_fraction | optional, default 0.02: the share of the crown that must be under bone before a tooth counts as bony; below it, no_bone_coverage |
curl -H "x-api-key: $KEY" -F model=m3cdt-1 -F image=@pano.png -F teeth=1,16,17,32 https://api.4handed.ai/v1/analyze
How long it takes, and retries
- About 13-16 s for four teeth.
- The first call after the service has been idle starts a fresh instance and can take 30-40 s.
- Set the client timeout to 60 s. The call is idempotent: retry once on a timeout, or on an error with
retryable: true.
// Node 20: fetch, FormData and Blob are built in.
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetch("https://api.4handed.ai/v1/analyze", { method: "POST", headers: { "x-api-key": apiKey }, body: form, signal: AbortSignal.timeout(60_000) });
const body = await res.json();
if (res.ok) return body; // results[], skipped[], request_id, model
if (!body.retryable) throw new Error(`${body.error}: ${body.message} (${body.request_id})`);
}
The answer
One result per third molar asked for, in request order:
{"request_id": "b7c1…", "model": "m3cdt-v1.3-2026-10-08", "image": {"status": "ok", "size": [2800, 1316]}, "min_bony_fraction": 0.02,
"results": [{"tooth": 17, "finding": "full_bony", "crown_covered_pct": 98, "cdt_code": "D7240",
"detail": "#17 full bony: 98% of the crown is under bone", "reason": null, "target": "numbered",
"box": [1981.0, 628.6, 2159.5, 802.4], "overlay_png": "<base64 PNG>", "root_formation": "present",
"position": {"winter_class": "horizontal", "winter_angle_deg": 88, "…": "see Position"}}],
"skipped": [{"tooth": 30, "reason": "not a third molar"}]}
finding is one of four:
| finding | meaning | cdt_code |
|---|---|---|
full_bony | 50% or more of the crown is under bone | D7240 |
partial_bony | more than 2% and under 50% | D7230 |
no_bone_coverage | no bone over the crown; erupted vs soft tissue is not decided from a film | null |
needs_review | could not be measured; detail says why in one sentence a coordinator can act on | null |
cdt_code is a suggestion for billing to confirm. detail is written to be shown as-is. overlay_png (base64 PNG) is the annotated crop: store it with the case. target says how the graded tooth was chosen (numbered, or last_molar when no tooth carried the number but the film showed a third molar on that side). needs_review reasons: tooth_not_found, crown_not_outlined, crown_outline_too_small. A tooth that is still forming is graded full_bony with crown_covered_pct: null and reason: developing_tooth. request_id is also the x-request-id header on every response: quote it when asking about a result.
Errors
Every error is {"error", "message", "request_id", "retryable"} with the x-request-id header.
| HTTP | error | what to do |
|---|---|---|
| 401 | unauthorized | key missing or wrong |
| 404 | model_not_found | the id is not live; use m3cdt-1 or an id from GET /v1/models |
| 413 | image_too_large | over 25 MB |
| 422 | unsupported_format | PDF or HEIC: request a JPEG or PNG export of the panoramic X-ray |
| 422 | unreadable | not an image: request a new export |
| 422 | not_panoramic | a periapical, bitewing or photo: try the next X-ray file, or request the pano |
| 422 | bad_teeth / bad_request | fix the request |
| 429 | rate_limited | more than 30 calls a minute on this key: wait and retry |
| 500 | internal_error | retry once; if it persists, send the request_id |
Position
Every third molar also gets root_formation and position: where the tooth sits on the X-ray, in the classes surgeons use. Read on lower wisdom teeth (#17, #32); on uppers position is not_read for now.
"root_formation": "present",
"position": {"winter_class": "horizontal", "winter_angle_deg": 88, "pell_gregory_depth": "C", "pell_gregory_ramus": "cant_tell",
"pederson_index": 7, "mandibular_canal": "crosses",
"flag": "raised", "flag_because": ["pell_gregory_c", "winter_horizontal", "crosses_mandibular_canal"],
"summary": "Horizontal (88°), Pell & Gregory position C, Pederson 7. Its outline crosses the mandibular canal's outline on the X-ray; a panoramic X-ray cannot show whether they touch.",
"overlay_png": "<base64 PNG>"}
| field | values |
|---|---|
root_formation | little_or_none (a developing tooth: graded full_bony, and Winter, Pell & Gregory and Pederson are not_read), present, not_read |
winter_class | vertical, mesioangular, horizontal, distoangular: the tooth's long axis against the second molar's |
winter_angle_deg | degrees between the two long axes, positive = tipped toward the tooth in front; null when the axes could not be drawn |
pell_gregory_depth | A, B, C: the crown's highest point against the second molar's biting surface and neck |
pell_gregory_ramus | I, II, III: space between the second molar and the front edge of the ramus, against the crown's width |
pederson_index | 3-10: Winter + depth + ramus (higher = harder by Pederson's scale) |
mandibular_canal | clear, near (within one canal width), overlaps (the outlines touch), crosses (the tooth cuts the canal outline in two) |
flag | raised, not_raised, not_read |
summary | one sentence in surgeons' terms, safe to show as-is |
overlay_png | the tooth and the tooth in front with both long axes, the neck line, the front edge of the ramus and the mandibular canal |
Every key is always present. A reading is a value, cant_tell (looked, could not tell), not_read (not looked at), or null when it does not exist for this jaw: pell_gregory_ramus, pederson_index and mandibular_canal are null on upper teeth. pederson_index is also null when winter_class or pell_gregory_depth was not read. Treat a value you do not recognise as cant_tell. pell_gregory_ramus is often cant_tell on deeply buried teeth; pederson_index then counts the ramus as class II.
These are read automatically from a panoramic X-ray. Surgeons classify by eye and disagree near class boundaries; treat the classes the same way, as a consistent first read, not a measurement.
flag is raised when the X-ray shows any of these, listed in flag_because:
pell_gregory_c: Pell & Gregory position C, with 95% or more of the crown under bonewinter_horizontal: Winter horizontalwinter_distoangular: Winter distoangular, tipped back 25° or morecrosses_mandibular_canal: the tooth's outline cuts across the mandibular canal's outline
These are the classes the published scales (Winter, Pell & Gregory, Pederson, WHARFE) score as harder, and the panoramic sign that guidelines use to suggest a 3-D scan (CBCT). not_raised: Winter, depth and the canal were all read and none applies. not_read: none applies but something could not be read. The flag cannot show whether the tooth touches the nerve; who looks at a flagged tooth is your decision.
Changes without breaking you
- New fields and values are added; existing ones keep their meaning. Ignore keys you do not use, and treat a value you do not recognise as
cant_tell. A change to what raisesflagis listed under What's new. modelin every response is the exact version that answered. Sendm3cdt-1to follow the current model. Only the current version is live: a request naming an older exact id gets404 model_not_found, so pin an exact id only if you want to be told when it changes.
What's new
- v1.3:
root_formationandpositionon every third molar (read on lowers): Winter's class, Pell & Gregory depth and ramus class, the Pederson index, the mandibular canal, and a flag for the classes published scales score as harder. Grading unchanged.targetvaluepositionrenamedlast_molar. - v1.2 (2026-10-08): 16-bit greyscale PNG / TIFF exports are read correctly (they came back as "tooth not found"). When no tooth carries the referred third molar's number but the film shows a third molar on that side, the last molar there is graded; fewer
needs_reviewresults. - v1.1 (2026-09-23): better bone edge behind upper third molars; overlay shows where the bone meets the crown.
Endpoints
/v1/modelsModels available to this deployment
| HTTP | body | when |
|---|---|---|
200 | ModelList | Successful Response |
/v1/analyzeAnalyze one radiograph with one model
Request: multipart/form-data
Request fields
| field | type | meaning |
|---|---|---|
model | string | Model id or alias from GET /v1/models, e.g. m3cdt-1 (follows the current third-molar model) or a pinned id like m3cdt-v1.0-2026-09-21 |
image | file | The panoramic radiograph: JPEG, PNG, TIFF or WebP, up to 25 MB. One image per call |
teeth | string | Universal tooth numbers, comma-separated, e.g. 1,16,17,32. Any teeth may be sent; the third-molar model grades 1, 16, 17 and 32 and lists the rest under skipped |
min_bony_fraction optional | number | Crown fraction that must be under bone before a tooth is called partial bony. Default 0.02 Default 0.02. |
| HTTP | body | when |
|---|---|---|
200 | AnalyzeResponse | Successful Response |
401 | ErrorResponse | API key missing or wrong |
404 | ErrorResponse | Unknown model |
413 | ErrorResponse | Body over 25 MB |
422 | ErrorResponse | unsupported_format (PDF / HEIC) | unreadable | not_panoramic | bad_teeth | bad_request |
429 | ErrorResponse | Over 30 requests per minute on this key |
500 | ErrorResponse | Unexpected error; retry once |
Objects
AnalyzeResponse
| field | type | meaning |
|---|---|---|
request_id | string | Also sent as the x-request-id header. Quote it when asking about a result |
model | string | The exact model version that answered, e.g. m3cdt-v1.0-2026-09-21 (an alias in the request resolves to this) |
image | ImageInfo | |
results | list of ToothResult | One entry per third molar in the request, in request order |
skipped | list of Skipped | Teeth in the request that this model does not grade |
min_bony_fraction | number |
ErrorResponse
| field | type | meaning |
|---|---|---|
error | string | unauthorized | model_not_found | image_too_large | unsupported_format | unreadable | not_panoramic | bad_teeth | bad_request | rate_limited | internal_error |
message | string | What to do, in one sentence |
request_id | string | |
retryable | boolean | true: the same request may succeed later (rate limit, internal error) |
ImageInfo
| field | type | meaning |
|---|---|---|
status | string | |
size | list of integer | [width, height] in pixels |
ModelInfo
| field | type | meaning |
|---|---|---|
id | string | |
aliases | list of string | |
task | string | |
inputs | map of string |
ModelList
| field | type | meaning |
|---|---|---|
data | list of ModelInfo |
Position
| field | type | meaning |
|---|---|---|
winter_class | vertical | mesioangular | horizontal | distoangular | cant_tell | not_read | Winter's class: the tooth's long axis against the second molar's. cant_tell: looked, could not tell. not_read: not looked at (uppers for now, developing teeth). Treat any value you do not recognise as cant_tell |
winter_angle_deg | integer or null | Degrees between the two teeth's long axes; positive = tipped toward the tooth in front, negative = tipped back. Null when the axes could not be drawn |
pell_gregory_depth | A | B | C | cant_tell | not_read | Pell & Gregory position: the crown's highest point against the second molar. A: at or above its biting surface; B: between the biting surface and the neck; C: below its neck |
pell_gregory_ramus | I | II | III | cant_tell | not_read or null | Pell & Gregory class: space between the second molar and the front edge of the ramus, against the crown's width. Often cant_tell on deeply buried teeth. Null on upper teeth (no ramus) |
pederson_index | integer or null | Pederson index, 3-10: Winter + Pell & Gregory depth + ramus class. An unreadable ramus class is counted as II. Null on upper teeth, and when winter_class or pell_gregory_depth was not read |
mandibular_canal | clear | near | overlaps | crosses | cant_tell | not_read or null | The tooth's outline against the mandibular (inferior alveolar) canal's outline on the X-ray. near: within one canal width; overlaps: the outlines touch; crosses: the tooth cuts the canal outline in two. A panoramic X-ray cannot show whether they touch. Null on upper teeth |
flag | raised | not_raised | not_read | raised when the X-ray shows any of the classes in flag_because. not_raised: winter_class, pell_gregory_depth and mandibular_canal were all read and none applies. not_read: none applies but something could not be read |
flag_because | list of pell_gregory_c | winter_horizontal | winter_distoangular | crosses_mandibular_canal | What raised the flag; empty otherwise. pell_gregory_c also needs 95%+ of the crown under bone; winter_distoangular needs 25 degrees or more. New ids may be added |
summary | string | One sentence in surgeons' terms, e.g. 'Mesioangular (34°), Pell & Gregory class II position B, Pederson 5. Clear of the mandibular canal on the X-ray.' Safe to show as-is |
overlay_png | string or null | Base64 PNG: the tooth and the tooth in front with both long axes (yellow, cyan), the neck line of the tooth in front (orange), the front edge of the ramus (magenta) and the mandibular canal (pink). Null when nothing was read |
Skipped
| field | type | meaning |
|---|---|---|
tooth | integer | |
reason | string | e.g. 'not a third molar' |
ToothResult
| field | type | meaning |
|---|---|---|
tooth | integer | Universal tooth number, as sent |
finding | full_bony | partial_bony | no_bone_coverage | needs_review | full_bony: 50%+ of the crown under bone. partial_bony: more than min_bony_fraction and under 50%. no_bone_coverage: no bone over the crown (erupted vs soft tissue is not decided from a film). needs_review: could not be measured; see reason and detail |
crown_covered_pct | integer or null | Percent of the anatomical crown (above the CEJ) inside the bone mask; null when not measured |
cdt_code | string or null | D7240 for full_bony, D7230 for partial_bony, otherwise null. A suggestion for billing to confirm |
detail | string | One sentence a coordinator can act on, e.g. '#17 full bony: 98% of the crown is under bone'. Safe to show as-is |
reason | string or null | Why a tooth needs review: tooth_not_found | crown_not_outlined | crown_outline_too_small; or developing_tooth when a developing third molar was graded full bony |
target | numbered | last_molar or null | How the graded tooth was chosen: numbered = the detector numbered it as the referred tooth; last_molar = no tooth carried that number, and the film showed a third molar on that side (two teeth numbered as the second molar, three molars, or eight teeth), so the last molar there was graded. Null when no tooth was graded |
box | list of number or null | [x0, y0, x1, y1] of the graded tooth in image pixels |
overlay_png | string or null | Base64 PNG: the crop with tooth box, crown outline, bone edge and the detail line as caption. Store it on the referral |
root_formation | little_or_none | present | not_read | little_or_none: a developing tooth (crown formed, little or no root); it is graded full_bony with reason developing_tooth, and Winter, Pell & Gregory and Pederson are not_read. not_read: the tooth was not found or not outlined |
position | Position | Where the tooth sits on the X-ray, in surgeons' classes. Every key is always present: a value, cant_tell, not_read, or null when it does not exist for this jaw. Read on lower wisdom teeth (#17, #32); not_read on uppers for now |