Skip to content

OMR Interactive Jobs API

The Interactive Jobs API exposes Flat's Optical Music Recognition (OMR) pipeline directly through the /omr/jobs endpoints. Use it when you need more than a one-shot import: live progress, an optional review step to confirm the detected instruments, incremental capture (add pages one by one, ideal on mobile), or MusicXML output without creating a Library score.

If you just want a score in the Library from a single PDF, the Auto simple import is fewer calls. New to OMR? Start with the OMR overview for scopes, credits, and error codes.

Job lifecycle

A job is a small state machine. You create it, start it, optionally answer interactive steps while it runs, and read the result when it is done.

OMR job lifecycle state machine: draft to processing to done, with an optional pause and submit loop through awaitingInput, and terminal transitions to canceled and error.

statusMeaning
draftCreated; files can still be added. Not started or charged yet.
processingThe pipeline is running. See progress.
awaitingInputPaused on currentStep, waiting for you to submit it.
doneFinished. See result.
errorFailed. See errorCode and errorMessage.
canceledCanceled by the client.

In this page:

Prerequisites

Every request needs the omr scope (with a Personal Access Token or an OAuth2 token). With the default output: library, the job creates a score, so an OAuth2 token also needs the scores scope. output: musicxml only needs omr. See Authentication and scopes.

The examples below use these variables:

bash
export TOKEN='<my_api_personal_access_token>'
export BASE='https://api.flat.io/v2'
js
const TOKEN = '<my_api_personal_access_token>'
const BASE = 'https://api.flat.io/v2'
const auth = { Authorization: `Bearer ${TOKEN}` }
python
import base64, time, requests

TOKEN = "<my_api_personal_access_token>"
BASE = "https://api.flat.io/v2"
auth = {"Authorization": f"Bearer {TOKEN}"}

Draft vs one-shot

There are two ways to create a job:

  • Draft (this walkthrough): create an empty job, add files one at a time, then start it. Best for multiple images or incremental mobile capture, where pages arrive over time.
  • One-shot: send the files inline with autoStart: true in a single request. Best for a single PDF or a straightforward third-party integration.

Both produce the same kind of job. Pick whichever fits how your client captures files.

Step 1. Create a draft job

Create the job with POST /omr/jobs. Omit files to get a draft. Declare the interactive steps your client can render in interactiveSteps (here, details); the pipeline pauses only at the steps you list and auto-resolves the rest, so future steps never break an older client.

bash
curl -X POST "$BASE/omr/jobs" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "output": "musicxml",
    "interactiveSteps": ["details"],
    "locales": ["en"]
  }'
js
let res = await fetch(`${BASE}/omr/jobs`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    output: 'musicxml',
    interactiveSteps: ['details'],
    locales: ['en'],
  }),
})
let job = await res.json()
const jobId = job.id
python
job = requests.post(
    f"{BASE}/omr/jobs",
    headers=auth,
    json={"output": "musicxml", "interactiveSteps": ["details"], "locales": ["en"]},
).json()
job_id = job["id"]

The response is a job in draft:

json
{
  "id": "665f3a1c9b2e4a0012ab34cd",
  "status": "draft",
  "output": "musicxml",
  "interactiveSteps": ["details"],
  "locales": ["en"],
  "creationDate": "2026-07-08T09:24:00.000Z"
}

This example uses output: "musicxml", which only needs the omr scope and lets you download the result directly. To import the result into the Flat Library instead, set output: "library" (the default), add the scores scope, and use collection to target a specific collection. Pass idempotencyKey to make retries safe (a repeated key returns the existing job).

Step 2. Add files

Add one image or PDF per call with POST /omr/jobs/{job}/files. Files keep their upload order. The file is Base64-encoded in file.

bash
curl -X POST "$BASE/omr/jobs/$JOB/files" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{ \"file\": \"$(base64 < page1.jpg | tr -d '\n')\", \"filename\": \"page1.jpg\" }"
js
import { readFileSync } from 'node:fs'

res = await fetch(`${BASE}/omr/jobs/${jobId}/files`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    file: readFileSync('page1.jpg').toString('base64'),
    filename: 'page1.jpg',
  }),
})
const { fileCount } = await res.json()
python
with open("page1.jpg", "rb") as f:
    payload = {"file": base64.b64encode(f.read()).decode(), "filename": "page1.jpg"}

added = requests.post(f"{BASE}/omr/jobs/{job_id}/files", headers=auth, json=payload).json()

The response reports the assigned index and running count: { "fileIndex": 0, "fileCount": 1 }. Provide either a filename with a recognizable extension or an explicit mimeType; if the type cannot be determined from either, the call returns 400 (OMR_MIME_REQUIRED). Repeat for each page.

