# Guide to Speechmatics Webhooks: Features and Best Practices

Speechmatics webhooks, which its docs call notifications, tell you when a batch transcription job has finished. You submit an audio file, Speechmatics transcribes it in the background, and when the job is done it calls your URL, optionally with the transcript or the original media attached. That saves you from polling the jobs API and lets you hand the transcript straight to the next stage: storage, search, summaries or an LLM.

This guide covers how notifications are configured on each job, what a callback looks like, how to authenticate it with a header you set in `auth_headers`, how failed deliveries are retried, and the best practices for running it in production.

## What are Speechmatics webhooks?

Speechmatics has no webhook settings page. Notifications belong to the batch job: you add a `notification_config` array to the job config when you submit it, and each entry names a URL, what to send, and any headers to send with it. A job can notify up to three URLs, and you can split the output across them, for example `jobinfo` to your application and the transcript to an object store.

There are no event types. Speechmatics appends two query parameters to your URL: `id`, the job ID, and `status`, the outcome of the job.

```
POST /webhooks/speechmatics?id=r6sr3jlzjj&status=success HTTP/1.1
Host: example.com
User-Agent: Speechmatics-API/2.0

```

Speechmatics doesn't sign notifications. It gives you two ways to check them: `auth_headers`, a list of literal `Name: value` strings that it adds to the request unchanged, and a published list of egress IP addresses that you can allowlist.

## Speechmatics webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per job: `notification_config` in the batch job config |
| Destinations | Up to 3 URLs per job |
| Trigger | The job finishes, successfully or not |
| Method | `POST` by default, or `PUT` via `method` |
| Outcome | `status` query parameter: `success`, `error`, `fetch_error` or `trim_error` |
| Job reference | `id` query parameter |
| Body | Whatever `contents` you request: JSON, plain text, multipart or empty |
| Authentication | `auth_headers`: free-form `Name: value` strings, set per job |
| Signature | None |
| Source IPs | Published per region: EU1, EU2, US1, US2 and AU1 |
| Retries | Any non-2xx is retried until a timeout threshold the docs don't state |
| Ordering | Callbacks run in parallel and may arrive in any order |
| Failure record | `errors` array on `GET /jobs/{id}` |

## Notification outcomes

The `status` query parameter is the only thing that tells callbacks apart:

| `status` | Sent when |
| --- | --- |
| `success` | The job was processed successfully |
| `error` | Something went wrong while processing the job. Only `jobinfo` is sent |
| `fetch_error` | Speechmatics couldn't fetch the data file from the URL you gave it |
| `trim_error` | Speechmatics couldn't process the audio file before transcription |

These aren't the job status values that the jobs API returns (`running`, `done` and so on), so don't reuse the same enum for both.

## What the body contains

The body is whatever you list in `contents`, and its format depends on how many items you ask for:

| `contents` requested | Content type | Body |
| --- | --- | --- |
| Exactly one of `transcript`, `transcript.json-v2` or `jobinfo` | `application/json` | The JSON item itself |
| Exactly one of `transcript.txt` or `transcript.srt` | `text/plain` | The plain-text or SRT transcript |
| `data`, or two or more items | `multipart/form-data` | One part per item |
| Nothing | None | Empty. Only `id` and `status` in the query string |

`transcript` and `transcript.json-v2` are the same JSON v2 transcript. `jobinfo` is the configuration the job was submitted with, which is also included in the JSON v2 transcript. `data` is the media file you submitted, so asking for it can make the callback very large.

A single item is sent as the raw request body, not wrapped in an envelope. That's deliberate, so that one callback can write straight to an object store such as Amazon S3, but it means your receiver can't assume a fixed JSON shape.

> **Inspect Speechmatics webhooks as they arrive.** Point Speechmatics at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to inspect and replay real deliveries, with no account or setup required. It's a quick way to see how each `contents` choice changes the body.

## Setting up Speechmatics webhooks

Pick the header your receiver will check and generate a long random value for it:

```bash
openssl rand -hex 32

```

Then add a `notification_config` entry to the job config. Each `auth_headers` entry is a single `Name: value` string:

```json
{
  "type": "transcription",
  "transcription_config": { "language": "en" },
  "notification_config": [
    {
      "url": "https://example.com/webhooks/speechmatics",
      "contents": ["jobinfo"],
      "auth_headers": ["X-Speechmatics-Secret: <your random value>"]
    }
  ]
}

```

