Gareth Wilson Gareth Wilson

Guide to Soniox Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPer transcription: webhook_url, webhook_auth_header_name, webhook_auth_header_value
TriggerThe transcription completes or fails
MethodPOST
Body{ "id": "...", "status": "completed" } or "status": "error"
Event typesNone
AuthenticationThe header you set on the transcription. No signature or timestamp
MetadataQuery parameters on webhook_url are kept
Limits256 characters each for the URL, header name and header value
Retries"Multiple times over a short period", then permanently failed
Delivery recordwebhook_status_code on the transcription
Source IPsNot published

What the callback contains

{
  "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:

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.

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:

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 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 over a window you choose. 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 and 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.

Acknowledge fast

Return 200 before fetching the transcript or running any AI step. See why to process webhooks asynchronously.

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 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 for free and handle Soniox webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.