Step 3. Start the job

Start processing with POST /omr/jobs/{job}/start. This validates the files, runs the permission, quota, and credit checks, charges the page-based credits, and queues the job.

bash
curl -X POST "$BASE/omr/jobs/$JOB/start" -H "Authorization: Bearer $TOKEN"
js
res = await fetch(`${BASE}/omr/jobs/${jobId}/start`, { method: 'POST', headers: auth })
job = await res.json() // status: "processing", estimatedCredits: N
python
job = requests.post(f"{BASE}/omr/jobs/{job_id}/start", headers=auth).json()

The job returns as processing with estimatedCredits set to what was charged. A start with no files returns 400; insufficient credits or quota returns 402.

Step 4. Follow progress

Poll GET /omr/jobs/{job} to track the job. Optionally pass wait (0 to 25 seconds) to long-poll: the call returns as soon as the state changes (or live progress advances), then you call again. This keeps progress live without a tight loop.

While processing, the job carries a progress object with a percent, a localized text, and a stable key (for example OMR_QUEUED, OMR_DETECTING_STAVES, OMR_READING_NOTES) you can map to your own stepper UI.

bash
# Returns as soon as the state changes, or after 25s.
curl -H "Authorization: Bearer $TOKEN" "$BASE/omr/jobs/$JOB?wait=25"
js
async function pollUntilSettled(jobId) {
  while (true) {
    const res = await fetch(`${BASE}/omr/jobs/${jobId}?wait=25`, { headers: auth })
    const job = await res.json()
    if (job.status !== 'processing') return job // awaitingInput, done, error, canceled
    console.log(job.progress?.percent, job.progress?.key)
  }
}
python
def poll_until_settled(job_id):
    while True:
        job = requests.get(f"{BASE}/omr/jobs/{job_id}", params={"wait": 25}, headers=auth).json()
        if job["status"] != "processing":
            return job  # awaitingInput, done, error, canceled
        print(job.get("progress", {}).get("percent"), job.get("progress", {}).get("key"))

The job settles into awaitingInput (if a detection needs your review), done, or error.

Step 5. Review detected instruments

If you declared details in interactiveSteps, the job may pause at awaitingInput after recognition, with currentStep: "details", so you can review low-confidence detections. It may also resolve automatically and continue straight to done, for example when every part is recognized with high confidence. Always handle both outcomes: after processing, a job can be awaitingInput or already done.

When it does pause, pendingStep.data holds the detected title and instruments for review (the "check your score details" screen):

json
{
  "id": "665f3a1c9b2e4a0012ab34cd",
  "status": "awaitingInput",
  "currentStep": "details",
  "estimatedCredits": 3,
  "pendingStep": {
    "step": "details",
    "data": {
      "step": "details",
      "title": "Sonata in C",
      "instruments": [
        { "index": 0, "partName": "Violin I", "instrumentId": "strings.violin", "instrumentName": "Violin", "midiProgram": 40, "resolvedConfidence": "high" },
        { "index": 1, "partName": "Alto", "instrumentId": "strings.violin", "instrumentName": "Violin", "midiProgram": 40, "resolvedConfidence": "low" }
      ]
    }
  }
}

The "check your score details" review step: a work title field and a list of detected parts with instrument names, Flat instrument IDs, and high/low confidence chips, with a low-confidence part being corrected via an instrument picker.

To show the pages being reviewed, fetch each input image by index with GET /omr/jobs/{job}/files/{index} (0-based; returns the input page as an image).

Present these to the user, then resume the pipeline by submitting your corrections to POST /omr/jobs/{job}/steps/details. Send only what you change: omitted fields keep the detected values. Here we fix the second part and tidy the title:

bash
curl -X POST "$BASE/omr/jobs/$JOB/steps/details" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "step": "details",
    "title": "Sonata in C major",
    "instruments": [
      { "index": 1, "instrumentId": "strings.viola" }
    ]
  }'
js
res = await fetch(`${BASE}/omr/jobs/${jobId}/steps/details`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    step: 'details',
    title: 'Sonata in C major',
    instruments: [{ index: 1, instrumentId: 'strings.viola' }],
  }),
})
job = await res.json() // status back to "processing"
python
job = requests.post(
    f"{BASE}/omr/jobs/{job_id}/steps/details",
    headers=auth,
    json={
        "step": "details",
        "title": "Sonata in C major",
        "instruments": [{"index": 1, "instrumentId": "strings.viola"}],
    },
).json()

The job resumes and re-runs only the cheap assembly and finalize stages. Poll again (step 4) until it is done.

Correcting a part

