GraphQL API

The GraphQL API is the supported way to integrate with Audio Audit programmatically. It is what our API customers use today, and it covers listing your series, listing reports, and reading a finished report.

A series is a show being audited — what the dashboard calls a podcast. A podcast is the only kind today; YouTube channels and audiobooks are planned. The podcast-named operations this page used to document still work and always will — see "Deprecated Podcast Aliases" near the end of this page.

Endpoint

POST https://audioaudit.io/api/graphql

Send a JSON body containing a query string and, optionally, a variables object. Authenticate with an API key in the Authorization header — see Authentication for how to create one.

Authorization: Bearer your-api-key-here
Content-Type: application/json

GraphQL reports problems in an errors array in the response body, so check for that rather than relying on the HTTP status code alone.

Exploring the Schema

GraphiQL is available at /api/graphql. This web UI allows you to explore the schema. In the top-right corner is a link to the auto-generated documentation. You can also start typing a query in the editor using Ctrl+Space to trigger auto-complete and Ctrl+Enter to execute.

If you are logged in, in another tab, then your current session cookies will be used automatically — so GraphiQL is for exploring, and API keys are for the integrations you build. Please read "What We Support" at the end of this page before building on anything you find in GraphiQL that is not documented here.

/schema.graphql is the complete SDL for the live API — every type, field, argument and mutation, with descriptions. It is generated from the running schema and checked on every pull request, so it tracks the API closely. That makes it accurate, not stable: outside the operations documented on this page, it follows the schema's changes rather than shielding you from them.

It is also the file to hand to an LLM or a code generation tool when you want one to write queries against Audio Audit for you.

Workspaces

An API key belongs to the workspace it was created in — either your personal workspace or one Organisation. A key created against an Organisation acts as that Organisation's owner account.

The seriesList and reports queries take an optional organisationId. Pass it to get that Organisation's shows and reports; leave it out and you get the personal workspace of the account the key acts as. If your key belongs to an Organisation, always pass organisationId.

You can look up the IDs of the Organisations available to your key:

curl -X POST https://audioaudit.io/api/graphql \
  -H "Authorization: Bearer your-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query { organisations { id name } }"
  }'

Listing Your Series

seriesList returns the shows in the workspace, ordered by title. limit defaults to 20.

feedUrl and lastCheck are marked deprecated in the schema: a show's feed now lives on a separate source record, so that a series can eventually have more than one. Both keep working indefinitely and answer for the show's RSS source, so there is nothing you have to change. To read them from their new home instead, select sources { feedUrl lastCheck }.

curl -X POST https://audioaudit.io/api/graphql \
  -H "Authorization: Bearer your-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query SeriesList($limit: Int, $organisationId: UUID) { seriesList(limit: $limit, organisationId: $organisationId) { id title kind feedUrl lastCheck coverImageUrl } }",
    "variables": {"limit": 20, "organisationId": null}
  }'
{
  "data": {
    "seriesList": [
      {
        "id": "816ee4c4-c4c5-4078-9dfa-865618b45200",
        "title": "Example Podcast",
        "kind": "PODCAST",
        "feedUrl": "https://example.com/feed.xml",
        "lastCheck": "2026-08-05T09:12:44.318472+00:00",
        "coverImageUrl": "https://example.com/artwork.jpg"
      }
    ]
  }
}

Keep the id — it is what you match report rows against in the next query.

series fetches a single show by that id, with the same fields:

curl -X POST https://audioaudit.io/api/graphql \
  -H "Authorization: Bearer your-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query Series($id: UUID, $organisationId: UUID) { series(id: $id, organisationId: $organisationId) { id title kind feedUrl lastCheck coverImageUrl } }",
    "variables": {"id": "816ee4c4-c4c5-4078-9dfa-865618b45200", "organisationId": null}
  }'

