Skip to content

Webhooks

Webhooks let Audio Audit call your application the moment something happens — a report finishes, a new episode is ingested, a feed goes quiet — instead of you polling the GraphQL API for it. When a subscribed event occurs we send an HTTP POST to the URL you registered, with a JSON body and a signature you can verify.

Adding an Endpoint

Register endpoints in the Audio Audit web app:

  1. Go to Settings → Developers → Webhook endpoints
  2. Click Add endpoint
  3. Enter the HTTPS URL that will receive events
  4. Choose which of the five events it should receive, and optionally scope it to a single podcast
  5. Save

When the endpoint is created we show you its signing secret once — a value that begins with whsec_. Copy it then; it is never shown again. If you lose it, delete the endpoint and create a new one.

A workspace can have up to 5 endpoints, each with its own URL, event subscription set, secret and enabled flag. Endpoints belong to the workspace the same way API keys do — a personal workspace or one Organisation — so a key or endpoint reaches that workspace and nothing else. See Authentication for how workspaces and keys relate.

Subscribing Programmatically

Integrations can manage endpoints over REST instead of the UI, authenticating with the same API key scheme as the rest of the API — a Bearer token in the Authorization header (see Authentication):

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

Create an endpoint with POST /webhook/subscribe and a JSON body of url, events (an array of event names), and an optional series_id. A series is a show being audited — what the dashboard calls a podcast — and series_id scopes the endpoint to one of your workspace's own series, as listed by GET /api/rest/v1/series or the GraphQL seriesList query. An id that is not one of your workspace's series is refused with 400:

curl -X POST https://audioaudit.io/webhook/subscribe \
  -H "Authorization: Bearer your-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/audio-audit",
    "events": ["report.completed", "series.feed_broken"]
  }'
{
  "id": "6f1c4e0a-3b2d-4c8e-9a11-2f5d7c9e0b34",
  "secret": "whsec_c2VjcmV0LWtleS1ieXRlcy1oZXJl"
}

The secret is returned this once only — store it to verify signatures. Remove an endpoint with DELETE /webhook/subscribe/<id>, using the id from the create response:

curl -X DELETE https://audioaudit.io/webhook/subscribe/6f1c4e0a-3b2d-4c8e-9a11-2f5d7c9e0b34 \
  -H "Authorization: Bearer your-api-key-here"

A successful delete returns 204 No Content. The 5-endpoint workspace limit applies to endpoints created this way too; a create that would exceed it is refused with 409 Conflict. Endpoints created over REST are managed over REST — they do not appear in, and cannot be deleted from, the Settings UI, so an integration turning off its subscription can never remove an endpoint a person added by hand.

The Envelope

Every event shares the same top-level shape:

{
  "type": "report.completed",
  "timestamp": "2026-08-14T10:30:00+00:00",
  "data": {}
}
  • type — the event name, one of the five below. Treat these as stable match tokens.
  • timestamp — when the event occurred, in ISO 8601 UTC. This is event time, frozen into the body, so a delivery retried hours later still reports when the thing happened. The per-attempt clock you check for replay protection is the webhook-timestamp header, not this field.
  • data — the event-specific body, described per event below.

Test events sent from the Settings page carry an extra top-level "test": true alongside type, timestamp and data. Ignore any event with that flag in production processing.

Events

Audio Audit sends five events.

report.completed

An audio report finished analysing. data.report carries the full result. The example trims issues to two of its rows; passed is false because of a Required check that isn't shown:

{
  "type": "report.completed",
  "timestamp": "2026-08-14T10:30:00+00:00",
  "data": {
    "report": {
      "id": "73d6c51c-cfdf-4a6d-903a-cd7e67b4382f",
      "url": "https://audioaudit.io/reports/73d6c51c-cfdf-4a6d-903a-cd7e67b4382f",
      "status": "complete",
      "passed": false,
      "score": 87,
      "passes": 21,
      "failures": 3,
      "total": 24,
      "filename": "episode-42.mp3",
      "client_reference": "",
      "created_at": "2026-08-14T10:25:00+00:00",
      "series": {
        "id": "816ee4c4-c4c5-4078-9dfa-865618b45200",
        "title": "My Podcast",
        "feed_url": "https://example.com/feed.xml"
      },
      "episode": {
        "id": "0b7a1f2c-9d3e-4a6b-8c1d-2e5f7a9b0c11",
        "title": "Episode 42",
        "guid": "example-episode-42",
        "pub_date": "2026-08-14"
      },
      "issues": [
        {
          "name": "loudness_lufs",
          "verbose_name": "Loudness",
          "status": "PASS",
          "value": -16.1,
          "measurement_unit": "LUFS",
          "description": "Integrated loudness of the whole programme.",
          "warning_message": null,
          "link": "",
          "severity": "required",
          "limit": "−17 to −15 LUFS"
        },
        {
          "name": "profanities",
          "verbose_name": "Profanities",
          "status": "FAIL",
          "value": [
            {"start": 12340, "finish": 12800, "description": "\"word\" (85% confidence)"}
          ],
          "measurement_unit": null,
          "description": "Flagged strong language.",
          "warning_message": "Found 1 profanity; this standard allows none.",
          "link": "",
          "severity": "info",
          "limit": "none allowed"
        }
      ]
    }
  }
}

