---
title: "Webhooks Guide | Scriptivox API"
description: "Set up webhook notifications for transcription completion. HMAC-signed payloads, retry logic, and integration examples."
canonical: "https://platform.scriptivox.com/docs/webhooks"
last-updated: "2026-05-18"
---

# Webhooks

Receive real-time notifications when transcriptions complete or fail instead of polling.

---

## Overview

When you provide a `webhook_url` in your transcription request, we'll POST progress and result events to that URL as the job moves through the pipeline. Webhooks are signed with HMAC-SHA256 so you can verify they came from Scriptivox.

Webhooks fire for both upload-based and URL-based transcription flows. The `webhook_url` field is optional — if omitted, no webhooks are sent and you should poll [`GET /v1/transcribe/{id}`](/docs/api-reference) instead.

You can also set a default webhook URL in your [account settings](/platform/settings) that is used for any transcription that does not specify one in the request body.

> **Webhooks are best-effort:** 
> Webhook delivery is **fire-and-forget with no retries**. If your endpoint is down or returns a non-2xx response, the event is dropped — the transcription itself still completes normally. Always pair webhooks with polling `GET /v1/transcribe/{id}` as a fallback so you never miss a completion.

## Setting up webhooks

Pass a `webhook_url` when starting a transcription:

```python
requests.post("https://api.scriptivox.com/v1/transcribe",
    headers={"Authorization": "sk_live_YOUR_KEY"},
    json={
        "upload_id": "abc-123",
        "webhook_url": "https://your-server.com/webhook"
    })
```

```javascript
await fetch("https://api.scriptivox.com/v1/transcribe", {
  method: "POST",
  headers: {
    Authorization: "sk_live_YOUR_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    upload_id: "abc-123",
    webhook_url: "https://your-server.com/webhook",
  }),
});
```

```bash
curl -X POST https://api.scriptivox.com/v1/transcribe \
  -H "Authorization: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "upload_id": "abc-123",
    "webhook_url": "https://your-server.com/webhook"
  }'
```

## Webhook events

Three events fire per job: `transcription.processing` once the file has been validated and dispatched to a GPU, then exactly one of `transcription.completed` or `transcription.failed` when the job terminates. The `created` and `downloading` states are visible in `GET /v1/transcribe/{id}` polling but do **not** emit webhooks.

### transcription.processing

Sent when the audio file has been downloaded and validated, and the transcription job has been submitted for processing. Includes the detected duration and reserved cost.

```json
{
  "event": "transcription.processing",
  "transcription_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "processing",
  "duration_seconds": 120,
  "cost_cents": 0.5
}
```

> **Info:** 
> Both URL-based and upload-based transcriptions receive this event after the file has been validated and the job is submitted.

### transcription.completed

Sent when a transcription finishes successfully. Includes the full result.

```json
{
  "event": "transcription.completed",
  "transcription_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "completed",
  "duration_seconds": 120,
  "cost_cents": 0.5,
  "result": {
    "full_transcript": "Hello, thanks for joining...",
    "language": "en",
    "duration_seconds": 120,
    "speakers": ["SPEAKER 1", "SPEAKER 2"],
    "utterances": [
      {
        "start": 0.5,
        "end": 3.2,
        "text": "Hello, thanks for joining the call today.",
        "speaker": "SPEAKER 1",
        "confidence": 0.95,
        "words": [...]
      }
    ]
  }
}
```

### transcription.failed

Sent any time a transcription ends in the `failed` state. Reserved balance is automatically released so a failed job costs $0.