Pass organisationId here too if your key belongs to an Organisation. A series is readable by whoever holds its id — most of what it carries is already public in the RSS feed anyway (title, artwork, feed health) — but the report ids inside historicScores are not: each point's report_id comes back null unless you can actually see this show yourself, because a report id is a working link into report(id:). A series now belongs to exactly one workspace, so "can see this show" means the show is yours: your own personal show, or one of your Organisation's — where Owners and Admins see every show and only a plain Member needs a per-show grant. An API key acts as an owner of the workspace it belongs to, so your key always gets the ids for your own shows. This does not depend on organisationId: that argument selects which reports reports returns, and the report ids on a series come back the same with it or without it.

Listing Your Reports

reports returns the reports in the workspace, newest first. limit defaults to 10. Select series { id title } on each row so you can tell which show a report belongs to.

curl -X POST https://audioaudit.io/api/graphql \
  -H "Authorization: Bearer your-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query Reports($organisationId: UUID, $limit: Int) { reports(organisationId: $organisationId, limit: $limit) { id name status createdAt stats series { id title } } }",
    "variables": {"organisationId": null, "limit": 10}
  }'

As with seriesList, pass your organisationId if your key belongs to an Organisation, and leave it out for a personal key.

reports also takes offset for paging, forSeriesId to narrow to a single show inside the workspace you are scoped to, and createdSince / updatedSince to fetch only what has changed since your last poll. The two Since arguments are exclusive — pass the timestamp of the newest row you already have and you will not receive it again. limit has no maximum, but ask for what you need rather than everything.

There is also a status argument, and it is the one place this API is inconsistent with itself: it matches the stored value, which is lower case with underscores (pending, scheduled, in_progress, complete, failed, out_of_quota, awaiting_review — that last one belongs to enhancement jobs pausing for review and is never set on a report), while the status field on a report comes back as the upper-case enum (COMPLETE). Passing COMPLETE to the argument is not an error — it simply matches nothing. Send the lower-case form.

If some other filter would help you, email info@audioaudit.io and tell us the shape you need.

Prefer forSeriesId over seriesId. reports also accepts a seriesId (and its deprecated podcastId twin). It no longer reaches another customer's data — a series belongs to exactly one workspace, and the filter only reaches shows you can see — but it is still not part of the supported surface, for two reasons.

It is scoped to you, not to the workspace your key names: it answers from every show you can see in any workspace you belong to. So a personal key passing the id of one of your Organisation's shows gets that Organisation's reports back in place of the personal reports the key is for. And it is its own branch, reached only when organisationId is not supplied, so passing both together silently ignores seriesId and gives you the whole workspace when you asked for one show.

forSeriesId has neither problem: it narrows inside the organisation branch and the personal branch, so it can only ever remove rows you were already entitled to. Use it instead. (This is why the REST API's ?series_id= maps to forSeriesId and never to seriesId.)

Fetching a Report

report takes the id of a single report.

curl -X POST https://audioaudit.io/api/graphql \
  -H "Authorization: Bearer your-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query Report($id: UUID) { report(id: $id) { id name status createdAt stats fileInfo metadata results transcript audioUrl } }",
    "variables": {"id": "73d6c51c-cfdf-4a6d-903a-cd7e67b4382f"}
  }'

Ask only for the fields you need: results and transcript can be large.

Report IDs work like share links — anyone holding one can fetch that report, without an API key. That is deliberate, so you can share a result with someone outside your workspace, but it means you should treat report IDs as you would any other secret and avoid putting them somewhere public.

JSON-Encoded Fields

stats, fileInfo, metadata, results, transcript and coverImage are returned as JSON-encoded strings, not as GraphQL objects. Parse them a second time on your side:

{
  "data": {
    "report": {
      "id": "73d6c51c-cfdf-4a6d-903a-cd7e67b4382f",
      "name": "Episode 42: The One About Loudness",
      "status": "COMPLETE",
      "createdAt": "2026-08-05T09:14:02.771903+00:00",
      "stats": "{\"passes\": 21, \"failures\": 3, \"total\": 24, \"percent\": 87, \"time_remaining\": 0}"
    }
  }
}

