Music identification for social posts

MatchTune Audit API

Send links to YouTube, TikTok, Instagram or Facebook posts. For each one the pipeline downloads the audio, works out what kind of sound it contains, identifies the music and checks whether the track is in a MatchTune target library. You get one result document per post.

Base URLhttps://audit-api.matchtune.com
Formatapplication/json
AuthX-API-Key
Spec/openapi.json

Quickstart

Processing is asynchronous: you submit posts, then come back for the results. A post usually takes a few minutes. It can take up to about 30 minutes when the target-library lookups are busy.

1

Submit the posts

curl -X POST https://audit-api.matchtune.com/v1/requests \
  -H "X-API-Key: ak_your_key" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"url": "https://www.youtube.com/watch?v=XXXXXXXXXXX"},
                {"url": "https://www.tiktok.com/@example_brand/video/1234567890123456789"}]}'

Each item can carry the columns that only you know: tracked_account is the account the post is audited for, external_id is your own id for the post, and metadata is any JSON object of your own attributes (a campaign, a market, your internal ids; up to 8 KB). Both are stored with the item and returned untouched on the item, next to its result in /results, and in every webhook for it. On the request, target_libraries names the libraries that count as yours, by their MatchTune names. Simpler still, pass your project_code (for example BEN-2607161): the project's scoped libraries and rightsholder flags are then taken from the MatchTune dashboard.

  -d '{"project_code": "ABC-2607161", "external_request_id": "batch-2026-09-22-001",
      "items": [{"url": "https://www.instagram.com/p/XXXXXXXXXXX/",
                 "tracked_account": "example_brand", "external_id": "post-48213",
                 "metadata": {"campaign": "spring-2026", "market": "FR"}}]}'
201 Created{ "request_id": "3f2b8c1e-5a7d-4e9b-8c21-6d0f4a9b7e10", "external_request_id": "batch-2026-09-22-001",
  "total_items": 1, "trace_id": "5e1c9a7b3d2f48e6a0b4c8d7e2f1a9b3", "reused": false,
  "items": [ { "job_id": "b41d7e92-8c0a-4f63-9e15-2a7c6f3d8b04", "position": 0, "url": "https://www.instagram.com/p/XXXXXXXXXXX/", "external_id": "post-48213" } ] }
2

Poll until the request is finished

Every 30 to 60 seconds, check status and progress_percent. The request is done when status is COMPLETED.

curl https://audit-api.matchtune.com/v1/requests/3f2b8c1e-5a7d-4e9b-8c21-6d0f4a9b7e10 \
  -H "X-API-Key: ak_your_key"
3

Fetch the results

curl https://audit-api.matchtune.com/v1/requests/3f2b8c1e-5a7d-4e9b-8c21-6d0f4a9b7e10/results \
  -H "X-API-Key: ak_your_key"

If you'd rather not poll, pass a callback_url and we will POST each result to you as soon as it's ready, signed so you can check it came from us.

Authentication

Every call needs your API key. Send it in the X-API-Key header, or as Authorization: Bearer ak_… (both are declared as security schemes in the OpenAPI document). Keys start with ak_ and are issued by MatchTune. We store only a hash of your key, so we can't show it to you again. If you lose it, ask us to rotate it: the old key stops working immediately.

Each key belongs to one customer account, and everything is scoped to that account:

  • Requests you create are filed under your account. Any customer_id you put in the body is ignored.
  • Lists only contain your requests.
  • Requests and items of other accounts answer 404 Not Found, exactly as if they didn't exist.

A missing, unknown or disabled key gets 401 Unauthorized with error code UNAUTHORIZED.

Requests, items and steps

A request is one submission: a batch of up to 100,000 URLs. Each URL becomes an item with its own job_id. Items are processed independently and in parallel, so one broken link never holds up the others.

A request can end up with more items than you submitted: a carousel that holds several videos is audited one item per video. Read total_items from the request rather than counting what you sent.

Each item moves through four steps, in this order. An item's current_stage is always one of them.

  1. DOWNLOAD
  2. PROCESSING
  3. ENRICHMENT
  4. COMPLETE
