> ## Documentation Index
> Fetch the complete documentation index at: https://ayuda.gudink.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API: avisos automáticos (webhooks)

> Cómo recibir un aviso de Gudink cada vez que un pedido creado por API cambia de estado: alta del destino, eventos, el aviso de prueba, cómo responder, reintentos, cómo ponerte al día después de una caída y cómo verificar la firma.

En vez de consultar cada pedido cada tanto, podés recibir un aviso en tu servidor cuando algo cambia: se pagó, entró a producción, se despachó, se entregó o se canceló.

## Dar de alta un destino

Los destinos se administran sólo desde tu panel, en **Integraciones > API > Avisos**. No se pueden crear por API, a propósito: así una clave filtrada no puede sumar un destino para llevarse datos.

| Regla | Detalle |
| - | - |
| La URL | `https`, puerto 443, con un nombre de host (no una IP), sin usuario ni contraseña en la URL, y no puede redirigir |
| El secreto de firma | `whsec_…`. Se muestra **una sola vez**, al crear el destino. Guardalo igual que la clave: en una variable de entorno, nunca en un repositorio |
| Cuántos | Hasta 3 destinos por cuenta |
| "Mandar prueba" | Envía un aviso `webhook.test` firmado. Como mucho 5 por minuto |
| Historial | El panel muestra las últimas 20 entregas de cada destino |
| Entornos | El destino de pruebas y el de producción son distintos, cada uno con su secreto. Ver [Entorno de pruebas](/api/entorno-de-pruebas) |

