English | 中文
640111575-610cf448-2a9d-47b1-91eb-9036423831a4_PlatformIOS.mp4
The whole round trip (53 s, no audio) — the doctor starts the scan from the web app, this app
takes over and scans upper arch, lower arch and bite, really sends the case, then steps aside so the
browser is back in front with the arches uploaded. Scan processing and the upload are sped up;
everything else runs at real speed. The same file is in the repo, for reading this offline:
docs/demo-mode.mp4.
A reference implementation and example of the desktop-app side of SprintRay's device-login + scan-upload integration. Use it to understand the flow and to test your integration end to end before building it into your real desktop scanner app.
It ships two front ends over one shared, fully-instrumented flow (src/core/):
- a desktop UI (Electron) —
npm run app— with two skins: a demo mode that waits for a launch, plays a realistic chairside scan of that case, really sends it, and hands the screen back to the browser; and a developer mode that shows the decoded launch payload, a live pipeline of every step, and every HTTP request and its full response on the wire, so a tester can watch the whole data flow. Pressdfive times to switch (see Desktop UI); - a command-line runner —
npm start— same flow, logged to the console.
Both front ends also serve the local HTTP service on 127.0.0.1 — the second way the web app
can reach a desktop scanner, alongside the URL scheme (see
Local HTTP service).
The CLI and its core are zero-dependency (Node.js ≥ 18 built-ins only). Electron is an optional
devDependency, pulled in only for the UI; electron-builder only for packaging.
From your desktop app's point of view, there are five steps — no browser, no re-login, and no token ever travels in the launch URL:
- Launch. From a treatment page, the SprintRay web app opens your app through its custom URL
scheme with a base64-encoded JSON payload —
yourscheme://<base64_json>— carrying a one-time, short-livedcode. (The same payload can instead arrive over the local HTTP service, if your app runs one.) - Decode. Base64-decode the payload and read the
code, the token-endpoint path, and the treatment/case identifiers (see Launch payload). - Exchange. POST the
code+ your client credentials over HTTPS to obtain the signed-in doctor'saccess_token. - Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch
payload's
fileTypeisnull) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming afileTypeuploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically. - Finish. Call the scan-finish endpoint once, and report along with it what the session captured — scan mode, missing teeth, segmented teeth, which arches. SprintRay answers with presigned links you PUT the segmented-tooth and gingiva meshes to. Every metadata field is optional: reporting nothing still closes the session out, exactly as before.
sequenceDiagram
actor Doctor
participant Web as SprintRay Web App
participant App as Your Desktop App
participant BE as SprintRay Backend
participant S3 as S3 (presigned)
Doctor->>Web: click Scan
Web->>BE: request a device-login code
BE-->>Web: code + scanJobId + tokenEndpoint path
Web->>App: open custom URL scheme (code inside, no token)
activate App
App->>App: base64-decode payload, read code + tokenEndpoint
App->>BE: exchange code + client credentials for a token
BE-->>App: access_token + expires_in
loop each scan file, in parallel (full-mouth scan = upper + lower)
App->>BE: request presigned upload URL (scanJobId + externalScanFileType in the body)
BE-->>App: presigned upload URL
App->>S3: PUT raw file bytes
S3-->>App: 200 / 204
end
App->>BE: scan session finished (id + scan metadata)
BE-->>App: 200 + presigned links (segmented teeth, gingiva)
opt reported segmented teeth / arches
App->>S3: PUT tooth_N.ply + gingiva meshes (in parallel)
S3-->>App: 200 / 204
end
BE-->>Web: scan-session status event
deactivate App
Note over Doctor,S3: scans are attached to the treatment
{
"caller": { "name": "SprintRay", "version": "1.0.10.0" },
"case": { "name": "<patient name>", "ID": "<scan-job id>" },
"treatment": {
"teeth": [
{ "teeth": 3, "notes": "", "toothApplianceType": 3, "groupNumber": null }
]
},
"fileType": null,
"language": "en_US",
"serverType": 0,
"toothSystem": "fdi",
"auth": {
"code": "<one-time-code>",
"tokenEndpoint": "/integration/device-login-token",
"expiresIn": 600
},
"treatmentId": "<treatment id>",
"externalCaseId": "<external case id>"
}| Field | Use |
|---|---|
caller | who launched the app (SprintRay + web app version) |
case.name | patient display name |
case.ID | the scan session of this launch. Send it back as scanJobId on every upload and on the scan-finish call |
treatment.teeth[] | selected teeth — teeth (tooth number), notes, toothApplianceType, groupNumber |
fileType | requested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded |
language | UI locale, e.g. en_US |
serverType | server type indicator |
toothSystem | tooth numbering: fdi or utn |
auth.code | one-time device-login code to exchange |
auth.tokenEndpoint | token endpoint path — join onto the backend origin |
auth.expiresIn | code lifetime, seconds |
treatmentId | treatment the uploaded scans attach to |
externalCaseId | optional case reference; null from SprintRay's web app, which sends none. Echo it back on upload when it is there. It is not a session id — two launches can carry the same one — so case.ID is what identifies the session, and the only field to correlate on |
The
auth,treatmentIdandexternalCaseIdfields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.
Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for
your environment:
| Environment | {ORIGIN} |
|---|---|
| development | https://dev-apx.sprintray.com |
| staging | https://staging-apx.sprintray.com |
| production | https://apx.sprintray.com |
SprintRay provides the origin for your target environment.
Every call must carry x-api-key — the gateway API key SprintRay issues for your integration
(a different thing from the client id / client secret: the API key identifies the caller and
selects its usage plan, the client credentials exchange the code for the doctor's token). Without
it the gateway rejects the request with 403 before it reaches the SprintRay backend.
Gateway paths carry no
/apiprefix. Always build the token call from the launch payload'sauth.tokenEndpointinstead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.
POST {ORIGIN}{auth.tokenEndpoint}
x-api-key: <your-api-key>Content-Type: application/json
{ "code": "<code>", "clientId": "<your-client-id>", "clientSecret": "<your-client-secret>" }200 → { "access_token": "…", "token_type": "Bearer", "expires_in": 86400 }
Errors: 400 code missing/expired/already used · 401 bad client credentials · 403 missing or
invalid x-api-key. When the token expires, re-launch to obtain a new one.
POST {ORIGIN}/integration/file/uploadAuthorization: Bearer <access_token>x-api-key: <your-api-key>Content-Type: application/json
{ "fileName": "upper.stl", "fileSize": 3083734, "treatmentId": "<treatment-id>",
"scanJobId": "<case.ID from the launch payload>",
"treatmentFileType": 1, "arch": 1, "externalScanFileType": "UpperArch",
"externalCaseId": "<external-case-id>" }200 → a presigned upload URL (a JSON string, or { "url": "…" })
PUT<presignedUrl>Content-Type: application/octet-streamContent-Length: <fileSize><raw file bytes>200/204 on success. No auth header on the PUT — the presigned URL is self-authorizing.
scanJobId: the launch payload'scase.ID. It names the scan session this file belongs to. Send it on every upload — it is what lets SprintRay track the session's progress, and it is the only way a launch that carries no treatment gets its uploads recorded at all.treatmentIdkeeps its own job of binding the file to the treatment; the two coexist.externalScanFileType: required on every upload. Your own name for what this file is —UpperArch,LowerJaw,BiteScan, whatever your app already calls it; you do not have to adopt SprintRay's numbering. A name SprintRay has not seen before is registered against your integration on first sight, and a SprintRay admin maps it once to the matching SprintRay file type and/or indication — from then on that mapping is what decides the type of every file uploaded under the name, ahead of anytreatmentFileTypeyou send. Until a name is mapped the file is still stored and still recorded against the session, it simply carries no SprintRay file type, so hand over the list of names your app uses during onboarding rather than letting the first upload introduce them. Casing is not significant when matching, but the first spelling SprintRay sees is the one it stores — spell it the same way every time. (The field is not enforced with a400— an upload without it succeeds, so that clients written before this contract keep working. It is required of your integration: without it nothing can map the file's type.)treatmentFileType:1= upper jaw,2= lower jaw. Optional, and a fallback: when yourexternalScanFileTypeis mapped to a SprintRay file type, that mapping decides the file's type and this value is not used. It answers for the case the mapping cannot — a name that is registered but not mapped to a file type yet — so send it while you are being onboarded; it stops affecting the outcome once your names are mapped.arch(optional):1= upper,2= lower. Which arch this file captures. Omit it for a file that captures no one arch — a bite scan, for instance. It is what the scan-finish metadata is split by, so a file with noarchgets no missing-teeth or segmented-teeth metadata attached.- Scan files are STL.
- Files are independent of each other. A link request and its PUT concern one file only, and nothing in the contract orders them, so send as many at once as your uplink is happy with — the two arches of a full-mouth session together, and the mesh links below in batches. The only ordering the contract does impose is the finish call, which comes after your last scan upload.
Call this once, after your last scan upload. Uploading files does not say "the scan is over": SprintRay sees one upload event per arch and cannot tell "the upper jaw arrived" from "the doctor is done scanning". This call is what closes the session out and pushes the event the web app waits on, so the doctor's browser can leave the scanning screen.
It is also where you report what the session captured — the scan mode, the missing teeth, the segmented teeth, which arches — and where SprintRay hands back presigned links for the segmented-tooth and gingiva meshes.
POST {ORIGIN}/integration/scan-job/completeAuthorization: Bearer <access_token>x-api-key: <your-api-key>Content-Type: application/json
{
"id": "<case.ID from the launch payload>",
"scanMode": "quickScan",
"hasUpper": true,
"hasLower": true,
"missingTeeth": [1, 16],
"segmentedTeeth": [
{ "toothNumber": 8, "filename": "tooth_8.ply", "confidence": 0.97 }
]
}200 → the finished session, plus one presigned PUT link per mesh you reported:
{ "id": "<scan-job id>", "treatmentId": "<treatment id or null>", "caseId": "<external case id>",
"status": 3, "externalProviderId": "scanpro",
"files": [ { "fileType": 1, "fileGuid": "…", "status": 3 } ],
"scanMode": "quickScan", "missingTeeth": [1, 16], "hasUpper": true, "hasLower": true,
"segmentedTeethUploadLinks": [ { "toothNumber": 8, "url": "https://…" } ],
"gingivaUploadLink": { "upper": "https://…", "lower": "https://…" },
"createdDate": "2026-08-20T07:31:00Z", "modifiedDate": "2026-08-20T07:36:12Z" }idis the resolution key, and it is simply the launch payload'scase.ID.scanJobIdis the original name for the same field and is still accepted, so a shipped app needs no change;idwins if both are sent.caseIdis accepted instead of the id only if you did not keep it, and only if you were given one — SprintRay's web app sends none, soexternalCaseIdis normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keepcase.ID; it is always there.- Every metadata field is optional. A body of just
{ "id": "…" }finishes the session exactly as it did before — report only what your scanner actually produces. scanMode: your own vocabulary —quickScan,restorative, whatever your app calls it, the same arrangement asexternalScanFileTypeon the upload. A name SprintRay has not seen is registered against your integration on first sight; casing follows the first spelling, so keep it stable.missingTeethandsegmentedTeeth[].toothNumberare universal tooth numbers (1-32), always — the launch payload'stoothSystemgoverns display only, never this call.hasUpper/hasLower: whether the session captured each arch. They gate the gingiva links — nohasLower, nogingivaUploadLink.lower.segmentedTeeth[]declares the per-tooth meshes you are about to upload: thetoothNumber, thefilenameyou will use, and the segmentationconfidence. One link comes back per tooth, insegmentedTeethUploadLinks.- Idempotent, metadata included. A retry re-issues links pointing at the same objects, so a mesh you already PUT stays where it is; the reported metadata is overwritten, so a same-payload retry converges. Reporting metadata on a session that is already finished works too — submitting the treatment finishes the session on SprintRay's side, and that may beat your call.
- Once a session is finished it takes no further scan uploads. A re-scan is a new launch and a new session. The mesh links from this call keep working (see below).
Then PUT each mesh to its link:
PUT<segmentedTeethUploadLinks[].url | gingivaUploadLink.upper | gingivaUploadLink.lower>Content-Type: application/octet-streamContent-Length: <fileSize><raw mesh bytes>- Same rules as the scan PUT: no auth header,
200/204on success. These links expire in 30 minutes — call the finish endpoint again to get fresh ones for the same objects. - The object's extension comes from the
filenameyou reported (tooth_8.ply). A tooth reported without a filename, and every gingiva mesh, is named by SprintRay and defaults to.ply. - There is nothing to call after the PUT — no confirm, no second finish call. These meshes are session metadata, not treatment files: they never attach to the treatment and never show up in the doctor's Cloud Drive.
Errors: 400 no id at all, a tooth number outside 1-32, the same toothNumber twice, or a
filename whose extension is not allowed · 401 expired/missing access token · 403 missing or
invalid x-api-key · 404 no such session, or it belongs to another doctor (the two are
deliberately indistinguishable).
Your app does not need this; it is here because it is the same session resource. It answers "which arches has SprintRay got, and where does the session stand" — useful when something went wrong mid-scan and you want to see what actually landed.
GET {ORIGIN}/integration/scan-job/{scanJobId}Authorization: Bearer <access_token>x-api-key: <your-api-key>200 → the same body shape as the finish call, minus the upload links — including the reported
scanMode, missingTeeth, hasUpper and hasLower (null on a session that reported none).
Errors: 401 · 403 · 404 as above.
status values: 1 pulled · 2 transferring · 3 done. Per-file status: 1 pending ·
2 uploaded · 3 attached to the treatment. A file's fileType is null when neither source
answered: its externalScanFileType is not mapped to a file type, and the upload sent no
treatmentFileType either.
Numeric enum values referenced by the payload and the upload call.
Sent as treatmentFileType on upload and received as fileType in the launch payload. For
intra-oral scanning you only need:
| Value | Name |
|---|---|
1 | UpperJaw |
2 | LowerJaw |
All TreatmentFiles values
| Value | Name |
|---|---|
| 1 | UpperJaw |
| 2 | LowerJaw |
| 3 | LeftSide |
| 4 | RightSide |
| 5 | Other |
| 6 | Spr |
| 7 | SingleStl |
| 8 | DesignPhoto |
| 9 | CBCT |
| 10 | SingleStlWithSupports |
| 11 | BaseStl |
| 12 | BaseSpr |
| 13 | PonticStl |
| 14 | PonticSpr |
| 15 | PatientPhoto |
| 16 | SurgicalGuideStl |
| 17 | SurgicalGuideSpr |
| 18 | CementedRestorationStl |
| 19 | CementedRestorationSpr |
| 20 | RemovableDieStl |
| 21 | RemovableDieSpr |
| 22 | CustomBleachingTrayStl |
| 23 | CustomBleachingTraySpr |
| 24 | WaxUpUpperStl |
| 25 | TrialSmileUpperStl |
| 26 | WaxUpSpr |
| 27 | TrialSmileSpr |
| 28 | DesignVideo |
| 29 | MonolithicTryInDentureStl |
| 30 | MonolithicTryInDentureSpr |
| 31 | DentureGumBaseStl |
| 32 | DentureGumBaseSpr |
| 33 | DentureTeethStl |
| 34 | DentureTeethSpr |
| 35 | WaxUpLowerStl |
| 36 | TrialSmileLowerStl |
| 37 | CephXRayPhoto |
| 38 | PanoXRayPhoto |
| 39 | FrontFace |
| 40 | FrontSmile |
| 41 | RightSideFace |
| 42 | LeftSideFace |
| 43 | FrontTeeth |
| 44 | RightSideTeeth |
| 45 | LeftSideTeeth |
| 46 | UpperJawImage |
| 47 | LowerJawImage |
| 48 | PreppedToothIntraoralScans |
| 49 | DentureWaxSetup |
| 50 | UpperTissueScan |
| 51 | LowerTissueScan |
| 52 | PhotogrammetryData |
| 53 | MonolithicHybridDenturesStl |
| 54 | MonolithicHybridDenturesSpr |
| 55 | AICrownPreviewImage |
| 56 | AICrownStl |
| 57 | AICrownDieStl |
| 58 | BiteScanCombo |
| 59 | DentureUpperStl |
| 60 | DentureLowerStl |
| 61 | SmileDesignStl |
| 63 | SmileDesignFrontSmile |
| 64 | UpperJawRetainer |
| 65 | LowerJawRetainer |
| 66 | UpperJawAligner |
| 67 | LowerJawAligner |
| 68 | SprRetainer |
| 69 | SprAligner |
| 70 | UpperAppliance |
| 71 | LowerAppliance |
| 72 | UpperAntagonist |
| 73 | LowerAntagonist |
| 74 | VeneersDesignFrontSmile |
| 75 | VeneersStl |
| 76 | VeneersSpr |
| 77 | PreppedUpperJaw |
| 78 | PreppedLowerJaw |
| 79 | DentalModelDieStl |
| 80 | Link |
| 81 | ImplantCrownStl |
| 82 | ImplantShellTempStl |
| 83 | ImplantBridgeStl |
| 84 | UpperDirectPrintAppliance |
| 85 | LowerDirectPrintAppliance |
| 86 | UpperDirectPrintTemplate |
| 87 | LowerDirectPrintTemplate |
| 88 | SingleStlOnlyView |
| 89 | UpperJawOnlyViewStl |
| 90 | LowerJawOnlyViewStl |
| 91 | TrackingLink |
| 92 | PartialDentureBaseStl |
| 93 | PartialDentureBaseSpr |
| 94 | TreatmentTeethImage |
| 95 | AISmilePreviewImage |
| 96 | AISmilePreviewVideo |
| 97 | PreOpUpperJaw |
| 98 | PreOpLowerJaw |
| 99 | CorrectedUpperJaw |
| 100 | CorrectedLowerJaw |
| 101 | Profile45Degree |
| 102 | UpperScanbodyScan |
| 103 | LowerScanbodyScan |
Value 62 is unused.
| Value | Name |
|---|---|
| 1 | PonticSites |
| 2 | Clasps |
| 3 | Crown |
| 4 | SplintCrown |
| 5 | Splint |
| 6 | Inlay |
| 7 | Onlay |
| 8 | ShellTemp |
| 9 | Wings |
| 10 | Base |
| 11 | Extraction |
Which arch an upload captures (arch on the upload call). Optional — omit it for a file that
captures no one arch, such as a bite scan.
| Value | Meaning |
|---|---|
1 | upper |
2 | lower |
3 | both — one file carrying the whole mouth |
A scanner that sends one file per jaw only ever needs 1 and 2. 3 is for a single file that
carries both arches; it is the value that takes the whole 1-32 metadata set from the finish
call, where 1 takes 1-16 and 2 takes 17-32.
A string derived from the doctor's tooth-numbering preference (DentalNotation):
toothSystem | Meaning |
|---|---|
utn | Universal Tooth Numbering (DentalNotation.Utn = 1) — default |
fdi | FDI World Dental Federation (DentalNotation.Fdi = 2) |
This governs how teeth are displayed to the doctor. Tooth numbers you send SprintRay —
missingTeeth and segmentedTeeth[].toothNumber on the scan-finish call — are always universal
(1-32), whatever toothSystem says.
No enum is defined for this yet; it is currently always the fixed value 0.
| Value | Env var | Notes |
|---|---|---|
| Gateway origin | SCANPRO_BASE_URL | fixed per environment (dev / staging / prod — see above) |
| Gateway API key | SCANPRO_API_KEY | sent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan |
| Client id | SCANPRO_CLIENT_ID | your integration's public id |
| Client secret | SCANPRO_CLIENT_SECRET | keep server-side / in your app only |
| URL scheme | SCANPRO_URL_SCHEME | the scheme your app registers, e.g. openScanPro |
Not a credential, but part of the same onboarding, and it goes the other way: externalScanFileType
is required on every upload, so hand SprintRay the list of names your app uses — those, plus the
scanMode names — for an admin to map each one to the matching SprintRay file type / indication.
Until a name is mapped, files uploaded under it carry no SprintRay file type.
Prerequisites: Node.js ≥ 18 (--env-file needs ≥ 20.6). macOS / Windows / Linux (macOS is the
tested path for scheme registration).
cp .env.example .env.dev # fill in origin, client id/secret, schemeOne file per environment — .env.dev, .env.staging, .env.prod — all gitignored (.env.example
is the only one committed). The CLI reads .env; the desktop UI reads the file its script names.
npm install # pulls in Electron (a devDependency)
npm run app # launch the desktop UI against .env.dev
npm run app:staging # …or .env.staging
npm run app:prod # …or .env.prodAny other file works too, without touching package.json — the flag is what the scripts above pass:
npm run app -- --env-file=.env.qa # SCANPRO_ENV_FILE=.env.qa also works, for launches# that cannot pass arguments (URL scheme, Finder)Which file was actually loaded is shown next to the Configuration heading, so a run pointed at the wrong environment is visible rather than guessed at.
The window has two skins over the same flow, and pressing d five times switches between
them at any time:
| Skin | For | Opens by default |
|---|---|---|
| Demo mode | showing what the integration looks like to a doctor | yes |
| Developer mode | testing the integration and reading the wire traffic | SCANPRO_UI_MODE=dev |
This is the skin in the walkthrough at the top.
A stand-in for a real intra-oral scanner app: dark stage, tool rails, live camera preview, scan quality legend. It follows the desktop app's real lifecycle, the same one the developer skin runs on:
- Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
- A launch payload arrives — the OS URL scheme, or
POST /scanpro/v1/starton the local service — and the case plays: the upper arch sweeps in under a virtual wand (the bundled STL arches, revealed in scan order, with holes and layering marked on the raw mesh), then the lower arch, then bite registration, then a refine pass that closes the holes and smooths the models. The patient name, case id and selected teeth come from the payload; a payload naming afileTypescans only that arch. A launch arriving mid-case restarts on the new one. - Back to the browser. Once the case is sent, the card counts down and the app steps out of the way — hidden on macOS, minimized on Windows — so the page the doctor started from is in front again. The next launch brings the window back. A failed send stays on screen instead, until it is dismissed.
The send is real. It calls the same runFlow() the developer skin does, so with the credentials
in .env set, the case really is exchanged, uploaded and closed out — the progress on the card is
actual HTTP progress, and the card names the treatment and file sizes the backend accepted. Without
credentials the card says so and the transfer is simulated.
The observability-focused way to test the integration. It runs the exact same flow the CLI does, but renders it visually so you can watch each step and inspect every byte on the wire. Its window has three parts:
- Left — Configuration & input. Gateway origin, API key, client id/secret, and URL scheme are prefilled
from
.env(editable per run). Paste aopenScanPro://<base64>launch URL, or switch to Manual code to run with an explicitcode+ treatment id. Optionally pick a custom scan file for the upper and lower arch separately, and toggle the token-refresh step. - Right — Observability.
- Pipeline — the desktop-app steps in order (decode → exchange → optional refresh → presigned URL → S3 PUT → finish the session → PUT the tooth/gingiva meshes), each showing live status and a one-line detail.
- Decoded launch payload — the extracted fields (
code,tokenEndpoint,treatmentId,externalCaseId,fileType) plus the full decoded JSON. Decode payload shows this without touching the network. - HTTP transactions — one expandable card per call, each with the complete request (method,
URL, headers, body) and the complete response (status, headers, body, duration). Bodies are
pretty-printed and copyable; the S3 PUT body is shown as
<binary N bytes>. - Log — the same timestamped step/ok/fail/info stream the CLI prints.
Launch from the browser. The app registers itself as the OS handler for the URL scheme
(app.setAsDefaultProtocolClient), so clicking OR Scan in the SprintRay web app can open it
directly — the deep link lands in the launch-URL field and auto-decodes. The Claim handler button
(top-right) re-claims the scheme; on macOS this is reliable from a packaged build, so during
development pasting the launch URL is the sure path.
Make the OS route yourscheme://… to this example app, so clicking the launch entry in the browser
starts it for real:
npm run register # register the scheme with the OS
npm run status # show what the scheme currently resolves to
npm run unregister # remove it- macOS: an app is created under
~/Applications; the first launch asks to control Terminal (to show the run) — click OK, ornpm run register -- --headlessto log to a file instead. Re-runregisterafter changing code or.env. - Windows / Linux: registers a per-user handler (registry /
.desktop).
# Form A — the deep link handed over by the browser
node --env-file=.env src/index.js "yourscheme://<base64_json>"# Form B — an explicit code (no launch URL)
node --env-file=.env src/index.js --code <code> --base-url <origin> --treatment-id <guid>Add --demo-refresh to also exercise the token-refresh endpoint; --upper-file <p> / --lower-file <p>
swap the file sent for either arch. --concurrency <n> sets how many files go up at once
(default $SCANPRO_UPLOAD_CONCURRENCY, else 4; --concurrency 1 sends them one at a time).
The scan report the finish call sends is derived from the arches the run uploaded, and every part of it can be overridden:
| Flag | What it changes |
|---|---|
--scan-mode <name> | the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan) |
--missing-teeth 1,16 | reported missingTeeth, universal numbering (default: none) |
--segmented-teeth 8,9 | the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing) |
--no-metadata | report nothing at all: the finish call sends the id alone, the way a client written before this contract does |
--upper-scan-type <n> / --lower-scan-type <n> | the externalScanFileType sent for each arch (default $SCANPRO_SCAN_FILE_TYPE_UPPER / _LOWER, else UpperArch / LowerArch) |
--tooth-file <p> / --gingiva-file <p> | the mesh PUT to each returned link (default fixtures/tooth.ply / fixtures/gingiva.ply) |
A full-mouth run with no flags therefore reports both arches, 32 segmented teeth and no missing
ones — which comes back as 34 presigned links, and 34 PUTs. --segmented-teeth none is the
quickest way to watch the same flow with two gingiva meshes and nothing else.
Each run exchanges the code, then uploads the way the scanner really does — a full-mouth scan
(fileType is null) sends fixtures/upper.stl and fixtures/lower.stl at the same time, and a
payload naming an arch sends only that one — under one progress bar covering the batch, and each
naming the scan type it carries (externalScanFileType) and the arch it captures.
Uploads run concurrently, but the log does not interleave: each file narrates into its own buffer and is printed as one block, in file order, so the transaction log still reads one file at a time while the bytes overlap on the wire.
After the last upload it makes the scan-finish call, reporting what the session captured: the scan
mode, which arches, the segmented teeth and the missing ones. SprintRay answers with one presigned
link per segmented tooth plus one per arch's gingiva, and the run PUTs a mesh to each, several at
a time — so it ends exactly the way a real session does. Those meshes are session metadata: nothing is called after the
PUT, and they never appear in the doctor's Cloud Drive. Form B (--code, no launch URL) has no
case.ID, so there is no session to finish and both steps report as skipped.
The run also reports one telemetry event, scanner.connected, right after the exchange — that is
the first moment the doctor behind the launch is known. See Telemetry.
Every backend request and response is logged in full (method, URL, headers, body / status,
headers, body) so you can see exactly what to send and what to expect. Swap the files in fixtures/
to upload your own scans — upper.stl / lower.stl are the arches, tooth.ply / gingiva.ply
stand in for the per-tooth and gingiva meshes.
The second way the web app can reach the desktop. Instead of handing the payload to an OS URL scheme, the browser probes a fixed port range on loopback for a resident service and posts the payload to it. It is the same base64 JSON payload either way, and in this example app both transports end up in the same window.
This app implements the service side of that contract, so you can point the web app at it and see exactly what a caller sees — including the CORS behaviour, which is where browser-to-loopback integrations usually break.
The desktop UI starts the service on launch; the server chip in the top-right shows the port it took (hover for the endpoints). To run it on its own, without Electron:
npm run serve # bind a port; /start launches the desktop app via the URL scheme
npm run serve -- --run-flow # /start instead exchanges the code and uploads a scan in-process
npm run serve -- --help # all options: port range, reported version/state, host checkRun headlessly, /start launches the app the way the real resident service does — by handing the
payload to the OS handler for the URL scheme, so whatever npm run register or an installed build
claimed is what starts. The launch is then confirmed: the launcher exiting 0 only means the OS
accepted the request, and a stale handler that starts and dies immediately would otherwise pass as
success, so the response reports what actually happened:
errorCode | Meaning |
|---|---|
NO_HANDLER_REGISTERED | nothing claims the scheme — install a build or run npm run register |
LAUNCH_NOT_CONFIRMED | the OS accepted the launch but no process stayed up (usually a stale handler) |
LAUNCH_FAILED | the OS launcher itself reported an error |
There is no fixed port — the service takes the first one it can bind, so the caller has to probe. Both sides must agree on the range:
| Port range | 29083–29183 inclusive (101 ports) |
| Selection | on startup, try 29083 upwards; first port that binds wins |
| Bind address | 127.0.0.1 only — never an external interface |
| Range exhausted | the service does not start; it reports telemetry instead (see below) |
How a caller probes:GET /scanpro/v1/status on each port from 29083 upwards. The first one
that answers 200 with "service": "SprintRayScanService" is this service. Cache that port and
reuse it; only probe again after a request to it fails.
Matching on
servicematters. A response carrying only aversionfield is not enough to tell this service apart from any unrelated program that happens to hold the port.
Installed state, running state and version in one call — no need to probe them separately.
$ curl -s http://127.0.0.1:29083/scanpro/v1/status{"service":"SprintRayScanService","running":true,"installed":true,"version":"0.2.0"}| Field | Type | Meaning |
|---|---|---|
service | string | always SprintRayScanService — the discovery marker |
running | bool | ScanPro is running |
installed | bool | ScanPro is installed |
version | string | ScanPro's version |
Starts ScanPro with a launch payload. The call blocks until the start has succeeded or failed,
so give it a generous timeout — and if you do time out, call /status before retrying, because
ScanPro may well be up already.
argument is the launch payload as base64-encoded JSON — the same payload the URL scheme
carries. It is required and must not be empty.
ARGUMENT=$(node -e 'console.log(Buffer.from(JSON.stringify({ caller: { name: "SprintRay", version: "1.0.10.0" }, case: { name: "Jane Doe", ID: "04024e3b-ff28-4d6a-bdea-4c777e4cfb0d" }, language: "en_US", serverType: 0, toothSystem: "fdi", treatment: { teeth: [{ number: "17", workType: "Crown" }] }})).toString("base64"))')
curl -s -X POST http://127.0.0.1:29083/scanpro/v1/start \
-H 'Content-Type: application/json' \
-d "{\"argument\":\"$ARGUMENT\"}"{ "status": true, "started": true }status is the field the contract defines; started is the same value under a clearer name, sent
alongside it so either reading works. A failed start adds errorCode and message.
Sending a payload that also carries SprintRay's auth block makes this a complete launch: in the
desktop UI the window comes forward with the payload decoded, and under serve --run-flow the
example app exchanges the code and uploads a scan before answering the request.
200 means the request was handled, not that the business result was positive — "ScanPro is not
installed" is a 200 with installed: false. Genuine errors use status codes and a fixed envelope:
{ "error": { "code": "ARGUMENT_REQUIRED", "message": "`argument` is required and must be a non-empty string" } }| Status | code | When |
|---|---|---|
400 | INVALID_JSON | the request body is not JSON |
400 | ARGUMENT_REQUIRED | argument missing, not a string, or empty |
400 | ARGUMENT_NOT_BASE64_JSON | argument does not decode to a JSON object |
403 | HOST_NOT_ALLOWED | the Host header is not a loopback name (see below) |
404 | NOT_FOUND | unknown path |
405 | METHOD_NOT_ALLOWED | right path, wrong method |
413 | PAYLOAD_TOO_LARGE | body over 256 KB |
500 | START_ERROR / STATUS_ERROR | the service itself failed |
code is a stable constant — branch on it, not on message.
The caller is an HTTPS page reaching into http://127.0.0.1, which is cross-origin. Without the
right headers the browser discards the response even though the request succeeded, so the service:
- echoes the request's
OrigininAccess-Control-Allow-Originand always sendsVary: Origin; - answers
OPTIONSpreflights with the allowed methods and headers; - answers a preflight carrying
Access-Control-Request-Private-Network: truewithAccess-Control-Allow-Private-Network: true— Chrome blocks the call without this.
By default any origin is echoed, which is the easiest thing to test against. Set
SCANPRO_LOCAL_SERVER_ORIGINS to a comma-separated list to make it an allowlist; any other origin
then gets no Access-Control-Allow-Origin back and the browser blocks it.
The service is unauthenticated and relies on being reachable only over loopback. That holds only
while requests really are addressed to loopback, so a request whose Host header is some other
name — the shape a DNS-rebinding attack takes — is rejected with 403. Pass --allow-any-host to
turn the check off while debugging a proxy.
If all 101 ports are busy the service does not start, the web app's probe finds nothing, and to the doctor it just looks like clicking Scan does nothing. Nothing on the machine notices, so the service reports it:
eventName | local_server.port_unavailable |
severity | error |
eventData | { portRangeStart, portRangeEnd, attempted, lastErrorCode } |
This one carries no scanner object — the failure has nothing to do with the scanner, and a batch
sends scanner only for the events that require it — and no userId: the service starts before
anyone has signed in, and the spec would rather have the field absent than filled with a
placeholder. Everything else about how it is sent is in Telemetry below.
Four additions, all backwards-compatible — a client that ignores them still works:
| Addition | Why |
|---|---|
service in /status | version alone cannot identify the service during a port probe |
{ error: { code, message } } on 4xx/5xx | the contract only defines success bodies; code is a stable constant, not localized prose |
started next to status | /status uses semantic names (running, installed); /start returning a generic status reads inconsistently |
loopback Host check | an unauthenticated loopback service otherwise trusts any name that resolves to 127.0.0.1 |
One deliberate difference in behaviour: a real service hands argument to ScanPro untouched, while
this one decodes it and answers 400 when it is not base64 JSON. That is the point of a simulator —
you find out here that the payload is malformed, instead of watching a scanner sit idle.
Two events go to SprintRay's telemetry endpoint:
eventName | When | eventData |
|---|---|---|
scanner.connected | every time the app is launched with a case — stamped at the launch, sent once the code has been exchanged | { connection, firmwareVersion } |
local_server.port_unavailable | the whole port range is taken, so the local service never starts (see above) | { portRangeStart, portRangeEnd, attempted, lastErrorCode } |
There is nothing to configure. The endpoint is a path on the same API gateway as the token
exchange and the uploads, behind the same SCANPRO_API_KEY, so it is derived from the origin
this app is already pointed at:
${SCANPRO_BASE_URL}/telemetry/SprintRay/events
Point SCANPRO_BASE_URL at dev, staging or production and telemetry follows — including a per-run
origin typed into the desktop UI, which wins over the .env for that run. A wrong key is the usual
403 {"message":"Forbidden"} from the gateway; with no origin at all nothing is sent — the event is
logged locally and the app carries on.
Three optional settings cover what the default cannot know:
SCANPRO_TELEMETRY_BRAND | your integration's segment of the path, if SprintRay registered you under another name (default SprintRay). It is checked, not free text — an unknown brand is refused with 400 Unknown telemetry brand |
SCANPRO_TELEMETRY_URL | the whole endpoint, if the route ever moves off this gateway |
SCANPRO_TELEMETRY_CHANNEL | release / beta / internal / dev — which build stream the events came from. This example always reports dev, because everything it sends is test traffic; your app reports its own |
A launch means a doctor started a case and the scanner is at the chair, so that is where this
example reports the connection. Every launch reports it, once, whichever transport carried it:
the OS URL scheme, the local service's POST /scanpro/v1/start, and the CLI handling a launch URL
(Form A). A resident app handed a second case reports a second event under the same sessionId —
that id identifies one run of the app, not one case.
Stamped at the launch, sent after the token exchange. The two halves are deliberately apart:
occurredAtandeventIdare fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;userIdonly exists after the exchange. The launch payload carries a one-time code, not an identity, and that code cannot be spent twice — so the app cannot look the doctor up on its own, and the id comes from thesubclaim of the access token the run already fetched. It is reported verbatim (auth0|…, no lowercasing, no trimming); an id that was reshaped joins to nothing on SprintRay's side.
The consequence worth knowing: a launch whose code is never exchanged — the developer skin sitting on a decoded payload nobody ran, or an exchange that fails — sends nothing. That is the intended trade: the spec (§5.4) would rather have no event than one attributed to nobody, and every launch that actually scans does exchange first.
The send never fails the run and never blocks the launch — a bad telemetry endpoint costs the doctor nothing, and the pipeline steps over it. In the developer skin it is a step of its own in the pipeline, and the whole batch and the endpoint's answer are in the traffic log like every other call, so you can read exactly what this app sent rather than take it on trust.
scanner.* events require the batch to name the scanner (a batch without it is rejected with
SCANNER_REQUIRED), and this example has no hardware to ask, so it reports what the .env says:
SCANPRO_SCANNER_SERIAL=SPX1-2024-0007391 # default: EXAMPLE-<first 12 chars of deviceId>
SCANPRO_SCANNER_MODEL=ScanPro S1
SCANPRO_SCANNER_FIRMWARE=1.0.0
SCANPRO_SCANNER_CONNECTION=usb3 # usb2 | usb3 | usbc | wifi | unknownIn your own app all four come off the scanner you just enumerated. The serial matters most: report
it verbatim and unhashed — it is the only thing tying this data to a physical device — and
report connection as the link speed actually negotiated, not the socket the cable is in, because
a device that fell back to USB 2 explains most of what gets reported as "the scan feels slow".
deviceId is a SHA-256 of the OS machine id (macOS IOPlatformUUID, Windows MachineGuid), so no
raw machine identifier leaves the host, and installationId is a uuid generated once. Both are
persisted in identity.json under ~/.sprintray-scanpro-example/ — the app's user-data directory
in a packaged build — which is what keeps them stable across restarts and upgrades. A machine whose
id cannot be read falls back to a persisted random uuid: still stable for this install.
0— token exchange + all uploads succeeded (or a register/status/unregister command completed)1— bad arguments, missing env, a failed exchange/upload, orservefinding no free port
npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zipEach platform builds on its own OS. Targets:
| Target | Arch | Output | Supported on |
|---|---|---|---|
| Windows | x64 | NSIS installer (.exe), per-user, no admin needed | Windows 10 1809 and newer |
| macOS | arm64 | .dmg and .zip | Apple silicon, macOS 12+ |
The packaged app registers the openScanPro scheme with the OS by itself and reads its .env from
next to the executable, falling back to the per-user data directory (the UI's Configuration panel
shows which file it found, and the fields stay editable per run).
Without a Developer ID certificate the macOS build is only ad-hoc signed, and on macOS 15 and
newer Gatekeeper rejects that. The failure gives you nothing to go on: the app starts and is
killed within a second, with no dialog and no output — so opening it from Finder, through the
openScanPro:// scheme, or through the local service's /start all look like "nothing happened".
Running the binary straight from a terminal still works, which is what makes this so easy to miss:
# works even when the app cannot be launched normally"/Applications/ScanPro Integration Example.app/Contents/MacOS/ScanPro Integration Example"# what the OS actually thinks of the build
spctl -a -vvv -t exec"/Applications/ScanPro Integration Example.app"# -> rejectedTo ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:
| Secret | Purpose |
|---|---|
MAC_CSC_LINK | Developer ID Application certificate (.p12, base64-encoded) |
MAC_CSC_KEY_PASSWORD | password for that .p12 |
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID | notarization |
Without them the workflow still builds, logs a warning, and prints the resulting signature and Gatekeeper verdict in the job output.
Running an unsigned build anyway. Right-click the app > Open once and confirm, or approve it under System Settings > Privacy & Security. Clearing the quarantine attribute on its own is not enough on current macOS:
xattr -dr com.apple.quarantine "/Applications/ScanPro Integration Example.app"The Windows build is unsigned too, but there SmartScreen only warns — click More info > Run anyway.
Releases. Pushing a v* tag builds both targets and attaches them to a GitHub Release under
that tag (.github/workflows/release.yml). The tag sets the version the app reports, so v0.3.0
produces an app whose /status reports 0.3.0:
git tag v0.3.0 && git push origin v0.3.0Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.
Files a doctor uploads when submitting a treatment, exported from DS production
(TreatmentType ⨝ TreatmentTypeFile, FileKind = 0 = Original). Active files only; the
Not Selected placeholder and all Studio * types are omitted. Type is the TreatmentFiles
enum (value + name); a blank MaxMB means no explicit size cap.
| TreatmentType | Title | Type (TreatmentFiles) | Required | Accept | MaxMB |
|---|---|---|---|---|---|
| AI Night Guard | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply | 1024 |
| AI Night Guard | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply | 1024 |
| AI Restorations | Upper Prepped Scan | 77 (PreppedUpperJaw) | Yes | .stl | 1024 |
| AI Restorations | Lower Prepped Scan | 78 (PreppedLowerJaw) | Yes | .stl | 1024 |
| AI Retainer | Upper Scan | 1 (UpperJaw) | No | .stl,.ply | 1024 |
| AI Retainer | Lower Scan | 2 (LowerJaw) | No | .stl,.ply | 1024 |
| AI Sports Guard | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply | 1024 |
| AI Sports Guard | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply | 1024 |
| Bleaching Tray Models | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Bleaching Tray Models | Supporting Images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Bleaching Tray Models | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Bonded Restorations | Upper Scan | 77 (PreppedUpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Bonded Restorations | Supporting Images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Bonded Restorations | Upper Scan | 97 (PreOpUpperJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Bonded Restorations | Lower Scan | 78 (PreppedLowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Bonded Restorations | Lower Scan | 98 (PreOpLowerJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Bonded Restorations | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Bracket Removal | Maxillary scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Bracket Removal | Supporting Images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Bracket Removal | Mandibular scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Clear Aligners | Maxillary scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Clear Aligners | PANO X-ray | 38 (PanoXRayPhoto) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Front Face | 39 (FrontFace) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Front Smile | 40 (FrontSmile) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Right Side Face | 41 (RightSideFace) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Left Side Face | 42 (LeftSideFace) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Upper Jaw | 46 (UpperJawImage) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Lower Jaw | 47 (LowerJawImage) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Front Teeth | 43 (FrontTeeth) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Right Side Teeth | 44 (RightSideTeeth) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Left Side Teeth | 45 (LeftSideTeeth) | Yes | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Mandibular scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Clear Aligners | CEPH X-ray | 37 (CephXRayPhoto) | No | .jpeg,.jpg,.png | 1024 |
| Clear Aligners | Bite Scan | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Definitive Crown | Maxillary scan | 1 (UpperJaw) | Yes | .stl | 1024 |
| Definitive Crown | Left side | 3 (LeftSide) | No | .stl | 1024 |
| Definitive Crown | Supporting Images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 1024 |
| Definitive Crown | Mandibular scan | 2 (LowerJaw) | Yes | .stl | 1024 |
| Definitive Crown | Right side | 4 (RightSide) | No | .stl | 1024 |
| Dental Model | Upper Scan | 1 (UpperJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Dental Model | Lower Scan | 2 (LowerJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Dental Model | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Full Dentures | Upper Scan | 1 (UpperJaw) | Yes | .stl,.zip | 300 |
| Full Dentures | Upload any additional images. | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Full Dentures | Upper Wax Rim Scan | 24 (WaxUpUpperStl) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Full Dentures | Lower Wax Rim Scan | 35 (WaxUpLowerStl) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Full Dentures | Lower Scan | 2 (LowerJaw) | Yes | .stl,.zip | 300 |
| Full Dentures | Upper Denture Scan | 59 (DentureUpperStl) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Full Dentures | Lower Denture Scan | 60 (DentureLowerStl) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Full Dentures | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Upper Jaw | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Upper Tissue Scan | 50 (UpperTissueScan) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Patient Records or Files | 52 (PhotogrammetryData) | No | .zip | 1024 |
| Hybrid Dentures | Upper Appliance Scan | 70 (UpperAppliance) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Upper Antagonist | 72 (UpperAntagonist) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Lower Jaw | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Lower Tissue Scan | 51 (LowerTissueScan) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Upload Pictures Of Patient Smiling | 15 (PatientPhoto) | No | .jpeg,.jpg,.png | 1024 |
| Hybrid Dentures | Bite Scan | 58 (BiteScanCombo) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Lower Appliance Scan | 71 (LowerAppliance) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Hybrid Dentures | Lower Antagonist | 73 (LowerAntagonist) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Implant Planning and Surgical Guide | Upper Scan | 1 (UpperJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Implant Planning and Surgical Guide | Upload full .ZIP file | 9 (CBCT) | Yes | .dicom,.zip | 1024 |
| Implant Planning and Surgical Guide | Upload any additional images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Implant Planning and Surgical Guide | Denture/Wax Setup Scan | 49 (DentureWaxSetup) | No | .stl,.ply,.obj,.dcm | 1024 |
| Implant Planning and Surgical Guide | Lower Scan | 2 (LowerJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Implant Restorations | Upper Scan | 77 (PreppedUpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Implant Restorations | Lower Scan | 78 (PreppedLowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Implant Restorations | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Implant Restorations | Upper Scanbody Scan | 102 (UpperScanbodyScan) | No | .stl,.dcm,.ply,.obj | 1024 |
| Implant Restorations | Lower Scanbody Scan | 103 (LowerScanbodyScan) | No | .stl,.dcm,.ply,.obj | 1024 |
| Moment | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | |
| Moment | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | |
| Moment | Pictures of Patient Smiling | 74 (VeneersDesignFrontSmile) | Yes | .jpeg,.jpg,.png,.gif,.svg,.bmp | |
| Moment | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | |
| Neer Veneer | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Neer Veneer | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Neer Veneer | Pictures of Patient Smiling | 74 (VeneersDesignFrontSmile) | Yes | .jpeg,.jpg,.png,.gif,.svg,.bmp | 1024 |
| Neer Veneer | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Night Guard | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Night Guard | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Night Guard | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Overdenture | Upper Jaw | 1 (UpperJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Overdenture | Upper Tissue Scan | 50 (UpperTissueScan) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Overdenture | Patient Records or Files | 52 (PhotogrammetryData) | No | .zip | 1024 |
| Overdenture | Upper Appliance Scan | 70 (UpperAppliance) | No | .stl,.ply,.obj,.dcm | 1024 |
| Overdenture | Lower Jaw | 2 (LowerJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Overdenture | Lower Tissue Scan | 51 (LowerTissueScan) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Overdenture | Upload Pictures Of Patient Smiling | 15 (PatientPhoto) | No | .jpeg,.jpg,.png | 1024 |
| Overdenture | Lower Appliance Scan | 71 (LowerAppliance) | No | .stl,.ply,.obj,.dcm | 1024 |
| Partial Denture | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Partial Denture | Supporting Images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Partial Denture | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | |
| Partial Denture | Supporting Images | 94 (TreatmentTeethImage) | Yes | .jpeg,.jpg,.png,.gif,.svg,.bmp | 1 |
| Partial Denture | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Retainer | Upper Scan | 1 (UpperJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Retainer | Lower Scan | 2 (LowerJaw) | No | .stl,.ply,.obj,.dcm | 1024 |
| Smile Correct (up to 7 stages) | Front Face | 39 (FrontFace) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Smile Correct (up to 7 stages) | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Smile Correct (up to 7 stages) | Panorex or FMX | 38 (PanoXRayPhoto) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Front Smile | 40 (FrontSmile) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Smile Correct (up to 7 stages) | Right Side Face | 41 (RightSideFace) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Upper Jaw | 46 (UpperJawImage) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Lower Jaw | 47 (LowerJawImage) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Front Teeth | 43 (FrontTeeth) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Right Side Teeth | 44 (RightSideTeeth) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Correct (up to 7 stages) | Left Side Teeth | 45 (LeftSideTeeth) | Yes | .jpeg,.jpg,.png | 1024 |
| Smile Design | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Smile Design | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Smile Design | Pictures of Patient Smiling | 63 (SmileDesignFrontSmile) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 1024 |
| Smile Design | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Sports Guard | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Sports Guard | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Sports Guard | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Surgical Guide with Restoration | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Surgical Guide with Restoration | Upload full .ZIP file | 9 (CBCT) | Yes | .dicom,.zip | 1024 |
| Surgical Guide with Restoration | Upload any additional images | 5 (Other) | No | .jpeg,.jpg,.png,.gif,.svg,.bmp | 100 |
| Surgical Guide with Restoration | Denture/Wax Setup Scan | 49 (DentureWaxSetup) | No | .stl,.ply,.obj,.dcm | 1024 |
| Surgical Guide with Restoration | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Trial Smile | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply | 1024 |
| Trial Smile | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply | 1024 |
| Trial Smile | Bite Scan | 58 (BiteScanCombo) | No | .stl,.ply | 1024 |
| Trial Smile | Frontal | 40 (FrontSmile) | Yes | .jpg,.jpeg,.png,.bmp,.webp | 1024 |
| Trial Smile | Profile 45 Degree | 101 (Profile45Degree) | Yes | .jpg,.jpeg,.png,.bmp,.webp | 1024 |
| Trial Smile | Left Side | 42 (LeftSideFace) | Yes | .jpg,.jpeg,.png,.bmp,.webp | 1024 |
| Trial Smile | Right Side | 41 (RightSideFace) | Yes | .jpg,.jpeg,.png,.bmp,.webp | 1024 |
| Veneers | Upper Scan | 1 (UpperJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |
| Veneers | Bite Scans | 58 (BiteScanCombo) | No | .stl,.ply,.obj,.dcm | 1024 |
| Veneers | Pictures of Patient Smiling | 74 (VeneersDesignFrontSmile) | Yes | .jpeg,.jpg,.png,.gif,.svg,.bmp | 1024 |
| Veneers | Lower Scan | 2 (LowerJaw) | Yes | .stl,.ply,.obj,.dcm | 1024 |