Field notes:

  • passed is true when no Required check failed, the same verdict the Audio Audit web app shows for the report's standard. An Informational check outside its limit does not fail the report, but it still counts in failures, so passed can be true while failures is above zero. score is the percentage shown in the Audio Audit web app; passes, failures and total are the check counts behind it, Required and Informational checks alike.
  • issues is the complete per-check list, including PASS rows — it is not filtered to failures. Each entry carries name, verbose_name, status (PASS or FAIL), the measured value, its measurement_unit, a description, a warning_message (populated only on failing checks, otherwise null), a link, its severity in the report's standard (required or info), and its limit — the standard's limit in words, such as "−17 to −15 LUFS", or null for checks with a fixed rule (cover image, metadata tags, quiet channel) and for the explicit flag. Required checks are listed first, then Informational ones. The value type depends on the check — a number, a string, or a list of timestamped items as in the profanities example. If you only care about failures, filter on status == "FAIL" yourself.
  • filename is the analysed file's original name. client_reference is a caller-supplied correlation string; it is an empty string unless set.
  • series and episode are null for manual uploads, which have no episode record. For reports produced from a tracked feed they carry the show — your workspace's own series, with the title your workspace sees and the feed it tracks — and the episode. series alone is null on a feed report whose show your workspace has since removed; its episode is still filled in.

report.failed and report.out_of_quota

A report ended without a full analysis — report.failed when analysis could not complete, report.out_of_quota when the workspace had no credits to analyse it. Both use the same data.report shape as report.completed, with status set to "failed" or "out_of_quota" and the scored fields — passed, score, passes, failures, total and issues — all null:

{
  "type": "report.failed",
  "timestamp": "2026-08-14T10:30:00+00:00",
  "data": {
    "report": {
      "id": "73d6c51c-cfdf-4a6d-903a-cd7e67b4382f",
      "url": "https://audioaudit.io/reports/73d6c51c-cfdf-4a6d-903a-cd7e67b4382f",
      "status": "failed",
      "passed": null,
      "score": null,
      "passes": null,
      "failures": null,
      "total": null,
      "filename": "episode-42.mp3",
      "client_reference": "",
      "created_at": "2026-08-14T10:25:00+00:00",
      "series": null,
      "episode": null,
      "issues": null
    }
  }
}

The three report events are not mutually exclusive over a report's lifetime — a report that is retried can, in principle, emit a failure and later a completion — so key your handling on the event type you receive rather than assuming only one report event will ever arrive for a given report id.

episode.created

The feed checker ingested a genuinely new episode of a feed your workspace tracks. It does not fire for backfilled or previously-seen episodes. Several workspaces can track the same feed, and each receives the event under its own series: data.series is always your workspace's series, never another workspace's.

{
  "type": "episode.created",
  "timestamp": "2026-08-14T10:30:00+00:00",
  "data": {
    "series": {
      "id": "816ee4c4-c4c5-4078-9dfa-865618b45200",
      "title": "My Podcast",
      "feed_url": "https://example.com/feed.xml"
    },
    "episode": {
      "id": "0b7a1f2c-9d3e-4a6b-8c1d-2e5f7a9b0c11",
      "title": "Episode 42",
      "guid": "example-episode-42",
      "pub_date": "2026-08-14T09:00:00+00:00",
      "audio_url": "https://example.com/episodes/42.mp3"
    }
  }
}

The episode carries its enclosure audio_url, so you can fetch the audio without a second API call.

series.feed_broken

A tracked feed crossed into a broken state — fired once on the transition, not on every failed check, so a feed that flaps in and out of broken is reported at most once a day. As with episode.created, every workspace tracking the feed hears about it under its own series. data.failure.kind distinguishes the two independent health signals:

{
  "type": "series.feed_broken",
  "timestamp": "2026-08-14T10:30:00+00:00",
  "data": {
    "series": {
      "id": "816ee4c4-c4c5-4078-9dfa-865618b45200",
      "title": "My Podcast",
      "feed_url": "https://example.com/feed.xml"
    },
    "failure": {
      "kind": "fetch",
      "consecutive_failures": 3,
      "last_error": "HTTP 404"
    }
  }
}
  • kind: "fetch" — the feed itself became unreadable (DNS, connection, HTTP error).
  • kind: "ingest" — the feed reads fine, but its newest episode could not be stored.

