Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

SprintRay Desktop Scanner Integration — Example App

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. Press d five times to switch (see Desktop UI);
  • a command-line runnernpm 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.

How the integration works

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:

  1. 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-lived code. (The same payload can instead arrive over the local HTTP service, if your app runs one.)
  2. Decode. Base64-decode the payload and read the code, the token-endpoint path, and the treatment/case identifiers (see Launch payload).
  3. Exchange. POST the code + your client credentials over HTTPS to obtain the signed-in doctor's access_token.
  4. Upload. A scanner captures both arches in one session, so a full-mouth scan (the launch payload's fileType is null) requests a presigned upload URL for each file and PUTs them — nothing orders one file behind another, so send them concurrently; a payload naming a fileType uploads only that arch. Every upload names the scan type it carries (externalScanFileType). Scans attach to the treatment automatically.
  5. 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.

Flow

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
Loading

Launch payload

{
"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>"
}
FieldUse
callerwho launched the app (SprintRay + web app version)
case.namepatient display name
case.IDthe 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
fileTyperequested file type (TreatmentFiles; see Enums); null means a full-mouth scan, where both arches are uploaded
languageUI locale, e.g. en_US
serverTypeserver type indicator
toothSystemtooth numbering: fdi or utn
auth.codeone-time device-login code to exchange
auth.tokenEndpointtoken endpoint path — join onto the backend origin
auth.expiresIncode lifetime, seconds
treatmentIdtreatment the uploaded scans attach to
externalCaseIdoptional 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, treatmentId and externalCaseId fields are the SprintRay silent-auth + upload context; the rest is the standard ScanPro launch payload.

API contract

Three calls. All go through the SprintRay API gateway; {ORIGIN} is the fixed gateway origin for your environment:

Environment{ORIGIN}
developmenthttps://dev-apx.sprintray.com
staginghttps://staging-apx.sprintray.com
productionhttps://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/api prefix. Always build the token call from the launch payload's auth.tokenEndpoint instead of hardcoding a path — that field is there so SprintRay can change the route without a change in your app.

1. Exchange the code for a token

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.

2. Get a presigned upload URL, then PUT the file

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's case.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. treatmentId keeps 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 any treatmentFileType you 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 a 400 — 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 your externalScanFileType is 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 no arch gets 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.

3. Tell SprintRay the scan session is finished

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" }
  • id is the resolution key, and it is simply the launch payload's case.ID. scanJobId is the original name for the same field and is still accepted, so a shipped app needs no change; id wins if both are sent.
  • caseId is 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, so externalCaseId is normally null. It is a weaker key regardless: a case id is not unique per launch, so SprintRay resolves the newest session carrying it. Keep case.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 vocabularyquickScan, restorative, whatever your app calls it, the same arrangement as externalScanFileType on 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.
  • missingTeeth and segmentedTeeth[].toothNumber are universal tooth numbers (1-32), always — the launch payload's toothSystem governs display only, never this call.
  • hasUpper / hasLower: whether the session captured each arch. They gate the gingiva links — no hasLower, no gingivaUploadLink.lower.
  • segmentedTeeth[] declares the per-tooth meshes you are about to upload: the toothNumber, the filename you will use, and the segmentation confidence. One link comes back per tooth, in segmentedTeethUploadLinks.
  • 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/204 on 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 filename you 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).

4. Read a scan session back (optional)

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.

Enums

Numeric enum values referenced by the payload and the upload call.

treatmentFileType / fileTypeTreatmentFiles

Sent as treatmentFileType on upload and received as fileType in the launch payload. For intra-oral scanning you only need:

