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

# Webhooks

> Eventos, formato del cuerpo, reintentos y verificación de la firma.

Los webhooks te avisan cuando algo cambia en una tienda donde está instalada tu app, para que no tengas que consultar la API todo el tiempo.

## Cómo se entregan

Hacemos un `POST` con un cuerpo JSON a la **URL de webhook** de tu app.

* La URL tiene que ser `https` y pública. **No funciona con `localhost`**: para desarrollar usá un túnel (ngrok, cloudflared).
* Tenemos 5 segundos para recibir tu respuesta y **no seguimos redirects**.
* Cualquier respuesta `2xx` cuenta como recibido; cualquier otra cosa (o un timeout) se reintenta.
* El cuerpo pesa como máximo 256 KB. De tu respuesta leemos como mucho 1 KB, sólo para mostrártela en el log del portal.
* Respondé rápido y procesá después: guardá el evento y devolvé `200` antes de hacer trabajo pesado.

### Headers

| Header | Contenido |
| - | - |
| `Content-Type` | `application/json` |
| `X-Commercy-Event` | Tipo de evento, por ejemplo `order.updated`. |
| `X-Commercy-Event-Id` | Id único del evento. Es el mismo en todos los reintentos y replays: **usalo para deduplicar**. |
| `X-Commercy-Delivery` | Id de esta entrega. Cambia con cada replay. |
| `X-Commercy-Store` | Id de la tienda (`store_id`) donde ocurrió el evento. |
| `X-Commercy-Timestamp` | Segundos Unix en que se envió. Se genera de nuevo en cada intento. |
| `X-Commercy-Signature` | Firma HMAC-SHA256 con prefijo `v1=`. Ver [Verificar la firma](#verificar-la-firma). |

## Cuerpo del evento

```json theme={null}
{
  "id": "evt_01J9...",
  "type": "order.updated",
  "created_at": "2026-10-09T12:00:00.000Z",
  "store_id": "6710b3c2e4a1f20012ab34ce",
  "data": { "id": "6710d1e0e4a1f20012ab3600", "status": "confirmed" },
  "meta": { "status": "confirmed", "previous_status": "pending", "changed": ["status"] }
}
```

| Campo | Detalle |
| - | - |
| `id` | Id del evento (igual a `X-Commercy-Event-Id`). |
| `type` | Tipo de evento. |
| `created_at` | Cuándo ocurrió, en ISO 8601 UTC. |
| `store_id` | Id de la tienda. |
| `data` | El recurso **completo y actual**, con el mismo formato que devuelve la [API](/api). |
| `meta` | Datos extra del evento. Va al **nivel superior, junto a `data`** (no adentro), y aparece sólo en los eventos que lo traen. |

<Warning>
  `data` se arma en el momento de enviar, no cuando ocurrió el evento. Tratá cada webhook como un aviso de que "algo cambió": si dos cambios ocurren seguidos, los dos eventos pueden traer el mismo estado final. Si necesitás el estado exacto, pedilo por la API.
</Warning>

### Qué trae `meta` en cada evento

| Evento | `data` | `meta` |
| - | - | - |
| `order.created` | La venta. | `{ "status" }` |
| `order.updated` | La venta. | `{ "status", "previous_status", "changed": [...] }` si cambió el estado. Si cambió otra cosa (por ejemplo los datos de envío) trae `{ "status", "changed": [...] }`. |
| `order.paid` | La venta. | `{ "status", "paid_via" }` donde `paid_via` es `pos`, `card`, `online`, `terminal` o `delivery_confirmation`. |
| `order.cancelled` | La venta. | `{ "status", "previous_status", "changed": [...], "reason" }` con `reason` = `cancelled`, `rejected`, `returned` o `expired`. |
| `product.created`, `product.updated` | El producto. | No trae `meta`. |
| `product.deleted` | `{ "id" }` (el producto ya no existe). | `{ "sku" }` si el producto tenía SKU. |
| `products.bulk_updated` | `{ "count", "updated_since" }` | **No trae `meta`.** |
| `stock.changed` | La fila de stock (`shop_id`, `product_id`, `variant_id`, `sku`, `on_hand`, `available`). | No trae `meta`. |
| `customer.created`, `customer.updated` | El cliente. | No trae `meta`. |

Ejemplos:

```json theme={null}
{
  "id": "evt_01J9...",
  "type": "order.cancelled",
  "created_at": "2026-10-09T13:10:00.000Z",
  "store_id": "6710b3c2e4a1f20012ab34ce",
  "data": { "id": "6710d1e0e4a1f20012ab3600", "status": "cancelled" },
  "meta": { "status": "cancelled", "previous_status": "confirmed", "changed": ["status"], "reason": "cancelled" }
}
```

```json theme={null}
{
  "id": "evt_01J9...",
  "type": "product.deleted",
  "created_at": "2026-10-09T13:15:00.000Z",
  "store_id": "6710b3c2e4a1f20012ab34ce",
  "data": { "id": "6710c0d1e4a1f20012ab3500" },
  "meta": { "sku": "REM-001" }
}
```

```json theme={null}
{
  "id": "evt_01J9...",
  "type": "products.bulk_updated",
  "created_at": "2026-10-09T13:20:00.000Z",
  "store_id": "6710b3c2e4a1f20012ab34ce",
  "data": { "count": 12000, "updated_since": "2026-10-09T13:15:00.000Z" }
}
```

Cuando un cambio masivo toca más de 5.000 productos, en lugar de miles de `product.updated` mandamos un único `products.bulk_updated`. Es una señal para que vuelvas a sincronizar con `GET /products?updated_since=...`. Lo reciben las apps suscriptas a `product.updated`.

## Eventos disponibles

Elegís a qué eventos suscribirte en el portal. Para recibir un evento, tu instalación necesita el scope `read_` del recurso (ver [Scopes y permisos](/scopes)).

| Evento | Cuándo ocurre | Scope requerido |
| - | - | - |
| `order.created` | Se crea una venta. | `read_orders` |
| `order.updated` | Cambia una venta (estado, envío, etc.) sin ser una cancelación. | `read_orders` |
| `order.paid` | Se cobra una venta. Se emite **una vez por venta**, aunque el cobro se detecte por varias vías. Las ventas a cuenta corriente no lo emiten en v1. | `read_orders` |
| `order.cancelled` | Una venta pasa a cancelada, rechazada o devuelta. | `read_orders` |
| `product.created` | Se crea un producto. | `read_products` |
| `product.updated` | Se modifica un producto, incluidos sus precios. | `read_products` |
| `product.deleted` | Se da de baja un producto. | `read_products` |
| `products.bulk_updated` | Cambio masivo de más de 5.000 productos. | `read_products` |
| `stock.changed` | Cambia el stock físico o disponible de un producto o variante. | `read_stock` |
| `customer.created` | Se crea un cliente. | `read_customers` |
| `customer.updated` | Se modifica un cliente. | `read_customers` |
| `app.uninstalled` | El comerciante o la app desinstala la app. Obligatorio. | Ninguno |
| `store.redact` | Pasaron 48 horas de la desinstalación y la app no se reinstaló. Obligatorio. | Ninguno |
| `customers.redact` | El comerciante pide borrar los datos de un cliente. Obligatorio. | Ninguno |
| `customers.data_request` | Un cliente pide una copia de sus datos. Obligatorio. | Ninguno |

### Los 4 webhooks obligatorios

`app.uninstalled`, `store.redact`, `customers.redact` y `customers.data_request` **se entregan siempre**, sin que te suscribas y aunque tu instalación no tenga scopes de clientes. Una app sin URL de webhook no se puede publicar.

| Evento | `data` | `meta` | Qué tenés que hacer |
| - | - | - | - |
| `app.uninstalled` | `{ "installation_id", "app_id", "store_id", "status", "uninstalled_at" }` | `{ "app_id", "uninstalled_at", "uninstalled_by": "merchant" \| "app" }` | Dejá de sincronizar y descartá los tokens. Después de esto no te mandamos más eventos de esa tienda. |
| `store.redact` | La instalación, igual que arriba. | `{ "app_id", "uninstalled_at" }` | A las **48 horas** de desinstalar, si la tienda no reinstaló la app: **borrá todos los datos del comercio**. |
| `customers.redact` | `{ "customer_id", "email_hash" }` | No trae `meta`. | Borrá o anonimizá los datos de ese cliente. |
| `customers.data_request` | `{ "customer_id", "email_hash" }` | `{ "request_id", "requested_at" }` | Reuní los datos del cliente y entregáselos al comerciante. |

`email_hash` es el SHA-256 en hexadecimal del email en minúsculas (no mandamos el email en claro, porque estos eventos llegan a todas las apps). Es `null` si el email ya fue anonimizado. Si el cliente ya no existe, `data` es sólo `{ "id": "<id del cliente>" }`.

## Garantías

* **Al menos una vez.** Puede llegarte el mismo evento más de una vez. Deduplicá por `X-Commercy-Event-Id`.
* **Sin orden garantizado.** Un `order.updated` puede llegar antes que el `order.created` o después. Guiate por el estado que trae `data`, no por el orden de llegada.
* **Sin eco.** Los cambios que hace tu propia app por la API no te vuelven como webhook.

## Reintentos y baja automática

Si tu endpoint no responde `2xx`, reintentamos hasta **12 veces más** (13 intentos en total, durante unas 44 horas). Los tiempos de espera, con una variación aleatoria de ±10 %, son:

30 s, 1 min, 2 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h, 16 h

Después del último intento la entrega queda como `dead`. Si **todas** las entregas de tu app fallan durante 48 horas seguidas, damos de baja el webhook y te avisamos por email. Mientras está de baja no se envían eventos. Lo reactivás desde el portal, una vez que tu endpoint esté sano.

### Replay y log

En el portal ves el log de entregas de los últimos 30 días, con el código HTTP, el tiempo de respuesta y el inicio de tu respuesta. Desde ahí podés **reenviar** una entrega puntual o todas las fallidas de un rango de hasta 7 días. El replay conserva el mismo `X-Commercy-Event-Id` y genera un `X-Commercy-Delivery` nuevo.

### Ping de prueba

Desde el portal podés enviar un evento `ping` a tu URL. Viene firmado igual que los demás, con `store_id: null`, `data: { "app_id": "..." }` y sin el header `X-Commercy-Store`.

## Verificar la firma

Firmamos cada entrega para que puedas comprobar que viene de Commercy y que nadie la alteró. La firma es:

```text theme={null}
HMAC-SHA256(secreto_de_webhook, "<X-Commercy-Timestamp>.<cuerpo crudo>")
```

en hexadecimal y con el prefijo `v1=`. Para verificarla:

1. Leé el **cuerpo crudo** (los bytes tal como llegaron, **antes** de parsear el JSON). Si lo parseás y lo volvés a serializar, la firma no coincide.
2. Calculá la firma con tu secreto de webhook completo, incluido el prefijo `whsec_`.
3. Compará con `X-Commercy-Signature` con una comparación de **tiempo constante**.
4. Rechazá las entregas con un timestamp que difiera **más de 5 minutos** de tu reloj (protección contra replay).

<CodeGroup>
  ```js Node theme={null}
  // commercy-verify-node
  const crypto = require("crypto");

  const TOLERANCE_SECONDS = 300; // 5 minutos

  // headers: nombres en minúscula (como los entrega Node/Express).
  function verifyCommercyWebhook(rawBody, headers, secret, nowSec = Math.floor(Date.now() / 1000)) {
    const timestamp = headers["x-commercy-timestamp"];
    const signature = headers["x-commercy-signature"];
    if (!timestamp || !signature) return false;

    const ts = Number(timestamp);
    if (!Number.isInteger(ts) || Math.abs(nowSec - ts) > TOLERANCE_SECONDS) return false;

    const expected = "v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(String(signature));
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  module.exports = { verifyCommercyWebhook };
  ```

  ```php PHP theme={null}
  <?php
  function verify_commercy_webhook(string $raw, array $headers, string $secret, ?int $now = null): bool {
      $now = $now ?? time();
      $headers = array_change_key_case($headers, CASE_LOWER); // HTTP/2 manda los nombres en minúscula
      $ts = $headers['x-commercy-timestamp'] ?? '';
      $sig = $headers['x-commercy-signature'] ?? '';
      if ($ts === '' || $sig === '' || !ctype_digit($ts)) return false;
      if (abs($now - (int)$ts) > 300) return false;

      $expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
      return hash_equals($expected, $sig);
  }

  $raw = file_get_contents('php://input'); // cuerpo crudo
  $ok = verify_commercy_webhook($raw, getallheaders(), getenv('COMMERCY_WEBHOOK_SECRET'));
  http_response_code($ok ? 200 : 401);
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time
  from typing import Optional

  def verify_commercy_webhook(raw: bytes, headers, secret: str, now: Optional[int] = None) -> bool:
      ts = headers.get("X-Commercy-Timestamp", "")
      sig = headers.get("X-Commercy-Signature", "")
      if not ts.isdigit() or not sig:
          return False
      if abs((now if now is not None else int(time.time())) - int(ts)) > 300:
          return False
      expected = "v1=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, sig)

  # Flask: raw = request.get_data()  (cuerpo crudo, antes de request.json)
  ```
</CodeGroup>

Con Express, usá el parser `raw` **en esa ruta** para conservar el cuerpo crudo:

```js theme={null}
app.post("/webhooks/commercy", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyCommercyWebhook(raw, req.headers, process.env.COMMERCY_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(raw);
  // Guardá event.id y procesá después. Respondé rápido:
  res.sendStatus(200);
});
```

### Vector de prueba

Usalo para comprobar tu implementación sin esperar un webhook real. Con este secreto, timestamp y cuerpo, tu código tiene que generar exactamente la firma indicada.

| Dato | Valor |
| - | - |
| Secreto | `whsec_ejemplo_0123456789abcdef` |
| Timestamp | `1760011200` |
| Firma esperada | `v1=d3ea8d3f370970dc60882f38307b25b182aa262b4004145c7955f1940a0bd24c` |

Cuerpo (una sola línea, sin saltos ni espacios extra):

```text theme={null}
{"id":"evt_example_1","type":"order.updated","created_at":"2026-10-09T12:00:00.000Z","store_id":"6710b3c2e4a1f20012ab34ce","data":{"id":"6710d1e0e4a1f20012ab3600"},"meta":{"status":"confirmed","previous_status":"pending","changed":["status"]}}
```

## Rotación del secreto

Desde el portal podés rotar el secreto de webhook. Las entregas nuevas se firman con el secreto nuevo desde el momento de la rotación: **no hay un período con doble firma**. Actualizá el secreto en tu servidor apenas lo rotes; las entregas que fallen en el medio se reintentan y se firman con el secreto vigente.


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