# Webhooks einrichten

> Empfange signierte Webhooks für abgeschlossene oder fehlgeschlagene Transkriptionen und prüfe ihre Signatur.

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

Hinterlege beim Anlegen einer Transkription eine `webhook_url`. Sobald die Transkription fertig ist oder fehlschlägt, sendet transcript1 einen signierten `POST` an diese URL.

Webhooks kannst du nur über die REST-API einrichten. Beim [MCP-Server](https://transcript1.com/de/docs/mcp) gibt es dafür keinen Parameter.

## Wohin soll der Webhook gehen?

Für einen unterstützten Video-Link oder einen Upload in Chunks setzt du `webhook_url` im JSON-Body von `POST /v1/transcriptions`. Beim Chunk-Upload machst du das schon beim Anlegen, bevor du die Datei sendest. Lädt dein Request die ganze Datei auf einmal hoch, gibst du `webhook_url` als Formularfeld mit.

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

Verwende eine HTTPS-URL ohne Benutzername oder Passwort. Der Host muss auf eine öffentliche IP-Adresse auflösen. Private Adressen, Loopback, Link-Local und andere nicht öffentliche Adressen lehnt die API mit `422 invalid_request` ab. Diese Prüfung läuft beim Anlegen der Transkription und erneut bei jeder Zustellung.

Wenn du `webhook_url` angibst, darfst du nicht gleichzeitig `reuse_recent=true` setzen.

## Bei diesen Events kommt ein Webhook

| `type` | Auslöser |
|---|---|
| `transcription.completed` | Die Transkription ist fertig und hat den Status `completed`. |
| `transcription.failed` | Die Transkription ist mit Status `failed` fehlgeschlagen. Den Fehler findest du als `code` und `message` in `data.error`. |

Brichst du eine Transkription ab, sendet transcript1 keinen Webhook.

## So sieht der Webhook-Body aus

Der Webhook enthält einen 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
  }
}
```

Unter `created_at` steht, wann das Event entstanden ist. In `data` steckt das Transkriptionsobjekt, aber ohne `transcript`; das Beispiel oben zeigt nur einen Ausschnitt. Hole den Text separat mit `GET /v1/transcriptions/:id` ab. Die passende `id` findest du in `data`.

Außerdem sendet transcript1 die Header `Content-Type: application/json`, `User-Agent` mit dem Wert `transcript1-webhooks/1.0` und den folgenden Signatur-Header.

## Prüfe die Signatur vor dem Verarbeiten

Im Header `X-Transcript1-Signature` stehen bei jeder Zustellung diese Werte:

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

- `t` gibt als Unix-Zeit in Sekunden an, wann transcript1 den Request signiert hat.
- Für `v1` wird der HMAC-SHA256 von `"<t>.<raw body>"` hexadezimal kodiert: erst der Wert von `t`, dann ein Punkt, dann der unveränderte Request-Body. Der HMAC-Schlüssel ist dein vollständiges Signing Secret mit dem Präfix `whsec_`.

Berechne die Signatur aus den empfangenen Bytes, bevor du JSON parst. Wenn du den Body erst parst und dann neu serialisierst, können sich Leerzeichen oder die Reihenfolge der Schlüssel ändern. Die Signatur passt dann nicht mehr.

Vergleiche die Signaturen in konstanter Zeit. Prüfe auch `t` und lehne Requests ab, deren Zeitstempel zu weit von der aktuellen Zeit abweicht. Die folgenden Beispiele tolerieren 5 Minuten.

### 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));
};
```

Lass Express den Body als Text einlesen. So kannst du die Signatur prüfen, bevor JSON geparst wird:

```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)
```

In Flask holst du den unveränderten Body mit `request.get_data(as_text=True)` und übergibst ihn als `raw_body`.

## Signing Secret anzeigen oder wechseln

Dein Signing Secret beginnt mit `whsec_` und gilt für das gesamte Team. Damit signiert transcript1 die Webhooks aller Transkriptionen dieses Teams.

Unter `/dashboard/webhooks` kannst du das Secret auf der [Webhooks-Seite](https://transcript1.com/dashboard/webhooks) anzeigen oder durch ein neues ersetzen. Der Wechsel gilt sofort. Alle danach gesendeten Webhooks tragen eine Signatur mit dem neuen Secret.

## Zustellung, Wiederholungen und Duplikate

- Antworte mit einem `2xx`-Status, um den Empfang zu bestätigen.
- Ein anderer Statuscode oder ein Verbindungsfehler zählt als fehlgeschlagener Versuch. Dasselbe gilt, wenn dein Server nicht innerhalb von 10 Sekunden antwortet.
- Schlägt die Zustellung fehl, versucht transcript1 es erneut. Insgesamt gibt es höchstens 6 Versuche. Die Abstände wachsen exponentiell: 2, 4, 8, 16 und 32 Minuten. Der letzte Versuch liegt damit etwa eine Stunde nach dem ersten.
- Gib direkt die endgültige URL an. transcript1 folgt keinen Weiterleitungen und wertet auch `3xx` als fehlgeschlagenen Versuch.
- Rechne mit mehrfachen Zustellungen, etwa wenn dein Server erst nach dem Timeout antwortet. Erkenne Duplikate an der Kombination aus `data.id` und `type` und ignoriere sie.