ValueName
1UpperJaw
2LowerJaw
All TreatmentFiles values
ValueName
1UpperJaw
2LowerJaw
3LeftSide
4RightSide
5Other
6Spr
7SingleStl
8DesignPhoto
9CBCT
10SingleStlWithSupports
11BaseStl
12BaseSpr
13PonticStl
14PonticSpr
15PatientPhoto
16SurgicalGuideStl
17SurgicalGuideSpr
18CementedRestorationStl
19CementedRestorationSpr
20RemovableDieStl
21RemovableDieSpr
22CustomBleachingTrayStl
23CustomBleachingTraySpr
24WaxUpUpperStl
25TrialSmileUpperStl
26WaxUpSpr
27TrialSmileSpr
28DesignVideo
29MonolithicTryInDentureStl
30MonolithicTryInDentureSpr
31DentureGumBaseStl
32DentureGumBaseSpr
33DentureTeethStl
34DentureTeethSpr
35WaxUpLowerStl
36TrialSmileLowerStl
37CephXRayPhoto
38PanoXRayPhoto
39FrontFace
40FrontSmile
41RightSideFace
42LeftSideFace
43FrontTeeth
44RightSideTeeth
45LeftSideTeeth
46UpperJawImage
47LowerJawImage
48PreppedToothIntraoralScans
49DentureWaxSetup
50UpperTissueScan
51LowerTissueScan
52PhotogrammetryData
53MonolithicHybridDenturesStl
54MonolithicHybridDenturesSpr
55AICrownPreviewImage
56AICrownStl
57AICrownDieStl
58BiteScanCombo
59DentureUpperStl
60DentureLowerStl
61SmileDesignStl
63SmileDesignFrontSmile
64UpperJawRetainer
65LowerJawRetainer
66UpperJawAligner
67LowerJawAligner
68SprRetainer
69SprAligner
70UpperAppliance
71LowerAppliance
72UpperAntagonist
73LowerAntagonist
74VeneersDesignFrontSmile
75VeneersStl
76VeneersSpr
77PreppedUpperJaw
78PreppedLowerJaw
79DentalModelDieStl
80Link
81ImplantCrownStl
82ImplantShellTempStl
83ImplantBridgeStl
84UpperDirectPrintAppliance
85LowerDirectPrintAppliance
86UpperDirectPrintTemplate
87LowerDirectPrintTemplate
88SingleStlOnlyView
89UpperJawOnlyViewStl
90LowerJawOnlyViewStl
91TrackingLink
92PartialDentureBaseStl
93PartialDentureBaseSpr
94TreatmentTeethImage
95AISmilePreviewImage
96AISmilePreviewVideo
97PreOpUpperJaw
98PreOpLowerJaw
99CorrectedUpperJaw
100CorrectedLowerJaw
101Profile45Degree
102UpperScanbodyScan
103LowerScanbodyScan

Value 62 is unused.

treatment.teeth[].toothApplianceTypeToothApplianceType

ValueName
1PonticSites
2Clasps
3Crown
4SplintCrown
5Splint
6Inlay
7Onlay
8ShellTemp
9Wings
10Base
11Extraction

archArchType

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.

ValueMeaning
1upper
2lower
3both — 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.

toothSystem

A string derived from the doctor's tooth-numbering preference (DentalNotation):

toothSystemMeaning
utnUniversal Tooth Numbering (DentalNotation.Utn = 1) — default
fdiFDI 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.

serverType

No enum is defined for this yet; it is currently always the fixed value 0.

What you need from SprintRay

ValueEnv varNotes
Gateway originSCANPRO_BASE_URLfixed per environment (dev / staging / prod — see above)
Gateway API keySCANPRO_API_KEYsent as x-api-key on every call, telemetry included; identifies the caller and selects its usage plan
Client idSCANPRO_CLIENT_IDyour integration's public id
Client secretSCANPRO_CLIENT_SECRETkeep server-side / in your app only
URL schemeSCANPRO_URL_SCHEMEthe 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.

Running the example app

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, scheme

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

Desktop UI (Electron)

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

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

SkinForOpens by default
Demo modeshowing what the integration looks like to a doctoryes
Developer modetesting the integration and reading the wire trafficSCANPRO_UI_MODE=dev