stats is the headline summary: how many checks passed, how many failed, the total number of checks, and percent — the score shown in the Audio Audit web app. results is the check-by-check detail behind it, each entry carrying a name and verbose_name, a status of PASS or FAIL, the measured value, its measurement_unit, and a description. transcript is a list of start/end/text/speaker phrases, with times in milliseconds; speaker is "Speaker 1", "Speaker 2", … in order of first appearance, or null when the report predates speaker detection or detection failed for it.

Waiting for a Report to Finish

There are no webhooks or callbacks yet, so poll. A report's status is one of:

  • PENDING — accepted, not started
  • SCHEDULED — queued for a worker
  • IN_PROGRESS — being analysed
  • COMPLETE — finished, results are available
  • FAILED — analysis did not complete
  • OUT_OF_QUOTA — not analysed, because the workspace was out of credits

The first three are transient; the last three are final. The pattern:

  1. Call reports for your workspace with forSeriesId set to the show you care about, selecting id status, and take the first row — rows come back newest first. If you would rather not filter server-side, select series { id } as well and pick the newest matching row yourself, paging with offset if you need to look further back.
  2. If its status is COMPLETE, you already have what you need — stop.
  3. Otherwise call report with that row's id every 30 seconds or so, asking only for id status stats, until the status becomes COMPLETE, FAILED or OUT_OF_QUOTA.

Hold on to report ids you have already seen, so that a later run can tell a genuinely new report from one you have processed.

While a report is IN_PROGRESS, the time_remaining value inside stats is our estimate of the milliseconds left, which is a good basis for how long to wait between polls. It is 0 in every other state, and before we have measured the audio. It goes negative when a report is taking longer than we estimated — treat that as "still working", not as a failure.

A report for a one-hour episode typically takes around five minutes, so poll patiently rather than tightly, and give up after a generous ceiling rather than looping forever.

Deprecated Podcast Aliases

Earlier versions of this page documented podcast-named operations. We renamed them because a show will not always be a podcast — YouTube channels and audiobooks are planned — and every one of the old names was kept as an alias. They are still there, still answer with exactly the same rows, and will keep working indefinitely — if your integration uses them, there is nothing you have to do and no migration deadline. They are marked deprecated in the schema so that new integrations pick the series names instead.

DeprecatedUse instead
podcasts(limit:, organisationId:)seriesList(limit:, organisationId:)
podcast(id:)series(id:)
reports { podcast { id title } }reports { series { id title } }
reports(podcastId:)reports(seriesId:) — not the supported filter under either name, see above

Each pair is one resolver behind two names, so an alias can never drift from its replacement. New integrations should use the series names; existing ones do not need to change.

What We Support

The operations documented on this page and on Authenticationuser, organisations, seriesList, series, reports and report, with the arguments and fields described above — are stable, and so are the podcast-named aliases listed above: podcasts, podcast and reports { podcast { id title } } are covered by the same promise and keep working indefinitely. We will not rename or remove a documented field, argument or enum value without telling you first. The one exception is called out explicitly above: reports(seriesId:), and its deprecated podcastId twin, is not part of this promise — not because it leaks (it does not; like everything else here, it only ever returns shows you can see), but because it is scoped to you rather than to the workspace your key names, and because it silently does nothing once organisationId is also supplied. Use forSeriesId instead.

These operations evolve additively. New fields, new optional arguments and new status values can appear at any time. Build your integration so that fields you do not recognise are ignored, and so that an unfamiliar status value does not break it.

The schema is fully introspectable through GraphiQL, and it contains a great deal more than this page describes. Everything not documented here is internal. It exists to serve the Audio Audit web app, it is not part of the API contract, and it may change or disappear without notice. If you need something that is only reachable that way, email info@audioaudit.io and tell us what you are building — we would rather document it than have you depend on it quietly.

There is also a REST API. It sits alongside the GraphQL API rather than replacing it.