Gudink no puede mandar avisos a `localhost`, a una IP ni a un puerto que no sea 443. Para probar en tu máquina, ver [Probar tu destino sin publicarlo](#probar-tu-destino-sin-publicarlo).

## Eventos

| Evento | Cuándo llega |
| - | - |
| `order.created` | Se creó el pedido |
| `order.paid` | Se acreditó tu pago. También llega si el pedido saltó directo a un estado posterior |
| `order.in_production` | Entró a producción |
| `order.shipped` | Se despachó. Trae `tracking` si ya hay número |
| `order.tracking_updated` | Apareció o cambió el número de seguimiento después del despacho |
| `order.delivered` | Se entregó |
| `order.on_hold` | Se detuvo, o cambió el motivo (`hold_reason`) |
| `order.cancelled` | Se canceló, también cuando vence a las 72 horas por falta de pago. Trae `cancel_reason` |
| `webhook.test` | Apretaste "Mandar prueba". No es de ningún pedido |

* Sólo se avisan los pedidos creados por la API.
* `ready_to_ship`, `delivery_failed` y la vuelta de `on_hold` a `pending_payment` no tienen evento propio: los ves en el `status` del próximo aviso o con `GET /orders/:id`.
* No hay avisos de productos: un evento `product.updated` todavía no existe.
* **Volver a entrar al mismo estado no se avisa dos veces.** Si un pedido se pausa, se reanuda y se vuelve a pausar por el mismo motivo, recibís un solo `order.on_hold`. Si necesitás el estado exacto de ahora, pedilo con `GET /orders/:id`.

## El cuerpo del aviso

Un aviso de pedido:

```json theme={null}
{
  "id": "evt_5f0c3a9e1b7d4c2a8e6f1d3b9a7c5e2f",
  "type": "order.shipped",
  "created_at": "2026-10-04T13:22:10.000Z",
  "order": {
    "id": 1043,
    "number": "GU-1000412",
    "external_id": "woo-k7p2-10432",
    "status": "shipped",
    "hold_reason": null,
    "cancel_reason": null,
    "tracking": {
      "number": "TEST-GU-1000412",
      "url": "https://example.com/envio-de-prueba/TEST-GU-1000412",
      "carrier": "Envío de prueba"
    },
    "delivery_type": "home",
    "pickup_point": null
  }
}
```

| Campo | Qué es |
| - | - |
| `id` | El id del evento. Es el mismo valor que la cabecera `webhook-id` y no cambia entre reintentos |
| `type` | Uno de los eventos de la tabla |
| `created_at` | Cuándo se generó el evento |
| `order.status`, `order.hold_reason`, `order.cancel_reason` | El estado del pedido en ese momento, con los mismos valores que [Estados](/api/pedidos#estados). `cancel_reason` dice por qué se canceló (`unpaid_expired`, `cancelled_by_seller`, `cancelled_by_gudink`) y es `null` si no está cancelado o si el motivo no quedó registrado |
| `order.tracking` | `number`, `url` y `carrier`. Cada uno puede ser `null` |
| `order.delivery_type` | `home` (a domicilio) o `pickup` (retiro en sucursal) |
| `order.pickup_point` | En un retiro, la sucursal elegida, con la misma forma que en [Retiro en sucursal](/api/pedidos#retiro-en-sucursal). `null` en un envío a domicilio |

El aviso no trae el nombre, el teléfono ni la dirección de tu comprador. Si los necesitás, consultá el pedido con tu clave. Sí trae, en un retiro en sucursal, la sucursal en `pickup_point`: es la dirección pública de la sucursal, no la de tu comprador, y te sirve para avisarle adónde retirar sin otra consulta.

### El aviso de prueba

El aviso de "Mandar prueba" es un evento propio, **sin `order`**. En la especificación es el esquema `WebhookTestEvent`. Llega con las mismas tres cabeceras y la misma firma que cualquier aviso:

```
POST /tu/ruta/de/avisos HTTP/1.1
Content-Type: application/json
webhook-id: evt_test_3f9a1c7e5b2d8a4f6c0e1b9d
webhook-timestamp: 1791120130
webhook-signature: v1,cLcEaKkq4dm9INfGgPgBS6Vaj/e0wq/rwCcN0a80T2k=

{"id":"evt_test_3f9a1c7e5b2d8a4f6c0e1b9d","type":"webhook.test","created_at":"2026-10-04T13:22:10.000Z"}
```

Trae sólo `id`, `type` y `created_at`. Verificá la firma igual que en cualquier aviso, respondé 2xx y no busques ningún pedido.

## Cómo tiene que responder tu servidor

1. **Verificá la firma** antes de hacer nada con el contenido (ver [Verificar la firma](#verificar-la-firma)).
2. **Respondé cualquier 2xx en menos de 10 segundos.** Guardá el aviso, respondé, y procesalo después. No hagas el trabajo pesado antes de contestar.
3. **Deduplicá por `webhook-id`.** El mismo aviso puede llegar más de una vez.
4. **No dependas del orden de llegada**, ni entre pedidos distintos ni dentro de un mismo pedido: `order.in_production` puede llegar antes que `order.paid`. El campo `status` del aviso, o `GET /orders/:id`, dice dónde está el pedido. Quedate con el último que procesaste por `created_at`.

Qué status devolver en cada caso:

| Situación | Respondé | Por qué |
| - | - | - |
| Aviso válido, lo guardaste | 2xx | |
| Aviso duplicado (ya viste ese `webhook-id`) | 2xx y descartalo | Un 4xx no lo arregla y Gudink lo va a reintentar |
| `type` que no conocés (uno nuevo de esta misma versión) | 2xx y descartalo | Ídem |
| Pedido que no encontrás en tu sistema | 2xx y descartalo | Ídem |
| `webhook.test` | 2xx | |
| Firma inválida, o `webhook-timestamp` con más de 5 minutos de diferencia | 401, y no lo proceses | Ojo: cuenta como una falla y se reintenta |

Si recibís avisos reales con firma inválida, tu secreto está mal cargado: corregilo antes de que el destino se desactive.

## Reintentos de un aviso

| Regla | Número |
| - | - |
| Qué cuenta como falla | Cualquier respuesta que no sea 2xx (3xx, 4xx, 5xx), o más de 10 segundos sin responder |
| Intentos por aviso | Hasta 8 |
| Esperas entre intentos | 1, 2, 4, 8, 16, 32 y 64 minutos |
| Último intento | Unos 127 minutos después del primero |
| Si los 8 fallan | Ese aviso se pierde. El pedido sigue igual: ponete al día con la receta de abajo |
| Cuándo se desactiva el destino | Después de 20 fallas seguidas, sumando todos sus avisos. Una entrega buena vuelve el contador a cero |
| Qué pasa al desactivarse | Te avisamos por mail. Un destino desactivado **no se reactiva**: borralo y creá otro desde el panel |
| El destino nuevo | Viene con un secreto nuevo, que tenés que cargar en tu servidor |

Además, tus destinos se desactivan junto con tus claves cuando cambia quién controla la cuenta. Ver [Cuándo se revocan solas](/api/claves-y-autenticacion#cuando-se-revocan-solas).

## Cómo ponerte al día después de una caída

Si tu servidor estuvo caído, o un destino se desactivó, no hace falta esperar avisos: reconstruí el estado consultando.

1. Guardá siempre el `created_at` del último pedido que tenés.
2. Pedí `GET /orders?created_after=<ese instante>` y seguí con `next_cursor` hasta que sea `null`. Son los pedidos que te faltan.
3. Para los pedidos que ya tenías, pedí `GET /orders?updated_after=<la última vez que te pusiste al día>` y seguí con `next_cursor` hasta que sea `null`. Actualizá los que tengan un `updated_at` más nuevo que el tuyo. Es una sola consulta paginada, en vez de un `GET /orders/:id` por pedido.

## Verificar la firma

Cada aviso trae tres cabeceras, con el formato [Standard Webhooks](https://www.standardwebhooks.com/):

| Cabecera | Valor |
| - | - |
| `webhook-id` | El id del evento |
| `webhook-timestamp` | El momento del intento, en segundos desde 1970 (UTC) |
| `webhook-signature` | `v1,<firma en base64>` |

La firma es un HMAC-SHA256 del texto `<webhook-id>.<webhook-timestamp>.<cuerpo crudo>`. La clave del HMAC es tu secreto, sin el prefijo `whsec_` y decodificado de base64.

Tres reglas:

* Usá el **cuerpo crudo**, tal como llegó. Si lo convertís a objeto y lo volvés a serializar, la firma no coincide.
* Rechazá el aviso si la firma no coincide.
* Rechazá el aviso si `webhook-timestamp` difiere en más de 5 minutos de tu reloj.

Ejemplo en Node.js. También sirve cualquier librería de Standard Webhooks. Devuelve `true` o `false` y nunca tira una excepción, así un encabezado raro termina en 401 y no en 500:

```js theme={null}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

function verifyGudinkWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (typeof id !== "string" || typeof timestamp !== "string" || typeof signatureHeader !== "string") return false;
  // Sólo dígitos: con "abc", Number() da NaN y la comparación de abajo daría false (pasaría sin mirar la hora).
  if (!/^\d{1,15}$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return signatureHeader.split(" ").some((part) => {
    const [version, signature] = part.split(",");
    if (version !== "v1" || typeof signature !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(signature)) return false;
    // Se comparan los BYTES decodificados, con el mismo largo: timingSafeEqual nunca recibe largos distintos.
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}
```

Dos detalles que el ejemplo ya cubre y que conviene copiar en cualquier lenguaje:

* El timestamp tiene que ser sólo dígitos antes de compararlo con tu reloj.
* La firma se compara como bytes decodificados de base64, con el mismo largo, en tiempo constante. Nunca como texto.

### Vector de prueba

Con estos valores tu verificación tiene que dar `true`. Si tu código mira la hora, fijá el reloj en ese timestamp; con la hora real, la ventana de 5 minutos lo rechaza, y eso también está bien.

| | |
| - | - |
| Secreto | `whsec_pkPZbQtP5BLxMICIC/btfPvvY1wSzLHCLFu/EdynkUM=` |
| `webhook-id` | `evt_test_3f9a1c7e5b2d8a4f6c0e1b9d` |
| `webhook-timestamp` | `1791120130` |
| Cuerpo crudo | `{"id":"evt_test_3f9a1c7e5b2d8a4f6c0e1b9d","type":"webhook.test","created_at":"2026-10-04T13:22:10.000Z"}` |
| `webhook-signature` esperada | `v1,cLcEaKkq4dm9INfGgPgBS6Vaj/e0wq/rwCcN0a80T2k=` |

Es el mismo [aviso de prueba](#el-aviso-de-prueba) de arriba. El secreto es sólo de ejemplo: no sirve para ningún destino real. Cambiá un carácter del cuerpo y tiene que dar `false`.

## Probar tu destino sin publicarlo

Dos formas, y conviene usar las dos:

1. **Sin red, firmando vos los avisos.** Tu verificación no sabe quién firmó: firmá un cuerpo de aviso con el secreto del [vector de prueba](#vector-de-prueba) (o con el de tu destino) y mandáselo a tu ruta local con cualquier cliente HTTP. Así probás la firma, el 401, la deduplicación por `webhook-id`, un timestamp viejo y cada `type`, en tus tests y sin internet:

   ```js theme={null}
   import crypto from "node:crypto";

   function signGudinkWebhook(rawBody, secret, id, timestamp = Math.floor(Date.now() / 1000)) {
     const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
     const signature = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
     return { "content-type": "application/json", "webhook-id": id, "webhook-timestamp": String(timestamp), "webhook-signature": `v1,${signature}` };
   }
   ```

   Con el cuerpo, el id y el timestamp del vector, `webhook-signature` tiene que dar exactamente la del vector.

2. **Con avisos reales.** Exponé tu servidor local con cualquier túnel `https` que te dé un nombre de host público en el puerto 443, dá de alta esa URL como destino en el panel de pruebas y tocá "Mandar prueba". Para ver los avisos de cada estado, hacé avanzar un pedido de prueba con la [simulación del entorno de pruebas](/api/entorno-de-pruebas#simular-el-recorrido-de-un-pedido). Cuando termines, borrá ese destino: un túnel apagado acumula fallas y el destino se desactiva.

## Si no querés usar avisos

No son obligatorios. Podés consultar cada cierto tiempo `GET /orders?updated_after=<tu última consulta>`, que trae en una sola consulta paginada los pedidos que cambiaron, o `GET /orders/:id` para los pedidos que todavía no terminaron. Respetá los [límites de uso](/api/errores-y-limites#limites) y dejá de consultar un pedido cuando llega a `delivered` o `cancelled`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.