Demo mode

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:

  1. Idle. The window waits, showing which launch transports are live (the URL scheme, and the port the local service is listening on). Nothing scans.
  2. A launch payload arrives — the OS URL scheme, or POST /scanpro/v1/start on 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 a fileType scans only that arch. A launch arriving mid-case restarts on the new one.
  3. 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.

Developer mode

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 a openScanPro://<base64>launch URL, or switch to Manual code to run with an explicit code + 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.

Register the URL scheme (real OS launch)

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, or npm run register -- --headless to log to a file instead. Re-run register after changing code or .env.
  • Windows / Linux: registers a per-user handler (registry / .desktop).

Run against a launch URL directly

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

FlagWhat it changes
--scan-mode <name>the reported scanMode (default $SCANPRO_SCAN_MODE, else quickScan)
--missing-teeth 1,16reported missingTeeth, universal numbering (default: none)
--segmented-teeth 8,9the teeth reported and uploaded — none reports zero (default: every tooth of the captured arches that is not missing)
--no-metadatareport 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.

What it does

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.

Local HTTP service (127.0.0.1)

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 check

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

errorCodeMeaning
NO_HANDLER_REGISTEREDnothing claims the scheme — install a build or run npm run register
LAUNCH_NOT_CONFIRMEDthe OS accepted the launch but no process stayed up (usually a stale handler)
LAUNCH_FAILEDthe OS launcher itself reported an error

Discovery

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 range2908329183 inclusive (101 ports)
Selectionon startup, try 29083 upwards; first port that binds wins
Bind address127.0.0.1 only — never an external interface
Range exhaustedthe 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 service matters. A response carrying only a version field is not enough to tell this service apart from any unrelated program that happens to hold the port.

GET /scanpro/v1/status

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"}
FieldTypeMeaning
servicestringalways SprintRayScanService — the discovery marker
runningboolScanPro is running
installedboolScanPro is installed
versionstringScanPro's version

POST /scanpro/v1/start

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.

Errors

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" } }
StatuscodeWhen
400INVALID_JSONthe request body is not JSON
400ARGUMENT_REQUIREDargument missing, not a string, or empty
400ARGUMENT_NOT_BASE64_JSONargument does not decode to a JSON object
403HOST_NOT_ALLOWEDthe Host header is not a loopback name (see below)
404NOT_FOUNDunknown path
405METHOD_NOT_ALLOWEDright path, wrong method
413PAYLOAD_TOO_LARGEbody over 256 KB
500START_ERROR / STATUS_ERRORthe service itself failed

code is a stable constant — branch on it, not on message.

CORS and Chrome's Private Network Access

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 Origin in Access-Control-Allow-Origin and always sends Vary: Origin;
  • answers OPTIONS preflights with the allowed methods and headers;
  • answers a preflight carrying Access-Control-Request-Private-Network: true with Access-Control-Allow-Private-Network: trueChrome 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.

When every port is taken

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:

eventNamelocal_server.port_unavailable
severityerror
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.

Where this goes beyond the written contract

Four additions, all backwards-compatible — a client that ignores them still works:

AdditionWhy
service in /statusversion alone cannot identify the service during a port probe
{ error: { code, message } } on 4xx/5xxthe 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 checkan 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.

Telemetry

Two events go to SprintRay's telemetry endpoint:

eventNameWheneventData
scanner.connectedevery time the app is launched with a case — stamped at the launch, sent once the code has been exchanged{ connection, firmwareVersion }
local_server.port_unavailablethe 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_BRANDyour 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_URLthe whole endpoint, if the route ever moves off this gateway
SCANPRO_TELEMETRY_CHANNELrelease / 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

scanner.connected on every launch

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:

  • occurredAt and eventId are fixed when the launch arrives, because that is when the scanner connected — not when the batch happened to go out;
  • userId only 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 the sub claim 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 | unknown

In 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".

What identifies the machine

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.

