> ## 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.

# Apps de envío

> Cómo una app cotiza, crea y cancela envíos para un comercio, y cómo informa el seguimiento.

Una app de envío le permite a un comercio ofrecer tu servicio de logística: tu app cotiza el envío en el checkout, crea y cancela los envíos cuando el comercio lo pide, y avisa cómo avanza la entrega.

La integración es **HTTP en los dos sentidos**:

* **Commercy te llama** a tres rutas de tu servidor (cotizar, crear y cancelar un envío). Cada request va firmado.
* **Vos llamás a la [API pública](https://docs.commercy.com.ar/api-reference.md)** para informar el seguimiento de la entrega.

No guardamos credenciales de tu servicio de logística: tu app identifica al comercio por la instalación y resuelve su cuenta con tu carrier en tu propio proceso de alta.

## Cómo se habilita

1. Pedí en tu app el scope **`write_orders`** (incluye `read_orders`). Sin `read_orders` no te mandamos cotizaciones ni altas, porque llevan datos del comprador. Para informar el seguimiento necesitás `write_orders`. Ver [Scopes y permisos](https://docs.commercy.com.ar/scopes.md).
2. Guardá el **secreto de webhook** de tu app (el `whsec_...` que ves en el portal): es con el que firmamos estos requests, el mismo de los [webhooks](https://docs.commercy.com.ar/webhooks.md). Tu app no necesita recibir eventos para ser carrier.
3. **Pedile a soporte que habilite tu app como carrier.** Pasale el id de tu app (el de la URL de tu app en el portal) y la **URL base de tu API** (`apiBaseUrl`): tiene que ser `https` y pública, por ejemplo `https://api.tuapp.com`. Esa base se concatena con las rutas de esta página.
4. El comercio instala la app. Desde ese momento la ofrecemos en **todas las tiendas** de ese comercio, siempre que su plan incluya las apps. Desinstalar la app es el interruptor para apagarla.

Mientras tu app esté en borrador, la habilitamos sólo en la tienda de desarrollo de tu cuenta, para que pruebes el flujo completo.

## Cómo te llamamos

Todas las operaciones son un `POST` con cuerpo JSON a `<apiBaseUrl><ruta>`.

* Usamos `https` hacia una dirección pública. **No seguimos redirects.**
* Leemos como máximo **64 KB** de tu respuesta. Si es más larga la tratamos como error, nunca como JSON parcial.
* Tiempos máximos: **3 segundos** para cotizar y **15 segundos** para crear o cancelar un envío.
* Respondé `2xx` con un cuerpo JSON. Cualquier otro código, un timeout o un JSON inválido cuentan como error.

### Headers

| Header | Contenido |
| - | - |
| `Content-Type` | `application/json` |
| `X-Commercy-Installation` | Id de la instalación (`installation_id`). |
| `X-Commercy-Timestamp` | Segundos Unix en que se firmó. Se genera de nuevo en cada request. |
| `X-Commercy-Signature` | Firma HMAC-SHA256 con prefijo `v1=`. |

El cuerpo de **todas** las operaciones lleva `installation_id` y `shop_id` (la sucursal de la tienda, el mismo `id` que devuelve `GET /shops`).

### Verificar la firma

Es el mismo esquema que los webhooks: `HMAC-SHA256(secreto, "<X-Commercy-Timestamp>.<cuerpo crudo>")` en hexadecimal y con el prefijo `v1=`, con tu secreto completo (incluido `whsec_`). Verificala siempre, porque cualquiera podría llamar a tus rutas:

1. Leé el **cuerpo crudo**, antes de parsear el JSON.
2. Comparala con `X-Commercy-Signature` en **tiempo constante**.
3. Rechazá timestamps que difieran **más de 5 minutos** de tu reloj.
4. Comprobá que el `installation_id` del cuerpo sea el de `X-Commercy-Installation`. La firma cubre el cuerpo, no la URL, así que el recurso viaja en el cuerpo.

```js theme={null}
// commercy-verify-carrier
const crypto = require("crypto");

const TOLERANCE_SECONDS = 300; // 5 minutos

// headers: nombres en minúscula (como los entrega Node/Express).
function verifyCommercyRequest(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 = { verifyCommercyRequest };
```

Con Express, usá el parser `raw` en esas rutas para conservar el cuerpo crudo:

```js theme={null}
const express = require("express");
const { verifyCommercyRequest } = require("./verify");

const app = express();

// Verifica la firma y que el cuerpo sea de la instalación del header. Deja el JSON en req.commercy.
function commercyAuth(req, res, next) {
  const raw = req.body.toString("utf8");
  if (!verifyCommercyRequest(raw, req.headers, process.env.COMMERCY_WEBHOOK_SECRET)) return res.sendStatus(401);
  const body = JSON.parse(raw);
  if (body.installation_id !== req.headers["x-commercy-installation"]) return res.sendStatus(401);
  req.commercy = body;
  next();
}

app.post("/commercy/shipping/quote", express.raw({ type: "application/json" }), commercyAuth, (req, res) => {
  // req.commercy.postal_code y req.commercy.items: calculá acá tus precios.
  res.json({ methods: [{ method_id: "standard", name: "Envío estándar", price: 4500, estimated_days: 3 }] });
});
```

## Cotizar un envío

Cuando el comprador carga su código postal en el checkout, te pedimos las opciones de envío.

```text theme={null}
POST <apiBaseUrl>/commercy/shipping/quote
```

```json theme={null}
{
  "installation_id": "6710b3c2e4a1f20012ab34cd",
  "shop_id": "6710b3c2e4a1f20012ab34d0",
  "postal_code": "1425",
  "items": [
    { "quantity": 2, "weight": { "value": 450, "unit": "g" }, "dimensions": { "length": 20, "width": 15, "height": 10, "unit": "cm" } }
  ]
}
```

* `postal_code` es el código postal de 4 dígitos que cargó el comprador.
* `items` trae un elemento por producto del carrito, con su `quantity`. `weight` (`g` o `kg`) y `dimensions` (`cm` o `m`) son los del producto y **faltan si el producto no los tiene**. `items` puede venir **vacío** (por ejemplo, cuando se cotiza desde el bot de WhatsApp): devolvé igual tus opciones.
* No mandamos valor declarado ni dirección de origen. Si los necesitás, leelos con la [API](https://docs.commercy.com.ar/api-reference.md) (`read_shops`, `read_orders`).

Respondé:

```json theme={null}
{
  "methods": [
    { "method_id": "standard", "name": "Envío estándar", "price": 4500, "estimated_days": 3 },
    { "method_id": "express", "name": "Entrega en el día", "price": 8200, "min_date": "2026-10-26", "max_date": "2026-10-26" }
  ]
}
```

| Campo | Regla |
| - | - |
| `method_id` | Obligatorio. Entre 1 y 40 caracteres: letras, números, `_` y `-`. Tiene que ser estable, porque es lo que vuelve en el alta. |
| `name` | Obligatorio, hasta 80 caracteres. Es lo que lee el comprador. |
| `price` | Obligatorio. Un **número** (no un texto) mayor o igual a 0, en pesos. Lo redondeamos a 2 decimales. |
| `estimated_days` | Opcional. Un texto de hasta 80 caracteres (`"2 a 4 días"`) o un entero entre 0 y 365. |
| `min_date`, `max_date` | Opcionales, fechas ISO 8601. Si venís sin `estimated_days` y mandás las dos, mostramos la ventana de entrega. |

Cómo tratamos tu respuesta:

* **Un método que no cumple las reglas se descarta**, y también uno repetido (mismo `method_id`). Los demás se ofrecen igual. Una respuesta sin la lista `methods` cuenta como error.
* **El precio que vio el comprador es el que se cobra.** Guardamos tu cotización **15 minutos** para ese carrito, tienda y código postal, y el confirmar la compra usa esa misma cotización. Sólo recotizamos si ya venció. Si al confirmar tu app ya no ofrece ese `method_id`, la compra se rechaza: nunca se cobra un envío de \$0.
* **Tu app puede quedar fuera del checkout por un rato.** Si no respondés a tiempo, la conexión falla o devolvés un `5xx`, dejamos de ofrecerte durante aproximadamente **1 minuto** y el checkout sigue sin tu app. Un `4xx` o una respuesta inválida no te saca: sólo esa cotización no aparece.
* El checkout nunca espera a tu app más de 3 segundos.

## Crear un envío

El comercio crea el envío desde la venta en Commercy («Enviar con tu app»). Sólo se puede cuando la venta es un envío a domicilio con dirección completa (calle y código postal) y no tiene ya otro envío vigente.

```text theme={null}
POST <apiBaseUrl>/commercy/shipping/shipments
```

```json theme={null}
{
  "installation_id": "6710b3c2e4a1f20012ab34cd",
  "shop_id": "6710b3c2e4a1f20012ab34d0",
  "idempotency_key": "6710d1e0e4a1f20012ab3600:1",
  "method_id": "standard",
  "order": {
    "id": "6710d1e0e4a1f20012ab3600",
    "number": "A-1042",
    "items": [
      { "name": "Remera lisa", "sku": "REM-001-M", "quantity": 2, "weight": { "value": 180, "unit": "g" } }
    ],
    "shipping_address": {
      "street": "Av. Santa Fe",
      "street_number": "1234",
      "apartment": "5B",
      "city": "CABA",
      "state": "Buenos Aires",
      "postal_code": "1425",
      "notes": "Timbre 5B"
    },
    "recipient": { "name": "Lucía Pérez", "phone": "+54 11 5555-1234", "email": "lucia@example.com" }
  }
}
```

* **`idempotency_key` es obligatoria y tenés que respetarla.** Tiene el formato `<id de la venta>:<n>`. **La key identifica un intento de envío, no la venta**: si no te llega respuesta (por ejemplo un timeout) y el comercio reintenta, la key es **la misma** y tenés que devolver **el mismo envío**, sin crear otro. Cuando un envío se cancela o se desvincula, el siguiente alta de esa venta trae una key **nueva** (`:2`, `:3`...) y ahí sí creás un envío nuevo.
* `method_id` es el método que eligió el comprador si la venta se cotizó con tu app. Si la venta se cotizó con otro carrier o se cargó a mano viene **`null`**: usá tu servicio por defecto.
* Los datos que no tenemos vienen `null` (`number`, `sku`, `name`, `apartment`, `notes`, `phone`, `email`...). El email del destinatario sólo está en las ventas de invitados.
* `items[].weight` y `items[].dimensions` siguen el mismo formato que en la cotización y faltan si el producto no los tiene.

Respondé:

```json theme={null}
{ "code": "CRC-889213", "tracking_number": "CRC889213AR", "tracking_url": "https://tuapp.com/seguimiento/CRC889213AR" }
```

| Campo | Regla |
| - | - |
| `code` | Obligatorio. Es el id del envío en tu sistema, entre 1 y 64 caracteres: letras, números, `_` y `-`. Es lo que recibís para cancelar. |
| `tracking_number` | Opcional, hasta 100 caracteres. |
| `tracking_url` | Opcional. Un link `https` de hasta 500 caracteres. |

**Una respuesta que no cumple las reglas es un error**: el comercio ve un aviso y puede reintentar. Como puede que ya hayas creado el envío, el reintento trae la misma `idempotency_key` y tenés que devolver el mismo `code`. Si el envío quedó creado en tu sistema pero Commercy no pudo guardarlo, le mostramos el `code` al comercio para que te avise.

Guardamos el `code` y el seguimiento (si lo devolviste) en la venta.

## Cancelar un envío

```text theme={null}
POST <apiBaseUrl>/commercy/shipping/shipments/cancel
```

```json theme={null}
{ "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0", "code": "CRC-889213" }
```

Respondé `{ "canceled": true }`. Cualquier otra respuesta, un error o un timeout dejan el envío **vigente** en Commercy y el comercio puede reintentar. Cancelar sólo se puede mientras tu app siga instalada.

## Informar el seguimiento

**No te consultamos el estado de los envíos: lo empujás vos** con la [API pública](https://docs.commercy.com.ar/api-reference.md#escrituras), con el token de acceso de la instalación y el scope `write_orders`. El `order.id` del alta es el id de la venta en la API.

* **`POST /orders/:id/status`** con `{ "status": "shipped" }` cambia el estado de la venta. Los estados son `confirmed`, `preparing`, `shipped` y `delivered`, y tienen que existir en el flujo de estados de la tienda.
  * **`delivered` cierra la venta**: descuenta stock, mueve la caja y genera el asiento contable. Mandalo sólo cuando el paquete se entregó.
  * Una venta cancelada, rechazada o devuelta no se reabre: responde `409 conflict` con `details[0].reason = "order_closed"`.
  * Si la entrega exige confirmar un cobro contra entrega, responde `409 conflict` con `details[0].reason = "payment_confirmation_required"`: esa confirmación la hace el comercio en Commercy.
* **`POST /orders/:id/fulfillment`** con `{ "tracking_number": "...", "tracking_url": "..." }` carga o actualiza el seguimiento de la venta. Si cambió el número o el link, usalo para corregirlos. El `tracking_url` puede ser `http` o `https`.

Los dos emiten `order.updated`. Ver los errores posibles en [Errores](https://docs.commercy.com.ar/errores.md).

## Qué pasa con la venta

* **Si el comercio cancela la venta, no cancelamos el envío en tu sistema.** Escuchá el webhook [`order.cancelled`](https://docs.commercy.com.ar/webhooks) y cancelalo vos.
* **Si el comercio desinstala tu app**, sus envíos vigentes quedan desvinculados en Commercy **sin llamar a tu app** (recibís el webhook `app.uninstalled`). El comercio ve el aviso para gestionarlos directamente con tu servicio.
* **Si la instalación queda revocada** (por ejemplo, porque se reutilizó un refresh token), los envíos **no** se desvinculan: el comercio sigue viendo el aviso de envío vigente y no puede cancelarlo desde Commercy hasta que reinstale la app.

## Checklist de salida

* [ ] Verificás la firma sobre el cuerpo crudo y que `installation_id` coincida con el header.
* [ ] Cotizás en menos de 3 segundos y con `price` numérico.
* [ ] El alta devuelve el mismo `code` para la misma `idempotency_key`.
* [ ] Cancelás el envío cuando recibís `order.cancelled`.
* [ ] Empujás `shipped`, `delivered` y el seguimiento con la API.

## Ejemplo

[`shipping-carrier`](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/shipping-carrier) es una app de envío de ejemplo, lista para copiar y adaptar.


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