StepWhat happens
DOWNLOADFetches the post's audio and its public metadata: caption, date, likes, account.
PROCESSINGAnalyses the audio: how much is music and how much is speech, which track is playing, and whether it is in a MatchTune target library. This is usually the longest step.
ENRICHMENTCompletes the track details, such as label and ISRC.
COMPLETEWrites the result document and sends the item.completed webhook to your callback_url, if you gave one.

Carousels

An Instagram carousel can hold several videos, each with its own music. We audit every video slide as its own item: the item you submitted keeps the slide its url points at (or the first video when the url names the carousel itself), and each further video slide is added to the request as a new item while the request runs. Image slides produce nothing.

  • A slide item has parent_job_id (the item it was split from) and inherits its external_id, metadata and tracked_account. It appears in /items, /results and webhooks like any other item, and the request's total_items grows accordingly.
  • In its result, POST URL is the slide's own link (https://www.instagram.com/p/<slide id>/), PARENT POST URL is the carousel, and POST TYPE is Carousel. Date, caption, likes and comments are the carousel's.
  • A carousel with no video at all is one item with AUDIO STATUS No Audio.
  • Slides are added during the DOWNLOAD step, so poll the request (or list its items) rather than assuming the list is final at submission time.

Submitting one carousel that holds two videos gives two records:

GET /v1/requests/{id}/results{
  "results": [
    { "job_id": "5d1b0fb3-…", "parent_job_id": null, "position": 0,
      "result": { "POST TYPE": "Carousel",
                  "POST URL": "https://www.instagram.com/p/SLIDE1/",
                  "PARENT POST URL": "https://www.instagram.com/p/CAROUSEL/", … } },
    { "job_id": "e6edb49c-…", "parent_job_id": "5d1b0fb3-…", "position": 2,
      "result": { "POST TYPE": "Carousel",
                  "POST URL": "https://www.instagram.com/p/SLIDE2/",
                  "PARENT POST URL": "https://www.instagram.com/p/CAROUSEL/", … } }
  ]
}

Statuses

Items, requests and the individual steps of an item share one set of statuses. An item's current_stage tells you which step it is in.

StatusMeaning
QUEUEDAccepted, waiting to start.
PROCESSINGBeing worked on.
RETRYINGA step failed temporarily (for example, the platform rate-limited the download) and is being retried automatically.
COMPLETEDFinished. The item has a result document.
FAILEDCould not be processed. last_error says why, for example a private, deleted or unsupported post.
CANCELLEDStopped before it finished.
SKIPPEDOnly on steps: the step never ran because an earlier step failed.

A request's status becomes COMPLETED once every item has finished, whether the item completed or failed. Always check each item's status, not only the request's.

The result document

Each completed item has one result document. The keys are in upper case so they map directly onto audit spreadsheet columns, and an empty value is written -, as in the spreadsheet. The document contains those columns and nothing else; the item's job_id is on the item and results endpoints around it.

Audio category

AUDIO CATEGORY takes one of these values:

Music OnlyMusic Under DialogueMusic & DialogueDialogue OnlyMixed SourcesSound Effects OnlySilent Track

Only the first three contain music we identify. For Dialogue Only, Sound Effects Only, Silent Track and Mixed Sources (several short, overlapping sound sources, where a match is far more often wrong than right) no track is reported and AUDIO STATUS is Audio Not Recognized.

Re-uploads and covers

Social platforms are full of copies of commercial songs published under an uploader's own name, sometimes with the real performer hidden in the title ("Song (feat. Real Artist)" by an anonymous account). Recognition services index those copies as tracks in their own right. When we can establish beyond doubt which recording a copy reproduces, the track columns carry the original — its title, artist, label and ISRC — so the licensing question is answered against the real rightsholder. A genuine collaboration or a remix is never rewritten.

Match confidence

Every recognised track comes with MATCH CONFIDENCE. We report a track as High only when it is established: two independent recognition services name the same recording, one service matches strongly and nothing contradicts it, or a fingerprint check against the catalogue's own recording confirms it. When several candidates compete, or the only one is too weak to stand on its own (for example a single service matching a copy with no ISRC), nothing is established: the post is reported as Audio Not Recognized, with no track. A wrong identification costs more than a miss.

Fields