consecutive_failures is how many checks in a row failed at the point the feed was declared broken, and last_error is the most recent error message (it may be null).

Verifying Signatures

Every delivery is signed following the Standard Webhooks specification, so you can confirm it really came from Audio Audit and was not altered in transit. Each request carries three headers:

HeaderMeaning
webhook-idThe delivery's unique ID. Stable across every retry of the same delivery — use it to deduplicate.
webhook-timestampUnix time in seconds when this attempt was sent. Fresh on each attempt.
webhook-signatureA space-delimited list of signatures, each v1,<base64 HMAC-SHA256>. We send one today.

The signature is an HMAC-SHA256 over the string {webhook-id}.{webhook-timestamp}.{raw body}, keyed with your secret's decoded bytes, base64-encoded, and prefixed with v1,. Sign and verify against the raw request body bytes exactly as received — do not re-serialise the JSON first, or the signature will not match.

You should also reject any request whose webhook-timestamp is more than 5 minutes away from your clock, to stop an attacker replaying a captured delivery.

With the standardwebhooks Libraries

The off-the-shelf libraries handle the header parsing, the timestamp tolerance and a constant-time comparison for you. Pass them the whole whsec_ secret.

Python — pip install standardwebhooks:

from standardwebhooks import Webhook

# secret is the whsec_... value shown when you created the endpoint
wh = Webhook(secret)

# body is the raw request body (bytes or str); headers is a dict-like with the
# webhook-id / webhook-timestamp / webhook-signature keys. Raises on any mismatch.
payload = wh.verify(body, headers)

Node — npm i standardwebhooks:

const { Webhook } = require("standardwebhooks")

// secret is the whsec_... value shown when you created the endpoint
const wh = new Webhook(secret)

// rawBody must be the exact bytes received, before any JSON parsing
const payload = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
})

By Hand

If you would rather not add a dependency, the check is a few lines. This matches what Audio Audit sends exactly:

import base64
import hashlib
import hmac
import time


def verify(secret, headers, body):
    """Return the raw body if the signature is valid, else raise ValueError.

    secret  the whsec_... value shown once when the endpoint was created
    headers a mapping with the lowercase webhook-* keys
    body    the raw request body as bytes, exactly as received
    """
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signature_header = headers["webhook-signature"]

    # Reject replays: the send-time header clock must be within five minutes
    if abs(time.time() - int(timestamp)) > 5 * 60:
        raise ValueError("timestamp outside tolerance")

    # The whsec_ prefix is portable packaging; the key is the base64 that follows
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode()

    # The header can hold several space-delimited "v1,<sig>" tokens; match any
    for token in signature_header.split(" "):
        version, _, sig = token.partition(",")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return body

    raise ValueError("no matching signature")

Note the two easy-to-miss details that make off-the-shelf verifiers agree with you: the key is the base64 decoded bytes of the secret (not the base64 text), and the signed content is id.timestamp.body joined with literal dots.

Delivery and Retries

When an event occurs we attempt the POST immediately. Your endpoint has 10 seconds to respond. Any 2xx status is success; anything else — a non-2xx status, a timeout, a connection or DNS error — is a failed attempt and is retried.

Retries use exponential backoff, up to 10 attempts spread over roughly 24 hours, then the delivery is given up on.

Redirects are not followed. A 3xx response is treated as a failed delivery, not a hop to a new location. This is a security control, so your endpoint must respond 2xx directly at the URL you registered — a permanent redirect will fail every attempt and eventually disable the endpoint.

If an endpoint fails five deliveries in a row — each having exhausted its full retry ladder — Audio Audit automatically disables it and emails the workspace owner. Re-enable it from Settings → Developers → Webhook endpoints once the receiver is healthy again.

To keep deliveries fast and reliable, acknowledge with 2xx as soon as you have stored the event, then do any slow work asynchronously. Returning quickly and processing later beats holding the connection open.

Idempotency

Delivery is at least once — a network blip after your endpoint has already processed an event can cause the same event to be delivered again. The webhook-id header is stable across every retry of a delivery, so record the IDs you have handled and skip any you have already seen. Do not deduplicate on the body timestamp or on a report id, which are not unique per delivery.

Testing an Endpoint

The endpoint's page in Settings → Developers → Webhook endpoints has a Send test event button. It sends a report.completed-shaped payload — using your workspace's most recent completed report when there is one, or a canned sample otherwise — carrying a top-level "test": true. It is signed and delivered exactly like a real event, so it exercises your signature verification and endpoint end to end. The same page shows a delivery log — recent events, their status, attempt counts, response codes and last error — for debugging.