ReluAutomate
Quickstarts

Send cases to the Inbox

Send a private lab's cases and files to Relu without creating an order.

API integration

Use this integration when you built your own LMS or scanner intake for one lab and want to send its cases directly to the Inbox.

If your software serves several independent labs, do not store one customer's Relu API key in a shared connector. See Integration paths and contact Relu about a managed source connector instead.

This quickstart uploads two scans, commits the uploads, and creates one Inbox case. It does not create an order or start a Relu service.

Before you start

Create an API key. In the token dialog, choose Limited scopes, expand Inbox cases, and select:

  • create:inbox-cases to register files, commit uploads, and create the case.
  • read:inbox-cases if your integration also reads the accepted case.

Keep the API key on your server. Do not put it in a browser or desktop bundle.

Choose a stable external_case_id from your LMS. Relu uses this ID to make retries safe. You may also send a short source namespace, such as lab-lms or scanner-room-2, when several internal systems can produce the same ID. The source is free-form and does not have to name a scanner supported by Relu.

File-first flow

For every file:

  1. Call POST /inbox-cases/files to register it.
  2. PUT the bytes to the returned upload_url without a Relu API key.
  3. Call POST /inbox-cases/files/{file_id}/commit.

After every upload is committed, call POST /inbox-cases with the returned file IDs. This creates the case with its files already attached.

Set semantic_type whenever your system knows what a file is. Examples include lower_ios for a lower intraoral scan, bite_scan for an occlusion scan, and prescription for a PDF prescription. An explicit type lets downstream processing start sooner. If you do not know the canonical type, omit the field and pass through the metadata returned by the scanner. Relu can use that metadata to determine the file type.

The classification guide explains the full vocabulary and how to send scanner metadata.

Add case metadata

The metadata object on POST /inbox-cases is optional. Use it to include information that helps the lab identify and process the case:

{
  "external_case_id": "CASE-1042",
  "metadata": {
    "patient_name": "Jane Doe",
    "doctor_name": "Dr Example",
    "due_date": "2026-09-01",
    "comment": "Match the adjacent tooth."
  },
  "files": [{ "file_id": "<upper_file_id>" }, { "file_id": "<lower_file_id>" }]
}

Every field inside metadata is optional. Use comment for case notes. You may also send delivery_date, lab_name, and an external_id used by another source system. Omit fields your system does not know.

Complete examples

Both examples expect RELU_API_KEY, EXTERNAL_CASE_ID, and two file paths. PATIENT_NAME, RELU_API_BASE, and CASE_SOURCE are optional. Patient name is example case metadata, not a requirement for Inbox intake.

Install requests, then run:

python -m pip install requests
RELU_API_KEY=... \
EXTERNAL_CASE_ID=CASE-1042 \
PATIENT_NAME="Jane Doe" \
python inbox_intake.py upper.stl lower.stl
import os
import sys
from pathlib import Path

import requests


API_BASE = os.getenv("RELU_API_BASE", "https://api.automate.relu.ai").rstrip("/")
API_KEY = os.environ["RELU_API_KEY"]
EXTERNAL_CASE_ID = os.environ["EXTERNAL_CASE_ID"]
PATIENT_NAME = os.getenv("PATIENT_NAME")
CASE_SOURCE = os.getenv("CASE_SOURCE")

api = requests.Session()
api.headers.update({"X-API-Key": API_KEY})

# Keep storage requests separate so the API key is never sent to a signed URL.
storage = requests.Session()


def upload_case_file(path_string: str, semantic_type: str) -> str:
    path = Path(path_string)
    content_type = "application/octet-stream"

    registration = api.post(
        f"{API_BASE}/inbox-cases/files",
        json={
            "original_file_name": path.name,
            "name": path.stem,
            "display_name": path.name,
            "extension": path.suffix.lower(),
            "mime_type": content_type,
            "size_bytes": path.stat().st_size,
            "semantic_type": semantic_type,
        },
        timeout=(10, 30),
    )
    registration.raise_for_status()
    upload = registration.json()

    with path.open("rb") as file:
        stored = storage.put(
            upload["upload_url"],
            data=file,
            headers={"Content-Type": upload["upload_content_type"]},
            timeout=(10, 300),
        )
    stored.raise_for_status()

    committed = api.post(
        f"{API_BASE}/inbox-cases/files/{upload['file_id']}/commit",
        timeout=(10, 30),
    )
    committed.raise_for_status()
    return upload["file_id"]


if len(sys.argv) != 3:
    raise SystemExit("Usage: python inbox_intake.py UPPER_SCAN LOWER_SCAN")

upper_file_id = upload_case_file(sys.argv[1], "upper_ios")
lower_file_id = upload_case_file(sys.argv[2], "lower_ios")

case_body = {
    "external_case_id": EXTERNAL_CASE_ID,
    "files": [
        {"file_id": upper_file_id},
        {"file_id": lower_file_id},
    ],
}
if PATIENT_NAME:
    case_body["metadata"] = {"patient_name": PATIENT_NAME}