FieldTypeDescription
AUDIO STATUSstringAudio Recognized, Audio Not Recognized, or No Audio when the post carries no audio track at all.
AUDIO CATEGORYstringWhat the soundtrack is made of (see above).
TRACK NAMEstringIdentified track title, as registered for its ISRC when the ISRC is known. When the audio is a re-upload of a commercial song published under someone else's name, the columns name the original recording (see below).
TRACK ARTISTstringIdentified artist, as registered for its ISRC when the ISRC is known.
TRACK RECORD LABELstringRecord label that owns the ISRC, looked up in a music catalogue by ISRC; otherwise the label the recognition services reported.
TRACK ISRCstringInternational Standard Recording Code.
MUSIC SOURCEstringThe music source the track's record label resolves to in the MatchTune library dictionary (for example Epidemic Sound, Meta Sound Collection, Universal Music Group), or Not Found in Target Libraries. - unless the audio was recognised.
IN TARGET LIBRARYstringYes when the music source is one of the project's scoped libraries (from project_code or target_libraries), No otherwise; answered only for a recognised track, - for the rest.
LICENSE END DATEstringLicense end date of the library track, when known.
REVIEW FLAGstringReview Required when a person should check the answer (for example a lookup that could not finish); how sure the identification is lives in MATCH CONFIDENCE. Cross-Platform Use when a platform library's track appears on another platform. - otherwise.
MATCH CONFIDENCEstringHigh when a track is reported: the identification is established (see Match confidence). - when no track is reported.
RISK STATUSstringAt Risk, Compliant or Unverified: the MatchTune risk rules applied to this post.
MUSIC DURATION (SEC)numberSeconds of music detected.
MUSIC PRESENCE SCOREnumber0–100, how clearly music is present.
PLATFORMstringPlatform the post comes from: YouTube, TikTok, Instagram or Facebook.
POST URLstringCanonical URL of the post.
POST ORIGINstringThe account that published the post, as a handle (example_brand), not a display name.
TRACKED ACCOUNTstringThe account this post is audited for, as sent with the url. Defaults to POST ORIGIN when you don't send one.
POST DATEstringPublication date and time, YYYY-MM-DD HH:MM:SS (date only when the platform gives no time).
POST TYPEstringMedia type of the post: Video, Carousel for a slide of a multi-media post, Image.
POST CAPTIONstringCaption or description.
POST LIKESintegerLike count at download time.
POST COMMENTSintegerComment count at download time.
VIDEO DURATION (SEC)numberLength of the post.
ARTIST POPULARITYstringReach of the identified artist, from MatchTune's audience data: Tier 1: Mega Artists, Tier 2: Mainstream Artists, Tier 3: Established Artists, Tier 4: Moderate Reach Artists or Tier 5: Long-Tail Artists. - when the track was not recognised or the artist has no audience data.
ACCOUNT TYPEstringOwned when the post was published by the tracked account itself, Collaboration when someone else published it for them. Needs tracked_account.
PARENT POST URLstringFor a carousel slide, the carousel it belongs to (see Carousels). - otherwise.

Example

A YouTube post, recognised{
  "PLATFORM": "YouTube",
  "POST URL": "https://www.youtube.com/watch?v=XXXXXXXXXXX",
  "POST ORIGIN": "example_brand",
  "TRACKED ACCOUNT": "example_brand",
  "ACCOUNT TYPE": "Owned",
  "POST DATE": "2024-05-01 09:12:44",
  "AUDIO STATUS": "Audio Recognized",
  "AUDIO CATEGORY": "Music Only",
  "IN TARGET LIBRARY": "No",
  "MUSIC SOURCE": "Not Found in Target Libraries",
  "REVIEW FLAG": "-",
  "MATCH CONFIDENCE": "High",
  "RISK STATUS": "At Risk",
  "TRACK NAME": "Example Track",
  "TRACK ARTIST": "Example Artist",
  "TRACK RECORD LABEL": "Example Records",
  "TRACK ISRC": "ZZX012600001",
  "VIDEO DURATION (SEC)": 13,
  "MUSIC DURATION (SEC)": 13.009,
  "MUSIC PRESENCE SCORE": 94.8,
  "POST LIKES": 312,
  "POST COMMENTS": 7,
  … plus the remaining fields listed above
}

Callbacks

When a request includes callback_url, we POST a JSON event to that URL for each of these moments:

