- Documentation
- »Developers
- »REST API
REST API
The REST API covers the whole reporting loop: list the podcasts and episodes in your workspace, create a report from a feed or from a file you upload, poll it until it finishes, and read its results, transcript and your credit balance.
It is a thin, published layer over the same code the Audio Audit dashboard runs, so plan limits, credit charging and permissions behave identically whichever way a report is created. The GraphQL API remains fully supported and is not going anywhere — if you already integrate through it, nothing you have built needs to change.
OpenAPI and the API explorer
The API describes itself, and both of these are public — no key needed to read them:
- /api/rest/v1/openapi.json — the OpenAPI 3.0 specification, generated from the same definitions that serve the requests. It is the machine-readable version of this page, and it is what to point a code generator or a no-code platform at. See Integrations.
- /api/rest/v1/docs — an API explorer over that spec, with a "Try it out" button on every endpoint. Signed in, it will use your browser session for read requests; for writes, paste a key into Authorize.
Which workspace "Try it out" reads is worth knowing before you conclude something is broken. A browser session always resolves to your personal workspace, so if your shows live in an Organisation the explorer will show you an empty listing while the same request with an organisation key returns them. Paste an organisation key into Authorize to try it against that Organisation.
Neither counts against your rate limits.
Base URL
https://audioaudit.io/api/rest/v1
The version is in the path and stays there. Everything below is v1.
Authentication
Send an API key as a Bearer token on every request:
Authorization: Bearer your-api-key-here
Keys are created on the developers settings page; see Authentication for the steps.
The key is the workspace. A key is created in exactly one workspace — your personal workspace, or a single Organisation — and on REST that binding is absolute. There is no workspace parameter on any endpoint, and no way to ask an organisation key for anything outside its organisation. This is stricter than the GraphQL API, where the workspace is an argument you pass and a key can reach any workspace the account behind it belongs to.
An organisation key acts as one of that Organisation's owner accounts — an arbitrary one if the Organisation has several, and not necessarily the same one after the owners change. Two consequences worth knowing before you build on it:
- Reports created by an organisation key are attributed to a workspace owner, not to a distinct "API user". Which owner is not something you can pin down or rely on.
- If an Organisation has no owner at all, its keys stop working and every request returns
401 invalid_api_key. Serving a workspace nobody owns is not something we are willing to do.
Create a separate key per integration so you can revoke one without disturbing the others. Every endpoint is available to every key — there are no read-only keys and no scopes in v1.
Conventions
- Field names are
snake_case. - Timestamps are ISO-8601 with an explicit UTC offset, e.g.
2026-08-04T11:32:07.918204+00:00. Dates without a time — an episode'spublished_on— are plainYYYY-MM-DD. - Request bodies are JSON, and
Content-Type: application/jsonis required on writes. - Enumerated values are sent and received as their upper-case names: a report's
statusisCOMPLETE, nevercomplete. A value read out of a response can always be sent straight back into a filter. - Ignore fields you do not recognise. New response fields can appear on v1 at any time — see Versioning and compatibility at the end of this page. A client that rejects unknown keys will break on a change we are explicitly allowed to make.
Pagination
Every list endpoint returns the same envelope:
{
"items": [],
"limit": 50,
"offset": 0,
"has_more": false
}
limit defaults to 50 and may be 1–100; offset defaults to 0. has_more tells you whether another page exists — it is answered by fetching one row more than you asked for and discarding it, which is cheap and exact.
There is deliberately no total. Counting every matching row on every page costs a second query that almost no caller reads, and has_more answers the question a paging loop actually asks. If you need a count, page to the end and count what you received.
offset is a window over a live listing, not a stable cursor. Listings are ordered newest first, so a row created between two of your requests shifts everything down by one, and a deep offset walk can show you the same row twice or skip one. That is fine for an export you run to completion; it is the wrong tool for polling. Use the updated_since filter on GET /reports for that, as described under Integrations.
Errors
Every failure is the same envelope, and a response is never partial data plus an error:
{
"error": {
"code": "not_found",
"message": "Not found."
}
}
Branch on code, which is stable. message is written for a human and may be reworded; where it is useful it carries detail from further in — which parameter was wrong, which plan limit was hit, why a feed could not be read.
| Status | code | When |
|---|---|---|
| 400 | validation_error | A parameter or body field is missing, malformed, out of range, or not one of the allowed values. Also the "exactly one of" rule on POST /reports, and a feed URL we could not read |
| 401 | invalid_api_key | The Authorization header is missing, or the key is unknown, disabled, or belongs to an Organisation with no owner |
| 402 | insufficient_credits | The workspace is out of credits or out of its monthly allowance. Nothing was created and nothing was charged |
| 403 | forbidden | Authenticated, but the underlying operation refused on permission. A v1 key acts as a workspace owner, so a correctly issued key does not meet this — treat it as an unexpected condition worth reporting rather than as something to handle |
| 403 | plan_limit_reached | A plan cap stands in the way, most often adding another podcast. The message names the plan and the cap |
| 404 | not_found | No such resource in this workspace. A resource that exists but belongs to someone else is reported exactly this way, deliberately — a 403 would confirm the id is real and turn the endpoint into a lookup service for other customers' ids |
| 405 | method_not_allowed | The route exists but not for this method. The response carries an Allow header listing the methods it does take |
| 409 | conflict | The request has already been carried out. Today that means a report_id from POST /uploads which has already produced a report; retrying is safe and will keep returning this |
| 429 | rate_limited | A rate limit was exceeded. The response carries Retry-After, in seconds |
| 500 | internal_error | Something went wrong on our side. The detail is logged, not returned. Retry, and tell us if it persists |
| 503 | — | The server was busy and refused the request before it reached the API. The only response that is not the envelope — see below |
That 503 is the one reply with no envelope in it. It is emitted by the server that runs the application, not by the application, when a worker is already holding as many requests as it will hold at once — over that line requests are refused rather than queued. The body is text/plain and reads Service Unavailable, there is no error object and so no code to branch on, and there is no Retry-After to read a delay from. It reaches you unchanged. Nothing was created and nothing was charged, so retry it on a backoff of your own.
The general rule worth coding once: check the content type before parsing. Any 5xx that is not JSON came from somewhere in front of the API rather than from it, and every one of them is worth retrying — routing them through the same path as an enveloped error saves you an unhandled parse failure at the worst moment.
Rate limits
Three limits apply, on three independent counters — exhausting one leaves the others untouched. The first two are spent by authenticated requests; the third is spent by requests whose credentials are refused, whether or not an Authorization header was sent at all.
| Limit | Applies to | Counted against |
|---|---|---|
| 100 requests per minute | every endpoint | your API key |
| 30 requests per hour | POST /reports and POST /uploads | your API key |
| 20 rejected credentials per 5 minutes | any endpoint, whenever a request's credentials are refused — a missing, unknown or disabled key among them | the calling IP address |
Crossing any of them returns 429 rate_limited with a Retry-After header giving the seconds until the window resets. The windows are fixed rather than rolling, so that number is exact rather than an estimate.
Three details that are easier to read here than to discover:
- The per-key limits are per key, not per workspace. Two keys on one workspace get a bucket each — which is how you stop a nightly bulk job throttling an interactive integration. Use separate keys for separate jobs.
- The 100-per-minute figure covers the endpoints, not the documentation.
GET /api/rest/v1/openapi.jsonandGET /api/rest/v1/docssit outside both per-key limits and are counted by neither. Reading the spec is not use of a key. - The third limit counts rejections, not requests. It increments only when a request is refused for its credentials — a key we do not recognise, one that has been disabled, one whose workspace has no owner, or no key at all — so a working key never touches it and can never be refused because of it — including when it shares an office IP address with a colleague whose integration is retrying a key that was disabled last week. What it bounds is key guessing. If you do meet it you are sending a token we do not recognise, so fix the token rather than backing off; the window clears itself in five minutes.
Endpoints
Every example below is a complete, runnable command. Set your key once:
export AUDIOAUDIT_API_KEY=your-api-key-here
GET /me — check a key works
Returns the workspace a key is bound to, and the key's own record. No billing lookup, so it is fast and safe to use as an integration's "test connection" step.
curl https://audioaudit.io/api/rest/v1/me \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"workspace": {
"type": "organisation",
"id": "88ff7ef1-18a0-4aea-984c-2da3f7c38b14",
"name": "The Guardian"
},
"key": {
"id": "0137423a-9fe3-47aa-b8a7-a342aca9bc3d",
"description": "Zapier",
"masked_key": "Kx7pQ2mR...",
"created_at": "2026-08-06T07:27:46.930595+00:00",
"last_used_at": "2026-08-06T07:28:13.573413+00:00"
}
}
workspace.type is organisation or personal. last_used_at is stamped at most once a minute, so it can lag a busy integration by up to sixty seconds.
GET /podcasts — list the shows in this workspace
curl "https://audioaudit.io/api/rest/v1/podcasts?limit=2" \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"items": [
{
"id": "66937339-76d4-4df9-a6ac-3f854dee2c39",
"title": "Football Weekly",
"feed_url": "https://www.theguardian.com/football/series/footballweekly/podcast.xml",
"automated_reporting": true,
"created_at": "2025-08-05T13:12:46.831979+00:00"
},
{
"id": "0e94bd4d-f7ab-4a26-a9b9-d9b2286e4a4c",
"title": "Today in Focus",
"feed_url": "https://www.theguardian.com/news/series/todayinfocus/podcast.xml",
"automated_reporting": true,
"created_at": "2025-07-16T14:13:56.303588+00:00"
}
],
"limit": 2,
"offset": 0,
"has_more": false
}
automated_reporting is this workspace's own setting for the show — whether we report on new episodes automatically as they appear.
This workspace holds exactly two shows, so a page of two is the whole listing and has_more is false. has_more is only ever true on a full page — it is answered by the row beyond the one you asked for — so a short page always means you have reached the end.
GET /podcasts/{podcast_id} — one show
curl https://audioaudit.io/api/rest/v1/podcasts/0e94bd4d-f7ab-4a26-a9b9-d9b2286e4a4c \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
The same fields as a listing entry, unwrapped. A show this workspace does not hold is a 404.
GET /podcasts/{podcast_id}/episodes — episodes we have seen
curl "https://audioaudit.io/api/rest/v1/podcasts/0e94bd4d-f7ab-4a26-a9b9-d9b2286e4a4c/episodes?limit=2" \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"items": [
{
"id": "8caa503d-47e3-444a-bba8-8c8e78e9fcc9",
"title": "The 21 million women and girls the world forgot",
"guid": "urn:bbc:podcast:p0p2mgym",
"audio_url": "http://open.live.bbc.co.uk/mediaselector/6/redir/.../p0p2mg3w.mp3",
"published_on": "2026-08-04",
"first_seen_at": "2026-08-06T08:09:29.160817+00:00"
},
{
"id": "74de9cbe-41ad-412b-9111-affa64d6ded3",
"title": "The gen Z investors",
"guid": "urn:bbc:podcast:p0p2hqcf",
"audio_url": "http://open.live.bbc.co.uk/mediaselector/6/redir/.../p0p2hq4d.mp3",
"published_on": "2026-08-04",
"first_seen_at": "2026-08-06T08:09:15.237555+00:00"
}
],
"limit": 2,
"offset": 0,
"has_more": true
}
Newest first by first_seen_at — the order we recorded them in, not the order the feed publishes them in. The two usually agree, but a show that back-fills an old episode, or a feed we read for the first time, will hand you rows whose published_on is not descending. Sort on published_on yourself if that is the order you need.
These are the episodes Audio Audit has seen since the show was added, not the show's full back catalogue — first_seen_at is when we recorded it, published_on is what the feed says. To report on an episode we have not seen, use POST /reports with the feed URL and an episode_index.
GET /reports — list reports
curl "https://audioaudit.io/api/rest/v1/reports?status=COMPLETE&limit=2" \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"items": [
{
"id": "8b73617b-0ce8-42b8-be0b-af82af0183e8",
"status": "COMPLETE",
"created_at": "2026-07-29T05:00:53.760629+00:00",
"updated_at": "2026-07-29T05:02:18.748354+00:00",
"original_filename": "20260814tifprisons.mp3",
"podcast": {
"id": "0e94bd4d-f7ab-4a26-a9b9-d9b2286e4a4c",
"title": "Today in Focus"
},
"episode": {
"id": "e87f68bb-5c84-4d34-b226-8c6b2e3a439b",
"title": "Prisons are full - who should be released early?",
"published_on": "2026-07-29"
}
},
{
"id": "34824aaa-339c-4320-b90b-db24845ab704",
"status": "COMPLETE",
"created_at": "2026-07-28T16:00:41.953652+00:00",
"updated_at": "2026-07-28T16:02:07.214383+00:00",
"original_filename": "20260817tifproducer.mp3",
"podcast": {
"id": "0e94bd4d-f7ab-4a26-a9b9-d9b2286e4a4c",
"title": "Today in Focus"
},
"episode": {
"id": "10933463-8179-4804-b89e-ebf83540a3c2",
"title": "The super-producer who made an album from his hospital bed",
"published_on": "2026-07-28"
}
}
],
"limit": 2,
"offset": 0,
"has_more": true
}
Newest first, by creation time. podcast and episode are null on a report created from an uploaded file, which belongs to no show.
Filters, all optional and all combinable:
| Parameter | Effect |
|---|---|
podcast_id | Only reports for this show, within this workspace |
status | One of PENDING, SCHEDULED, IN_PROGRESS, COMPLETE, FAILED, OUT_OF_QUOTA |
created_since | Only reports created strictly after this instant |
updated_since | Only reports updated strictly after this instant — the one to poll on |
Both _since bounds take an ISO-8601 date-time and are exclusive, so a value read out of a response is not handed straight back to you. A value with no offset is read as UTC. Encode a + offset as %2B, or use the Z form — an unencoded + in a query string decodes to a space.
curl "https://audioaudit.io/api/rest/v1/reports?status=COMPLETE&updated_since=2026-07-29T00:00:00Z" \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
Poll on updated_since, not on created_since. A report is created when it is queued and reaches COMPLETE through an update, and reports finish out of order — a short episode queued second can overtake a long one queued first. A poller that has advanced a created_at watermark past the second report will never be handed the first. The full recipe, including how to take the watermark correctly, is under Integrations.
GET /reports/{report_id} — one report in full
curl https://audioaudit.io/api/rest/v1/reports/8b73617b-0ce8-42b8-be0b-af82af0183e8 \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"id": "8b73617b-0ce8-42b8-be0b-af82af0183e8",
"status": "COMPLETE",
"name": "Prisons are full - who should be released early?",
"created_at": "2026-07-29T05:00:53.760629+00:00",
"updated_at": "2026-07-29T05:02:18.748354+00:00",
"started_at": "2026-07-29T05:00:53.760371+00:00",
"original_filename": "20260814tifprisons.mp3",
"personal_report": false,
"credits_charged": "304.00",
"audio_url": "https://storage.googleapis.com/audioaudit-artifacts/audio/podcast-episode/e87f68bb.mp3",
"stats": { "passes": 11, "failures": 6, "total": 17, "percent": 64, "time_remaining": 0 },
"results": [
{
"name": "loudness_lufs",
"verbose_name": "Loudness (LUFS)",
"status": "FAIL",
"value": -21.4,
"measurement_unit": "LUFS",
"description": "You may have noticed having to turn up the volume to listen to a particular podcast but then have your ears blasted when you receive a phone notification…",
"warning_message": "Average volume is too quiet (below -17 LUFS).",
"link": "/articles/podcast/loudness-lufs"
},
{
"name": "peak",
"verbose_name": "Peak Volume",
"status": "PASS",
"value": -2.411563447672172,
"measurement_unit": "dB",
"description": "Peak volume refers to the point in an audio file which has the highest signal strength. It isn’t representative of overall loudness because it might only last for a millisecond…",
"warning_message": null,
"link": ""
}
],
"file_info": { "size": 14675452, "duration": 1820730, "codec_name": "mp3", "format_name": "mp3", "channels": 1, "sample_rate": 44100 },
"metadata": { "title": "…", "artist": "The Guardian", "album": "Today in Focus", "track_num": null, "year": "2026", "copyright": "(c) BBC 2026", "chapters": [], "comments": "…" },
"cover_image": { "width": 800, "height": 800, "file_size": 106530, "url": "https://storage.googleapis.com/…" },
"podcast": { "id": "0e94bd4d-…", "title": "Today in Focus", "feed_url": "https://…" },
"episode": { "id": "e87f68bb-…", "title": "…", "guid": "…", "audio_url": "…", "published_on": "2026-07-29" }
}
results is the interesting part: one entry per check, each with a status of PASS or FAIL, the measured value and its measurement_unit, and a warning_message that is populated on failures and null on passes. stats counts them — total is how many checks ran, and percent is the share that passed.
Two things to know before you parse results. value is whatever the check measured: usually a number, but the metadata and cover-image checks report an object and several checks report a list. And which checks run is not fixed — key on name, tolerate names you do not know, and do not assume a fixed length.
Two notes on the example above. description is shown abridged, cut at the …: a real one is multi-paragraph markdown — line breaks and **bold** runs, a few hundred words on some checks — and the response carries the whole thing. And a numeric value arrives exactly as the check measured it: some checks round and some do not, so peak really is that long. Round for display yourself rather than assuming a precision.
stats, results, file_info, metadata and cover_image are empty or partial while a report is still running, and credits_charged is null until it completes. Read them once status is COMPLETE. file_info carries size and duration for every report; the codec detail is there only when we were able to probe the file.
The transcript is not in this response — it is by far the largest thing a report carries and most callers never read it, so it has an endpoint of its own.
GET /reports/{report_id}/transcript
curl https://audioaudit.io/api/rest/v1/reports/8b73617b-0ce8-42b8-be0b-af82af0183e8/transcript \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"report_id": "8b73617b-0ce8-42b8-be0b-af82af0183e8",
"transcript": [
{ "start": 8140, "end": 11800, "text": "With American politics, everything seems to be changing by the seconds." }
]
}
start and end are milliseconds from the beginning of the episode. transcript is null for a report that has none.
POST /reports — create a report
One route, three ways of saying what to report on. Send exactly one of feed_url, podcast_id or report_id; zero or two is a 400.
All three return 201 with the new report's id and status:
{ "id": "9467d13a-0f3a-4afc-b7bb-bf1e29e78d2d", "status": "PENDING" }
The status you get back is whatever the report has reached by the time we answer, so PENDING and IN_PROGRESS are both normal and neither is the final word. Poll GET /reports/{report_id} until status is COMPLETE or FAILED.
1. From a feed URL. episode_index is optional and counts from the newest episode, so 0 — the default — is the latest.
curl -X POST https://audioaudit.io/api/rest/v1/reports \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"feed_url": "https://www.theguardian.com/news/series/todayinfocus/podcast.xml", "episode_index": 4}'
If the workspace does not already hold the show, reporting on it adds it — which is why a workspace at its plan's podcast limit is refused here with 403 plan_limit_reached. A feed we cannot read is a 400 whose message says what went wrong ("The feed returned HTTP 404", "The feed is not valid XML"), and so is an episode_index past the end of the feed.
2. From a podcast you already hold. Identical behaviour, addressed by id rather than by URL — the feed is still fetched, so this is a convenience and not a shortcut.
curl -X POST https://audioaudit.io/api/rest/v1/reports \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"podcast_id": "0e94bd4d-f7ab-4a26-a9b9-d9b2286e4a4c", "episode_index": 3}'
A podcast_id this workspace does not hold is a 404.
3. From a file you uploaded. See the upload flow below. This variant also returns estimated_credits, the pre-flight estimate for the duration you declared:
{ "id": "29b71bc0-a1e8-4f55-ba57-ccf577c5e359", "status": "PENDING", "estimated_credits": 306 }
A full analysis costs 10 credits per audio-minute, rounded up, with a ten-minute minimum charge — so the 30m 34s file below estimates at 306. estimated_credits is exactly that arithmetic over the duration_ms you sent; the amount actually charged is recomputed at completion from the duration we measure in the file, and appears as credits_charged on the finished report.
A workspace with too few credits, or one that has used up its monthly allowance, gets 402 insufficient_credits from any of the three variants. Nothing is created and nothing is charged.
POST /uploads — analyse your own audio
Reporting on a file you hold takes three calls.
Step 1 — mint an id and a signed upload URL. duration_ms is the length of the audio in milliseconds, used for the credit estimate. file_type is signed into the URL, so it has to match the Content-Type you PUT with, exactly.
curl -X POST https://audioaudit.io/api/rest/v1/uploads \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"file_name": "episode-42.mp3", "file_type": "audio/mpeg", "duration_ms": 1834000}'
{
"report_id": "29b71bc0-a1e8-4f55-ba57-ccf577c5e359",
"upload_url": "https://storage.googleapis.com/audioaudit-artifacts/audio/29b71bc0-….mp3?X-Goog-Algorithm=…"
}
Step 2 — PUT the audio to upload_url. No Audio Audit credentials on this request: the URL is already signed, and it expires after 15 minutes.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: audio/mpeg" \
--upload-file episode-42.mp3
Step 3 — create the report from the id.
curl -X POST https://audioaudit.io/api/rest/v1/reports \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"report_id": "29b71bc0-a1e8-4f55-ba57-ccf577c5e359", "original_filename": "episode-42.mp3", "duration_ms": 1834000}'
Three rules govern that id, and they exist because it travels — it comes back to you in a response, it is embedded in the upload URL, and it ends up in automation run logs and support screenshots:
- Only the workspace that minted it can use it. An id minted by another workspace is a
404, exactly as though it had never existed. - Only once. A second
POST /reportswith the samereport_idis a409 conflict. Mint a new id rather than retrying the old one. - Only for seven days. After that the reservation lapses and the id is a
404. The signed upload URL expires long before that — fifteen minutes — so any client still able to upload is comfortably inside the window.
GET /credits — balance and allowance
curl https://audioaudit.io/api/rest/v1/credits \
-H "Authorization: Bearer $AUDIOAUDIT_API_KEY"
{
"balance": 24600,
"plan_allowance": 24000,
"plan_used": 0,
"plan_remaining": 24000,
"topup_balance": 0,
"progress": 0.0,
"finish_date": "2026-08-28T00:00:00+00:00",
"charging_enabled": true
}
balance is what you can spend now, across every credit you hold — it is not always plan_remaining + topup_balance, as the example shows. Credits are held as separate lots and balance adds up every lot that has not expired, while plan_remaining is only the current period's plan lot; a lot left over from a previous arrangement counts towards what you can spend without appearing in either of the other two figures. Gate on balance, and treat plan_allowance / plan_used / plan_remaining as reporting on this period's plan rather than as the arithmetic behind it.
finish_date is when the current plan period ends and the allowance resets. This is the one endpoint that consults the billing provider, so it is a little slower than the rest; keep it out of tight polling loops.
Versioning and compatibility
The version is in the path, and v1 evolves additively only. All of these can appear on v1 at any time, without notice:
- new endpoints;
- new optional parameters;
- new fields in a response;
- relaxed validation, and deprecation notices in the spec.
Anything that could break a working client gets a new /api/v2/ route instead, and v1 keeps serving unchanged alongside it. That covers removing or renaming a field or an endpoint, changing a field's type, making a parameter required, changing the status or code returned for an existing condition, and changing the pagination envelope.
The one obligation this puts on you: ignore fields you do not recognise. Because new response fields are a non-breaking change we may ship at any point, a client that fails on an unexpected key has opted out of the guarantee. In most languages that means decoding into a permissive structure, or configuring your deserialiser to skip unknown properties rather than throw.
When a v2 does arrive, v1 endpoints are marked deprecated in the spec and keep working. We retire a version only once its access logs are empty.
Getting help
Email info@audioaudit.io with the endpoint, the code you got back and roughly when, if something is not behaving as this page says it should.