# Webhooks instellen

> Ontvang een ondertekend request zodra een transcriptie klaar is of mislukt. Controleer de handtekening voordat je het verwerkt.

Canonical: https://transcript1.com/nl/docs/webhooks
Language: nl
Last updated: 2026-10-14

Wil je niet blijven pollen? Geef bij het aanmaken van de transcriptie een `webhook_url` mee. Zodra de transcriptie klaar is of mislukt, sturen we een ondertekend `POST`-request naar die URL.

Je kunt webhooks alleen via de REST API instellen. De [MCP-server](https://transcript1.com/nl/docs/mcp) heeft hiervoor geen parameter.

## Geef je webhook-URL mee

Stuur `webhook_url` mee met `POST /v1/transcriptions`. Bij een ondersteunde videolink of het aanmaken van een upload in chunks zet je die in de JSON-body. Upload je een bestand in één request, gebruik dan een formulierveld:

```json
{
  "platform": "youtube",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "webhook_url": "https://example.com/hooks/transcript1"
}
```

Gebruik een HTTPS-URL zonder gebruikersnaam of wachtwoord. De URL moet uitkomen op een openbaar adres. Verwijst die naar een privéadres, loopback-adres, link-local-adres of een ander niet-openbaar adres, dan krijg je `422 invalid_request`. We controleren het adres bij het aanmaken en opnieuw bij elke poging om de webhook te versturen.

Je kunt `webhook_url` niet samen met `reuse_recent=true` gebruiken.

## Welke events ontvang je?

| `type` | Wanneer ontvang je dit? |
|---|---|
| `transcription.completed` | Zodra de status `completed` is. |
| `transcription.failed` | Zodra de status `failed` is. In `data.error` staan de `code` en `message`. |

Annuleer je een transcriptie, dan sturen we geen webhook.

## Wat staat er in het request?

De webhook heeft een JSON-body:

```json
{
  "type": "transcription.completed",
  "created_at": "2026-10-13T09:30:04.000Z",
  "data": {
    "id": "tr_8fK2Lm0PqRsTuVwXyZaBcD",
    "object": "transcription",
    "status": "completed",
    "transcript_source": "youtube_captions",
    "billed_minutes": 4,
    "error": null
  }
}
```

In `created_at` staat wanneer het event is aangemaakt. `data` bevat het transcriptieobject, maar niet het veld `transcript`. Het voorbeeld hierboven is ingekort. Gebruik de `id` uit `data` om de tekst op te halen met `GET /v1/transcriptions/:id`.

Je krijgt ook `Content-Type: application/json` en een `User-Agent` met de waarde `transcript1-webhooks/1.0`. De header met de handtekening lees je hieronder.

## Controleer de handtekening

Elk webhookrequest bevat `X-Transcript1-Signature`:

```text
X-Transcript1-Signature: t=<unix seconds>,v1=<hex digest>
```

- `t` geeft in Unix-seconden aan wanneer we het request hebben ondertekend.
- `v1` bevat de HMAC-SHA256 als hexwaarde. Bereken die over `"<t>.<raw body>"`: `t`, een punt en de body zoals je die hebt ontvangen. Gebruik je volledige geheime sleutel, dus inclusief `whsec_`.

Controleer de handtekening op de oorspronkelijke bytes, vóór je de JSON parset. Als je JSON eerst parset en daarna weer omzet naar tekst, kunnen spaties of de volgorde van velden veranderen. De handtekening klopt dan niet meer. Vergelijk handtekeningen in constante tijd. Weiger ook requests waarvan `t` te ver van de huidige tijd ligt; de voorbeelden hieronder staan een verschil van 5 minuten toe.

### Node.js

```javascript
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';

// header: X-Transcript1-Signature, rawBody: the request body exactly as received,
// secret: your full signing secret including the whsec_ prefix.
export const verifySignature = (secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) => {
  const fields = Object.fromEntries(header.split(',').map(part => part.trim().split('=')));
  const t = Number(fields.t);

  if (!Number.isInteger(t) || !fields.v1 || Math.abs(nowSeconds - t) > 300) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');

  return fields.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(fields.v1), Buffer.from(expected));
};
```

Laat Express de body als tekst inlezen. Zo blijft die intact voor de controle:

```javascript
import process from 'node:process';
import express from 'express';
import { verifySignature } from './verify-signature.js';

const app = express();

app.post('/hooks/transcript1', express.text({ type: 'application/json' }), (request, response) => {
  const header = request.get('X-Transcript1-Signature') ?? '';

  if (!verifySignature(process.env.TRANSCRIPT1_WEBHOOK_SECRET, header, request.body)) {
    response.sendStatus(400);
    return;
  }

  const { type, data } = JSON.parse(request.body);
  console.log(type, data.id, data.status);
  response.sendStatus(200);
});
```

### Python

```python
import hashlib
import hmac
import time


def verify_signature(secret, header, raw_body, now=None):
    # header: the X-Transcript1-Signature header, raw_body: the request body exactly as received,
    # secret: your full signing secret including the whsec_ prefix.
    fields = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        t = int(fields["t"])
    except (KeyError, ValueError):
        return False

    now = int(time.time()) if now is None else now
    if abs(now - t) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(fields.get("v1", ""), expected)
```

Gebruik je Flask? Geef dan `request.get_data(as_text=True)` door als `raw_body`.

## Je geheime sleutel beheren

Je geheime sleutel begint met `whsec_` en geldt voor je hele team. We ondertekenen hiermee de webhooks van alle transcripties die je team aanmaakt. Op [“Webhooks”](https://transcript1.com/dashboard/webhooks), onder `/dashboard/webhooks`, kun je de sleutel bekijken of vervangen. Een nieuwe sleutel gaat direct in: elke webhook die we daarna versturen gebruikt die nieuwe sleutel.

## Aflevering en nieuwe pogingen

- Antwoordt je server met `2xx`, dan beschouwen we de webhook als afgeleverd.
- Een andere statuscode, een verbindingsfout of geen antwoord binnen 10 seconden telt als een mislukte poging.
- Na een mislukte poging proberen we het opnieuw, tot maximaal 6 pogingen in totaal. De wachttijd loopt exponentieel op: 2, 4, 8, 16 en 32 minuten. De laatste poging is daardoor ongeveer een uur na de eerste.
- Geef meteen de uiteindelijke URL op. transcript1 volgt geen redirects; ook `3xx` telt als mislukt.
- Dezelfde webhook kan vaker aankomen, bijvoorbeeld als je server pas na de time-out antwoordt. Voorkom dubbele verwerking met `data.id` en `type`.