Each override in instruments[] is matched to a detected part by its index. To set the instrument, use either of these (you do not need both):

  • instrumentId: a Flat instrument ID such as brass.horn. Browse the Instrument IDs catalog, or resolve IDs programmatically with the @flat/instruments package.
  • midiProgram: a General MIDI program number (0 to 127), resolved server-side. Handy when you would rather not map Flat instrument IDs.

If both are sent, instrumentId wins. You can also set partName and transposeKey (a pitch class like F or Bb, for transposing instruments) independently. At the submission level, title and mainLanguage (BCP 47, overrides the job locale for lyric and text reading) apply to the whole score.

Step 6. Get the result

When the job is done, result tells you what is available:

json
{
  "status": "done",
  "result": {
    "exports": ["musicxml", "mxl", "midi"]
  }
}
  • With output: musicxml (used above), the result stays out of the Flat Library. You download it directly as a file (MusicXML, MIDI, and more) from the exports endpoint. Use this when your integration keeps files in its own system and does not need a Flat score.
  • With output: library (the default), the result is imported into the user's Flat Library as a score. result.score holds the new score ID. Fetch it with GET /scores/{score}, and it behaves like any other Flat score. You can still download the raw files from the exports endpoint.

One-shot import

To skip the draft sequence, send the files inline with autoStart: true. The job is created, started, and charged in one call, returning 202 with a processing job. This is the simplest path for a single PDF when you do not need to add pages incrementally.

bash
curl -X POST "$BASE/omr/jobs" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{
    \"output\": \"musicxml\",
    \"autoStart\": true,
    \"files\": [ { \"file\": \"$(base64 < score.pdf | tr -d '\n')\", \"filename\": \"score.pdf\" } ]
  }"
js
res = await fetch(`${BASE}/omr/jobs`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    output: 'musicxml',
    autoStart: true,
    files: [{ file: readFileSync('score.pdf').toString('base64'), filename: 'score.pdf' }],
  }),
})
job = await res.json()
python
with open("score.pdf", "rb") as f:
    files = [{"file": base64.b64encode(f.read()).decode(), "filename": "score.pdf"}]

job = requests.post(
    f"{BASE}/omr/jobs",
    headers=auth,
    json={"output": "musicxml", "autoStart": True, "files": files},
).json()

From here, follow progress (step 4) as usual. If you also declared interactiveSteps, the one-shot job still pauses for review.

Exports

Once a job is done, download the result in any available format with GET /omr/jobs/{job}/exports/{format}. The Content-Disposition header carries a filename derived from the resolved work title.

formatFileContent type
musicxmlUncompressed MusicXML (.xml)application/vnd.recordare.musicxml+xml
mxlCompressed MusicXML (.mxl)application/vnd.recordare.musicxml
midiStandard MIDI (.mid)audio/midi
thumbnail.pngPNG preview of the first pageimage/png
bash
curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/omr/jobs/$JOB/exports/musicxml" -o score.xml

New formats may be added over time, so request the format your client supports. result.exports lists what is available for a given job.

List and resume jobs

GET /omr/jobs lists the caller's jobs, newest first, with an optional status filter and cursor pagination via the Link response header. Use it to resume an interrupted job or clean up abandoned drafts.

bash
curl -H "Authorization: Bearer $TOKEN" "$BASE/omr/jobs?status=draft"

Cancel a job

POST /omr/jobs/{job}/cancel cancels a job that is not being actively recognized: a draft, a queued job, or a job paused at an interactive step (awaitingInput). Any credits charged at start are reversed.

bash
curl -X POST "$BASE/omr/jobs/$JOB/cancel" -H "Authorization: Bearer $TOKEN"

Only a job the worker is actively recognizing cannot be canceled; that returns 409 (OMR_JOB_NOT_CANCELLABLE), so poll and retry if needed. Canceling a job that is already done, error, or canceled is a harmless no-op.

Errors

See the OMR overview for the shared errorCode list and the common HTTP statuses. The job lifecycle adds these request errors:

SituationStatusCode
Adding a file to a job that is no longer a draft400OMR_JOB_NOT_DRAFT
Adding a file whose type cannot be determined400OMR_MIME_REQUIRED
Submitting a step whose body and path disagree400OMR_STEP_MISMATCH
Submitting a step the job is not awaiting409OMR_NOT_AWAITING_STEP
Submitting a step for a job whose task can no longer resume (for example a concurrent cancel)409OMR_JOB_NOT_RESUMABLE
Downloading an export before the job is done409OMR_JOB_NOT_DONE
Canceling a job the worker is actively processing409OMR_JOB_NOT_CANCELLABLE

Copyright © Tutteo Limited