| Option | Required | Notes |
| --- | --- | --- |
| `url` | Yes | Where the notification is sent |
| `contents` | No | `transcript`, `transcript.json-v2`, `transcript.txt`, `transcript.srt`, `data`, `jobinfo` |
| `auth_headers` | No | Literal `Name: value` strings sent with the request |
| `method` | No | `POST` (the default) or `PUT` |

`Authorization: Bearer <token>` works as well as a custom header. Whichever you choose, the value is the credential your service accepts, not your Speechmatics API key, which should never leave your backend.

Because the configuration lives on the job, every job your code submits must carry it. A job submitted without `auth_headers`, from a script or a different service, sends notifications that your receiver will reject. Build the job config in one place so the header can't be forgotten.

If your network filters inbound traffic, allowlist the egress IP addresses that Speechmatics publishes for your region on its [notifications page](https://docs.speechmatics.com/speech-to-text/batch/notifications). Speechmatics presents this as its way to confirm that callbacks come from it. Treat it as an extra layer alongside the header check rather than a replacement.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 speechmatics --path /webhooks/speechmatics` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as the `url` in `notification_config`.

**With Hookdeck.** Create a Speechmatics source in Hookdeck and enter the **API Key Header** and **API Key** exactly as they appear in `auth_headers`. The value is the whole string after the colon, so for `Authorization: Bearer abc123` the **API Key** is `Bearer abc123`, not `abc123`. The source accepts both `POST` and `PUT`, so it works whichever `method` the job sets. Use the source URL as the `url` in `notification_config`. Hookdeck accepts JSON, plain-text and multipart bodies, but its default [ingestion limit](/docs/sources#content-types) is 10 MiB, so send transcripts and `jobinfo` through Hookdeck and leave `data` out of `contents` unless your media files are small.

## Securing Speechmatics webhooks

Check the header before you read the body. Look it up case-insensitively, compare the whole value in constant time, and check lengths first because `crypto.timingSafeEqual` throws on buffers of different lengths. Accept both `POST` and `PUT`, and don't parse the body as JSON unless the content type says it is.

```javascript
const crypto = require('crypto');
const express = require('express');

const app = express();

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifySpeechmaticsRequest(headers) {
  const secret = process.env.SPEECHMATICS_WEBHOOK_SECRET;
  if (!secret) return false; // fail closed when unconfigured
  const name = (process.env.SPEECHMATICS_WEBHOOK_HEADER || 'X-Speechmatics-Secret').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, secret);
}

function handleNotification(req, res) {
  if (!verifySpeechmaticsRequest(req.headers)) {
    return res.sendStatus(401);
  }

  const jobId = req.query.id;
  const status = req.query.status;
  const contentType = req.headers['content-type'] || '';

  let body = null;
  if (contentType.startsWith('application/json')) {
    try {
      body = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
  }

  res.sendStatus(200);

  // Queue the work: jobId, status, and either the parsed JSON or the raw
  // text or multipart buffer in req.body, depending on contentType.
}

const rawBody = express.raw({ type: '*/*', limit: '50mb' });
app.post('/webhooks/speechmatics', rawBody, handleNotification);
app.put('/webhooks/speechmatics', rawBody, handleNotification);

```

Set the body size limit to match what you request. A transcript is small, but `data` sends the original media back, and a limit that's too low turns every callback into a `413` that Speechmatics keeps retrying.

The same check in Python:

```python
import hmac
import os

def verify_speechmatics_request(headers) -> bool:
    secret = os.environ.get("SPEECHMATICS_WEBHOOK_SECRET")
    if not secret:
        return False
    name = os.environ.get("SPEECHMATICS_WEBHOOK_HEADER", "X-Speechmatics-Secret")
    received = headers.get(name)  # framework header maps are case-insensitive
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A static header authenticates the sender, but it doesn't protect the body, and nothing stops a captured request from being replayed, because nothing is signed or timestamped. Use HTTPS for the notification URL and keep the value out of logs. Since the header is set per job, rotating it means submitting new jobs with the new value while still accepting the old one until jobs already in progress have called back.

> **Make Speechmatics webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your header on every notification, stores each one durably, and retries delivery to your service on a schedule you control.

## Speechmatics webhook limitations and pain points

### An unstated retry window

**The Problem:** You can't tell how long Speechmatics will keep trying if your receiver is down, so you can't tell when a notification is lost.

**Why It Happens:** The docs say Speechmatics retries any non-2xx response until it reaches "the set timeout threshold", without giving the threshold or the interval. The failure example on the same page shows a few attempts over about a minute before it gives up. When it does, the transcript is still available from the API and the failures are listed in an `errors` array on `GET /jobs/{id}`.

**Workarounds:**

* Return a 2xx as soon as the notification is authenticated and stored.
* Run a periodic check for recent jobs that finished without a callback, and fetch their results from the API.

**How Hookdeck Can Help:** Hookdeck answers Speechmatics as soon as the notification is stored, then delivers it to your service with [automatic retries](/docs/retries) on your own schedule. [Issues](/docs/issues) alert you when deliveries fail, and you can replay any stored notification without resubmitting the job.

### Errors are retried too

**The Problem:** A receiver that rejects a callback still receives it again and again.

**Why It Happens:** Speechmatics treats every non-2xx response as a failed delivery, including `401` and `413`. A wrong header value or a body size limit set too low produces a run of retries rather than a single clear failure.

**Workarounds:**

* Raise the body size limit to fit the largest item you request, or stop requesting `data` if you don't need the media back.
* Watch for repeated `401`s after you change the header value, since every job submitted before the change still carries the old one.

**How Hookdeck Can Help:** Hookdeck's Speechmatics source compares the whole header value with the one you paste. A request without a matching header still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination. The stored request shows the headers that actually arrived, which makes a missing or mistyped `auth_headers` entry easy to spot.

### No fixed payload shape

**The Problem:** The same endpoint can receive JSON, plain text, multipart form data or an empty body.

**Why It Happens:** The body is whatever `contents` the job requested, sent raw when there's one item and as multipart when there are several. There's no envelope and no event type field.

**Workarounds:**

* Standardise on one `contents` setting per endpoint, for example `["jobinfo"]` for your application and a separate notification for the transcript.
* Branch on `Content-Type` before parsing, and take the outcome from the `status` query parameter rather than from the body.

**How Hookdeck Can Help:** Hookdeck stores every notification as it arrived, with its query string, headers and body, so you can see exactly what a job sent. [Filters](/docs/filters) can route on the `status` query parameter, and [transformations](/docs/transformations) can reshape JSON bodies before they reach your service.

### Callbacks arrive in any order

**The Problem:** Downstream steps that depend on several callbacks from one job run before all of them have arrived.

**Why It Happens:** A job can send several notifications, and Speechmatics runs them in parallel. They can complete in any order, and some may succeed while others fail.

**Workarounds:**

* Key each callback by job ID and content, and only start the dependent step once everything you need is present.
* Fall back to the API for any piece that hasn't arrived.

**How Hookdeck Can Help:** A [max delivery rate](/docs/destinations#set-a-max-delivery-rate) on the destination smooths bursts when many jobs finish at once, and every stored callback can be replayed if a downstream step needs to run again.

## Best practices

### Request only what you need

Ask for `jobinfo` or a single transcript format in the callback, and fetch anything else from the API. Leave `data` out unless you want the media file posted back to you.

### Keep the header in one place

Generate the job config from a single function or template so that every job carries `auth_headers`, and so that rotating the value is a one-line change.

### Handle every outcome

Route on the `status` query parameter. `error`, `fetch_error` and `trim_error` are real outcomes: record them, check the job's `errors` from the API, and decide whether to resubmit.

### Make handlers idempotent

Retries can deliver the same notification more than once. Record the job ID with the status, plus the content type if a job sends several notifications to one endpoint, and skip repeats. Hookdeck's [deduplication](/docs/deduplication) can drop exact duplicates before they reach you. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast

Return a 2xx before writing large files or calling an LLM, and do that work in the background. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks), and for AI pipelines, [Hookdeck for AI agents and LLMs](/docs/use-cases/ai-agents).

## Conclusion

Speechmatics sends one unsigned notification per configured URL when a batch job finishes, with the job ID and outcome in the query string and whatever `contents` you asked for in the body. Authenticate it by comparing the whole header value you set in `auth_headers`, accept both `POST` and `PUT`, branch on `Content-Type` before parsing, and return a 2xx quickly, because every other response is retried until a timeout the docs don't state.

[Hookdeck Event Gateway](/event-gateway) checks the header, stores every notification and retries delivery to your service, so a finished transcript isn't lost to an outage on your side.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Speechmatics webhooks reliably in minutes.