What's New
- Billing (metered, pay-for-value) — All prediction endpoints now return a
billingobject in the response. You are only charged when the algorithm returns a valid VHS measurement. Failed predictions, rejected images, and rechecks are always free. - API Key Security — API keys are now stored as SHA-256 hashes. Raw keys are never stored server-side. Treat your API key like a password — it cannot be recovered if lost.
Authentication
All endpoints require the x-api-key header. Include your API key with every request:
x-api-key: YOUR_API_KEYAPI keys are provisioned per customer. Request an API key.
Endpoints Overview
| Method | Path | Description | Billable |
|---|---|---|---|
GET | /vhs/v3/health | Service health check | No |
POST | /vhs/v3/predict | Single or batch image prediction | Yes |
POST | /vhs/v3/predict/stream | Streaming batch prediction (SSE) | Yes |
POST | /vhs/v3/pop-session/start | Start a PoP capture session | No |
POST | /vhs/v3/pop-session | Send a base64-encoded frame to a PoP session | Yes |
POST | /vhs/v3/pop-session/frame/upload-url | Mint a short-lived signed URL for direct-to-GCS frame upload | No |
POST | /vhs/v3/pop-session/frame/by-key | Run inference on a frame already uploaded to GCS via signed URL | Yes |
GET /vhs/v3/health
Returns the current health status of the API service and loaded models.
Response
{
"status": "healthy",
"model_version": "3.6.0-ensemble"
}Response Fields
| Field | Type | Description |
|---|---|---|
status | string | Service health status. Always "healthy" in V3.6. A "degraded" value is reserved and not currently emitted by production. |
model_version | string | Current model version string |
POST /vhs/v3/predict
Submit one or more base64-encoded radiograph images for cardiac measurement. Returns VHS, VLAS, landmark coordinates, confidence metrics, and an annotated image.
Request Headers
| Header | Required | Value |
|---|---|---|
x-api-key | Yes | Your API key |
Content-Type | Yes | application/json |
Single Image Request
{
"image": "<base64-encoded-image>",
"uuid": "patient-123",
"render": true
}Batch Request (up to 10 images)
{
"images": ["<base64>", "<base64>"],
"uuid": "patient-123",
"render": true
}Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
image | string | Yes | Base64-encoded JPEG or PNG image |
uuid | string | No | Patient identifier for historical tracking. Also used for 24h recheck billing grace. |
render | boolean | No | If true, returns a rendered_image with overlaid landmarks and scores. Default: false. |
images | string[] | No | Array of base64-encoded images for batch mode (max 10). Use instead of image for batch requests. |
Single Image Response
{
"vhs": 10.4,
"vlas": 2.8,
"prediction": [
{ "x": 0.42, "y": 0.15, "class": "heart_top" },
{ "x": 0.41, "y": 0.44, "class": "heart_bottom" },
{ "x": 0.25, "y": 0.30, "class": "heart_right" },
{ "x": 0.57, "y": 0.29, "class": "heart_left" },
{ "x": 0.49, "y": 0.40, "class": "VLAS" },
{ "x": 0.36, "y": 0.12, "class": "Vertebra A" },
{ "x": 0.55, "y": 0.12, "class": "Vertebra B" }
],
"result_code": 1,
"result_description": "VHS and VLAS predicted successfully with high confidence.",
"model_confidence": "optimal",
"spread": 0.0032,
"vhs_status": "valid",
"vhs_status_message": null,
"show_result": true,
"line_confidence": {
"long_axis": "optimal",
"short_axis": "optimal",
"vertebral": "optimal",
"vlas": "good"
},
"retake_guidance": null,
"pop_detected": false,
"pipeline_version": "3.6.0",
"model_version": "3.6.0-ensemble",
"landmarks_detected": true,
"inference_time_ms": 612,
"rendered_image": "<base64-encoded-annotated-image>",
"historic_vhs": [["2026-03-27", 11.38]],
"historic_vlas": [["2026-03-27", 1.88]],
"billing": {
"billable": true,
"reason": "valid_vhs",
"call_type": "single",
"monthly_usage": 47
}
}Response Fields
| Field | Type | Description |
|---|---|---|
vhs | number | Vertebral Heart Score |
vlas | number | Vertebral Left Atrial Score |
prediction | object[] | Array of 7 landmark coordinates (x, y, class). Coordinates are normalized 0.0–1.0. |
result_code | integer | Numeric result code (see Result Codes) |
result_description | string | Human-readable result description |
model_confidence | string | Confidence level: "optimal", "good", or "review" |
spread | number | Ensemble spread metric (lower is better). Measures agreement across the 3 fold models. |
vhs_status | string | "valid", "unusual", "error", or "no_measurement". |
vhs_status_message | string | null | Human-readable status explanation. |
show_result | boolean | Whether to display the VHS value to the end user. |
line_confidence | object | null | Per-measurement-line confidence: long_axis, short_axis, vertebral, vlas, each "optimal"/"good"/"review". |
retake_guidance | string | null | Specific guidance for retaking the photo. |
pop_detected | boolean | Whether the crop detector identified a screen in the image (true for phone photos, false for clean DICOM uploads). |
pipeline_version | string | Processing pipeline version (e.g. "3.6.0"). |
model_version | string | Model version used for prediction. |
landmarks_detected | boolean | Whether all 7 landmarks were found. |
inference_time_ms | number | Total inference time in milliseconds. |
rendered_image | string | Base64-encoded annotated image with landmarks and measurements overlaid. Only present when render: true is set in the request. |
historic_vhs | null | [date, number][] | Previous measurements for this uuid as [[date, value]] pairs. Populated when a uuid is provided. Empty array if no history exists. |
historic_vlas | null | [date, number][] | Previous measurements for this uuid as [[date, value]] pairs. Populated when a uuid is provided. Empty array if no history exists. |
billing | object | Billing metadata for this request. See Billing section. |
Rejection Response
When an image is rejected (result_code 6), the response includes additional fields: classifier_confidence, rejected, and rejection_reason. VHS and VLAS will be null.
{
"classifier_confidence": 0.7937,
"model_version": "3.6.0-ensemble",
"rejected": true,
"rejection_reason": "not_a_valid_radiograph",
"result_code": 6,
"result_description": "Image rejected — not a valid radiograph.",
"vhs": null,
"vlas": null,
"billing": {
"billable": false,
"reason": "no_valid_vhs",
"monthly_usage": 47
}
}When an image is rejected, the following standard fields are absent from the response: prediction, model_confidence, spread, pop_detected, rendered_image, historic_vhs, historic_vlas.
Batch Response
Each object inside per_photo is the full single-image response (all fields from Single Image Response). The abbreviated entries below are shown for readability — expect the same shape returned by POST /vhs/v3/predict on a single image.
{
"vhs": 11.32,
"vlas": 1.87,
"best_photo_index": 0,
"best_vhs": 11.32,
"best_vlas": 1.87,
"median_vhs": 11.32,
"median_vlas": 1.87,
"vhs_std": null,
"vlas_std": null,
"vhs_confidence": "optimal",
"vlas_confidence": "optimal",
"n_photos_processed": 2,
"n_photos_with_landmarks": 1,
"processing_started": true,
"per_photo": [
{
"vhs": 11.32,
"vlas": 1.87,
"spread": 0.0023,
"model_confidence": "optimal",
"landmarks_detected": true,
"pop_detected": false
},
{
"vhs": null,
"vlas": null,
"spread": null,
"model_confidence": "review",
"landmarks_detected": false,
"rejected": true,
"rejection_reason": "not_a_valid_radiograph",
"classifier_confidence": 0.7937
}
],
"historic_vhs": [["2024-01-26", 9.23]],
"historic_vlas": [["2024-01-26", 2.12]],
"model_version": "3.6.0-ensemble",
"billing": {
"billable": true,
"reason": "valid_vhs",
"call_type": "single",
"images_total": 2,
"images_billable": 1,
"monthly_usage": 48
}
}Billing: Batch predictions are billed per image — each image that produces a valid VHS counts as one billable single. Images that fail to produce a VHS are not billed.
Batch Response Fields
| Field | Type | Description |
|---|---|---|
vhs | float | null | Best VHS measurement from the batch |
vlas | float | null | Best VLAS measurement from the batch |
best_photo_index | integer | Index of the best image in the batch |
best_vhs | float | Best VHS across frames with detected landmarks |
best_vlas | float | Best VLAS across frames with detected landmarks |
median_vhs | float | Median VHS across frames with detected landmarks |
median_vlas | float | Median VLAS across frames with detected landmarks |
vhs_std | float | null | Standard deviation of VHS across frames |
vlas_std | float | null | Standard deviation of VLAS across frames |
vhs_confidence | string | Batch-level aggregate confidence for VHS across the frames (optimal, good, review). Distinct from per-image model_confidence. |
vlas_confidence | string | Batch-level aggregate confidence for VLAS across the frames. Distinct from per-image model_confidence. |
n_photos_processed | integer | Total number of images processed |
n_photos_with_landmarks | integer | Number of images where landmarks were detected |
processing_started | boolean | Whether processing began |
per_photo | object[] | Per-image results. Each entry is the full single-image response (same shape as POST /vhs/v3/predict on a single image). |
per_photo[].vhs | float | null | VHS for this image |
per_photo[].vlas | float | null | VLAS for this image |
per_photo[].spread | float | null | Ensemble spread for this image (3-fold agreement, lower is better). |
per_photo[].model_confidence | string | Confidence tier for this image |
per_photo[].landmarks_detected | boolean | Whether landmarks were found |
per_photo[].pop_detected | boolean | Whether PoP was detected (only on successful frames) |
historic_vhs | array | Historical VHS as [[date, value]] pairs (top-level, not per-photo) |
historic_vlas | array | Historical VLAS as [[date, value]] pairs (top-level, not per-photo) |
billing | object | Billing metadata for this request. See Billing section. |
Error Responses
| HTTP Status | Meaning | Example Body |
|---|---|---|
400 | Bad Request — missing or invalid fields | {"error": "No image data"} |
403 | Forbidden — invalid or missing API key | {"error": "Access Denied"} |
413 | Payload Too Large — image exceeds 10 MB. Enforced by the Cloud Run request-size limit, not the application. | {"error": "Image exceeds maximum size of 10MB"} |
500 | Internal Server Error | {"error": "Internal server error"} |
503 | Service Unavailable — PoP pipeline not loaded | {"error": "PoP pipeline not available"} |
The error message string is human-readable and may vary (e.g. "No images array", "Malformed request", "Invalid base64"). Clients should branch on the HTTP status code, not the text.
POST /vhs/v3/predict/stream
Submit a batch of images and receive results as Server-Sent Events (SSE). The request body is the same as the batch /vhs/v3/predict endpoint.
SSE Events
processing_started
event: processing_started
data: {"n_images": 2, "model_version": "3.6.0-ensemble"}photo (emitted per image)
event: photo
data: {
"photo_index": 0,
"vhs": 11.32,
"vlas": 1.87,
"prediction": [ /* 7 landmark objects */ ],
"result_code": 1,
"result_description": "VHS and VLAS predicted successfully with high confidence.",
"model_confidence": "optimal",
"spread": 0.0023,
"vhs_status": "valid",
"vhs_status_message": null,
"show_result": true,
"line_confidence": {
"long_axis": "optimal",
"short_axis": "optimal",
"vertebral": "optimal",
"vlas": "optimal"
},
"retake_guidance": null,
"pop_detected": false,
"pipeline_version": "3.6.0",
"model_version": "3.6.0-ensemble",
"landmarks_detected": true,
"inference_time_ms": 612
}The photo event carries the full single-image response (see POST /vhs/v3/predict) plus a photo_index field and the model_version.
summary
event: summary
data: {
"best_vhs": 11.32,
"best_vlas": 1.87,
"median_vhs": 11.32,
"median_vlas": 1.87,
"vhs_std": null,
"vlas_std": null,
"vhs_confidence": "optimal",
"vlas_confidence": "optimal",
"best_photo_index": 0,
"n_photos_processed": 2,
"n_photos_with_landmarks": 1,
"historic_vhs": [["2026-03-27", 11.38]],
"historic_vlas": [["2026-03-27", 1.88]],
"billing": {
"billable": true,
"reason": "valid_vhs",
"call_type": "session",
"monthly_usage": 48
}
}Billing: Stream predictions are billed as a single session event, regardless of how many images are in the batch.
error
event: error
data: {"index": 1, "uuid": "patient-456", "error": "Invalid image data"}POST /vhs/v3/pop-session/start
Initialize a new Picture-of-Picture (PoP) capture session. The session guides the client through a multi-frame capture flow to obtain the best possible radiograph image from a screen photo.
Request
Send an empty JSON body:
POST /vhs/v3/pop-session/start
Content-Type: application/json
x-api-key: YOUR_API_KEY
{}Response
{
"session_id": "0960abaa8179476d",
"session_status": "ready",
"pop_pipeline_enabled": true,
"models_loaded": 3,
"pop_models_loaded": 1,
"model_version": "3.6.0-ensemble",
"session_config": {
"max_frames": 20,
"max_consecutive_failures": 5,
"session_ttl_seconds": 300
}
}Response Fields
| Field | Type | Description |
|---|---|---|
session_id | string | Unique session identifier to include in subsequent requests |
session_status | string | Current session state: "ready" |
pop_pipeline_enabled | boolean | Whether the PoP pipeline is available in this deployment |
models_loaded | integer | Number of main ensemble fold models loaded (3 in V3.6) |
pop_models_loaded | integer | Number of PoP preprocessing models loaded |
model_version | string | Model version identifier |
session_config | object | Session configuration including max frames, max consecutive failures, and session TTL |
POST /vhs/v3/pop-session
Send a captured frame to an active PoP session. The server evaluates the frame quality and either requests another frame or completes the session with a prediction.
Request
{
"image": "<base64-encoded-frame>",
"session_id": "sess_abc123def456",
"frame_uuid": "f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a",
"uuid": "patient-123"
}Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
image | string | Yes | Base64-encoded JPEG frame. |
session_id | string | Yes | Active session ID returned by /vhs/v3/pop-session/start. |
frame_uuid | string | Yes | Client-generated UUID for this frame. The server stores it for frames in which landmarks are detected and, on session completion, echoes the chosen frame's value back as chosen_frame_uuid so the client can map the result to a specific captured frame. Requests missing this field are rejected with 400 frame_uuid is required. |
uuid | string | No | Optional patient or case identifier echoed in logs and billing records. |
Response: Continue — Successful Frame
{
"session_id": "0960abaa8179476d",
"session_status": "continue",
"frame_index": 0,
"frames_processed": 1,
"good_frames": 1,
"reason": "1/3 good frames so far, need more",
"result_code": 1,
"result_description": "VHS and VLAS predicted successfully with high confidence.",
"frame_result": {
"vhs": 10.99,
"vlas": 1.51,
"spread": 0.0048,
"model_confidence": "good",
"landmarks_detected": true,
"pop_detected": true
},
"model_version": "3.6.0-ensemble"
}Response: Continue — Failed Frame
{
"session_id": "0960abaa8179476d",
"session_status": "continue",
"reason": "0/3 good frames so far, need more",
"frame_index": 0,
"frames_processed": 1,
"good_frames": 0,
"result_code": 5,
"result_description": "Neither VHS nor VLAS could be calculated — check image quality.",
"frame_result": {
"vhs": null,
"vlas": null,
"spread": null,
"model_confidence": "review",
"landmarks_detected": false,
"pop_detected": true
},
"model_version": "3.6.0-ensemble"
}No billing field is present during continue — billing is only evaluated on session completion or failure.
Response: Complete (session succeeded)
{
"session_id": "sess_abc123def456",
"session_status": "complete",
"frame_index": 3,
"frames_processed": 4,
"good_frames": 3,
"reason": "Solid measurement from 3 frame(s)",
"vhs": 10.42,
"vlas": 2.81,
"median_vhs": 10.41,
"median_vlas": 2.82,
"vhs_std": 0.08,
"vlas_std": 0.04,
"n_good_frames": 3,
"chosen_frame_uuid": "f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a",
"frame_result": {
"vhs": 10.4,
"vlas": 2.8,
"spread": 0.0028,
"model_confidence": "optimal",
"landmarks_detected": true,
"pop_detected": true,
"prediction": [
{ "x": 0.42, "y": 0.15, "class": "heart_top" },
{ "x": 0.41, "y": 0.44, "class": "heart_bottom" },
{ "x": 0.25, "y": 0.30, "class": "heart_right" },
{ "x": 0.57, "y": 0.29, "class": "heart_left" },
{ "x": 0.49, "y": 0.40, "class": "VLAS" },
{ "x": 0.36, "y": 0.12, "class": "Vertebra A" },
{ "x": 0.55, "y": 0.12, "class": "Vertebra B" }
]
},
"billing": {
"billable": true,
"reason": "valid_vhs",
"call_type": "session",
"monthly_usage": 49
}
}Complete Response Fields
| Field | Type | Description |
|---|---|---|
vhs / vlas | float | Best measurement from the session (from the frame with lowest ensemble spread). |
median_vhs / median_vlas | float | Median across good frames. |
vhs_std | float | null | Standard deviation of VHS across good frames. Null if fewer than 2 VHS measurements. |
vlas_std | float | null | Standard deviation of VLAS across good frames. Null if fewer than 2 VLAS measurements. |
n_good_frames | integer | Number of good frames aggregated into the result. |
chosen_frame_uuid | string | The frame_uuid the client sent for the frame whose measurement was selected as the session result (lowest ensemble spread among successful frames). The sole identifier for the best frame — use it to map session output back to the captured image. Only present on session_status: "complete"; never on continue or failed. |
good_frames | integer | Running good-frame counter at session end. |
frames_processed | integer | Total frames submitted during the session. |
reason | string | Human-readable completion reason (e.g. "Solid measurement from 3 frame(s)"). |
frame_result | object | Sanitized per-frame subset (vhs, vlas, spread, model_confidence, landmarks_detected, pop_detected). On session completion, frame_result also includes a prediction array with landmark coordinates from the best frame (lowest ensemble spread), matching the /vhs/v3/predict response shape. |
billing | object | Billing metadata. See Billing section. |
Response: Failed (session could not complete)
{
"session_id": "sess_abc123def456",
"session_status": "failed",
"frame_index": 20,
"frames_processed": 20,
"good_frames": 0,
"reason": "No landmarks detected in 5 consecutive frames",
"result_code": 5,
"result_description": "Neither VHS nor VLAS could be calculated — check image quality.",
"frame_result": {
"vhs": null,
"vlas": null,
"spread": null,
"model_confidence": "review",
"landmarks_detected": false,
"pop_detected": true
},
"model_version": "3.6.0-ensemble",
"billing": {
"billable": false,
"reason": "no_valid_vhs",
"monthly_usage": 49
}
}A PoP session is billed as a single event at the session rate when it completes with a valid VHS. Failed sessions are never billed.
Session Completion Logic
| Condition | Result |
|---|---|
| Frame quality is sufficient | Session completes with prediction |
Frame count reaches max_frames or max_consecutive_failures without enough good frames | Session fails with a human-readable reason (e.g. "No landmarks detected in 5 consecutive frames") |
| Frame quality insufficient | Returns continue with frame result details |
POST /vhs/v3/pop-session/frame/upload-url
Mint a short-lived V4 signed PUT URL so the client can upload a raw JPEG frame directly to Google Cloud Storage, bypassing base64 encoding and the JSON request body. Pair with POST /vhs/v3/pop-session/frame/by-key to run inference on the uploaded frame.
Use this path on mobile clients that capture many frames per session — uploading raw JPEG bytes shrinks the wire payload by ~25% versus base64 and lets you pipeline frame N's upload against frame N+1's capture and compress.
Request
POST /vhs/v3/pop-session/frame/upload-url
Content-Type: application/json
x-api-key: YOUR_API_KEY
{
"session_id": "0960abaa8179476d",
"frame_uuid": "f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a"
}Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
session_id | string | Yes | Active session ID returned by /vhs/v3/pop-session/start. Returns 404 if the session is missing or expired. |
frame_uuid | string | Yes | Client-generated UUID for this frame. Used as the GCS object filename so each upload is uniquely addressable. |
Response
{
"upload_url": "https://storage.googleapis.com/ra-v3-frames-us-central1/sessions/0960abaa8179476d/frames/f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a.jpg?X-Goog-Signature=...",
"object_key": "sessions/0960abaa8179476d/frames/f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a.jpg",
"expires_at": 1777167811363
}Response Fields
| Field | Type | Description |
|---|---|---|
upload_url | string | V4 signed PUT URL. Issue an HTTP PUT with Content-Type: image/jpeg and the raw JPEG bytes as the body. Do not send the API key on this request. |
object_key | string | GCS object key under which the frame will be stored. Pass this back to /vhs/v3/pop-session/frame/by-key. |
expires_at | integer | URL expiry, Unix milliseconds. Signed URLs are valid for 60 seconds — mint a new URL if the upload does not start within that window. |
Frames are written to a regional bucket co-located with the Cloud Run service and auto-deleted after 24 hours by a GCS lifecycle rule. Buckets are private with uniform bucket-level access; only the service account can read or write.
POST /vhs/v3/pop-session/frame/by-key
Run inference on a frame previously uploaded to GCS via a signed URL from /vhs/v3/pop-session/frame/upload-url. Returns the same response shape as POST /vhs/v3/pop-session — both paths invoke the identical inference pipeline, so VHS, VLAS, result codes, and session-completion behavior are bit-for-bit identical.
Request
POST /vhs/v3/pop-session/frame/by-key
Content-Type: application/json
x-api-key: YOUR_API_KEY
{
"session_id": "0960abaa8179476d",
"frame_uuid": "f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a",
"object_key": "sessions/0960abaa8179476d/frames/f3c1a2e4-7b9d-4c8e-a1f2-9d3b4e5c6f7a.jpg"
}Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
session_id | string | Yes | Active session ID. Same value passed to /upload-url. |
frame_uuid | string | Yes | Client-generated frame UUID. Same value passed to /upload-url. |
object_key | string | Yes | GCS object key returned by /upload-url. Must start with sessions/<session_id>/frames/ — cross-session keys are rejected. |
Response
Identical to POST /vhs/v3/pop-session: session_status (continue, complete, or failed), vhs, vlas, result_code, result_description, frame_result, and billing on completion. On complete only, the response also includes chosen_frame_uuid echoing the client's frame_uuid for the chosen frame — the sole identifier for the best frame. Never present on continue or failed. See the POST /vhs/v3/pop-session section above for the full response schemas and field reference.
Billing is unchanged: a session is billed once at the session rate when it completes with a valid VHS, regardless of which upload path the frames took. The frame blob is best-effort deleted from GCS after inference; the 24-hour lifecycle rule is the backstop.
Errors
| Status | Reason |
|---|---|
400 | object_key does not match sessions/<session_id>/frames/ prefix. |
404 | Session expired or missing, or the GCS object was not found (upload never completed or already cleaned up). |
Landmarks
Each prediction returns 7 anatomical landmarks identified on the radiograph. Coordinates are normalized (0.0–1.0) relative to image dimensions.
| Class | Description |
|---|---|
heart_top | Cranial-most point of the cardiac silhouette |
heart_bottom | Caudal-ventral apex of the cardiac silhouette |
heart_right | Right-most (cranial) extent of the heart border |
heart_left | Left-most (caudal) extent of the heart border |
VLAS | Caudal-dorsal point where the left atrium meets the vertebral column |
Vertebra A | Cranial edge of T4 vertebra (start of vertebral measurement) |
Vertebra B | Caudal edge of the vertebra where the long-axis measurement ends |
Result Codes
| Code | Description | Billable |
|---|---|---|
0 | Abnormal result — unexpected prediction state. | No |
1 | VHS and VLAS predicted successfully with high confidence. | Yes |
2 | VHS and VLAS predicted successfully with moderate confidence. | Yes |
3 | VHS and VLAS predicted but confidence is low — review recommended. | Yes |
4 | VHS predicted successfully, VLAS could not be calculated. | Yes |
5 | Neither VHS nor VLAS could be calculated — check image quality. | No |
6 | Image rejected — not a valid radiograph. | No |
7 | PoP preprocessing failed — landmarks could not be detected from screen photo. | No |
Measurements
VHS (Vertebral Heart Score)
The long axis (heart_top to heart_bottom) and short axis (heart_right to heart_left) are measured in pixels and normalized by the per-vertebra unit, defined as the T4–T9 length divided by 5.
VHS = (long_axis + short_axis) / ((length(T4, T9)) / 5)Normal range (canine): 8.5 – 10.5 vertebrae
VLAS (Vertebral Left Atrial Score)
Distance from heart_top to the VLAS landmark, normalized by the same per-vertebra unit (T4–T9 length divided by 5).
VLAS = distance(heart_top, VLAS_landmark) / ((length(T4, T9)) / 5)Normal range (canine): < 2.5 vertebrae. Values ≥ 2.5 suggest left atrial enlargement.
Confidence Scoring
Confidence is determined per-image by the 3-fold ensemble, which produces a spread value representing variance between fold predictions. Both clean (direct) radiographs and PoP (picture-of-picture) images run through the same ensemble in V3.6, so spread is always populated for successful predictions. Use model_confidenceas the primary confidence indicator.
Clean Radiographs (Ensemble)
| Confidence | Spread Threshold | Recommendation |
|---|---|---|
| Optimal | < 0.005 | Results are reliable |
| Good | < 0.008 | Results are usable; visual verification suggested |
| Review | ≥ 0.008 | Manual review recommended; consider re-submitting a higher-quality image |
PoP Images (Geometry & Sanity Fallback)
| Confidence | Criteria | Recommendation |
|---|---|---|
| Optimal | Valid geometry + high heatmap sharpness | Results are reliable |
| Good | Valid geometry + valid VHS range | Results are usable; visual verification suggested |
| Review | Geometry check failed or VHS out of range | Manual review recommended; try repositioning the camera |
Rate Limits
| Parameter | Limit |
|---|---|
| Max batch size | 10 images per request |
| Max image size | 10 MB per image |
| Accepted formats | JPEG, PNG |
| PoP session TTL | 300 seconds (5 minutes) |
| Max PoP frames per session | 20 frames |
| Request timeout | 300 seconds |
| Cold start latency | ~6–7 seconds |
| Warm inference (clean images) | ~1.5 seconds per image |
| Warm inference (PoP images) | ~2–3 seconds per image (includes PoP detection and cropping) |
Input Resolution
Images larger than 1080px on their longest side are automatically downsampled before processing. This ensures consistent performance across different camera resolutions and monitor types. No action required from the client — preprocessing is handled server-side. EXIF orientation data is also automatically applied.
Billing
Overview
The V3 API uses pay-for-value billing. You are only charged when the algorithm returns a valid VHS measurement (result codes 1–4). Failed predictions, rejected images, and utility endpoints are always free.
Billing Rules
| Rule | Description |
|---|---|
| Valid VHS = billed | Result codes 1–4 incur a charge |
| No VHS = free | Result codes 0, 5, 6 are never charged (code 7 is reserved and unused in V3.6) |
| Single image | 1 billable event per valid VHS |
| Batch | N billable events — 1 per valid VHS image in the batch |
| Stream | 1 billable session event |
| PoP session | 1 billable session event on completion |
| 24h recheck grace | VHS only. Same uuid within 24 hours is free across single, batch, stream, and PoP. Pass a patient identifier in the uuid field to enable this. Does not apply to USG calls. |
| USG calls | Every successful USG prediction is billed at a flat per-call rate. No recheck exemption. |
| Utility endpoints = free | /health, /pop-session/start, and /pop-session/frame/upload-url are never billed |
Pricing
Pricing is volume-based and negotiated per customer. As a rough anchor: VHS single-image calls start at $2 per image and USG calls start at $1 per call, with discounts as monthly volume grows. A single API key is metered against one combined monthly counter regardless of which algorithm you call.
Contact us with your expected monthly volume for a quote.
Call Types
| Endpoint | Call Type | Rate |
|---|---|---|
POST /vhs/v3/predict (single image) | single | Single rate |
POST /vhs/v3/predict (batch images) | session | Session rate |
POST /vhs/v3/predict/stream | session | Session rate |
POST /vhs/v3/pop-session (on complete) | session | Session rate |
POST /vhs/v3/pop-session/frame/by-key (on complete) | session | Session rate |
Billing Response Object
Three example shapes depending on the billing outcome:
Billable call
{
"billable": true,
"reason": "valid_vhs",
"call_type": "single",
"monthly_usage": 47
}Non-billable call
{
"billable": false,
"reason": "no_valid_vhs",
"monthly_usage": 47
}Non-billable recheck
{
"billable": false,
"reason": "recheck_24h",
"monthly_usage": 47
}Billing Response Fields
| Field | Type | Description |
|---|---|---|
billable | boolean | Whether this call was counted as a billable event |
reason | string | Why: "valid_vhs", "no_valid_vhs", "recheck_24h", or "billing_error" |
call_type | string | "single" for single image and batch requests, "session" for stream and PoP session requests. Only present on billable calls. |
images_total | integer | Total images in the batch request. Batch only. |
images_billable | integer | Number of images that produced a valid VHS and were billed. Batch only. |
monthly_usage | integer | Total billable calls this billing period |
Tips for Minimizing Costs
- Always pass
uuid— enables 24h recheck grace so repeat scans of the same patient within a day are free. - Use stream or PoP session for multiple images of the same patient — billed as a single session event regardless of how many frames are sent. Batch is billed per valid-VHS image.
- Pre-validate images before sending — ensure images are valid radiographs to avoid wasted requests.
Code Examples
Python — Single Image
import requests
import base64
# Read and encode the radiograph
with open("radiograph.jpg", "rb") as f:
image_data = base64.b64encode(f.read()).decode()
# Send prediction request
response = requests.post(
"https://api.radanalyzer.com/vhs/v3/predict",
headers={
"x-api-key": "YOUR_API_KEY",
"Content-Type": "application/json"
},
json={
"image": image_data,
"uuid": "patient-123",
"render": True
}
)
data = response.json()
print(f"VHS: {data['vhs']}")
print(f"VLAS: {data['vlas']}")
print(f"Confidence: {data['model_confidence']}")
print(f"Spread: {data['spread']}")
print(f"Result: {data['result_description']}")JavaScript — PoP Session Flow
const API_BASE = "https://api.radanalyzer.com";
const headers = {
"x-api-key": "YOUR_API_KEY",
"Content-Type": "application/json"
};
// Step 1: Start a PoP session
const startRes = await fetch(`${API_BASE}/vhs/v3/pop-session/start`, {
method: "POST",
headers,
body: JSON.stringify({})
});
const { session_id } = await startRes.json();
// Step 2: Send frames until session completes
let sessionComplete = false;
const framesByUuid = new Map(); // keep captures so we can recover the chosen frame
while (!sessionComplete) {
const { base64, blob } = await captureFrame(); // your capture function
const frame_uuid = crypto.randomUUID();
framesByUuid.set(frame_uuid, blob);
const frameRes = await fetch(`${API_BASE}/vhs/v3/pop-session`, {
method: "POST",
headers,
body: JSON.stringify({
image: base64,
session_id: session_id,
frame_uuid,
uuid: "patient-123"
})
});
const result = await frameRes.json();
if (result.session_status === "complete") {
console.log("VHS:", result.vhs);
console.log("VLAS:", result.vlas);
// Look up the frame the server picked as the best measurement
const chosenBlob = framesByUuid.get(result.chosen_frame_uuid);
sessionComplete = true;
} else if (result.session_status === "failed") {
console.error("Session failed:", result.reason);
sessionComplete = true;
} else {
console.log("Feedback:", result.reason);
// Show feedback to user, capture another frame
}
}JavaScript — PoP Session via Signed-URL Upload
Three-step flow per frame: mint a signed URL, PUT the raw JPEG to GCS, then ask the server to run inference on the uploaded object. Skips base64 encoding and shrinks the wire payload by ~25%.
const API_BASE = "https://api.radanalyzer.com";
const headers = {
"x-api-key": "YOUR_API_KEY",
"Content-Type": "application/json"
};
// Step 1: Start a PoP session
const startRes = await fetch(`${API_BASE}/vhs/v3/pop-session/start`, {
method: "POST",
headers,
body: JSON.stringify({})
});
const { session_id } = await startRes.json();
// Step 2: Capture and upload frames until the session completes
let sessionComplete = false;
while (!sessionComplete) {
const jpegBlob = await captureFrameAsJpeg(); // your capture function returns a Blob
const frame_uuid = crypto.randomUUID();
// 2a. Mint a signed URL
const urlRes = await fetch(`${API_BASE}/vhs/v3/pop-session/frame/upload-url`, {
method: "POST",
headers,
body: JSON.stringify({ session_id, frame_uuid })
});
const { upload_url, object_key } = await urlRes.json();
// 2b. PUT the raw JPEG bytes directly to GCS (no API key, no JSON wrapper)
await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": "image/jpeg" },
body: jpegBlob
});
// 2c. Run inference on the uploaded frame
const frameRes = await fetch(`${API_BASE}/vhs/v3/pop-session/frame/by-key`, {
method: "POST",
headers,
body: JSON.stringify({ session_id, frame_uuid, object_key })
});
const result = await frameRes.json();
if (result.session_status === "complete") {
console.log("VHS:", result.vhs, "VLAS:", result.vlas);
console.log("Chosen frame UUID:", result.chosen_frame_uuid);
sessionComplete = true;
} else if (result.session_status === "failed") {
console.error("Session failed:", result.reason);
sessionComplete = true;
} else {
console.log("Feedback:", result.reason);
}
}cURL — Health Check
curl -X GET https://api.radanalyzer.com/vhs/v3/health \
-H "x-api-key: YOUR_API_KEY"Changelog
| Version | Date | Changes |
|---|---|---|
3.6.0 | April 2026 | Unified 3-fold ensemble for clean and PoP images, per-line confidence, expanded public response fields (vhs_status, line_confidence, retake_guidance, pipeline_version), removed PoP perspective correction, added signed-URL frame upload path (/vhs/v3/pop-session/frame/upload-url + /vhs/v3/pop-session/frame/by-key) to skip base64 on mobile clients, frame_uuid required on every POST /vhs/v3/pop-session request (missing field returns 400 frame_uuid is required), and chosen_frame_uuid returned on session completion as the sole identifier of the best frame |
3.4.0 | April 2026 | Custom PoP landmark model (replaces v3.3 ensemble), resolution normalization, EXIF orientation handling, geometry-based confidence for PoP, reduced cold start time |
3.3.0 | March 2026 | PoP pipeline, session-based capture, corner detection, rectification |
3.0.0 | March 2026 | Initial V3 — 5-fold ensemble, batch mode, streaming, confidence scoring |