EventWhenCarries
item.completedAn item finished and has a result.job_id, external_id, metadata, parent_job_id (carousel slides), the result document.
item.failedAn item could not be processed.job_id, external_id, metadata, an error like the item's last_error.
request.completedEvery item of the request has finished, one way or the other.request_id, external_request_id.
POST {your callback_url}{
  "event": "item.completed",
  "request_id": "3f2b8c1e-5a7d-4e9b-8c21-6d0f4a9b7e10",
  "external_request_id": "batch-2026-09-22-001",
  "job_id": "b41d7e92-8c0a-4f63-9e15-2a7c6f3d8b04",
  "external_id": "post-48213",
  "metadata": { "campaign": "spring-2026", "market": "FR" },
  "parent_job_id": null,
  "status": "COMPLETED",
  "result": { … the result document … },
  "error": null,
  "timestamp": "2026-09-22T14:06:24.976633+00:00"
}

Every delivery carries three headers: X-MatchTune-Event (the event name), X-MatchTune-Delivery (an id that stays the same across retries of one event, so you can drop duplicates) and X-MatchTune-Signature.

Verifying the signature

Your account has a webhook secret (MatchTune gives it to you with your key). The signature header is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256(secret, "<t>.<raw request body>"). Compute it over the body bytes exactly as received, compare in constant time, and reject timestamps older than five minutes:

