# Webhooks

Un webhook es una URL de tu sistema a la que Ruta le hace un `POST` firmado cuando pasa algo con una entrega de la empresa, venga de la API, el panel, la app, un CSV, el formulario de un comercio o una integración.

## Registrar un endpoint

En **Configuración → Desarrolladores → Avisos a tu sistema (webhooks)** (un administrador):

- **URL**: `https://…` (en un entorno local de pruebas también `http://127.0.0.1` o `http://localhost`). Hasta 5 por empresa.
- **Eventos**: los que querés recibir (abajo). Si no elegís, recibís `delivery.created` y `delivery.status_changed`, como en la primera versión.
- **Comercio** (opcional): sólo las entregas de ese comercio. Útil para darle a cada tienda su propio endpoint.
- **Tipo**: **Real** (entregas reales, «producción») o **De prueba** (las del [entorno de prueba](entorno-de-prueba.html)).

El **secreto de firma** (`whsec_…`) se muestra una sola vez. Podés cambiar los eventos después con **Cambiar eventos**, pausar y reactivar el webhook y ver el registro de avisos.

## Eventos

| Evento | Cuándo | Datos extra en `data` |
| --- | --- | --- |
| `delivery.created` | Se creó una entrega. | — |
| `delivery.status_changed` | Cambió el estado o el intento (genérico; se conserva por compatibilidad). | `reason` sólo en un inicio deshecho (abajo) |
| `delivery.assigned` | Se asignó a un repartidor. | `reason` sólo en un inicio deshecho (abajo) |
| `delivery.picked_up` | El repartidor retiró el paquete. | `place` (`BASE` o `SHIPPER`), `pickedUpAt` |
| `delivery.out_for_delivery` | Salió a entregar. | — |
| `delivery.delivered` | Se entregó. | `collectedAmount`, `collectionMethod`, `currency` |
| `delivery.failed` | No se pudo entregar. | (`failureReasonCode` ya viene siempre) |
| `delivery.cancelled` | Se canceló. | — |
| `delivery.retry_scheduled` | Se programó otro intento tras un fallo. | (`attemptNumber` + 1) |
| `delivery.proof_added` | Se guardó el comprobante. | `proofId`, `recordedAt` |
| `delivery.collection_settled` | El courier marcó el cobro en efectivo como rendido: su repartidor ya le entregó el dinero. | `collectedAmount`, `collectionMethod`, `currency`, `collectedAt`, `settledAt` |
| `webhook.test` | Lo pediste con **Enviar evento de prueba**. | `endpointId`, `mode`, `message` |

Un mismo cambio puede generar dos avisos: por ejemplo, al entregar se emiten `delivery.status_changed` y `delivery.delivered`. Suscribite a uno de los dos estilos.

## El aviso

```http
POST /webhooks/ruta HTTP/1.1
Content-Type: application/json
User-Agent: Ruta-Webhooks/1
Ruta-Event-Id: 3b0f7c3e-8a51-4a43-9f39-0d4c6b1f2a10
Ruta-Event-Type: delivery.delivered
Ruta-Signature: t=1791651734,v1=5f2c…(64 hex)

{"id":"3b0f7c3e-8a51-4a43-9f39-0d4c6b1f2a10","type":"delivery.delivered","createdAt":"2026-10-10T15:20:00.000000Z","test":false,
 "data":{"deliveryId":"6f1c…","status":"DELIVERED","previousStatus":"OUT_FOR_DELIVERY","attemptNumber":1,"failureReasonCode":null,
         "externalReference":"1042","shipperId":null,"collectedAmount":"1250.00","collectionMethod":"CASH","currency":"UYU"}}
```

- `data` trae siempre `deliveryId`, `status`, `previousStatus`, `attemptNumber`, `failureReasonCode`, `externalReference` y `shipperId`, más los datos extra del evento. **Nunca** datos del destinatario: consultá `GET /api/v1/deliveries/{id}` si los necesitás.
- `reason: "START_UNDONE"` llega en `delivery.status_changed` y `delivery.assigned` cuando la entrega volvió de `OUT_FOR_DELIVERY` a `ASSIGNED` con el mismo `attemptNumber`: el repartidor o la oficina deshizo «Iniciar entrega» («Volver a asignada»). Sigue con el mismo repartidor; no es una asignación nueva. En los demás avisos `reason` no viene.
- `currency` es la moneda de `collectedAmount`: la del cobro, salvo que el repartidor haya cobrado en la otra moneda (entonces dice en cuál cobró).
- `test` es `true` para eventos del entorno de prueba y para `webhook.test`.
- El orden de las claves del JSON no está garantizado: verificá la firma sobre los **bytes crudos** que recibiste, antes de parsear.

## Verificar la firma

`v1` es el HMAC-SHA256, en hexadecimal, del texto `"<t>.<cuerpo crudo>"` con tu secreto. Rechazá avisos con `t` a más de 5 minutos de tu reloj y compará en tiempo constante.

<div class="tabs">

```js
// Node.js 18+ (Express con express.raw({ type: 'application/json' }))
import crypto from 'node:crypto'

export function verifyRuta(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map(part => part.trim().split('=')))
  const t = Number(parts.t)
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')
  const given = Buffer.from(parts.v1 ?? '', 'utf8')
  return given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected, 'utf8'))
}
```

```python
# Python 3
import hashlib, hmac, time

def verify_ruta(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(part.strip().split("=", 1) for part in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

```php
<?php // PHP 8 (por ejemplo, un plugin de WooCommerce)
function verify_ruta(string $secret, string $header, string $rawBody, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_map('trim', explode('=', $part, 2)) + [null, null];
        $parts[$k] = $v;
    }
    $t = (int) ($parts['t'] ?? 0);
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
// $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_RUTA_SIGNATURE'];
```

</div>

## Respuesta y reintentos

- Respondé **2xx en menos de 10 segundos** y procesá en segundo plano. No seguimos redirecciones.
- Cualquier otra respuesta, timeout o error de conexión se reintenta con espera creciente: 30 s, 2,5 min, 12,5 min, ~1 h, ~5 h y luego cada 6 h, hasta **8 intentos**. Después el aviso queda «Falló» y un administrador puede **reenviarlo** desde el registro.
- Tras **20 fallos seguidos** el webhook se **pausa**; reactivalo desde el panel cuando tu sistema vuelva a responder. Los avisos pendientes siguen donde quedaron.

## Duplicados y orden

- La entrega es «al menos una vez»: un reintento o un reenvío llevan el **mismo `Ruta-Event-Id` y el mismo cuerpo**. Guardá los ids procesados y descartá repetidos.
- Los avisos de una misma entrega llegan **en orden**: el siguiente espera mientras el anterior esté pendiente.

## Probar el endpoint

**Enviar evento de prueba** (o `POST /api/account/webhooks/{id}/test` con la sesión de un administrador) encola un `webhook.test` firmado igual que cualquier aviso, sin crear ninguna entrega ni consumir cupo. Hay uno por vez por endpoint; el registro muestra cómo salió.