This event covers every failure surface: URL download/validation errors, insufficient balance, internal queue/server errors, GPU processing failures, and **cleanup-timeout failures** (when a job sits stuck in `created`, `downloading`, or `processing` past its threshold). You will receive at most one `transcription.failed` event per transcription, so a single handler can cover all failure modes — branch on `error.code` (see [API reference: error codes](/docs/api-reference#error-codes)) for code-specific behavior.

`error.message` is a sanitized, customer-safe string; we don't forward raw GPU/library stack traces. For `PROCESSING_ERROR` cases where the message is generic, retrying is usually the right next step.

```json
{
  "event": "transcription.failed",
  "transcription_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "failed",
  "error": {
    "code": "PROCESSING_ERROR",
    "message": "Failed to process audio file"
  }
}
```

### Events summary

| Event | When | Flows |
| --- | --- | --- |
| transcription.processing | File validated, job submitted to GPU | Both |
| transcription.completed | Transcription finished successfully | Both |
| transcription.failed | Download, validation, or processing failed | Both |

## Verifying webhook signatures

Every webhook request includes two headers for signature verification:

| Header | Description |
| --- | --- |
| `X-Scriptivox-Signature` | HMAC-SHA256 hex digest of the signed payload |
| `X-Scriptivox-Timestamp` | Unix timestamp (seconds, as a string) of when the webhook was sent |

> **Signing formula:** 
> 1. `signing_secret = SHA256_hex(api_key)` — hex digest of your API key as a UTF-8 string
> 2. `signed_payload = f"{timestamp}.{body}"` — body is the exact raw JSON request body, byte-for-byte
> 3. `signature = HMAC_SHA256_hex(signing_secret, signed_payload)` — sent as `X-Scriptivox-Signature`
> 
> Reject any request whose `X-Scriptivox-Timestamp` is more than **5 minutes** old to prevent replay attacks.

```python
import hmac
import hashlib
import time

def get_signing_secret(api_key: str) -> str:
    """Your signing secret is SHA256 of your API key."""
    return hashlib.sha256(api_key.encode()).hexdigest()

def verify_webhook(body: bytes, signature: str, timestamp: str, api_key: str) -> bool:
    # Check timestamp is recent (within 5 minutes)
    now = int(time.time())
    if abs(now - int(timestamp)) > 300:
        return False

    # Compute expected signature using SHA256(api_key) as secret
    signing_secret = get_signing_secret(api_key)
    message = f"{timestamp}.{body.decode()}"
    expected = hmac.new(
        signing_secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

# In your webhook handler:
# signature = request.headers["X-Scriptivox-Signature"]
# timestamp = request.headers["X-Scriptivox-Timestamp"]
# is_valid = verify_webhook(request.body, signature, timestamp, API_KEY)
```

```javascript
import { createHash, createHmac, timingSafeEqual } from "crypto";

function getSigningSecret(apiKey) {
  // Your signing secret is SHA256 of your API key
  return createHash("sha256").update(apiKey).digest("hex");
}

function verifyWebhook(body, signature, timestamp, apiKey) {
  // Check timestamp is recent (within 5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false;
  }

  // Compute expected signature using SHA256(apiKey) as secret
  const signingSecret = getSigningSecret(apiKey);
  const message = `${timestamp}.${body}`;
  const expected = createHmac("sha256", signingSecret)
    .update(message)
    .digest("hex");

  return timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// In your Express handler — use the RAW body, not the parsed JSON:
//   app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
//     const sig = req.headers["x-scriptivox-signature"];
//     const ts  = req.headers["x-scriptivox-timestamp"];
//     const valid = verifyWebhook(req.body.toString("utf8"), sig, ts, API_KEY);
//     if (!valid) return res.sendStatus(401);
//     const payload = JSON.parse(req.body.toString("utf8"));
//     res.sendStatus(200);
//   });
```

## Delivery behavior

Webhooks are delivered on a **best-effort, fire-and-forget** basis:

- **No retries.** Connection failures, timeouts, and non-2xx responses are all final — the event is dropped.
- **One redirect followed.** If your endpoint returns a 3xx response with a `Location` header, the dispatcher re-POSTs the same body and signature/timestamp headers to the resolved URL exactly once. Only `http(s)` destinations are followed; the Location is re-parsed and rejected if it points anywhere else (open-redirect protection).
- **User-Agent** on every request is `scriptivox-webhook/1`.
- **Transcriptions complete normally** regardless of webhook delivery status — webhook health does not affect billing or job state.
- **We monitor our own delivery pipeline.** A heartbeat fires on every successful 2xx response from a customer URL; if no webhook delivery succeeds for several hours, we get alerted internally. This catches systemic delivery problems on our side fast — but it does not retry your specific dropped events, which is why polling is still required.

For reliable delivery, treat webhooks as a latency optimization and poll `GET /v1/transcribe/{id}` as a fallback. Live API uptime is published at **[status.scriptivox.com](https://status.scriptivox.com)**.

## Best practices

- **Return 2xx quickly** — Acknowledge the webhook fast and do any heavy work asynchronously. Slow responses risk timing out, and there are no retries.
- **Always verify signatures** — Check the HMAC-SHA256 signature against the raw body before trusting any payload.
- **Check timestamps** — Reject webhooks with `X-Scriptivox-Timestamp` older than 5 minutes to prevent replay attacks.
- **Be idempotent** — Each `transcription_id` produces one `processing` event followed by one terminal `completed` or `failed`; deduplicate by `(transcription_id, event)` in case a redirected delivery results in a repeat POST.
- **Use HTTPS** — Always use HTTPS for production webhook URLs. HTTP is allowed for local development only.
- **Poll as a fallback** — Since webhooks are never retried, also poll `GET /v1/transcribe/{id}` so a single dropped delivery does not strand a job.

---

- [API Reference](/docs/api-reference) — Full endpoint documentation
- [Pricing](/docs/pricing) — Pay-as-you-go at $0.20/hour