# Python
import hmac, hashlib, time
def verify(secret, body: bytes, header: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300: return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Delivery and retries

  • Answer with any 2xx within 10 seconds; do the work afterwards.
  • Anything else, or a timeout, is retried after 1 s, 5 s and 25 s, then given up. Use /results to catch up on anything you missed: it is always the source of truth.
  • Deliveries are sent from 51.91.64.130; allowlist that address if your endpoint filters by source.
  • Deliveries can arrive out of order, and rarely twice. Key on job_id, not on arrival order and not on external_id alone: a carousel's slides share the external_id of the item they came from.

Errors and limits

Every error, whatever its status, has the same shape: a machine-readable code, a message for humans, a trace_id to quote to support (also sent in the X-Trace-Id response header) and, when useful, details:

429 Too Many Requests{
  "error": {
    "code": "RATE_LIMITED",
    "message": "daily limit of 500 items reached (480 used in the last 24 h, 30 requested)",
    "trace_id": "5e1c9a7b3d2f48e6a0b4c8d7e2f1a9b3",
    "details": { "limit": 500, "used_24h": 480, "requested": 30 }
  }
}
StatusCodeWhen
401UNAUTHORIZEDMissing, unknown or disabled API key.
404NOT_FOUNDThe request or item doesn't exist, or belongs to another account.
409NOT_READYYou asked for an item's result before the item completed. details carries its current status and stage.
413PAYLOAD_TOO_LARGEMore than 100,000 items in one request.
422VALIDATION_ERRORThe body or a parameter isn't valid; details lists each field and what is wrong with it.
422UNKNOWN_PROJECT, UNKNOWN_LIBRARYA project_code or a name in target_libraries that MatchTune doesn't know.
422INVALID_CURSORA cursor that this API didn't issue.
429RATE_LIMITEDYour account's daily quota would be exceeded. The quota counts items submitted over a rolling 24 hours.
500INTERNAL_ERRORSomething went wrong on our side. Retry later and quote the trace_id if it persists.
503LIBRARIES_NOT_READYThe library dictionary isn't loaded yet; retry in a minute or omit target_libraries.

Item errors

When an item fails, its last_error has a class, the stage where it stopped, a readable message and, for downloads, the platform's own reason.

classMeaning
goneThe platform confirms the post no longer exists: deleted, or its account removed or made private. Resubmitting will not help.
permanentThe post cannot be processed as submitted: geo-restricted, unsupported url, malformed link.
transientA temporary problem; the item was retried automatically before failing.
timeoutThe download took too long.
too_longThe post is longer than your account's maximum post duration (3 minutes 30 seconds unless MatchTune set another limit for you) and was not processed. reason gives the post's length and the limit.

Pagination

List endpoints use a cursor. Pass the next_cursor you received as cursor on the next call, until next_cursor is null. For /items and /results it is the item position; for /requests it is an opaque string. Never build a cursor yourself.

Safe retries

Send an Idempotency-Key header with POST /v1/requests (any unique string, such as your own batch id). If the call is repeated with the same key, you get back the original request with "reused": true instead of a duplicate.

OpenAPI document

The machine-readable contract is at /openapi.json (OpenAPI 3.1): both security schemes, every request and response schema including the result document and the webhook event, and the error envelope on each operation. Point your generator or API client at it.

POST/v1/requests

Create a request

Submits posts for processing. Returns right away with 201 Created; the work happens in the background.

Body fieldTypeDescription
items requiredobject[]1 to 100,000 posts. Each one becomes an item, in this order. An item has url (required), tracked_account, external_id (your id for the post, up to 128 characters) and metadata (a JSON object of your own attributes, up to 8 KB, returned untouched on the item, in /results and in webhooks).
external_request_idstringYour id for this batch. Echoed on the request, in lists and in webhooks; filterable on GET /v1/requests.
project_codestringYour MatchTune project. Its scoped libraries and rightsholder flags are applied.
target_librariesstring[]Without a project: the MatchTune library names that count as yours.
callback_urlurlWhere to POST signed events as items complete. See Callbacks.
urls deprecatedstring[]The previous name of items; still accepted, with plain URL strings. Send one or the other.
HeaderDescription
Idempotency-KeyOptional. Makes retries safe (see Safe retries).
Request{
  "external_request_id": "batch-2026-09-22-001",
  "project_code": "ABC-2607161",
  "callback_url": "https://example.com/hooks/audit",
  "items": [
    { "url": "https://www.instagram.com/p/XXXXXXXXXXX/", "tracked_account": "example_brand", "external_id": "post-48213" }
  ]
}
201 Created{
  "request_id": "7c9e2a44-1b3f-4d8e-a6c5-0e8f2d1b9a37",
  "external_request_id": "batch-2026-09-22-001",
  "total_items": 1,
  "trace_id": "5e1c9a7b3d2f48e6a0b4c8d7e2f1a9b3",
  "reused": false,
  "items": [
    { "job_id": "b41d7e92-8c0a-4f63-9e15-2a7c6f3d8b04", "position": 0, "url": "https://www.instagram.com/p/XXXXXXXXXXX/", "external_id": "post-48213" }
  ]
}

items in the response lists every item's job_id in submission order, so you can track them right away without listing the request. A carousel can add further items later, so treat this list as the starting point, not the final one. Keep the request_id; the other endpoints take it. Quote the trace_id when you contact support about a request.

GET/v1/requests

List your requests

Returns your requests, newest first, with item counts per status. Paginated with an opaque cursor.

QueryTypeDescription
statusstringOnly requests in this status, for example PROCESSING.
external_request_idstringOnly the request(s) you submitted with this id.
project_codestringOnly requests for this project.
created_after, created_beforedatetimeISO 8601 bounds on created_at.
cursorstringThe next_cursor of the previous page.
limitintegerDefault 50, max 500.
200 OK{
  "requests": [
    {
      "request_id": "7c9e2a44-1b3f-4d8e-a6c5-0e8f2d1b9a37",
      "external_request_id": "batch-2026-09-22-001",
      "customer_id": "acme",
      "project_code": "ABC-2607161",
      "created_at": "2026-09-18T06:04:21.551319Z",
      "status": "PROCESSING",
      "total_items": 3,
      "counts": { "QUEUED": 0, "PROCESSING": 1, "RETRYING": 0, "COMPLETED": 2, "FAILED": 0, … },
      "progress_percent": 66.67,
      "rate_per_min": 0.4
    }
  ],
  "next_cursor": "MjAyNi0wOS0xOFQwNjowNDoyMS41NTEzMTkrMDA6MDB8N2M5ZTJhNDQ…"
}
GET/v1/requests/{request_id}

Get request progress

Everything from the list entry, plus an estimated time to completion and per-stage detail. This is the endpoint to poll.

FieldTypeDescription
statusstringPROCESSING until every item has finished, then COMPLETED.
total_itemsintegerItems in the request. It can grow while the request runs when a carousel is split into one item per video.
countsobjectNumber of items in each status.
progress_percentnumberShare of items that have finished (completed, failed or cancelled).
rate_per_minnumberItems completed per minute recently.
eta_minutesnumber | nullEstimated minutes left, once enough items have completed to estimate.
stagesobjectFor each step (DOWNLOAD, PROCESSING, ENRICHMENT, COMPLETE), how many items are in it, per status.
stage_durationsobjectFor each step: samples, avg_ms, p50_ms, p95_ms, p99_ms.
item_durationsobjectThe same statistics for whole completed items.
external_request_id, project_codestring | nullAs submitted.
target_librariesstring[] | nullThe libraries in scope for this request, resolved to MatchTune's names (from the project or from what you sent).
callback_url, trace_idstringAs submitted, and the id to quote to support.
GET/v1/requests/{request_id}/results

Get all results of a request

Every item in submission order, with its result document. result stays null until the item completes; for failed items, last_error explains why. You can call this at any time, so it also works for collecting results progressively.

QueryTypeDescription
statusstringOnly items in this status, for example COMPLETED.
cursorintegerPosition to start from. Default 0.
limitintegerDefault 100, max 500.
200 OK{
  "results": [
    {
      "job_id": "b41d7e92-8c0a-4f63-9e15-2a7c6f3d8b04",
      "external_id": "post-48213",
      "metadata": { "campaign": "spring-2026", "market": "FR" },
      "parent_job_id": null,
      "position": 0,
      "url": "https://www.youtube.com/watch?v=XXXXXXXXXXX",
      "status": "COMPLETED",
      "current_stage": "COMPLETE",
      "last_error": null,
      "result": { "AUDIO STATUS": "Audio Recognized", "TRACK NAME": "Example Track", … }
    },
    {
      "job_id": "e8a3f610-4d2b-4c7e-b951-7f0e3a2c6d18",
      "external_id": "post-48214",
      "position": 1,
      "url": "https://www.youtube.com/watch?v=YYYYYYYYYYY",
      "status": "FAILED",
      "current_stage": "DOWNLOAD",
      "last_error": {
        "class": "permanent",
        "stage": "DOWNLOAD",
        "message": "The post could not be downloaded: it is private, deleted, geo-restricted or not a supported post.",
        "reason": "Video unavailable"
      },
      "result": null
    }
  ],
  "next_cursor": null
}
GET/v1/requests/{request_id}/items

List items

A lighter view of the items without result documents. You can filter by status or stage, for example to find the failures.

QueryTypeDescription
statusstringOnly items in this status, such as FAILED.
stagestringOnly items currently in this step: DOWNLOAD, PROCESSING, ENRICHMENT or COMPLETE.
external_idstringOnly the item(s) you submitted with this id.
cursorintegerPosition to start from. Default 0.
limitintegerDefault 100, max 1000.

Each entry has job_id, external_id, metadata, parent_job_id (carousel slides only), position, url, status, current_stage, created_at, updated_at and last_error. The page ends with next_cursor.

GET/v1/items/{job_id}/result

Get one item's result

Returns the item's result document on its own. Before the item completes, you get 409 with its current state:

409 Conflict{ "error": { "code": "NOT_READY", "message": "item has no result yet", "trace_id": "5e1c9a7b…",
             "details": { "status": "PROCESSING", "current_stage": "PROCESSING", "last_error": null } } }
GET/v1/items/{job_id}

Follow one item

The item's status plus its four steps, each with its own status, start and end times, and duration. total_ms is the processing time summed over the steps. A step that is still running reports the time elapsed so far in duration_ms.

200 OK{
  "job_id": "b41d7e92-8c0a-4f63-9e15-2a7c6f3d8b04",
  "external_id": "post-48213",
  "request_id": "3f2b8c1e-5a7d-4e9b-8c21-6d0f4a9b7e10",
  "position": 0,
  "url": "https://www.youtube.com/watch?v=XXXXXXXXXXX",
  "status": "PROCESSING",
  "current_stage": "PROCESSING",
  "last_error": null,
  "total_ms": 538,
  "steps": [
    { "stage": "DOWNLOAD",   "status": "COMPLETED",  "started_at": "2026-09-16T14:06:10.137Z", "completed_at": "2026-09-16T14:06:10.675Z", "duration_ms": 538 },
    { "stage": "PROCESSING", "status": "PROCESSING", "started_at": "2026-09-16T14:06:10.737Z", "completed_at": null, "duration_ms": 9120 },
    { "stage": "ENRICHMENT", "status": "QUEUED",     "started_at": null, "completed_at": null, "duration_ms": null },
    { "stage": "COMPLETE",   "status": "QUEUED",     "started_at": null, "completed_at": null, "duration_ms": null }
  ]
}