# Guide to Soniox Webhooks: Features and Best Practices

Soniox webhooks tell you when an asynchronous transcription is done. You submit an audio file, Soniox transcribes it in the background, and when the job completes or fails it calls your URL, so you can fetch the transcript and pass it to the next step, such as search, summarisation or an LLM, without polling.

This guide covers how Soniox webhooks are requested, what the callback contains, how to authenticate it with a header you choose, how failed deliveries are handled, and the practices that make a Soniox receiver dependable.

## What are Soniox webhooks?

Soniox has no webhook settings page. Each async transcription asks for its own callback: you pass `webhook_url` when you create the transcription, and Soniox sends one `POST` to that URL "once the transcription finishes or fails".

The callback is deliberately small. It carries the transcription `id` and its `status`, and nothing else. There are no event types. To get the transcript, you call the API with the ID.

Soniox doesn't sign webhooks. Instead, you can ask it to include one header of your choosing by setting `webhook_auth_header_name` and `webhook_auth_header_value` on the transcription, and your server checks that header.

## Soniox webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per transcription: `webhook_url`, `webhook_auth_header_name`, `webhook_auth_header_value` |
| Trigger | The transcription completes or fails |
| Method | `POST` |
| Body | `{ "id": "...", "status": "completed" }` or `"status": "error"` |
| Event types | None |
| Authentication | The header you set on the transcription. No signature or timestamp |
| Metadata | Query parameters on `webhook_url` are kept |
| Limits | 256 characters each for the URL, header name and header value |
| Retries | "Multiple times over a short period", then permanently failed |
| Delivery record | `webhook_status_code` on the transcription |
| Source IPs | Not published |

## What the callback contains

```json
{
  "id": "548d023b-2b3d-4dc2-a3ef-cca26d05fd9a",
  "status": "completed"
}

```

`status` is either `completed` or `error`. On `completed`, fetch the transcript with `GET /v1/transcriptions/{id}/transcript`. On `error`, fetch the transcription itself to see what went wrong.

Soniox suggests putting your own context in the URL, because the body has none:

```
https://example.com/webhooks/soniox?customer_id=1234&order_id=5678

```

Those parameters come back on the callback request, so you can link the result to your own records without a lookup.

## Setting up Soniox webhooks

Pick a header name and generate a long random value, for example with `openssl rand -hex 32`. Then include the three webhook fields when you create each transcription:

```bash
curl -X POST https://api.soniox.com/v1/transcriptions \
  -H "Authorization: Bearer $SONIOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "<model>",
    "audio_url": "https://example.com/audio.mp3",
    "webhook_url": "https://example.com/webhooks/soniox?order_id=5678",
    "webhook_auth_header_name": "X-Soniox-Secret",
    "webhook_auth_header_value": "<your random value>"
  }'

```

The header value is the credential your receiver accepts. It isn't your Soniox API key, which should never leave your backend. When you read the transcription back, Soniox returns the header value masked.

Because the header is set per request, every code path that creates transcriptions must include it. Build the request in one place so it can't be forgotten.

**With Hookdeck.** Soniox webhooks are not signed. Send `webhook_auth_header_name` and `webhook_auth_header_value` with every async transcription, then create a Hookdeck source with the **Soniox** type and enter the same name in **API Key Header** and the same value in **API Key**. Hookdeck finds the header case-insensitively and compares the whole value, so if you send `Authorization` with `Bearer abc123`, enter `Bearer abc123`. Use the source URL, with any query parameters you want, as `webhook_url`. The source accepts `POST` only, which is what Soniox sends.

## Securing Soniox webhooks

Read the header you chose, compare its whole value with the stored one in constant time, and only then parse the body.

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

const HEADER = (process.env.SONIOX_WEBHOOK_HEADER || 'X-Soniox-Secret').toLowerCase();
const SECRET = process.env.SONIOX_WEBHOOK_SECRET; // the exact, whole header value

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/soniox', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER);
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let callback;
  try {
    callback = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // callback.id, callback.status and req.query.order_id: queue the transcript fetch, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER = os.environ.get("SONIOX_WEBHOOK_HEADER", "X-Soniox-Secret")
SECRET = os.environ.get("SONIOX_WEBHOOK_SECRET", "")

def verify_soniox_request(headers) -> bool:
    if not SECRET:
        return False  # fail closed when unconfigured
    received = headers.get(HEADER)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static header authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. The risk is small here, because the body is only an ID and a status and you fetch the transcript from the API yourself, but use HTTPS and a long random value all the same.

> **Make Soniox webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your header on every callback, stores each one durably, and delivers it to your service with retries you control.

## Soniox webhook limitations and pain points

### A short, unstated retry window

**The Problem:** If your receiver is down when a job finishes, the callback may never arrive.

**Why It Happens:** Soniox retries "multiple times over a short period" and then treats the delivery as permanently failed. The number of attempts, the interval and the timeout aren't published.

**Workarounds:**

* Log every transcription ID when you create it, as Soniox recommends, and periodically fetch any that haven't called back.
* Check `webhook_status_code` on the transcription to see what your server returned.

**How Hookdeck Can Help:** Hookdeck accepts each callback as soon as it arrives and stores it, then delivers it to your service with [retries](/docs/retries) over a window you choose. [Issues](/docs/issues) alert you when deliveries fail, and you can replay any stored callback.

### A header on every request

**The Problem:** Some callbacks arrive without the header and are rejected.

**Why It Happens:** The header belongs to the transcription request. A job created by a script, a different service or an older code path without `webhook_auth_header_name` and `webhook_auth_header_value` sends a callback with nothing to check.

**Workarounds:**

* Create transcriptions through one function that always adds the webhook fields.
* When you rotate the value, accept both values until jobs created before the change have called back.

**How Hookdeck Can Help:** Hookdeck's Soniox source compares the header you name with the exact value you enter. Hookdeck answers Soniox with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination, and the stored request shows which job it came from.

### No context in the body

**The Problem:** The callback tells you a job finished but not which customer or order it belongs to.

**Why It Happens:** The body holds only `id` and `status`.

**Workarounds:**

* Add your own identifiers to `webhook_url` as query parameters, as Soniox suggests, or store a mapping from transcription ID to your records.

**How Hookdeck Can Help:** Hookdeck keeps the full request URL with each callback, and [filters](/docs/filters) and [transformations](/docs/transformations) can use those query parameters to route or enrich callbacks before they reach your service.

## Best practices

### Treat the callback as a signal

Fetch the transcript from the API using `id`. The callback never contains it, and fetching it yourself means a replayed request can't inject data.

### Handle `error`

Record failed transcriptions, fetch the transcription for details, and decide whether to resubmit.

### Make handlers idempotent

Retries can deliver the same callback twice. Key on `id` and `status`, and skip pairs you've already handled. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast

Return `200` before fetching the transcript or running any AI step. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Soniox sends one small, unsigned `POST` per async transcription, with the job's `id` and `status`, to the URL and header you set on that transcription. Send the header with every job and compare its whole value, carry your own context in the URL's query string, fetch the transcript from the API, and keep a record of transcription IDs, because Soniox only retries for a short time.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the header and holds every callback until your service has processed it, so a finished transcript isn't lost to a brief outage.

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