Exit codes

  • 0 — token exchange + all uploads succeeded (or a register/status/unregister command completed)
  • 1 — bad arguments, missing env, a failed exchange/upload, or serve finding no free port

Building installers

npm run dist:win # Windows x64 → release/*.exe (NSIS installer)
npm run dist:mac # macOS arm64 → release/*.dmg + *.zip

Each platform builds on its own OS. Targets:

TargetArchOutputSupported on
Windowsx64NSIS installer (.exe), per-user, no admin neededWindows 10 1809 and newer
macOSarm64.dmg and .zipApple 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).

Signing (macOS: required, not optional)

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"# -> rejected

To ship a build testers can actually open, add these repository secrets and the release workflow signs (and notarizes) automatically:

SecretPurpose
MAC_CSC_LINKDeveloper ID Application certificate (.p12, base64-encoded)
MAC_CSC_KEY_PASSWORDpassword for that .p12
APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_IDnotarization

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

Run the workflow manually (Actions → release → Run workflow) to build both targets without cutting a release — the installers come back as workflow artifacts.

Treatment scan files by treatment type

Files a doctor uploads when submitting a treatment, exported from DS production (TreatmentTypeTreatmentTypeFile, 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.

TreatmentTypeTitleType (TreatmentFiles)RequiredAcceptMaxMB
AI Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
AI RestorationsUpper Prepped Scan77 (PreppedUpperJaw)Yes.stl1024
AI RestorationsLower Prepped Scan78 (PreppedLowerJaw)Yes.stl1024
AI RetainerUpper Scan1 (UpperJaw)No.stl,.ply1024
AI RetainerLower Scan2 (LowerJaw)No.stl,.ply1024
AI Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply1024
AI Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply1024
Bleaching Tray ModelsUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bleaching Tray ModelsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bleaching Tray ModelsLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bonded RestorationsUpper Scan97 (PreOpUpperJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Bonded RestorationsLower Scan98 (PreOpLowerJaw)No.stl,.ply,.obj,.dcm1024
Bonded RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Bracket RemovalMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Bracket RemovalSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Bracket RemovalMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersMaxillary scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersPANO X-ray38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Face42 (LeftSideFace)Yes.jpeg,.jpg,.png1024
Clear AlignersUpper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersLower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Clear AlignersFront Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersRight Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersLeft Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Clear AlignersMandibular scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Clear AlignersCEPH X-ray37 (CephXRayPhoto)No.jpeg,.jpg,.png1024
Clear AlignersBite Scan58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Definitive CrownMaxillary scan1 (UpperJaw)Yes.stl1024
Definitive CrownLeft side3 (LeftSide)No.stl1024
Definitive CrownSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Definitive CrownMandibular scan2 (LowerJaw)Yes.stl1024
Definitive CrownRight side4 (RightSide)No.stl1024
Dental ModelUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Dental ModelBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Full DenturesUpper Scan1 (UpperJaw)Yes.stl,.zip300
Full DenturesUpload any additional images.5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Full DenturesUpper Wax Rim Scan24 (WaxUpUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Wax Rim Scan35 (WaxUpLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Scan2 (LowerJaw)Yes.stl,.zip300
Full DenturesUpper Denture Scan59 (DentureUpperStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesLower Denture Scan60 (DentureLowerStl)Yes.stl,.ply,.obj,.dcm1024
Full DenturesBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Jaw1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesPatient Records or Files52 (PhotogrammetryData)No.zip1024
Hybrid DenturesUpper Appliance Scan70 (UpperAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpper Antagonist72 (UpperAntagonist)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Jaw2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
Hybrid DenturesBite Scan58 (BiteScanCombo)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Appliance Scan71 (LowerAppliance)Yes.stl,.ply,.obj,.dcm1024
Hybrid DenturesLower Antagonist73 (LowerAntagonist)Yes.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Implant Planning and Surgical GuideUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Implant Planning and Surgical GuideDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Implant Planning and Surgical GuideLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scan77 (PreppedUpperJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsLower Scan78 (PreppedLowerJaw)Yes.stl,.ply,.obj,.dcm1024
Implant RestorationsBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Implant RestorationsUpper Scanbody Scan102 (UpperScanbodyScan)No.stl,.dcm,.ply,.obj1024
Implant RestorationsLower Scanbody Scan103 (LowerScanbodyScan)No.stl,.dcm,.ply,.obj1024
MomentUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm
MomentBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
MomentPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp
MomentLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm
Neer VeneerUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Neer VeneerBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Neer VeneerPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Neer VeneerLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Night GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Night GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpper Jaw1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
OverdentureUpper Tissue Scan50 (UpperTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdenturePatient Records or Files52 (PhotogrammetryData)No.zip1024
OverdentureUpper Appliance Scan70 (UpperAppliance)No.stl,.ply,.obj,.dcm1024
OverdentureLower Jaw2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
OverdentureLower Tissue Scan51 (LowerTissueScan)Yes.stl,.ply,.obj,.dcm1024
OverdentureUpload Pictures Of Patient Smiling15 (PatientPhoto)No.jpeg,.jpg,.png1024
OverdentureLower Appliance Scan71 (LowerAppliance)No.stl,.ply,.obj,.dcm1024
Partial DentureUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Partial DentureSupporting Images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Partial DentureBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm
Partial DentureSupporting Images94 (TreatmentTeethImage)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1
Partial DentureLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
RetainerUpper Scan1 (UpperJaw)No.stl,.ply,.obj,.dcm1024
RetainerLower Scan2 (LowerJaw)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Front Face39 (FrontFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Bite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Upper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Panorex or FMX38 (PanoXRayPhoto)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Smile40 (FrontSmile)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Smile Correct (up to 7 stages)Right Side Face41 (RightSideFace)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Upper Jaw46 (UpperJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Lower Jaw47 (LowerJawImage)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Front Teeth43 (FrontTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Right Side Teeth44 (RightSideTeeth)Yes.jpeg,.jpg,.png1024
Smile Correct (up to 7 stages)Left Side Teeth45 (LeftSideTeeth)Yes.jpeg,.jpg,.png1024
Smile DesignUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Smile DesignBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Smile DesignPictures of Patient Smiling63 (SmileDesignFrontSmile)No.jpeg,.jpg,.png,.gif,.svg,.bmp1024
Smile DesignLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Sports GuardBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationUpload full .ZIP file9 (CBCT)Yes.dicom,.zip1024
Surgical Guide with RestorationUpload any additional images5 (Other)No.jpeg,.jpg,.png,.gif,.svg,.bmp100
Surgical Guide with RestorationDenture/Wax Setup Scan49 (DentureWaxSetup)No.stl,.ply,.obj,.dcm1024
Surgical Guide with RestorationLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024
Trial SmileUpper Scan1 (UpperJaw)Yes.stl,.ply1024
Trial SmileLower Scan2 (LowerJaw)Yes.stl,.ply1024
Trial SmileBite Scan58 (BiteScanCombo)No.stl,.ply1024
Trial SmileFrontal40 (FrontSmile)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileProfile 45 Degree101 (Profile45Degree)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileLeft Side42 (LeftSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
Trial SmileRight Side41 (RightSideFace)Yes.jpg,.jpeg,.png,.bmp,.webp1024
VeneersUpper Scan1 (UpperJaw)Yes.stl,.ply,.obj,.dcm1024
VeneersBite Scans58 (BiteScanCombo)No.stl,.ply,.obj,.dcm1024
VeneersPictures of Patient Smiling74 (VeneersDesignFrontSmile)Yes.jpeg,.jpg,.png,.gif,.svg,.bmp1024
VeneersLower Scan2 (LowerJaw)Yes.stl,.ply,.obj,.dcm1024

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages