# Affiliate Portal API reference > A REST API and MCP server for radio stations that carry network programming: read each week's order, audio and printable documents, report affidavits and show certificates, and acknowledge new versions, with a station key. Signing a week is done by a person in the portal. # Guide: Affiliate Portal API Automate the network week: pull the order, load the audio, report what aired and acknowledge changes. The station API gives your traffic and automation systems what the portal shows a person: each network's order for the week, its audio and printable documents, and a way to report what aired. It is JSON over HTTPS, and every call carries a station key. ## Base URL ```text https://affiliates.radioworkflow.com/api/v1 ``` Every path in this reference is under it. The same address serves the [OpenAPI 3.1 document](https://affiliates.radioworkflow.com/api/v1/openapi.json), without a key. ## Quick start: a first call in five minutes 1. **Create a key.** Sign in to the portal, open **API and MCP** ([/developers](https://affiliates.radioworkflow.com/developers)), choose the stations, give it the `read` and `report` scopes and copy the secret. It is shown once. Keep it in an environment variable: the samples read `AFFILIATE_API_KEY`. 2. **List your affiliations.** Each is one station's relationship with one network, and its `id` goes in every other path. 3. **Read the week.** Use the affiliation's `id` and the Monday of the broadcast week. Note `week.epoch`, and the `id` and `rev` of a unit whose window has closed. 4. **Report one time.** Send the unit's `id` and `rev`, the week's `epoch`, and the day and time it aired. The answer shows the unit as the network now sees it. 2. List your affiliations: ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` 3. Read the week: ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` 4. Report one time: ```bash # A new Idempotency-Key for every new request. Reuse it only to retry this same request. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/affidavits' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{ "epoch": 1, "items": [ { "unit_id": "u-01oso1x", "rev": 1, "status": "aired", "aired_date": "2026-09-22", "aired_at": "07:42" } ] }' ``` Every endpoint page has a **Try it** panel that runs the call on demo data with no key, so you can see each answer before you write any code. ## Every request - The key as a bearer token: `Authorization: Bearer rwaff_k_...`. See [Authentication](https://affiliates.radioworkflow.com/docs/authentication). - Bodies are JSON objects, sent with `Content-Type: application/json`; a log upload is `multipart/form-data`. - The calls that record acknowledgements, affidavits, show certificates and log matches take an `Idempotency-Key` header: a new one for every request and the same one to retry it, so a retry never records twice. `upload-log` is not idempotent: sending a file again is a new upload. See [Pagination and idempotency](https://affiliates.radioworkflow.com/docs/pagination). - A week is its Monday, `YYYY-MM-DD`. Times are minutes after midnight (0 to 1439) or `HH:MM`, in the station's time zone. ## Every response - `X-Request-Id` names the request; quote it when you ask about one. - `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` report the key's burst bucket. See [Rate limits](https://affiliates.radioworkflow.com/docs/rate-limits). - `Cache-Control: no-store`: answers are never cached. - A refusal carries a status, a code and one sentence in the error envelope below. See [Errors](https://affiliates.radioworkflow.com/docs/errors). The error envelope: ```json { "error": { "code": "epoch_changed", "message": "The week changed since you read it. Read it again, then retry.", "epoch": 2 } } ``` ## What a key cannot do A key reads and reports; it never signs. Signing a week is an attestation that the affidavits are true, so a person does it in the portal. `POST .../sign` answers 403 `key_cannot_sign` for every key. ## Assistants and coding agents - An assistant can use these endpoints as tools through the [MCP server](https://affiliates.radioworkflow.com/docs/mcp), all but `upload-log` and `get-document`; over MCP, `get-week-audio` returns a link to the audio ZIP rather than the audio, and `sign-week` only returns the link where a person signs. - A coding agent can read [/llms.txt](https://affiliates.radioworkflow.com/llms.txt), the index of these pages, or [/llms-full.txt](https://affiliates.radioworkflow.com/llms-full.txt), all of them in one file. - API tools and client generators read the [OpenAPI document](https://affiliates.radioworkflow.com/api/v1/openapi.json). # Guide: Authentication Station keys and their scopes, OAuth for assistants, and why signing stays a person's act. Every call carries a station key as a bearer token. There are no cookies and no sessions: a request with a valid key is complete on its own. ```http Authorization: Bearer rwaff_k_0123456789ab_... ``` ## Station keys - A person with access to the station makes a key in the portal under **API and MCP** ([/developers](https://affiliates.radioworkflow.com/developers)): a name, the stations it covers, its scopes and when it expires (never, or after 30, 90 or 365 days). - The secret is shown once, when the key is made. Store it like a password; the portal keeps only a hash and cannot show it again. - A key reaches only the stations it was made for, and only while the person who made it still has access to them. When their access ends, so does the key's. - A key can be revoked on the same page at any time. It stops working on its next call. - A key that is missing, malformed, unknown, revoked or expired answers 401 `invalid_principal`, the same way each time. ## Scopes | Scope | Unlocks | Also needs | |---|---|---| | `read` | Every read: affiliations, weeks, versions, status, clearance, creatives, audio, exports and documents. | Nothing more. | | `report` | Reporting affidavits and show certificates, uploading logs and applying their matches. | The key's owner holds the report or sign role, and the affiliation is not read-only. | | `acknowledge` | Acknowledging a new version. | The affiliation is not read-only. | Each endpoint page names the scope it needs. `GET /v1/affiliations` shows, per affiliation, what the key may do there: `canReport` and `readOnly`. A key without the scope an endpoint needs answers 403 `insufficient_scope` or `forbidden`. ## People only: signing An endpoint with no key scope is for people. Today that is one: signing a week. A signature is an attestation that the affidavits are true, and it records who signed, their title and when, so it is made by a person signed in to the portal and never by a key, a script or an assistant. `POST .../sign` answers 403 `key_cannot_sign` for every key, and over MCP the `sign_week` tool returns a link to the portal's signing panel instead of signing. ## OAuth for assistants (MCP) An assistant that connects to the [MCP server](https://affiliates.radioworkflow.com/docs/mcp) can use a station key like any client, or a person can sign in: the assistant opens a sign-in page on the portal, the person allows it, and it receives a token of its own (OAuth with PKCE). That token acts for the person, can report where they can, cannot sign, lasts 30 days from its last use (90 days at most) and appears under **API and MCP**, where the person can end it. It works only on the MCP server, never on the REST API. ## Keeping keys safe - Give each system its own key with only the scopes it needs, so one can be revoked without touching the others. - Never put a key in a web page, a browser or a public repository. Call the API from a server. - Choose an expiry for keys held by contractors or trials, and revoke a key the moment it may have leaked. # Guide: Errors Every code the error envelope carries, what causes it, how to fix it and whether to retry. A refusal answers a 4xx or 5xx status with a JSON body: `error.code` says what happened and `error.message` is one fixed sentence. Branch on the code, not on the status or the sentence: two codes can share a status. Some codes add extras: `errors` (per item), `latest`, `rev`, `epoch`, `missing` and `retry_after`. ```json { "error": { "code": "validation_failed", "message": "Some items could not be saved. Each error says why.", "errors": [ { "unit_id": "u-01oso1x", "error": "outside_window", "message": "That time is outside the window." } ] } } ``` ## Codes | Status | Code | What happened | What to do | Retry | |---|---|---|---|---| | 400 | `bad_request` | The request is not valid: a path value, a query value or a body field is missing, malformed or out of range. | Read the message, which names up to three fields and what is wrong with each, correct the request and send it again. | No | | 401 | `invalid_principal` | The key is missing, malformed, unknown, revoked or expired; all of these answer the same way. | Send the whole key as `Authorization: Bearer rwaff_k_...`. If it still fails, check under API and MCP in the portal that the key is active, or create a new one. | No | | 403 | `forbidden` | The key is valid but cannot do this for this affiliation: the affiliation is not one of the key's, or the person who made the key lacks the role. | Call `GET /v1/affiliations` to see what the key can reach and with which role. A key made for other stations needs replacing. | No | | 403 | `read_only` | The affiliation is read-only: the network paused or ended it, or the station outlet is inactive. Its history stays readable. | Only reads work on this affiliation. Ask the network if you expected it to be live. | No | | 403 | `key_cannot_sign` | A key asked to sign a week. Signing is an attestation, so only a person signed in to the portal can do it. | Send the person to the portal's signing panel (over MCP, `sign_week` returns the link). Keys report times; people sign. | No | | 403 | `insufficient_scope` | The key does not carry the scope this endpoint needs (`read`, `report` or `acknowledge`). | Scopes are fixed when a key is made. Create a key with the scope named on the endpoint's page and use it instead. | No | | 404 | `not_found` | Nothing was found for the values in the path: no audio is ready for the week yet, the upload id is unknown, or the record does not exist. | Check the ids against `GET /v1/affiliations` and `GET /v1/affiliations/{id}/weeks`. For audio, wait until `list-creatives` shows `mediaStatus` `ready`. | Yes | | 409 | `stale_version` | A newer version of the week exists than the one you sent: acknowledging the older one, or reporting dropped breaks by its break numbers after the show's breaks were renumbered, would be wrong. | Read the week again, then acknowledge the `latest` version the error carries, or report the dropped breaks again with the new break numbers. | No | | 409 | `epoch_changed` | The network reopened or changed the week after you read it, so the `epoch` you sent is out of date. | Read the week again, take `week.epoch` from it, check your times still apply and send the batch again. | No | | 409 | `week_signed` | The week is signed, so its affidavits can no longer change. | Ask the network to reopen the week if something needs correcting. | No | | 409 | `sign_blocked` | The week cannot be signed yet: items are still pending, or the broadcast week has not ended. `missing` lists what is left. | Report the units and show days listed in `missing`, then sign in the portal after Sunday. No API call signs. | No | | 409 | `conflict` | The week changed while the request was being handled. | Read the week again, then retry with the values it now shows. | Yes | | 409 | `stale` | The week changed after the log was read, so the matches were made against an older copy. | Read the week again and upload the log again, or apply with the new `epoch` the error carries. | No | | 409 | `already_applied` | Matches from this upload were already applied, and that result does not cover the units you sent now. | Report the other times with `report-affidavits`. An upload is applied once; the repeat of the same choice is answered with `replayed: true`. | No | | 409 | `not_ready` | The uploaded log is still being read, or reading it failed, so there is nothing to apply yet. | Poll `GET /api/uploads/{upload}` with the same station key until it answers `ready`, then apply. After a failure, type the times instead. | Yes | | 409 | `nothing_to_match` | No unit in this week is waiting for a time, so there is nothing a log could match. | Check the week and the station. Units become reportable once their window has closed. | No | | 409 | `nothing_to_apply` | The upload has no matched lines to apply. | Send the chosen `items` yourself, or report the times with `report-affidavits`. | No | | 413 | `payload_too_large` | The request body is over the limit: 256 KB of JSON (512 KB when applying log matches), or 10 MB for an uploaded log. | Split a report into smaller batches (at most 2,000 items each), or upload one broadcast week per file. | No | | 415 | `unsupported_media_type` | The body was not sent as JSON. | Send `Content-Type: application/json` with a JSON object body. | No | | 415 | `unsupported_file` | The uploaded file is not a log export the reader accepts. | Upload the CSV, TSV, text, spreadsheet (xls or xlsx) or PDF your automation system saves. | No | | 422 | `validation_failed` | Items were refused. With `all_or_nothing` true nothing was saved; `errors` says why for each item. | Fix each item named in `errors` (for example `rev_conflict` carries the current value) and send those items again. | No | | 422 | `upload_failed` | The log could not be read within the request. `upload_id` names the upload. | Try a CSV export of the log, or type the times with `report-affidavits`. | No | | 429 | `rate_limited` | Too many requests: the key's bucket, a daily limit or the failed sign-in brake refused this one. | Wait for `Retry-After` seconds, then retry. Pace calls with `RateLimit-Remaining` to stay clear of it. | Yes | | 500 | `not_saved` | The change could not be saved, so nothing changed. | Send the same request again with the same `Idempotency-Key`; a write that did save answers with its first result. | Yes | | 500 | `server_error` | Something went wrong on our side. The answer never carries internal detail. | Retry once. If it keeps happening, quote the `X-Request-Id` header when you contact the network. | Yes | | 502 | `upstream_failed` | The service behind the API answered in a way the portal could not use. | Retry after a short pause. Reads are safe to repeat; repeat a write with the same `Idempotency-Key`. If a write keeps failing with that key, the key may already have been used for a request to another endpoint: read the week to see what was saved, then send the write with a new key. | Yes | | 503 | `unavailable` | The service is not available right now: it could not be reached, or it is not set up yet. | Retry after a short pause, backing off. Nothing about the request needs to change. | Yes | | 503 | `demo_mode` | This copy of the portal runs on demo data, so the API is off. The body is exactly `{"error":"demo_mode"}`. | Use the "Try it" panel on each endpoint page, which runs on the demo data without a key, or call the live portal. | No | Every endpoint can answer 401 `invalid_principal`, 429 `rate_limited`, 500 `server_error`, 503 `unavailable`; each endpoint page lists the others it can give. ## Item errors A batch report saves the valid items and lists the others in `errors` with a code each (with `all_or_nothing` true, one refusal saves nothing and answers 422). Each entry names its `unit_id`, or its `vehicle_id` and `date`. | Code | Why the item was refused | |---|---| | `rev_conflict` | The item changed since you read it; `current` carries its value now. Re-apply your change to it. | | `not_reportable` | The unit's window has not closed yet, or the show day has not ended: at the end of the station's usual hours when it gave them, otherwise at the end of the day in its time zone. | | `outside_window` | An aired time is outside the unit's window. Report it as `outside` with a reason. | | `invalid_time` | The time does not fit the status: an `outside` time inside the window on one of the unit's own days is `aired`. | | `invalid_date` | The date is not one the unit or show day can take. | | `reason_required` | `outside`, `not_aired` and `not_carried` need a reason of 1 to 300 characters. | | `removed` | The unit was removed in a later version. | | `not_found` | No such unit, or no such show day, in this week. | | `break_not_found` | A dropped break is not a break of that show day (give its break number, counted straight through the show, and the hour that holds it). | | `local_break` | A dropped break is a local break, which the network does not bill. | ## Retrying - Retry a `Yes` code after a pause that grows each time (for example 1, 2, 4 then 8 seconds), and a 429 after its `Retry-After`. - Repeat a write with the same `Idempotency-Key`: if the first attempt did save, the repeat answers its first result instead of recording twice. - Do not retry a `No` code unchanged: the same request gets the same answer. - The demo portal answers every API call with 503 and exactly `{"error":"demo_mode"}`; use the Try it panels there. # Guide: Rate limits The limits a key meets and the RateLimit and Retry-After headers. Limits keep one busy system from slowing the others. The portal keeps a burst bucket for each key in front of the service, and the service keeps the limits that count across the whole platform. Whichever limit refuses a request, the answer is 429 `rate_limited` with a `Retry-After` header. ## The limits | Limit | Allowance | Bucket | Counts | |---|---|---|---| | Failed sign-ins per address | 10 per minute | `portal:auth` | Requests from one network address whose key is missing or refused. Once reached, every request from that address answers 429 until the minute is over, before its key is read. | | Burst per key | 60 per minute | `portal:key` | A bucket of 60 requests per key that refills at one a second. The `RateLimit-*` headers of every answer report it. | | Calls per key | 600 per 10 minutes | `key:{prefix}` | Every call a key makes, over REST and MCP together, counted across every instance of the portal. | | Writes per key | 120 per minute | `w:{prefix}` | Every call that records something: acknowledgements, affidavits, show certificates and applied log matches. | | Log uploads per affiliation | 20 per day | `up:a:{affiliation id}` | Uploaded logs for one affiliation in a day, whoever sends them. | | Log uploads per key | 10 per hour | `up:p:{prefix}` | Uploaded logs one key sends in an hour. | | Log reading per network | 2,000,000 per day | `llm:n:{network cloud}` | The reading budget of the network's stations for uploaded logs in a day, in units of text read. When it runs out, an upload answers that the reading limit for today is reached; type the times instead. | Each endpoint page lists the buckets its calls count against. `{prefix}` is the key's public prefix, the part of the key before its secret. ## The headers | Header | Meaning | |---|---| | `RateLimit-Limit` | The size of the key's burst bucket: 60. | | `RateLimit-Remaining` | Requests left in the burst bucket. On every 429 it is 0. | | `RateLimit-Reset` | Seconds until the burst bucket is full again. On a 429 it equals `Retry-After`. | | `Retry-After` | On every 429: the seconds to wait before trying again. | The `RateLimit-*` headers report the burst bucket as the REST API sees it; calls the same key makes over MCP or through downloads are not in that count, and the platform limits are counted by the service. Treat the headers as a guide and a 429 as the answer. ## Staying clear of them - Pace with `RateLimit-Remaining`: slow down as it nears 0 instead of waiting for a 429. - Report in batches. One `report-affidavits` call takes up to 2,000 items and counts once. - Use webhooks to hear about new versions and arrived audio instead of polling for them. - After a 429, wait `Retry-After` seconds, then retry once. Do not retry in a tight loop. # Guide: Pagination and idempotency How lists are bounded, and how Idempotency-Key makes a retried write safe. ## Pagination No endpoint pages today: every list comes back whole. `list-weeks` answers at most 26 weeks, newest first (about six months); an older week stays readable by its Monday with `get-week`. A week's order, versions and creatives are always one answer. Writes are bounded instead: a report takes at most 2,000 items, each unit or show day once, in a JSON body of at most 256 KB (512 KB when applying log matches). Split a bigger batch into several calls. ## Idempotency Every write that records something takes an `Idempotency-Key` header: `acknowledge-version`, `report-affidavits`, `report-show-airings` and `apply-log-matches`. It is 8 to 48 letters, digits, dashes or underscores, and a UUID is a good choice. The service keeps the first answer to each key: when a request times out or the connection drops, send it again with the same key, and if the first attempt did save you get its answer instead of a second record. ```http Idempotency-Key: ``` - The key is the identity of the request, not of its body: a repeat with the same key on the same endpoint answers the first result as an ordinary 200 **even when the body changed**, and records nothing new. - A key reused on **another endpoint** records nothing either. The service answers with the first request's stored result, which does not fit the new endpoint, so the request usually fails with 502 `upstream_failed`, and a retry with that key fails the same way. Send it again with a new key. - So make a **new key for every new request**, keep it with that request, and send it again only to retry that request. The code samples on each endpoint page make a new key every time they run; a script that hard-codes one key saves its first run only. - Without the header the portal makes a key for you, so a retry you send without one counts as a new request. - `X-Request-Id` on the answer carries the key you sent, so you can match answers to requests. - A malformed key answers 400 `bad_request` and records nothing. ### Log uploads are not idempotent `upload-log` stores a new upload every time: an `Idempotency-Key` is only echoed as `X-Request-Id`, and a repeat spends upload quota. When an upload answered 202 with its id, poll `GET /api/uploads/{upload}` with the same station key until it is ready instead of posting the file again. Applying its matches is protected differently: an upload is applied once, and a repeat of the same choice answers the first result with `replayed: true`. # Guide: Versioning and changelog The version policy and the dated list of changes. The API is at version 1: its paths start with `/v1`, and the service marks every answer with `api_version` "1". The OpenAPI document's `info.version` is the same number. ## Policy - **Additive changes ship without notice**: a new endpoint, a new optional input, a new field in an answer, a new error code or a new webhook event. Ignore fields you do not know, and handle codes you do not know by their status. - **A removal or an incompatible change gets a new version**, served beside the old one for at least 90 days, and is announced below before it ships. - Fixed sentences in `message` may be reworded at any time; branch on `code`. ## Changelog ### 2026-09-28: Version 1: show breaks by hour - Every show spot, show break, local break and dropped break carries `hour` with `breakNumber`, and `breakNumber` runs straight through the show in hour order, as the network's printed schedules number the breaks (with four breaks an hour, Hour 2 holds Breaks 5 to 8). - ShowVehicle `usualStart` and `usualEnd` appear only when the station gave its usual hours. - Report dropped breaks as `{hour, breakNumber, reason}`, with `hour` the hour that holds that break, and send the week's `version` with the report: if the show's breaks were renumbered in a newer version, the report answers `stale_version` and saves nothing. - A saved dropped break that the week's current numbers cannot name comes back as `{unreadable: true, reason}` or `{unavailable: true, hour, reason}`. Neither is ever sent back in a report. - A show spot kept from an older version whose break is not on the week's break map, or is now a local break, carries `breakNote`. Tell spots apart by `id`. ### 2026-09-26: Version 1: first public release - Station keys with the `read`, `report` and `acknowledge` scopes. - Affiliations, weeks, the week's order, versions and acknowledgements, what is outstanding and the station's clearance. - Creatives with short-lived audio links, the week's audio as one ZIP, order exports in CSV, tab-separated and XML, and printable documents. - Affidavit and show certificate reports, log uploads with proposed matches, and applying the matches you accept. - Webhooks for new versions, arrived audio, approaching deadlines and reopened weeks. - An MCP server for assistants, this reference, the OpenAPI document and llms.txt. # Guide: Webhooks Events, payloads, verifying the signature and how deliveries are retried. A webhook tells your system when something changes, so it does not have to poll. Create one in the portal under **API and MCP** ([/developers](https://affiliates.radioworkflow.com/developers)): an `https://` address, the events it wants, and a signing secret shown once. **Send a test** delivers a `ping`. ## Events | Event | Sent when | `data` | |---|---|---| | `version.published` | A week is published or revised | `{"summary":"1 added, 1 removed"}` | | `audio.arrived` | Audio that was pending arrives | `{"creatives":[{"id":"trhd","isci":"SR_TRHD0921060"}]}` | | `deadline.approaching` | Affidavits are due in two days | `{"due":"Fri, Oct 2, 5:00 PM","dueAt":1790978400}` | | `week.reopened` | The network reopens a signed week | `{"reason":"A unit was entered on the wrong day","epoch":2}` | | `ping` | You press Send a test (10 per webhook per hour) | `{}` | ## The payload Every event has the same envelope, and only `data` differs. A payload carries metadata, never the order itself: fetch the week with your key when you need its content. `event_id` is the same for every webhook of one event, so use it to ignore a repeat. ```json { "event_id": "evt_0f3a9c1d2e4b5a6978c0d1e2f3a4b5c6", "type": "version.published", "created": 1790259120, "network": "Summit Radio Network", "affiliation_id": "srn-ksmt", "station": "KSUMT-FM", "week": "2026-09-21", "version": 3, "data": { "summary": "1 added, 1 removed" } } ``` Each delivery is a `POST` with `Content-Type: application/json`, `User-Agent: AffiliateWebhooks/1`, `X-Aff-Event` (the event), `X-Aff-Delivery` (this attempt's id) and `X-Aff-Signature`. ## Verifying the signature `X-Aff-Signature` is `t=,v1=`, where the signature is the lowercase hex HMAC-SHA256 of the string `.`, keyed with the webhook's secret (the whole `whsec_` value, as text). Compute it over the body exactly as it arrived, before parsing it, compare it in constant time, and refuse a `t` more than 5 minutes from your clock, so a captured delivery cannot be replayed later. JavaScript (Node.js): ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; // secret: the whsec_ value shown once when the webhook was created. // header: the X-Aff-Signature header. // rawBody: the request body exactly as it arrived, before any JSON parsing. export function verifyWebhook(secret, header, rawBody, toleranceSeconds = 300) { const parts = {}; for (const part of header.split(",")) { const at = part.indexOf("="); if (at > 0) parts[part.slice(0, at)] = part.slice(at + 1); } if (!/^[0-9]+$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest(); return timingSafeEqual(expected, Buffer.from(parts.v1, "hex")); } ``` Python: ```python import hashlib import hmac import time def verify_webhook(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: """secret: the whsec_ value; header: X-Aff-Signature; raw_body: the body exactly as it arrived.""" parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part) timestamp, signature = parts.get("t", ""), parts.get("v1", "") if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` ### A worked example | Input | Value | |---|---| | Secret | `whsec_0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a` | | `t` | `1790000300` | | Body | `{"event_id":"evt_1","type":"version.published"}` | | Signed string | `1790000300.{"event_id":"evt_1","type":"version.published"}` | | `X-Aff-Signature` | `t=1790000300,v1=97125e644692830bc2a93e42da3592b3e3be48e5edfb3c3709f4d910ab536447` | Your code is right when it accepts this header for this body and secret (with the clock check set aside), and refuses it when one byte of the body changes. ## Answering and retries - Answer with any 2xx status within 10 seconds; the body is ignored. Do the work after answering. - Anything else (another status, a redirect, a timeout, a refused connection) is retried with a growing delay, up to 12 attempts in all. - After 20 failed deliveries in a row the webhook is disabled; turn it back on by saving it again in the portal. - Deliveries go only to public `https://` addresses on port 443 or 8443. ## Who owns a webhook A webhook belongs to the person who created it, and each affiliation has at most 5. It keeps working while its owner can report for that station; when the owner's access ends or the affiliation ends, it is disabled and its pending deliveries are cancelled. # Guide: MCP server Connecting an assistant: the server address, sign-in, the tools, resources and prompts. See https://affiliates.radioworkflow.com/docs/mcp. # Endpoints ## List affiliations `GET https://affiliates.radioworkflow.com/api/v1/affiliations` Read: this changes nothing. Stations and networks this key or person can see. Every affiliation the key can reach. An affiliation is one station's relationship with one network: a station that carries two networks has two, each with its own id, deadlines and people. Start here. Every other endpoint takes the `id` of an affiliation from this list, and each entry says what the key may do there: - `canReport` is true where the key may report times. It needs the `report` scope, and a station whose affidavits come from its own Radio Workflow log (`lane` `radioworkflow`) does not report here. - `readOnly` is true once the network has paused or ended the affiliation. Its history stays readable; nothing new can be reported or acknowledged. - `canSign` is always false for a key: a person signs a week in the portal. `vehicles` lists the network's shows and its run-of-schedule (ROS) vehicle this station carries. Show certificates and printed documents name a vehicle by its `id`. **Authentication:** A key with the `read` scope. **Response:** 200 `application/json`. The affiliations, in the order the portal lists them. ```json { "affiliations": [ { "id": "srn-ksmt", "networkName": "Summit Radio Network", "networkShort": "SRN", "stationCall": "KSUMT-FM", "stationName": "KSUMT 101.5 The Summit", "market": "Cedar Falls", "lane": "portal", "canSign": false, "canReport": true, "role": "sign", "readOnly": false, "timezone": "America/Chicago", "networkContact": { "name": "Marcus Bell", "title": "Network Traffic Coordinator", "email": "affiliates@summit-sample.example", "phone": "(555) 010-2440" }, "deadlineRule": "Friday after the broadcast week, 5:00 PM", "audioDelivery": "Delivered by the network's spot delivery service", "vehicles": [ { "id": "show-wd", "name": "The Summit Show", "kind": "show" }, { "id": "demo-ros", "name": "ROS", "kind": "ros" } ] } ] } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `affiliations` | Affiliation[] | yes | | Types used: `Affiliation` (see Types). **Errors:** 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `list_affiliations`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/list-affiliations ## Get a week's order `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}` Read: this changes nothing. The week's order: every unit, its window and its affidavit. The whole order for one broadcast week: every ROS unit with its window, copy and affidavit, every show day with its spots and certificate, the versions the network has published, the week's creatives and the station's shows. Windows and times are minutes after midnight in the station's time zone (`affiliation.timezone`): `360` is 6:00 AM and `1139` is 6:59 PM. `now` is the station's date and minute when the week was read; a unit takes a time once its window has closed. The report endpoints check what you read here. Send `week.epoch` and each unit's or show day's `rev` back unchanged: when the network reopens the week the epoch moves and a report built on the older read answers 409 `epoch_changed`, and an item someone else changed answers `rev_conflict` with its current value. Send `known_version` with the version you hold to learn whether the order changed: `notModified` is true when it did not. Affidavit state is always current. Reading a week records that the station has seen its latest version. A week the network has not published answers `week.published` false with empty lists. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `known_version` | query | integer | no | The version you already hold. `notModified` says whether the order has changed since. (at least 0) | **Response:** 200 `application/json`. The station, its clock, the week, the week's creatives and the station's shows. ```json { "affiliation": { "id": "srn-ksmt", "networkName": "Summit Radio Network", "networkShort": "SRN", "stationCall": "KSUMT-FM", "stationName": "KSUMT 101.5 The Summit", "market": "Cedar Falls", "lane": "portal", "canSign": false, "canReport": true, "role": "sign", "readOnly": false, "timezone": "America/Chicago", "networkContact": { "name": "Marcus Bell", "title": "Network Traffic Coordinator", "email": "affiliates@summit-sample.example", "phone": "(555) 010-2440" }, "deadlineRule": "Friday after the broadcast week, 5:00 PM", "audioDelivery": "Delivered by the network's spot delivery service", "vehicles": [ { "id": "show-wd", "name": "The Summit Show", "kind": "show" }, { "id": "demo-ros", "name": "ROS", "kind": "ros" } ] }, "now": { "date": "2026-09-24", "minutes": 630, "ts": 1790263800, "timezone": "America/Chicago" }, "week": { "start": "2026-09-21", "days": [ "2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-27" ], "version": 3, "state": "in_progress", "due": "Fri, Oct 2, 5:00 PM", "dueAt": 1790978400, "epoch": 1, "rev": 1, "published": true, "shows": [ { "show": { "id": "show-wd", "name": "The Summit Show", "kind": "daily", "days": [ 0, 1, 2, 3, 4 ], "hours": 5, "breakCount": 19, "usualStart": 1140, "usualEnd": 1439, "localBreaks": [ { "hour": 2, "breakNumber": 5 } ] }, "showNumber": "#26-39", "spots": [ { "id": "s-1sjkdlr", "showId": "show-wd", "date": "2026-09-21", "hour": 1, "breakNumber": 1, "position": 1, "creativeId": "mhcl-a", "advertiser": "Meridian Health Clinics", "product": "", "title": "Walk-In Care", "isci": "SR_MHCL0901030", "length": 30, "addedInVersion": 1 } ], "removed": [], "airings": [ { "key": "srn-ksmt:show-wd:2026-09-23", "showId": "show-wd", "date": "2026-09-23", "status": "pending", "rev": 1 } ], "breaks": [ { "hour": 1, "breakNumber": 1, "local": false }, { "hour": 1, "breakNumber": 2, "local": false } ], "excludedDays": [] } ], "rosName": "Summit ROS", "units": [ { "id": "u-01oso1x", "date": "2026-09-22", "daypart": "ROS", "windowStart": 360, "windowEnd": 1139, "creativeId": "trhd", "advertiser": "Trailhead Outfitters", "product": "", "title": "Hunting Season Kickoff", "isci": "SR_TRHD0921060", "length": 60, "cart": "K90388", "status": "pending", "addedInVersion": 1, "kind": "dated", "weekStart": "2026-09-21", "rev": 1, "copyStatus": "ready", "flags": [] } ], "removed": [], "revisions": [ { "version": 3, "publishedAt": "Thu, Sep 24, 9:12 AM", "publishedAtTs": 1790259120, "summary": "1 added, 1 removed", "changes": [ { "kind": "added", "vehicle": "The Summit Show", "date": "2026-09-24", "where": "Hour 2, Break 6", "advertiser": "Golden Grain Bakery", "isci": "SR_GGBK0901030", "length": 30, "detail": "Added inside the show. Nothing to schedule: it is in the show audio." } ] } ], "acknowledgedVersion": 2 }, "creatives": [ { "id": "trhd", "advertiser": "Trailhead Outfitters", "title": "Hunting Season Kickoff", "isci": "SR_TRHD0921060", "length": 60, "flightStart": "2026-09-21", "flightEnd": "2026-10-25", "audioReady": true, "cart": "K90388", "timesThisWeek": 5, "mediaStatus": "ready" } ], "shows": [ { "id": "show-wd", "name": "The Summit Show", "kind": "daily", "days": [ 0, 1, 2, 3, 4 ], "hours": 5, "breakCount": 19, "usualStart": 1140, "usualEnd": 1439, "localBreaks": [ { "hour": 2, "breakNumber": 5 } ] } ], "notModified": false } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `affiliation` | Affiliation | yes | | | `now` | Now | yes | | | `week` | OrderWeek | yes | | | `creatives` | Creative[] | yes | | | `shows` | ShowVehicle[] | yes | | | `notModified` | boolean | yes | | Types used: `Affiliation`, `Now`, `OrderWeek`, `Creative`, `ShowVehicle`, `ShowWeek`, `AffiliateUnit`, `Revision`, `ShowSpot`, `ShowAiring`, `Change` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `get_week`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/get-week ## Get what is outstanding `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/status` Read: this changes nothing. What is outstanding: times to report, weeks to sign, versions to acknowledge. One call for a dashboard or a nightly check: how many units still need a time, which weeks still need a signature and when each is due, which new versions the station has not acknowledged, and how many creatives are still waiting for audio. `nextDeadline` is the earliest unsigned week's due time, when there is one. `dueAt` values are Unix seconds; `due` is the same instant written out in the station's time zone. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | **Response:** 200 `application/json`. The station's clock, what is outstanding and the next deadline. ```json { "now": { "date": "2026-09-24", "minutes": 630, "ts": 1790263800, "timezone": "America/Chicago" }, "outstanding": { "to_report": 7, "to_sign": [ { "week": "2026-09-21", "due": "Fri, Oct 2, 5:00 PM", "dueAt": 1790978400, "overdue": false } ], "to_acknowledge": [ { "week": "2026-09-21", "version": 3 } ], "audio_pending": 1 }, "nextDeadline": { "week": "2026-09-21", "due": "Fri, Oct 2, 5:00 PM", "dueAt": 1790978400 } } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `now` | Now | yes | | | `outstanding` | object | yes | | | `outstanding.to_report` | integer | yes | | | `outstanding.to_sign` | object[] | yes | | | `outstanding.to_sign[].week` | date | yes | | | `outstanding.to_sign[].due` | string | yes | | | `outstanding.to_sign[].dueAt` | integer | yes | | | `outstanding.to_sign[].overdue` | boolean | yes | | | `outstanding.to_acknowledge` | object[] | yes | | | `outstanding.to_acknowledge[].week` | date | yes | | | `outstanding.to_acknowledge[].version` | integer | yes | | | `outstanding.audio_pending` | integer | yes | | | `nextDeadline` | object | no | | | `nextDeadline.week` | date | yes | | | `nextDeadline.due` | string | yes | | | `nextDeadline.dueAt` | integer | yes | | Types used: `Now` (see Types). **Errors:** 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `get_status`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/status' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/get-status ## Get a week's versions `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/versions` Read: this changes nothing. Every published version of the week, with its changes for this station. Every version the network has published for the week, newest first, each with a one-line summary and the changes that touch this station: units added or removed, copy that changed, spots moved inside a show. `acknowledged` says who acknowledged a version and when. Acknowledge the latest one with `acknowledge-version` once your log reflects it. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | **Response:** 200 `application/json`. The versions, newest first. ```json { "revisions": [ { "version": 3, "publishedAt": "Thu, Sep 24, 9:12 AM", "publishedAtTs": 1790259120, "summary": "1 added, 1 removed", "changes": [ { "kind": "added", "vehicle": "The Summit Show", "date": "2026-09-24", "where": "Hour 2, Break 6", "advertiser": "Golden Grain Bakery", "isci": "SR_GGBK0901030", "length": 30, "detail": "Added inside the show. Nothing to schedule: it is in the show audio." } ] } ] } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `revisions` | Revision[] | yes | | Types used: `Revision`, `Change` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `get_changes`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/versions' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/get-changes ## List a week's creatives `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/creatives` Read: this changes nothing. Creatives on the week, with short-lived audio links. The creatives the week's units and show spots use: advertiser, title, ISCI, length, flight dates, the network cart number and how many times it runs this week. `mediaStatus` says where the audio is. `ready` audio comes with an `audioUrl` that works for 15 minutes (`audioUrlExpiresAt`, Unix seconds); ask again for a fresh one. `pending` audio has not arrived yet. `external` means the network delivers the audio another way, and no link is given. Send `with_urls=0` to leave the links out, for example when you only need the list. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `with_urls` | query | integer | no | `1` includes the short-lived audio links; `0` leaves them out. (one of `0`, `1`; default `1`) | **Response:** 200 `application/json`. The week's creatives. ```json { "creatives": [ { "id": "trhd", "advertiser": "Trailhead Outfitters", "title": "Hunting Season Kickoff", "isci": "SR_TRHD0921060", "length": 60, "flightStart": "2026-09-21", "flightEnd": "2026-10-25", "audioReady": true, "cart": "K90388", "timesThisWeek": 5, "mediaStatus": "ready", "audioUrl": "https://media.example/SR_TRHD0921060.mp3?signature=0f3a9c", "audioUrlExpiresAt": 1790264700 } ] } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `creatives` | Creative[] | yes | | Types used: `Creative` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `list_creatives`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/creatives' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/list-creatives ## Download a week's audio `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/audio.zip` Read: this changes nothing. All of the week's audio in one ZIP, named by ISCI. Every creative of the week whose audio is ready, in one ZIP archive, each file named `{ISCI}.mp3`. Load it straight into your automation system. Only `ready` audio goes in: a creative that is still `pending`, or whose audio the network delivers another way, is left out. With nothing ready the answer is 404 `not_found`. Over MCP the tool returns a link to this download instead of the audio itself, so no audio or signed link passes through an assistant. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | **Response:** 200 `application/zip`. A ZIP archive with one file per ready creative. ``` A ZIP archive (binary), for example: SR_TRHD0921060.mp3 SR_UPLN0907060.mp3 SR_GGBK0901030.mp3 ``` Over MCP, `get_week_audio` answers: ```json { "url": "https://affiliates.radioworkflow.com/api/weeks/srn-ksmt/2026-09-21/audio.zip", "readyCreatives": 12 } ``` **Response schema over MCP: get_week_audio:** | Field | Type | Required | Description | |---|---|---|---| | `url` | string | yes | The portal's ZIP route for the week's ready audio. | | `readyCreatives` | integer | yes | How many creatives have audio in the ZIP. | **Errors:** 400 `bad_request`, 403 `forbidden`, 404 `not_found`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `get_week_audio`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/audio.zip' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ --output audio.zip ``` Reference: https://affiliates.radioworkflow.com/docs/get-week-audio ## Export a week's order `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/export` Read: this changes nothing. The order as CSV or an automation import file. The week's ROS units as a file your traffic or automation system can import. Show spots are not in it: they are in the show audio. - `csv` (the default): one row per unit with `unit_id`, `date`, `allowed_days`, `window_start`, `window_end`, `daypart`, `advertiser`, `product`, `title`, `isci`, `length` and `cart`, UTF-8 with a byte order mark. - `tab`: one row per distinct creative with `cart`, `length`, `isci`, `title` and `advertiser`, for a cart import. - `xml`: an `` element with one `` per unit and the CSV columns as attributes. The answer is an attachment with a file name. A cell that starts like a spreadsheet formula is prefixed with a single quote. Over MCP the tool returns `filename`, `content_type` and `body` as JSON instead of a file. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `format` | query | enum | no | The file format. (one of `csv`, `tab`, `xml`; default `csv`) | **Response:** 200 `text/csv; charset=utf-8`. The file, as an attachment. `tab` answers `text/tab-separated-values` and `xml` answers `application/xml`. ``` unit_id,date,allowed_days,window_start,window_end,daypart,advertiser,product,title,isci,length,cart "u-01oso1x","2026-09-22","2026-09-22","06:00","18:59","ROS","Trailhead Outfitters","","Hunting Season Kickoff","SR_TRHD0921060","60","K90388" ``` Over MCP, `export_order` answers: ```json { "filename": "KSUMT-FM_2026-09-21_v3.csv", "content_type": "text/csv; charset=utf-8", "body": "unit_id,date,allowed_days,window_start,window_end,daypart,advertiser,product,title,isci,length,cart\r\n\"u-01oso1x\",\"2026-09-22\",\"2026-09-22\",\"06:00\",\"18:59\",\"ROS\",\"Trailhead Outfitters\",\"\",\"Hunting Season Kickoff\",\"SR_TRHD0921060\",\"60\",\"K90388\"\r\n" } ``` **Response schema over MCP: export_order:** | Field | Type | Required | Description | |---|---|---|---| | `filename` | string | yes | | | `content_type` | string | yes | | | `body` | string | yes | | **Errors:** 400 `bad_request`, 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `export_order`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/export' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ --output order.csv ``` Reference: https://affiliates.radioworkflow.com/docs/export-order ## Get the station's clearance `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/clearance` Read: this changes nothing. Dayparts the station clears, units a day, deadlines and contacts. What the station has agreed to carry for this network: each daypart it clears with its days, window and units a day, the shows it carries, the deadline rule for signing, how audio is delivered, the network contact and the station's people with their roles. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | **Response:** 200 `application/json`. The clearance, shows, deadline rule, contacts and people. ```json { "clearance": [ { "name": "Summit ROS (ROS)", "days": "Mon to Fri", "windowStart": 360, "windowEnd": 1139, "maxPerDay": 2, "vehicleId": "demo-ros", "unitsByWeekday": [ 2, 2, 2, 2, 2, 0, 0 ] } ], "shows": [ { "id": "show-wd", "name": "The Summit Show", "kind": "daily", "days": [ 0, 1, 2, 3, 4 ], "hours": 5, "breakCount": 19, "usualStart": 1140, "usualEnd": 1439, "localBreaks": [ { "hour": 2, "breakNumber": 5 } ] } ], "deadlineRule": "Friday after the broadcast week, 5:00 PM", "networkContact": { "name": "Marcus Bell", "title": "Network Traffic Coordinator", "email": "affiliates@summit-sample.example", "phone": "(555) 010-2440" }, "audioDelivery": "Delivered by the network's spot delivery service", "people": [ { "name": "Dana Ortiz", "email": "traffic@ksmt-sample.example", "role": "Traffic Director", "canSign": true, "lastSeen": "Now", "title": "Traffic Director", "roleCode": "sign" } ] } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `clearance` | DaypartClearance[] | yes | | | `shows` | ShowVehicle[] | yes | | | `deadlineRule` | string | yes | | | `networkContact` | object | yes | | | `networkContact.name` | string | yes | | | `networkContact.title` | string | yes | | | `networkContact.email` | string | yes | | | `networkContact.phone` | string | yes | | | `audioDelivery` | string | yes | | | `people` | Person[] | yes | | Types used: `DaypartClearance`, `ShowVehicle`, `Person` (see Types). **Errors:** 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `get_clearance`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/clearance' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/get-clearance ## Acknowledge a version `POST https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/versions/{version}/acknowledge` Write: this tells the network the station has the version; repeating it changes nothing more. Tell the network the station has this version. Tells the network the station has the latest version of the week in its log. The network sees who acknowledged it and when. Only the latest version can be acknowledged: an older one answers 409 `stale_version` with `latest`, the version to read and acknowledge instead. Acknowledging again changes nothing. The body is an empty JSON object. **Authentication:** A key with the `acknowledge` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `version` | path | integer | yes | The version to acknowledge: the latest one, as `get-week` or `get-changes` shows it. (at least 0) | **Request body:** ```json {} ``` **Response:** 200 `application/json`. The acknowledgement as the network sees it. ```json { "acknowledged": { "version": 3, "by": "Automation key", "at": "Thu, Sep 24, 10:31 AM", "atTs": 1790263860 } } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `acknowledged` | object | yes | | | `acknowledged.by` | string | yes | | | `acknowledged.at` | string | yes | | | `acknowledged.atTs` | integer | yes | | | `acknowledged.version` | integer | yes | | **Errors:** 400 `bad_request`, 403 `forbidden`, 403 `read_only`, 403 `insufficient_scope`, 409 `stale_version`, 413 `payload_too_large`, 415 `unsupported_media_type`, 500 `not_saved`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`); Writes per key: 120 per minute (`w:{prefix}`). **Idempotency:** send a new `Idempotency-Key` header with every request, and the same one only to retry it; a retry answers the first result. **MCP tool:** `acknowledge_version`. **Example:** ```bash # A new Idempotency-Key for every new request. Reuse it only to retry this same request. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/versions/3/acknowledge' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{}' ``` Reference: https://affiliates.radioworkflow.com/docs/acknowledge-version ## Upload a log `POST https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/log-uploads` Write: this stores the log and proposes matches. It saves no affidavit. Upload a log in any format; returns proposed matches and saves nothing. Send the as-run log your automation system saves, in whatever format it uses, and get back proposed matches between its lines and the week's units. **Nothing is saved as an affidavit**: review the matches and apply the ones you accept with `apply-log-matches`. The file goes in the multipart field `file`: CSV, TSV, TXT, LOG, ASC, DAT, XLS, XLSX or PDF, at most 10 MB, one broadcast week per file. The answer waits up to 60 seconds for the file to be read. A file read in time answers 200 with the matches, and a file that could not be read answers 422 `upload_failed`. A file still being read answers 202 with its id: poll `GET /api/uploads/{upload}` with the same station key every few seconds until `status` is no longer `reading`. The poll answers `{"status": "ready", "result": {...}}`, where `result` is the object a 200 carries in `upload`, or `{"status": "failed", "message": "..."}`. Polling uses no upload quota. Each match has a `group`: `matched` lines can be applied as they are, `check` lines need a look, and `missing` units found no line. Uploads are limited per affiliation and per key, and are not idempotent: sending the same file again is a new upload. The key needs the `report` scope on an affiliation that takes reports. Without it, and for a read-only affiliation, the answer is 403 `forbidden` before the file is read; `read_only` comes only from an affiliation that became read-only during the upload. **Authentication:** A key with the `report` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `file` | form | file | yes | The log file, at most 10 MB: csv, tsv, txt, log, asc, dat, xls, xlsx or pdf. | **Response:** 202 `application/json`. The upload id while the file is still being read; 200 with the proposed matches when it was read within 60 seconds. ```json { "upload": { "id": "u1207_20260921_4f1c2a9e8b7d4c3fa0e1d2c3b4a59687", "status": "reading" } } ``` **Response schema:** One of `UploadCreated` or `UploadReady`. Types used: `UploadCreated`, `UploadReady`, `UploadResult` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 403 `read_only`, 404 `not_found`, 409 `nothing_to_match`, 413 `payload_too_large`, 415 `unsupported_file`, 422 `upload_failed`, 500 `not_saved`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`); Log uploads per affiliation: 20 per day (`up:a:{affiliation id}`); Log uploads per key: 10 per hour (`up:p:{prefix}`); Log reading per network: 2,000,000 per day (`llm:n:{network cloud}`). **Idempotency:** not applicable. **MCP:** not a tool. Files go through the REST route or the portal; an MCP message cannot carry a 10 MB log. **Example:** ```bash curl -X POST 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/log-uploads' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ -F 'file=@asrun.csv' \ --max-time 90 ``` Reference: https://affiliates.radioworkflow.com/docs/upload-log ## Apply log matches `POST https://affiliates.radioworkflow.com/api/v1/log-uploads/{upload}/apply` Confirm: this records an affidavit, the proof behind the network's invoices. Turn chosen matches from an upload into affidavits. Records the matches you accept from an uploaded log as affidavits. Send the lines you chose in `items`, or `all_matched: true` to apply every line in the `matched` group. Each item is checked again exactly as a typed report is, and the log is kept as evidence the network can see. An upload is applied once. Sending the same choice again answers the first result with `replayed: true`; sending a different choice answers 409 `already_applied`, and the other times go through `report-affidavits`. An `outside` item needs a `reason`. `epoch` is `week.epoch` from `get-week`. The key needs the `report` scope on an affiliation that takes reports: without it, and for a read-only affiliation, the answer is 403 `forbidden`. **Authentication:** A key with the `report` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `upload` | path | string | yes | The upload id `upload-log` returned. It names the affiliation and the week, so the path needs neither. | | `epoch` | body | integer | yes | `week.epoch` from `get-week`, sent back unchanged. It moves when the network reopens the week, so a report built on an older read is refused with 409 `epoch_changed`. (at least 0) | | `items` | body | array | no | The matches to apply, 1 to 2,000. Send `items` or `all_matched`, not both. | | `items[].unit_id` | body | string | yes | The unit's `id`. (1 to 20 characters) | | `items[].status` | body | enum | yes | What the log shows. One of `aired`, `outside`. | | `items[].aired_date` | body | date | yes | The day it aired, `YYYY-MM-DD`. | | `items[].aired_at` | body | time | yes | Minutes after midnight (0 to 1439) or `HH:MM`, in the station's time zone. | | `items[].reason` | body | string | no | Required for `outside`: why it aired outside its window, 1 to 300 characters once trimmed. Any other `outside` reason refuses the whole apply with 422 `validation_failed` (`reason_required`). Ignored for `aired`. (0 to 2000 characters) | | `all_matched` | body | boolean | no | `true` applies every line in the `matched` group. Send it instead of `items`. (one of `true`) | | `all_or_nothing` | body | boolean | no | Only `false` is accepted: the valid items are saved and the rest listed in `errors`. (one of `false`) | **Request body:** ```json { "epoch": 1, "items": [ { "unit_id": "u-01oso1x", "status": "aired", "aired_date": "2026-09-22", "aired_at": 462 } ] } ``` **Response:** 200 `application/json`. The evidence upload id, the units saved and the items refused. ```json { "uploadId": 44, "saved": [ { "id": "u-01oso1x", "date": "2026-09-22", "daypart": "ROS", "windowStart": 360, "windowEnd": 1139, "creativeId": "trhd", "advertiser": "Trailhead Outfitters", "product": "", "title": "Hunting Season Kickoff", "isci": "SR_TRHD0921060", "length": 60, "cart": "K90388", "status": "aired", "addedInVersion": 1, "kind": "dated", "weekStart": "2026-09-21", "rev": 2, "copyStatus": "ready", "flags": [], "airedAt": 462, "airedDate": "2026-09-22", "source": "api" } ], "errors": [] } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `uploadId` | integer | yes | | | `saved` | AffiliateUnit[] | yes | | | `errors` | ItemError[] | yes | | | `replayed` | true | no | | Types used: `AffiliateUnit`, `ItemError`, `ShowAiring` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 403 `read_only`, 404 `not_found`, 409 `not_ready`, 409 `stale`, 409 `already_applied`, 409 `nothing_to_apply`, 409 `week_signed`, 413 `payload_too_large`, 415 `unsupported_media_type`, 422 `validation_failed`, 500 `not_saved`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`); Writes per key: 120 per minute (`w:{prefix}`). **Idempotency:** send a new `Idempotency-Key` header with every request, and the same one only to retry it; a retry answers the first result. **MCP tool:** `apply_log_matches`. **Example:** ```bash # A new Idempotency-Key for every new request. Reuse it only to retry this same request. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST 'https://affiliates.radioworkflow.com/api/v1/log-uploads/u1207_20260921_4f1c2a9e8b7d4c3fa0e1d2c3b4a59687/apply' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{ "epoch": 1, "items": [ { "unit_id": "u-01oso1x", "status": "aired", "aired_date": "2026-09-22", "aired_at": 462 } ] }' ``` Reference: https://affiliates.radioworkflow.com/docs/apply-log-matches ## Report affidavits `POST https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/affidavits` Confirm: this records an affidavit, the proof behind the network's invoices. Report times, outside-window airings and misses for a batch of units. Records what happened to a batch of ROS units: the time each aired, an airing outside its window, or a miss. The network's invoices list these reports under each unit as proof, and a change after an invoice is sent becomes an adjustment, so every item is checked: - `aired`: `aired_date` is the unit's day (a weekly unit: one of its allowed days whose window has closed) and `aired_at` is inside its window. - `outside`: aired on a day of the broadcast week but not inside the window on one of the unit's own days, with a `reason` of 1 to 300 characters. - `not_aired`: with a `reason`. - `pending`: clears an earlier report, until the week is signed. A unit takes a time only once its window has closed. Send each unit's `rev` and the week's `epoch` from `get-week` unchanged. With `all_or_nothing` false (the default) the valid items are saved and `errors` lists the others with a code each; with `true` any refusal saves nothing and answers 422. At most 2,000 items per call, each unit once. **Authentication:** A key with the `report` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `epoch` | body | integer | yes | `week.epoch` from `get-week`, sent back unchanged. It moves when the network reopens the week, so a report built on an older read is refused with 409 `epoch_changed`. (at least 0) | | `all_or_nothing` | body | boolean | no | `true` saves nothing when any item is refused (422 `validation_failed`). The default `false` saves the valid items and lists the others in `errors`. (default `false`) | | `items` | body | array | yes | The units to report, 1 to 2,000, each once. | | `items[].unit_id` | body | string | yes | The unit's `id` from `get-week`. (1 to 20 characters) | | `items[].rev` | body | integer | yes | The unit's `rev` from `get-week`. (at least 0) | | `items[].status` | body | enum | yes | What happened. One of `aired`, `outside`, `not_aired`, `pending`. | | `items[].aired_date` | body | date | no | The day it aired, `YYYY-MM-DD`. Required for `aired` and `outside`. | | `items[].aired_at` | body | time | no | Minutes after midnight (0 to 1439) or `HH:MM`, in the station's time zone. Required for `aired` and `outside`. | | `items[].reason` | body | string | no | Required for `outside` and `not_aired`. (1 to 300 characters) | **Request body:** ```json { "epoch": 1, "items": [ { "unit_id": "u-01oso1x", "rev": 1, "status": "aired", "aired_date": "2026-09-22", "aired_at": "07:42" } ] } ``` **Response:** 200 `application/json`. The units saved, the items refused and the week's new state. ```json { "saved": [ { "id": "u-01oso1x", "date": "2026-09-22", "daypart": "ROS", "windowStart": 360, "windowEnd": 1139, "creativeId": "trhd", "advertiser": "Trailhead Outfitters", "product": "", "title": "Hunting Season Kickoff", "isci": "SR_TRHD0921060", "length": 60, "cart": "K90388", "status": "aired", "addedInVersion": 1, "kind": "dated", "weekStart": "2026-09-21", "rev": 2, "copyStatus": "ready", "flags": [], "airedAt": 462, "airedDate": "2026-09-22", "source": "api" } ], "errors": [], "week": { "state": "in_progress", "epoch": 1, "rev": 2 } } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `saved` | AffiliateUnit[] | yes | | | `errors` | ItemError[] | yes | | | `week` | object | yes | | | `week.state` | "signed" \| "ready_to_sign" \| "in_progress" \| "published" | yes | | | `week.epoch` | integer | yes | | | `week.rev` | integer | yes | | Types used: `AffiliateUnit`, `ItemError`, `ShowAiring` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 403 `read_only`, 403 `insufficient_scope`, 409 `epoch_changed`, 409 `week_signed`, 413 `payload_too_large`, 415 `unsupported_media_type`, 422 `validation_failed`, 500 `not_saved`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`); Writes per key: 120 per minute (`w:{prefix}`). **Idempotency:** send a new `Idempotency-Key` header with every request, and the same one only to retry it; a retry answers the first result. **MCP tool:** `report_affidavits`. **Example:** ```bash # A new Idempotency-Key for every new request. Reuse it only to retry this same request. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/affidavits' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{ "epoch": 1, "items": [ { "unit_id": "u-01oso1x", "rev": 1, "status": "aired", "aired_date": "2026-09-22", "aired_at": "07:42" } ] }' ``` Reference: https://affiliates.radioworkflow.com/docs/report-affidavits ## Sign a week `POST https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/sign` People only: no key can call this. A person signs the week in the portal. Sign the week. A person signed in, never a key. Signing a week is an attestation that the affidavits are true, so a person does it, signed in to the portal. **No key can sign**: this route always answers 403 `key_cannot_sign` and changes nothing. Over MCP, a person with the sign role gets `signUrl`, a link to the portal's signing panel for that week, and signs there. A week can be signed once its broadcast week has ended and nothing is pending. **Authentication:** No key can call it: people only. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | **Response:** 403 `application/json`. Always `key_cannot_sign`: people sign in the portal. ``` { "error": { "code": "key_cannot_sign", "message": "A key cannot sign a week. A person signs it in the portal." } } ``` Over MCP, `sign_week` answers: ```json { "signUrl": "https://affiliates.radioworkflow.com/affidavits?week=2026-09-21&affiliation=srn-ksmt" } ``` **Response schema over MCP: sign_week:** | Field | Type | Required | Description | |---|---|---|---| | `signUrl` | string | yes | The portal page where a person with the sign role signs the week. | **Errors:** 403 `key_cannot_sign`, and the ones every endpoint can give. **Idempotency:** not applicable. **MCP tool:** `sign_week`. **Example:** ```bash curl -X POST 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/sign' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/sign-week ## List weeks `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks` Read: this changes nothing. Published weeks for this station. The weeks the network has published for this station, newest first, at most 26 (about six months): each week's latest version, state, deadline and how many units have been reported. There is no paging: older weeks stay readable by their Monday with `get-week`. `state` moves from `published` to `in_progress` as times come in, to `ready_to_sign` once the broadcast week has ended and nothing is pending, and to `signed`. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | **Response:** 200 `application/json`. Up to 26 weeks, newest first. ```json { "weeks": [ { "start": "2026-09-21", "version": 3, "state": "in_progress", "due": "Fri, Oct 2, 5:00 PM", "dueAt": 1790978400, "publishedAt": "Thu, Sep 24, 9:12 AM", "publishedAtTs": 1790259120, "unitsTotal": 9, "unitsReported": 2, "signed": false } ] } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `weeks` | WeekSummary[] | yes | | Types used: `WeekSummary` (see Types). **Errors:** 403 `forbidden`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP tool:** `list_weeks`. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" ``` Reference: https://affiliates.radioworkflow.com/docs/list-weeks ## Report show airings `POST https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/show-airings` Confirm: this records an affidavit, the proof behind the network's invoices. Report program carriage and dropped breaks. Certifies each show day: whether the station carried the show, the hours it aired, and whether the network's spots aired unedited. - `carried`: `start` and `end` (an `end` before `start` runs past midnight), and either `unedited: true` or `dropped`, the breaks the station did not air, each with its break number, counted straight through the show, the hour that holds it and a reason: `[{"hour": 5, "breakNumber": 18, "reason": "..."}]`. A dropped break must be a network break of that day, not a local one. - `not_carried`: with a `reason`. - `pending`: clears an earlier report, until the week is signed. A show day can be certified once the station's usual hours for the show are over, or, when the station gave none, once the day has ended in its time zone. `aired_date` is for a show aired on another day, within 7 days after its date. Send each day's `rev`, and the week's `epoch` and `version`, from `get-week` unchanged: if the show's breaks were renumbered in a newer version, the report answers 409 `stale_version` and saves nothing. A saved dropped break that `get-week` shows as `unreadable` or `unavailable` is never sent back: report the day's dropped breaks by their current numbers. The batch rules are those of `report-affidavits`. **Authentication:** A key with the `report` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `epoch` | body | integer | yes | `week.epoch` from `get-week`, sent back unchanged. It moves when the network reopens the week, so a report built on an older read is refused with 409 `epoch_changed`. (at least 0) | | `version` | body | integer | no | The week version whose break numbers you used. Send the version you read: if the show's breaks were renumbered since, the report answers `stale_version` and saves nothing. (at least 1) | | `all_or_nothing` | body | boolean | no | `true` saves nothing when any item is refused (422 `validation_failed`). The default `false` saves the valid items and lists the others in `errors`. (default `false`) | | `items` | body | array | yes | The show days to certify, 1 to 2,000, each once. | | `items[].vehicle_id` | body | string | yes | The show's vehicle `id`. (1 to 20 characters) | | `items[].date` | body | date | yes | The show day, `YYYY-MM-DD`. | | `items[].rev` | body | integer | yes | The show day's `rev` from `get-week`. (at least 0) | | `items[].status` | body | enum | yes | Whether the station carried it. One of `carried`, `not_carried`, `pending`. | | `items[].start` | body | time | no | When the show started. Minutes after midnight (0 to 1439) or `HH:MM`, in the station's time zone. | | `items[].end` | body | time | no | When the show ended. Minutes after midnight (0 to 1439) or `HH:MM`, in the station's time zone. | | `items[].aired_date` | body | date | no | The day it aired, when not its own date. | | `items[].unedited` | body | boolean | no | `true` when every network spot aired as delivered. | | `items[].dropped` | body | array | no | The breaks the station did not air, each with its break number, counted straight through the show, the hour that holds it and a reason: `[{"hour": 5, "breakNumber": 18, "reason": "..."}]`. | | `items[].reason` | body | string | no | Required for `not_carried`. (1 to 300 characters) | **Request body:** ```json { "epoch": 1, "version": 3, "items": [ { "vehicle_id": "show-wd", "date": "2026-09-23", "rev": 1, "status": "carried", "start": "19:00", "end": "23:59", "unedited": true } ] } ``` **Response:** 200 `application/json`. The show days saved, the items refused and the week's new state. ```json { "saved": [ { "key": "srn-ksmt:show-wd:2026-09-23", "showId": "show-wd", "date": "2026-09-23", "status": "carried", "start": 1140, "end": 1439, "unedited": true, "source": "api", "airedDate": "2026-09-23", "rev": 2 } ], "errors": [], "week": { "state": "in_progress", "epoch": 1, "rev": 2 } } ``` **Response schema:** | Field | Type | Required | Description | |---|---|---|---| | `saved` | ShowAiring[] | yes | | | `errors` | ItemError[] | yes | | | `week` | object | yes | | | `week.state` | "signed" \| "ready_to_sign" \| "in_progress" \| "published" | yes | | | `week.epoch` | integer | yes | | | `week.rev` | integer | yes | | Types used: `ShowAiring`, `ItemError`, `AffiliateUnit` (see Types). **Errors:** 400 `bad_request`, 403 `forbidden`, 403 `read_only`, 403 `insufficient_scope`, 409 `epoch_changed`, 409 `stale_version`, 409 `week_signed`, 413 `payload_too_large`, 415 `unsupported_media_type`, 422 `validation_failed`, 500 `not_saved`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`); Writes per key: 120 per minute (`w:{prefix}`). **Idempotency:** send a new `Idempotency-Key` header with every request, and the same one only to retry it; a retry answers the first result. **MCP tool:** `report_show_airings`. **Example:** ```bash # A new Idempotency-Key for every new request. Reuse it only to retry this same request. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/show-airings' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{ "epoch": 1, "version": 3, "items": [ { "vehicle_id": "show-wd", "date": "2026-09-23", "rev": 1, "status": "carried", "start": "19:00", "end": "23:59", "unedited": true } ] }' ``` Reference: https://affiliates.radioworkflow.com/docs/report-show-airings ## Get a printable document `GET https://affiliates.radioworkflow.com/api/v1/affiliations/{id}/weeks/{week}/documents/{doc}` Read: this changes nothing. Printable station documents. One of the week's printed documents as a complete HTML page, ready to print or save as PDF: the certificate of performance, the commercial schedule or the copy playlist for one vehicle. The page is served with a strict content policy: it loads no scripts, and images only from the network's own asset host. **Authentication:** A key with the `read` scope. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string | yes | The affiliation id from `GET /v1/affiliations`: one station's relationship with one network. (1 to 64 characters) | | `week` | path | date | yes | Monday of the broadcast week, `YYYY-MM-DD`. Any other day answers 400. | | `doc` | path | enum | yes | Which document. (one of `certificate`, `commercial_schedule`, `copy_playlist`) | | `vehicle_id` | query | string | yes | The vehicle the document is for, from the affiliation's `vehicles`. (1 to 20 characters) | **Response:** 200 `text/html; charset=utf-8`. The document as an HTML page. ``` Certificate of Performance

Certificate of Performance

... ``` **Errors:** 400 `bad_request`, 403 `forbidden`, 404 `not_found`, 502 `upstream_failed`, and the ones every endpoint can give. **Rate limits:** Failed sign-ins per address: 10 per minute (`portal:auth`); Burst per key: 60 per minute (`portal:key`); Calls per key: 600 per 10 minutes (`key:{prefix}`). **Idempotency:** not applicable. **MCP:** not a tool. Returns printable HTML, not data. **Example:** ```bash curl -X GET 'https://affiliates.radioworkflow.com/api/v1/affiliations/srn-ksmt/weeks/2026-09-21/documents/certificate?vehicle_id=show-wd' \ -H "Authorization: Bearer $AFFILIATE_API_KEY" \ --output document.html ``` Reference: https://affiliates.radioworkflow.com/docs/get-document # Types The named types the response schemas above use, each once. ## Affiliation | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `networkName` | string | yes | | | `networkShort` | string | yes | | | `stationCall` | string | yes | | | `stationName` | string | yes | | | `market` | string | yes | | | `lane` | "portal" \| "radioworkflow" | yes | | | `canSign` | boolean | yes | | | `canReport` | boolean | yes | | | `role` | "view" \| "report" \| "sign" | yes | | | `readOnly` | boolean | yes | | | `timezone` | string | yes | | | `networkContact` | object | yes | | | `networkContact.name` | string | yes | | | `networkContact.title` | string | yes | | | `networkContact.email` | string | yes | | | `networkContact.phone` | string | yes | | | `deadlineRule` | string | yes | | | `audioDelivery` | string | yes | | | `vehicles` | object[] | yes | | | `vehicles[].id` | string | yes | | | `vehicles[].name` | string | yes | | | `vehicles[].kind` | "show" \| "ros" | yes | | ## Now | Field | Type | Required | Description | |---|---|---|---| | `date` | date | yes | | | `minutes` | integer (0 to 1439) | yes | | | `ts` | integer | yes | | | `timezone` | string | yes | | ## OrderWeek | Field | Type | Required | Description | |---|---|---|---| | `start` | date | yes | | | `days` | date[] | yes | | | `version` | integer | yes | | | `state` | "signed" \| "ready_to_sign" \| "in_progress" \| "published" | yes | | | `due` | string | yes | | | `dueAt` | integer | yes | | | `epoch` | integer | yes | | | `rev` | integer | yes | | | `published` | boolean | yes | | | `signed` | object | no | | | `signed.name` | string | yes | | | `signed.title` | string | yes | | | `signed.at` | string | yes | | | `signed.atTs` | integer | yes | | | `signed.via` | "portal" \| "mcp" | yes | | | `signed.late` | boolean | yes | | | `shows` | ShowWeek[] | yes | | | `rosName` | string | no | | | `units` | AffiliateUnit[] | yes | | | `removed` | AffiliateUnit[] | yes | | | `revisions` | Revision[] | yes | | | `acknowledgedVersion` | integer | yes | | ## Creative | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `advertiser` | string | yes | | | `title` | string | yes | | | `isci` | string | yes | | | `length` | integer | yes | | | `flightStart` | date | yes | | | `flightEnd` | date | yes | | | `audioReady` | boolean | yes | | | `replaces` | string | no | | | `instructions` | string | no | | | `cart` | string | yes | | | `timesThisWeek` | integer | yes | | | `mediaStatus` | "ready" \| "pending" \| "external" | yes | | | `audioUrl` | string | no | | | `audioUrlExpiresAt` | integer | no | | ## ShowVehicle | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `name` | string | yes | | | `kind` | "daily" \| "weekend" | yes | | | `days` | integer[] (0 to 6) | yes | | | `hours` | integer (0 to 24) | yes | | | `breakCount` | integer | yes | | | `usualStart` | integer (0 to 1439) | no | | | `usualEnd` | integer (0 to 1439) | no | | | `localBreaks` | object[] | yes | | | `localBreaks[].hour` | integer (1 to 24) | yes | | | `localBreaks[].breakNumber` | integer (1 to 1000) | yes | | ## ShowWeek | Field | Type | Required | Description | |---|---|---|---| | `show` | ShowVehicle | yes | | | `showNumber` | string | yes | | | `spots` | ShowSpot[] | yes | | | `removed` | ShowSpot[] | yes | | | `airings` | ShowAiring[] | yes | | | `breaks` | object[] | yes | | | `breaks[].hour` | integer (1 to 24) | yes | | | `breaks[].breakNumber` | integer (1 to 1000) | yes | | | `breaks[].local` | boolean | yes | | | `excludedDays` | object[] | yes | | | `excludedDays[].date` | date | yes | | | `excludedDays[].reason` | string | yes | | ## AffiliateUnit | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `date` | date | yes | | | `daypart` | string | yes | | | `windowStart` | integer (0 to 1439) | yes | | | `windowEnd` | integer (0 to 1439) | yes | | | `creativeId` | string | yes | | | `advertiser` | string | yes | | | `product` | string | yes | | | `title` | string | yes | | | `isci` | string | yes | | | `length` | integer | yes | | | `cart` | string | yes | | | `status` | "pending" \| "aired" \| "outside" \| "not_aired" | yes | | | `airedAt` | integer (0 to 1439) | no | | | `airedDate` | date | no | | | `reason` | string | no | | | `source` | "portal" \| "log_upload" \| "rw_log" \| "network" \| "api" \| "mcp" | no | | | `addedInVersion` | integer | yes | | | `removedInVersion` | integer | no | | | `kind` | "dated" \| "weekly" | yes | | | `weekStart` | date | yes | | | `allowedDays` | date[] | no | | | `rev` | integer | yes | | | `copyStatus` | "ready" \| "to_follow" | yes | | | `instructions` | string | no | | | `rwState` | "confirmed" \| "not_confirmed" | no | | | `flags` | string[] | yes | | ## Revision | Field | Type | Required | Description | |---|---|---|---| | `version` | integer | yes | | | `publishedAt` | string | yes | | | `publishedAtTs` | integer | yes | | | `summary` | string | yes | | | `changes` | Change[] | yes | | | `acknowledged` | object | no | | | `acknowledged.by` | string | yes | | | `acknowledged.at` | string | yes | | | `acknowledged.atTs` | integer | yes | | ## ShowSpot | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `showId` | string | yes | | | `date` | date | yes | | | `hour` | integer (1 to 24) | yes | | | `breakNumber` | integer (1 to 1000) | yes | | | `breakNote` | "Not on the break map" \| "Now a local break" | no | | | `position` | integer | yes | | | `creativeId` | string | yes | | | `advertiser` | string | yes | | | `product` | string | yes | | | `title` | string | yes | | | `isci` | string | yes | | | `length` | integer | yes | | | `addedInVersion` | integer | yes | | | `removedInVersion` | integer | no | | ## ShowAiring | Field | Type | Required | Description | |---|---|---|---| | `key` | string | yes | | | `showId` | string | yes | | | `date` | date | yes | | | `status` | "pending" \| "carried" \| "not_carried" | yes | | | `start` | integer (0 to 1439) | no | | | `end` | integer (0 to 1439) | no | | | `unedited` | boolean | no | | | `dropped` | (object or object or object)[] | no | | | `reason` | string | no | | | `source` | "portal" \| "log_upload" \| "rw_log" \| "network" \| "api" \| "mcp" | no | | | `airedDate` | date | no | | | `rev` | integer | yes | | ## Change | Field | Type | Required | Description | |---|---|---|---| | `kind` | "initial" \| "added" \| "removed" \| "changed" \| "copy" | yes | | | `vehicle` | string | no | | | `date` | date | no | | | `where` | string | no | | | `advertiser` | string | no | | | `isci` | string | no | | | `length` | integer | no | | | `detail` | string | yes | | | `unitId` | string | no | | ## DaypartClearance | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `days` | string | yes | | | `windowStart` | integer (0 to 1439) | yes | | | `windowEnd` | integer (0 to 1439) | yes | | | `maxPerDay` | integer | yes | | | `vehicleId` | string | yes | | | `unitsByWeekday` | integer[] | yes | | ## Person | Field | Type | Required | Description | |---|---|---|---| | `name` | string | yes | | | `email` | string | yes | | | `role` | string | yes | | | `canSign` | boolean | yes | | | `lastSeen` | string | yes | | | `title` | string | yes | | | `roleCode` | "view" \| "report" \| "sign" | yes | | ## UploadCreated | Field | Type | Required | Description | |---|---|---|---| | `upload` | object | yes | | | `upload.id` | string | yes | | | `upload.status` | "reading" | yes | | ## UploadReady | Field | Type | Required | Description | |---|---|---|---| | `upload` | UploadResult | yes | | ## UploadResult | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `fileName` | string | yes | | | `linesRead` | integer | yes | | | `linesIgnored` | integer | yes | | | `columns` | string[] | yes | | | `matches` | object[] | yes | | | `matches[].unitId` | string | yes | | | `matches[].group` | "matched" \| "check" \| "missing" | yes | | | `matches[].rowRef` | string | no | | | `matches[].raw` | string | no | | | `matches[].status` | "aired" \| "outside" | no | | | `matches[].airedDate` | date | no | | | `matches[].airedAt` | integer (0 to 1439) | no | | | `matches[].confidence` | number (0 to 1) | yes | | | `matches[].why` | string | yes | | ## ItemError | Field | Type | Required | Description | |---|---|---|---| | `unit_id` | string | no | | | `vehicle_id` | string | no | | | `date` | date | no | | | `error` | "rev_conflict" \| "not_reportable" \| "outside_window" \| "invalid_time" \| "invalid_date" \| "reason_required" \| "removed" \| "not_found" \| "break_not_found" \| "local_break" | yes | | | `message` | string | yes | | | `current` | AffiliateUnit or ShowAiring | no | | ## WeekSummary | Field | Type | Required | Description | |---|---|---|---| | `start` | date | yes | | | `version` | integer | yes | | | `state` | "signed" \| "ready_to_sign" \| "in_progress" \| "published" | yes | | | `due` | string | yes | | | `dueAt` | integer | yes | | | `publishedAt` | string | yes | | | `publishedAtTs` | integer | yes | | | `unitsTotal` | integer | yes | | | `unitsReported` | integer | yes | | | `signed` | boolean | yes | |