{"openapi":"3.1.0","info":{"title":"4handed vision API","description":"One endpoint for dental radiograph models. This page is the whole integration guide; it is served by the API itself, so it always matches\nthe version that is answering. The reference below the guide describes every field.\n\n## The call\n\n`POST /v1/analyze`, multipart form, header `x-api-key: <your key>` (keys are per customer).\n\n| field | value |\n|---|---|\n| `model` | `m3cdt-1` (follows the current third-molar model; recommended) or the exact id from `GET /v1/models` |\n| `image` | the panoramic X-ray: JPEG, PNG, TIFF or WebP, up to 25 MB. One image per call: call once per X-ray file |\n| `teeth` | Universal tooth numbers, comma-separated, e.g. `1,16,17,32,30`. Third molars are graded; other teeth come back under `skipped` |\n| `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` |\n\n```bash\ncurl -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\n```\n\n## How long it takes, and retries\n\n- About 13-16 s for four teeth.\n- The first call after the service has been idle starts a fresh instance and can take 30-40 s.\n- Set the client timeout to 60 s. The call is idempotent: retry once on a timeout, or on an error with `retryable: true`.\n\n```ts\n// Node 20: fetch, FormData and Blob are built in.\nfor (let attempt = 0; attempt < 2; attempt++) {\n  const res = await fetch(\"https://api.4handed.ai/v1/analyze\", { method: \"POST\", headers: { \"x-api-key\": apiKey }, body: form, signal: AbortSignal.timeout(60_000) });\n  const body = await res.json();\n  if (res.ok) return body;                                  // results[], skipped[], request_id, model\n  if (!body.retryable) throw new Error(`${body.error}: ${body.message} (${body.request_id})`);\n}\n```\n\n## The answer\n\nOne result per third molar asked for, in request order:\n\n```json\n{\"request_id\": \"b7c1…\", \"model\": \"m3cdt-v1.3-2026-10-08\", \"image\": {\"status\": \"ok\", \"size\": [2800, 1316]}, \"min_bony_fraction\": 0.02,\n \"results\": [{\"tooth\": 17, \"finding\": \"full_bony\", \"crown_covered_pct\": 98, \"cdt_code\": \"D7240\",\n              \"detail\": \"#17 full bony: 98% of the crown is under bone\", \"reason\": null, \"target\": \"numbered\",\n              \"box\": [1981.0, 628.6, 2159.5, 802.4], \"overlay_png\": \"<base64 PNG>\", \"root_formation\": \"present\",\n              \"position\": {\"winter_class\": \"horizontal\", \"winter_angle_deg\": 88, \"…\": \"see Position\"}}],\n \"skipped\": [{\"tooth\": 30, \"reason\": \"not a third molar\"}]}\n```\n\n`finding` is one of four:\n\n| finding | meaning | `cdt_code` |\n|---|---|---|\n| `full_bony` | 50% or more of the crown is under bone | D7240 |\n| `partial_bony` | more than 2% and under 50% | D7230 |\n| `no_bone_coverage` | no bone over the crown; erupted vs soft tissue is not decided from a film | null |\n| `needs_review` | could not be measured; `detail` says why in one sentence a coordinator can act on | null |\n\n`cdt_code` is a suggestion for billing to confirm. `detail` is written to be shown as-is. `overlay_png` (base64 PNG) is the annotated\ncrop: store it with the case. `target` says how the graded tooth was chosen (`numbered`, or `last_molar` when no tooth carried the number but\nthe 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\n`full_bony` with `crown_covered_pct: null` and `reason: developing_tooth`.\n`request_id` is also the `x-request-id` header on every response: quote it when asking about a result.\n\n## Errors\n\nEvery error is `{\"error\", \"message\", \"request_id\", \"retryable\"}` with the `x-request-id` header.\n\n| HTTP | `error` | what to do |\n|---|---|---|\n| 401 | `unauthorized` | key missing or wrong |\n| 404 | `model_not_found` | the id is not live; use `m3cdt-1` or an id from `GET /v1/models` |\n| 413 | `image_too_large` | over 25 MB |\n| 422 | `unsupported_format` | PDF or HEIC: request a JPEG or PNG export of the panoramic X-ray |\n| 422 | `unreadable` | not an image: request a new export |\n| 422 | `not_panoramic` | a periapical, bitewing or photo: try the next X-ray file, or request the pano |\n| 422 | `bad_teeth` / `bad_request` | fix the request |\n| 429 | `rate_limited` | more than 30 calls a minute on this key: wait and retry |\n| 500 | `internal_error` | retry once; if it persists, send the `request_id` |\n\n## Position\n\nEvery third molar also gets `root_formation` and `position`: where the tooth sits on the X-ray, in the classes surgeons use. Read on\nlower wisdom teeth (#17, #32); on uppers `position` is `not_read` for now.\n\n```json\n\"root_formation\": \"present\",\n\"position\": {\"winter_class\": \"horizontal\", \"winter_angle_deg\": 88, \"pell_gregory_depth\": \"C\", \"pell_gregory_ramus\": \"cant_tell\",\n  \"pederson_index\": 7, \"mandibular_canal\": \"crosses\",\n  \"flag\": \"raised\", \"flag_because\": [\"pell_gregory_c\", \"winter_horizontal\", \"crosses_mandibular_canal\"],\n  \"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.\",\n  \"overlay_png\": \"<base64 PNG>\"}\n```\n\n| field | values |\n|---|---|\n| `root_formation` | `little_or_none` (a developing tooth: graded `full_bony`, and Winter, Pell & Gregory and Pederson are `not_read`), `present`, `not_read` |\n| `winter_class` | `vertical`, `mesioangular`, `horizontal`, `distoangular`: the tooth's long axis against the second molar's |\n| `winter_angle_deg` | degrees between the two long axes, positive = tipped toward the tooth in front; null when the axes could not be drawn |\n| `pell_gregory_depth` | `A`, `B`, `C`: the crown's highest point against the second molar's biting surface and neck |\n| `pell_gregory_ramus` | `I`, `II`, `III`: space between the second molar and the front edge of the ramus, against the crown's width |\n| `pederson_index` | 3-10: Winter + depth + ramus (higher = harder by Pederson's scale) |\n| `mandibular_canal` | `clear`, `near` (within one canal width), `overlaps` (the outlines touch), `crosses` (the tooth cuts the canal outline in two) |\n| `flag` | `raised`, `not_raised`, `not_read` |\n| `summary` | one sentence in surgeons' terms, safe to show as-is |\n| `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 |\n\nEvery 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\nnot exist for this jaw: `pell_gregory_ramus`, `pederson_index` and `mandibular_canal` are null on upper teeth. `pederson_index` is also\nnull when `winter_class` or `pell_gregory_depth` was not read. Treat a value you do not recognise as `cant_tell`.\n`pell_gregory_ramus` is often `cant_tell` on deeply buried teeth; `pederson_index` then counts the ramus as class II.\n\nThese are read automatically from a panoramic X-ray. Surgeons classify by eye and disagree near class boundaries; treat the classes the\nsame way, as a consistent first read, not a measurement.\n\n`flag` is `raised` when the X-ray shows any of these, listed in `flag_because`:\n- `pell_gregory_c`: Pell & Gregory position C, with 95% or more of the crown under bone\n- `winter_horizontal`: Winter horizontal\n- `winter_distoangular`: Winter distoangular, tipped back 25° or more\n- `crosses_mandibular_canal`: the tooth's outline cuts across the mandibular canal's outline\n\nThese are the classes the published scales (Winter, Pell & Gregory, Pederson, WHARFE) score as harder, and the panoramic sign that\nguidelines 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\nsomething could not be read. The flag cannot show whether the tooth touches the nerve; who looks at a flagged tooth is your decision.\n\n## Changes without breaking you\n\n- 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\n  as `cant_tell`. A change to what raises `flag` is listed under What's new.\n- `model` in every response is the exact version that answered. Send `m3cdt-1` to follow the current model. Only the current version is\n  live: a request naming an older exact id gets `404 model_not_found`, so pin an exact id only if you want to be told when it changes.\n\n## What's new\n\n- **v1.3:** `root_formation` and `position` on every third molar (read on lowers): Winter's class, Pell & Gregory depth and ramus\n  class, the Pederson index, the mandibular canal, and a flag for the classes published scales score as harder. Grading unchanged.\n  `target` value `position` renamed `last_molar`.\n- **v1.2 (2026-10-08):** 16-bit greyscale PNG / TIFF exports are read correctly (they came back as \"tooth not found\"). When no tooth\n  carries the referred third molar's number but the film shows a third molar on that side, the last molar there is graded; fewer\n  `needs_review` results.\n- **v1.1 (2026-09-23):** better bone edge behind upper third molars; overlay shows where the bone meets the crown.\n","version":"m3cdt-v1.3-2026-10-08"},"paths":{"/health":{"get":{"summary":"Healthz","operationId":"healthz_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/ready":{"get":{"summary":"Readyz","description":"First call: every model loads with its pinned weights and a synthetic image goes through the pipeline (a failure is a 500, which is the\npoint). Later calls answer from memory: an unauthenticated probe must not cost a full model pass, or anyone could hold every instance busy.","operationId":"readyz_ready_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/models":{"get":{"summary":"Models available to this deployment","operationId":"models_v1_models_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelList"}}}}}}},"/v1/analyze":{"post":{"summary":"Analyze one radiograph with one model","operationId":"analyze_v1_analyze_post","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_analyze_v1_analyze_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeResponse"}}}},"401":{"description":"API key missing or wrong","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Unknown model","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Body over 25 MB","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"unsupported_format (PDF / HEIC) | unreadable | not_panoramic | bad_teeth | bad_request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Over 30 requests per minute on this key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Unexpected error; retry once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"schemas":{"AnalyzeResponse":{"properties":{"request_id":{"type":"string","title":"Request Id","description":"Also sent as the x-request-id header. Quote it when asking about a result"},"model":{"type":"string","title":"Model","description":"The exact model version that answered, e.g. m3cdt-v1.0-2026-09-21 (an alias in the request resolves to this)"},"image":{"$ref":"#/components/schemas/ImageInfo"},"results":{"items":{"$ref":"#/components/schemas/ToothResult"},"type":"array","title":"Results","description":"One entry per third molar in the request, in request order"},"skipped":{"items":{"$ref":"#/components/schemas/Skipped"},"type":"array","title":"Skipped","description":"Teeth in the request that this model does not grade"},"min_bony_fraction":{"type":"number","title":"Min Bony Fraction"}},"type":"object","required":["request_id","model","image","results","skipped","min_bony_fraction"],"title":"AnalyzeResponse"},"Body_analyze_v1_analyze_post":{"properties":{"model":{"type":"string","title":"Model","description":"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","examples":["m3cdt-1"]},"image":{"type":"string","contentMediaType":"application/octet-stream","title":"Image","description":"The panoramic radiograph: JPEG, PNG, TIFF or WebP, up to 25 MB. One image per call"},"teeth":{"type":"string","title":"Teeth","description":"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","examples":["1,16,17,32"]},"min_bony_fraction":{"type":"number","exclusiveMaximum":0.5,"minimum":0.0,"title":"Min Bony Fraction","description":"Crown fraction that must be under bone before a tooth is called partial bony. Default 0.02","default":0.02}},"type":"object","required":["model","image","teeth"],"title":"Body_analyze_v1_analyze_post"},"ErrorResponse":{"properties":{"error":{"type":"string","title":"Error","description":"unauthorized | model_not_found | image_too_large | unsupported_format | unreadable | not_panoramic | bad_teeth | bad_request | rate_limited | internal_error"},"message":{"type":"string","title":"Message","description":"What to do, in one sentence"},"request_id":{"type":"string","title":"Request Id"},"retryable":{"type":"boolean","title":"Retryable","description":"true: the same request may succeed later (rate limit, internal error)"}},"type":"object","required":["error","message","request_id","retryable"],"title":"ErrorResponse"},"ImageInfo":{"properties":{"status":{"type":"string","const":"ok","title":"Status"},"size":{"items":{"type":"integer"},"type":"array","title":"Size","description":"[width, height] in pixels"}},"type":"object","required":["status","size"],"title":"ImageInfo"},"ModelInfo":{"properties":{"id":{"type":"string","title":"Id"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases"},"task":{"type":"string","title":"Task"},"inputs":{"additionalProperties":{"type":"string"},"type":"object","title":"Inputs"}},"type":"object","required":["id","aliases","task","inputs"],"title":"ModelInfo"},"ModelList":{"properties":{"data":{"items":{"$ref":"#/components/schemas/ModelInfo"},"type":"array","title":"Data"}},"type":"object","required":["data"],"title":"ModelList"},"Position":{"properties":{"winter_class":{"type":"string","enum":["vertical","mesioangular","horizontal","distoangular","cant_tell","not_read"],"title":"Winter Class","description":"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":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Winter Angle Deg","description":"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":{"type":"string","enum":["A","B","C","cant_tell","not_read"],"title":"Pell Gregory Depth","description":"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":{"anyOf":[{"type":"string","enum":["I","II","III","cant_tell","not_read"]},{"type":"null"}],"title":"Pell Gregory Ramus","description":"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":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Pederson Index","description":"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":{"anyOf":[{"type":"string","enum":["clear","near","overlaps","crosses","cant_tell","not_read"]},{"type":"null"}],"title":"Mandibular Canal","description":"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":{"type":"string","enum":["raised","not_raised","not_read"],"title":"Flag","description":"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":{"items":{"type":"string","enum":["pell_gregory_c","winter_horizontal","winter_distoangular","crosses_mandibular_canal"]},"type":"array","title":"Flag Because","description":"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":{"type":"string","title":"Summary","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Overlay Png","description":"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"}},"type":"object","required":["winter_class","winter_angle_deg","pell_gregory_depth","pell_gregory_ramus","pederson_index","mandibular_canal","flag","flag_because","summary","overlay_png"],"title":"Position"},"Skipped":{"properties":{"tooth":{"type":"integer","title":"Tooth"},"reason":{"type":"string","title":"Reason","description":"e.g. 'not a third molar'"}},"type":"object","required":["tooth","reason"],"title":"Skipped"},"ToothResult":{"properties":{"tooth":{"type":"integer","title":"Tooth","description":"Universal tooth number, as sent"},"finding":{"type":"string","enum":["full_bony","partial_bony","no_bone_coverage","needs_review"],"title":"Finding","description":"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":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Crown Covered Pct","description":"Percent of the anatomical crown (above the CEJ) inside the bone mask; null when not measured"},"cdt_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cdt Code","description":"D7240 for full_bony, D7230 for partial_bony, otherwise null. A suggestion for billing to confirm"},"detail":{"type":"string","title":"Detail","description":"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":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason","description":"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":{"anyOf":[{"type":"string","enum":["numbered","last_molar"]},{"type":"null"}],"title":"Target","description":"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":{"anyOf":[{"items":{"type":"number"},"type":"array"},{"type":"null"}],"title":"Box","description":"[x0, y0, x1, y1] of the graded tooth in image pixels"},"overlay_png":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Overlay Png","description":"Base64 PNG: the crop with tooth box, crown outline, bone edge and the detail line as caption. Store it on the referral"},"root_formation":{"type":"string","enum":["little_or_none","present","not_read"],"title":"Root Formation","description":"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":{"$ref":"#/components/schemas/Position","description":"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"}},"type":"object","required":["tooth","finding","crown_covered_pct","cdt_code","detail","reason","target","box","overlay_png","root_formation","position"],"title":"ToothResult"}}}}