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

fieldvalue
modelm3cdt-1 (follows the current third-molar model; recommended) or the exact id from GET /v1/models
imagethe panoramic X-ray: JPEG, PNG, TIFF or WebP, up to 25 MB. One image per call: call once per X-ray file
teethUniversal tooth numbers, comma-separated, e.g. 1,16,17,32,30. Third molars are graded; other teeth come back under skipped
min_bony_fractionoptional, 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

// 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:

findingmeaningcdt_code
full_bony50% or more of the crown is under boneD7240
partial_bonymore than 2% and under 50%D7230
no_bone_coverageno bone over the crown; erupted vs soft tissue is not decided from a filmnull
needs_reviewcould not be measured; detail says why in one sentence a coordinator can act onnull

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.

HTTPerrorwhat to do
401unauthorizedkey missing or wrong
404model_not_foundthe id is not live; use m3cdt-1 or an id from GET /v1/models
413image_too_largeover 25 MB
422unsupported_formatPDF or HEIC: request a JPEG or PNG export of the panoramic X-ray
422unreadablenot an image: request a new export
422not_panoramica periapical, bitewing or photo: try the next X-ray file, or request the pano
422bad_teeth / bad_requestfix the request
429rate_limitedmore than 30 calls a minute on this key: wait and retry
500internal_errorretry 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>"}
fieldvalues
root_formationlittle_or_none (a developing tooth: graded full_bony, and Winter, Pell & Gregory and Pederson are not_read), present, not_read
winter_classvertical, mesioangular, horizontal, distoangular: the tooth's long axis against the second molar's
winter_angle_degdegrees between the two long axes, positive = tipped toward the tooth in front; null when the axes could not be drawn
pell_gregory_depthA, B, C: the crown's highest point against the second molar's biting surface and neck
pell_gregory_ramusI, II, III: space between the second molar and the front edge of the ramus, against the crown's width
pederson_index3-10: Winter + depth + ramus (higher = harder by Pederson's scale)
mandibular_canalclear, near (within one canal width), overlaps (the outlines touch), crosses (the tooth cuts the canal outline in two)
flagraised, not_raised, not_read
summaryone sentence in surgeons' terms, safe to show as-is
overlay_pngthe 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:

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

What's new

Endpoints

GET /v1/models

Models available to this deployment

HTTPbodywhen
200ModelListSuccessful Response
POST /v1/analyze

Analyze one radiograph with one model

Request: multipart/form-data

Request fields

fieldtypemeaning
modelstringModel 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
imagefileThe panoramic radiograph: JPEG, PNG, TIFF or WebP, up to 25 MB. One image per call
teethstringUniversal 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 optionalnumberCrown fraction that must be under bone before a tooth is called partial bony. Default 0.02 Default 0.02.
HTTPbodywhen
200AnalyzeResponseSuccessful Response
401ErrorResponseAPI key missing or wrong
404ErrorResponseUnknown model
413ErrorResponseBody over 25 MB
422ErrorResponseunsupported_format (PDF / HEIC) | unreadable | not_panoramic | bad_teeth | bad_request
429ErrorResponseOver 30 requests per minute on this key
500ErrorResponseUnexpected error; retry once

Objects

AnalyzeResponse

fieldtypemeaning
request_idstringAlso sent as the x-request-id header. Quote it when asking about a result
modelstringThe exact model version that answered, e.g. m3cdt-v1.0-2026-09-21 (an alias in the request resolves to this)
imageImageInfo
resultslist of ToothResultOne entry per third molar in the request, in request order
skippedlist of SkippedTeeth in the request that this model does not grade
min_bony_fractionnumber

ErrorResponse

fieldtypemeaning
errorstringunauthorized | model_not_found | image_too_large | unsupported_format | unreadable | not_panoramic | bad_teeth | bad_request | rate_limited | internal_error
messagestringWhat to do, in one sentence
request_idstring
retryablebooleantrue: the same request may succeed later (rate limit, internal error)

ImageInfo

fieldtypemeaning
statusstring
sizelist of integer[width, height] in pixels

ModelInfo

fieldtypemeaning
idstring
aliaseslist of string
taskstring
inputsmap of string

ModelList

fieldtypemeaning
datalist of ModelInfo

Position

fieldtypemeaning
winter_classvertical | mesioangular | horizontal | distoangular | cant_tell | not_readWinter'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_deginteger or nullDegrees 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_depthA | B | C | cant_tell | not_readPell & 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_ramusI | II | III | cant_tell | not_read or nullPell & 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_indexinteger or nullPederson 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_canalclear | near | overlaps | crosses | cant_tell | not_read or nullThe 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
flagraised | not_raised | not_readraised 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_becauselist of pell_gregory_c | winter_horizontal | winter_distoangular | crosses_mandibular_canalWhat 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
summarystringOne 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_pngstring or nullBase64 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

fieldtypemeaning
toothinteger
reasonstringe.g. 'not a third molar'

ToothResult

fieldtypemeaning
toothintegerUniversal tooth number, as sent
findingfull_bony | partial_bony | no_bone_coverage | needs_reviewfull_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_pctinteger or nullPercent of the anatomical crown (above the CEJ) inside the bone mask; null when not measured
cdt_codestring or nullD7240 for full_bony, D7230 for partial_bony, otherwise null. A suggestion for billing to confirm
detailstringOne sentence a coordinator can act on, e.g. '#17 full bony: 98% of the crown is under bone'. Safe to show as-is
reasonstring or nullWhy 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
targetnumbered | last_molar or nullHow 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
boxlist of number or null[x0, y0, x1, y1] of the graded tooth in image pixels
overlay_pngstring or nullBase64 PNG: the crop with tooth box, crown outline, bone edge and the detail line as caption. Store it on the referral
root_formationlittle_or_none | present | not_readlittle_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
positionPositionWhere 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