if CASE_SOURCE:
    case_body["source"] = CASE_SOURCE

created = api.post(
    f"{API_BASE}/inbox-cases",
    json=case_body,
    timeout=(10, 30),
)
created.raise_for_status()
print(created.json()["id"])

The TypeScript example runs on Node.js 20 or newer. Save it as inbox-intake.ts and run it with tsx:

RELU_API_KEY=... \
EXTERNAL_CASE_ID=CASE-1042 \
npx tsx inbox-intake.ts upper.stl lower.stl
import { readFile, stat } from 'node:fs/promises';
import { basename, extname } from 'node:path';

const apiBase = (
  process.env.RELU_API_BASE ?? 'https://api.automate.relu.ai'
).replace(/\/$/, '');
const apiKey = process.env.RELU_API_KEY;
const externalCaseId = process.env.EXTERNAL_CASE_ID;
const patientName = process.env.PATIENT_NAME;
const caseSource = process.env.CASE_SOURCE;

if (!apiKey || !externalCaseId) {
  throw new Error('Set RELU_API_KEY and EXTERNAL_CASE_ID before running.');
}

async function relu(path: string, init: RequestInit = {}): Promise<Response> {
  const response = await fetch(`${apiBase}${path}`, {
    ...init,
    headers: {
      'X-API-Key': apiKey,
      ...(init.body ? { 'Content-Type': 'application/json' } : {}),
      ...init.headers,
    },
    signal: AbortSignal.timeout(30_000),
  });
  if (!response.ok) {
    throw new Error(
      `Relu API returned ${response.status}: ${await response.text()}`,
    );
  }
  return response;
}

async function uploadCaseFile(
  path: string,
  semanticType: 'upper_ios' | 'lower_ios',
): Promise<string> {
  const fileName = basename(path);
  const extension = extname(fileName).toLowerCase();
  const fileSize = (await stat(path)).size;
  const contentType = 'application/octet-stream';

  const registration = await relu('/inbox-cases/files', {
    method: 'POST',
    body: JSON.stringify({
      original_file_name: fileName,
      name: fileName.slice(0, -extension.length),
      display_name: fileName,
      extension,
      mime_type: contentType,
      size_bytes: fileSize,
      semantic_type: semanticType,
    }),
  });
  const upload = (await registration.json()) as {
    file_id: string;
    upload_url: string;
    upload_content_type: string;
  };

  const bytes = await readFile(path);
  const stored = await fetch(upload.upload_url, {
    method: 'PUT',
    headers: { 'Content-Type': upload.upload_content_type },
    body: new Uint8Array(bytes),
    signal: AbortSignal.timeout(300_000),
  });
  if (!stored.ok) {
    throw new Error(`Storage upload returned HTTP ${stored.status}`);
  }

  await relu(`/inbox-cases/files/${upload.file_id}/commit`, { method: 'POST' });
  return upload.file_id;
}

async function main(): Promise<void> {
  const [upperPath, lowerPath] = process.argv.slice(2);
  if (!upperPath || !lowerPath) {
    throw new Error('Usage: npx tsx inbox-intake.ts UPPER_SCAN LOWER_SCAN');
  }

  const upperFileId = await uploadCaseFile(upperPath, 'upper_ios');
  const lowerFileId = await uploadCaseFile(lowerPath, 'lower_ios');
  const body = {
    external_case_id: externalCaseId,
    ...(caseSource ? { source: caseSource } : {}),
    ...(patientName ? { metadata: { patient_name: patientName } } : {}),
    files: [{ file_id: upperFileId }, { file_id: lowerFileId }],
  };

  const created = await relu('/inbox-cases', {
    method: 'POST',
    body: JSON.stringify(body),
  });
  const inboxCase = (await created.json()) as { id: string };
  console.log(inboxCase.id);
}

void main();

Add prescription information

After the file-first flow works, include the doctor's prescription when it is available. Relu can use the procedure, tooth scope, and material to automate which CAD service runs for the case.

If your system already has structured prescription fields, send them as source_prescription when you call POST /inbox-cases. If the prescription comes from scanner metadata, pass the raw case record in metadata.additional_metadata.<scanner>. You do not need to map every scanner field yourself. Relu can extract prescription information from reviewed scanner profiles.

See Send an Inbox prescription for the structured format and the scanner metadata option.

Retry rules

The upload and API calls are separate operations. Follow Upload files for storage timeouts and retries, and API errors and retries for API calls.

Treat any 2xx response from the Relu API or storage upload as success. The current create, register, and commit operations return 200.

You may repeat the same POST /inbox-cases intent after a timeout. Relu returns the existing case for the same API-key organization, optional source, and external_case_id. Equivalence is based on the validated case fields and the same set of file IDs; JSON object order, file order, and the on_duplicate retry-control field do not matter. Changing metadata, case fields, or the set of files returns 409 with detail.code: "inbox_case_identity_conflict". Treat that as a data conflict; do not retry it unchanged.

Deleting an Inbox case frees the identity for a new case. Converting a case to an order does not. An equivalent retry still returns the original case and does not change the order.

API reference

On this page