# Webhooks

> Recibe un aviso firmado cuando la transcripción termina o falla. Aprende a comprobar la firma y manejar los reintentos.

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

Incluye `webhook_url` al crear la transcripción. Cuando termine o falle, transcript1 enviará un `POST` firmado a esa dirección.

Esta opción solo está en la API REST. El [servidor MCP](https://transcript1.com/es/docs/mcp) no acepta un parámetro para webhooks.

## Configura dónde recibir el aviso

Si envías un enlace de video admitido o reservas una subida por partes, agrega `webhook_url` al JSON de `POST /v1/transcriptions`. Si subes el archivo completo en un request, agrégalo como campo del formulario:

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

Usa una URL HTTPS que resuelva a una dirección pública, sin usuario ni contraseña en la URL. Las direcciones privadas, loopback, link-local y cualquier otra dirección no pública devuelven `422 invalid_request`. La API comprueba la dirección al crear la transcripción y antes de cada envío.

No combines `webhook_url` con `reuse_recent=true`.

## Eventos

| `type` | Cuándo lo recibes |
|---|---|
| `transcription.completed` | Cuando la transcripción pasa a `completed`. |
| `transcription.failed` | Cuando pasa a `failed`. Revisa `code` y `message` en `data.error`. |

Si cancelas la transcripción, no se envía un webhook.

## Qué contiene el request

El aviso llega con un cuerpo JSON:

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

En `created_at` tienes la fecha y hora de creación del evento. `data` contiene el objeto de transcripción, pero no incluye `transcript`; el ejemplo está abreviado. Para obtener el texto, toma el `id` de `data` y consulta `GET /v1/transcriptions/:id`.

Los headers incluyen `Content-Type: application/json`, `User-Agent` con el valor `transcript1-webhooks/1.0` y la firma que se explica en la siguiente sección.

## Comprueba la firma antes de leer el JSON

Cada envío lleva el header `X-Transcript1-Signature`:

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

- `t` indica cuándo se firmó el request, en segundos Unix.
- `v1` contiene el HMAC-SHA256 en hexadecimal de `"<t>.<raw body>"`. Para calcularlo, concatena el valor de `t`, un punto y el cuerpo exacto que recibiste. Usa tu secreto de firma completo como clave, sin quitar el prefijo `whsec_`.

Comprueba la firma con los bytes originales del cuerpo, antes de parsear el JSON. Si lo parseas y vuelves a serializar, los espacios o el orden de las claves pueden cambiar y la firma dejará de coincidir. Haz la comparación en tiempo constante y rechaza los requests cuyo `t` se aleje demasiado de la hora actual. Los ejemplos aceptan una diferencia de hasta 5 minutos.

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

En Express, recibe el cuerpo como texto sin parsearlo primero:

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

En Flask, obtén el cuerpo con `request.get_data(as_text=True)` y pásalo a `raw_body`.

## Dónde consultar o rotar el secreto

El secreto empieza con `whsec_` y es compartido por todo el equipo: firma los webhooks de todas sus transcripciones. Puedes verlo o rotarlo en [“Webhooks”](https://transcript1.com/dashboard/webhooks), dentro de `/dashboard/webhooks`. Si lo rotas, el cambio es inmediato. Todos los envíos posteriores usan el secreto nuevo.

## Respuestas, reintentos y duplicados

- Responde con cualquier código `2xx` para confirmar la recepción.
- Otro código HTTP, un error de conexión o más de 10 segundos sin respuesta cuentan como un intento fallido.
- Si falla el envío, transcript1 vuelve a intentarlo hasta completar 6 intentos en total. Espera 2, 4, 8, 16 y 32 minutos entre intentos; el último ocurre aproximadamente una hora después del primero.
- Configura la URL final. transcript1 no sigue redirecciones: un `3xx` también cuenta como fallo.
- Puedes recibir el mismo aviso más de una vez, por ejemplo si tu servidor responde después del timeout. Usa la combinación de `data.id` y `type` para detectar e ignorar duplicados.
