# Referencia de la API Source: https://docs.commercy.com.ar/api-reference Endpoints, convenciones, paginación e idempotencia de la API pública v1. La API pública de Commercy es una API REST en JSON. Todos los ejemplos de esta página usan la versión `2026-10`. | Qué | Valor | | - | - | | URL base | `https://api.commercy.com.ar/api/public/2026-10` | | Autenticación | `Authorization: Bearer ` (ver [Autenticación OAuth](/oauth)) | | Formato | JSON, `Content-Type: application/json` en las escrituras | | Tamaño máximo del body | 1 MiB | | CORS | No tiene: llamá a la API desde tu servidor | ## Versionado La versión va en la URL y es una fecha (`2026-10`). Una versión nueva se publica como otro prefijo, sin romper la anterior, y cada versión se mantiene al menos 12 meses después de que sale la siguiente. Una versión inexistente responde `404 not_found`. ## Convenciones * Los nombres de campo son `snake_case`. * Los ids son strings. * Los montos son números en pesos argentinos y los recursos con importes traen `"currency": "ARS"`. * Las fechas son ISO 8601 en UTC, por ejemplo `2026-10-09T12:00:00.000Z`. * Los campos sin valor vienen como `null`. Los campos de costo sólo aparecen si tu app tiene el scope `read_costs`. * **Los parámetros y campos desconocidos se rechazan** con `400 invalid_request` (no se ignoran en silencio), para que no creas que aplicó un filtro que no existe. * Todas las respuestas traen el header `X-Request-Id`; mandalo a soporte si necesitás ayuda con un request puntual. * Un recurso de otro comercio responde `404 not_found`, igual que uno que no existe. Nunca confirmamos que existe. ## Respuestas Un recurso: ```json theme={null} { "data": { "id": "6710b3c2e4a1f20012ab34cf", "name": "Sucursal Centro" } } ``` Una colección: ```json theme={null} { "data": [ { "id": "..." } ], "next_cursor": "eyJrIjoiNjcxMGIz..." } ``` Los errores tienen su propio formato, descripto en [Errores](/errores). ## Paginación Las colecciones se paginan con cursor. Parámetros: | Parámetro | Detalle | | - | - | | `limit` | Entero de 1 a 100. Por defecto 50. | | `cursor` | El `next_cursor` de la página anterior. | Mientras `next_cursor` no sea `null`, hay más páginas. El cursor es opaco y está atado a los filtros con los que se pidió: si lo usás con otros filtros recibís `400 invalid_request`. Las listas de productos, ventas y clientes se ordenan por fecha de modificación ascendente, así que podés sincronizar de forma incremental guardando la última fecha vista y pidiendo `updated_since`. `updated_since` y `created_since` son fechas ISO 8601 e incluyen el instante indicado (`>=`). ## Idempotencia Mandá un header `Idempotency-Key` (1 a 255 caracteres ASCII imprimibles, por ejemplo un UUID) para que un reintento no repita la operación. * Es **obligatorio** en `POST /stock/adjustments` y opcional en el resto de los `POST`, `PUT` y `PATCH`. `DELETE` no usa idempotencia. * La clave vale 24 horas y se guarda por instalación. * Repetir la misma clave con el mismo body devuelve la respuesta original, con el header `Idempotent-Replayed: true`. * La misma clave con **otro body** responde `422 idempotency_conflict`. * Si el primer request todavía está en curso (hasta 60 segundos) responde `409 conflict`: esperá y reintentá. * Las respuestas `5xx` no se guardan, así que podés reintentar con la misma clave. ## Tienda ### `GET /store` Sin scope. Datos del comercio donde está instalada la app. ```json theme={null} { "data": { "id": "6710b3c2e4a1f20012ab34ce", "name": "Mi Tienda", "currency": "ARS", "plan_tier": "professional", "shops": [{ "id": "6710b3c2e4a1f20012ab34cf", "name": "Sucursal Centro" }] } } ``` `plan_tier` es `professional`, `enterprise`, `demo` o `developer`. ## Sucursales Scope `read_shops`. ### `GET /shops` y `GET /shops/:id` ```json theme={null} { "data": { "id": "6710b3c2e4a1f20012ab34cf", "name": "Sucursal Centro", "description": null, "image": null, "phone": "+5491155550000", "email": "centro@mitienda.com", "address": { "street": "Av. Corrientes", "street_number": 1234, "cross_streets": null, "city": "Buenos Aires", "postal_code": "C1043", "full": "Av. Corrientes 1234, Buenos Aires" }, "schedule": [{ "day": "monday", "open": "09:00", "close": "18:00" }], "is_active": true, "created_at": "2026-01-10T15:00:00.000Z", "updated_at": "2026-09-30T18:20:00.000Z" } } ``` La sucursal que no pertenece al comercio responde `404`. ## Categorías y marcas Scope `read_products`. `GET /categories` y `GET /brands` aceptan `limit` y `cursor`. ```json theme={null} { "id": "6710...", "name": "Remeras", "slug": "remeras", "parent_id": null, "depth": 0, "is_active": true, "image": null, "updated_at": "2026-09-01T10:00:00.000Z" } ``` Las marcas traen `id`, `name`, `slug`, `is_active`, `image` y `updated_at`. ## Productos Scope `read_products`. ### `GET /products` | Filtro | Detalle | | - | - | | `updated_since` | Sólo productos modificados desde esa fecha. | | `sku` | Productos cuyo SKU, o el de alguna variante, coincide. **El SKU no es único**: devuelve una lista. | | `category` | Id de una categoría del comercio. | | `brand` | Id de una marca del comercio. | | `limit`, `cursor` | Paginación. | No se listan los productos en borrador. Un `category` o `brand` que no es del comercio responde `404`. ### `GET /products/:id` Devuelve un producto con sus variantes. ```json theme={null} { "data": { "id": "6710c0d1e4a1f20012ab3500", "name": "Remera básica", "slug": "remera-basica", "description": "Algodón peinado", "short_description": null, "sku": "REM-001", "barcode": null, "type": "simple", "images": ["https://cdn.commercy.com.ar/..."], "category_ids": ["6710..."], "brand_ids": [], "tags": ["verano"], "shop_ids": ["6710b3c2e4a1f20012ab34cf"], "visibility": "PUBLIC", "is_active": true, "price": 12500, "currency": "ARS", "has_variants": true, "variant_axes": ["talle"], "variants": [ { "id": "6710c0d1e4a1f20012ab3501", "sku": "REM-001-M", "barcode": null, "name": "M", "attributes": { "talle": "M" }, "images": [], "price": 12500, "compare_at_price": null, "is_active": true, "position": 0 } ], "iva_rate": 21, "weight": { "value": 200, "unit": "g" }, "created_at": "2026-01-10T15:00:00.000Z", "updated_at": "2026-09-30T18:20:00.000Z" } } ``` `price` es el precio base. Para el precio que realmente paga un cliente en una sucursal y lista de precios usá `GET /prices`. Con el scope `read_costs` el producto trae además `cost`, y cada variante su propio `cost`. ## Precios Scope `read_prices`. ### `GET /prices` Precio **resuelto** de cada producto y variante en una sucursal, con el mismo motor que usa el punto de venta. | Parámetro | Detalle | | - | - | | `shop` | **Obligatorio.** Id de una sucursal del comercio. | | `price_list` | Id de una lista de precios. Si no la mandás se usa la lista por defecto de la sucursal. | | `product` | Limita el resultado a un producto. | | `limit`, `cursor` | En este endpoint `limit` va de 1 a **25** (por defecto 25). Una página puede traer menos productos si tienen muchas variantes. | ```json theme={null} { "data": [ { "product_id": "6710c0d1e4a1f20012ab3500", "shop_id": "6710b3c2e4a1f20012ab34cf", "price_list_id": null, "price": 12500, "original_price": 12500, "source": "base", "currency": "ARS", "variants": [ { "variant_id": "6710c0d1e4a1f20012ab3501", "price": 12500, "original_price": 12500, "source": "base" } ] } ], "next_cursor": null } ``` `price_list_id` solo viene informado si el comercio usa listas de precios. `source` indica de dónde sale el precio resuelto. ## Stock Scope `read_stock`. ### `GET /stock` Una fila por producto o, si tiene variantes, por variante. | Parámetro | Detalle | | - | - | | `shop` | **Obligatorio.** Id de una sucursal del comercio. | | `product` | Limita el resultado a un producto. | | `limit`, `cursor` | La paginación es por producto: una página puede traer más filas que `limit` si hay variantes. | ```json theme={null} { "data": [ { "shop_id": "6710b3c2e4a1f20012ab34cf", "product_id": "6710c0d1e4a1f20012ab3500", "variant_id": "6710c0d1e4a1f20012ab3501", "sku": "REM-001-M", "on_hand": 14, "available": 12 } ], "next_cursor": null } ``` `on_hand` es el stock físico y `available` lo que se puede vender (descontadas las reservas). Nunca se exponen lotes, costos ni proveedores. ## Ventas Scope `read_orders`. Sólo se listan las **ventas** del comercio; las compras a proveedores no aparecen. ### `GET /orders` | Filtro | Detalle | | - | - | | `status` | Estado de la venta (`pending`, `confirmed`, `delivered`, etc.; depende del flujo de estados de la tienda). | | `updated_since`, `created_since` | Fechas ISO 8601. | | `shop` | Id de una sucursal del comercio. | | `limit`, `cursor` | Paginación. | ### `GET /orders/:id` El `:id` acepta el `id` de la venta o su `order_number`. ```json theme={null} { "data": { "id": "6710d1e0e4a1f20012ab3600", "order_number": "A-000123", "status": "confirmed", "source": "storefront", "shop_id": "6710b3c2e4a1f20012ab34cf", "seller_shop_ids": ["6710b3c2e4a1f20012ab34cf"], "customer": { "id": "6710...", "name": "Ana Pérez", "email": "ana@example.com", "phone": "+5491155551111" }, "line_items": [ { "product_id": "6710c0d1e4a1f20012ab3500", "variant_id": "6710c0d1e4a1f20012ab3501", "sku": "REM-001-M", "name": "Remera básica", "quantity": 2, "unit_price": 12500, "total": 25000, "is_custom": false } ], "total": 25000, "discount": 0, "currency": "ARS", "payment": { "gateway": "mercadopago", "status": "approved" }, "shipping": { "method_type": "delivery", "method_name": "Envío a domicilio", "price": 2500, "address": { "street": "Calle Falsa", "street_number": "123", "apartment": null, "city": "CABA", "state": "Buenos Aires", "postal_code": "C1000", "notes": null }, "recipient_name": "Ana Pérez", "recipient_phone": "+5491155551111", "recipient_document": "30111222", "carrier": null, "tracking_number": null, "tracking_url": null }, "created_at": "2026-10-09T12:00:00.000Z", "updated_at": "2026-10-09T12:05:00.000Z" } } ``` Con el scope `read_costs` cada línea trae además `cogs` (costo de la mercadería vendida). `customer` es `null` si la venta no tiene un comprador identificable. ## Clientes Scope `read_customers`. Sólo los clientes del comercio: no incluye al equipo ni a los contactos que todavía no son clientes. ### `GET /customers` y `GET /customers/:id` | Filtro | Detalle | | - | - | | `updated_since` | Fecha ISO 8601. | | `email` | Coincidencia exacta (sin distinguir mayúsculas). | | `limit`, `cursor` | Paginación. | ```json theme={null} { "data": { "id": "6710e2f1e4a1f20012ab3700", "name": "Ana Pérez", "email": "ana@example.com", "phone": "+5491155551111", "document": { "type": "DNI", "number": "30111222" }, "customer_type": "individual", "tags": ["mayorista"], "shipping_addresses": [ { "street": "Calle Falsa", "street_number": "123", "apartment": null, "city": "CABA", "state": "Buenos Aires", "postal_code": "C1000", "notes": null } ], "created_at": "2026-03-01T10:00:00.000Z", "updated_at": "2026-09-01T10:00:00.000Z" } } ``` `document` solo viene informado para un DNI. Los datos fiscales (CUIT, CUIL) nunca se exponen. ## Escrituras Todas requieren el scope `write_*` correspondiente y devuelven el recurso actualizado en el mismo formato que la lectura. Los `POST` que crean un recurso responden `201`. ### Productos (`write_products`) * `POST /products` crea un producto. **No acepta precio ni costo** (ni en el producto ni en sus variantes): la API responde `400` si los mandás. El producto nace con precio 0 y visibilidad interna; asignale el precio con `PUT /prices/:productId` y publicalo después con `PATCH`. * `PATCH /products/:id` edita los datos del producto. En las variantes sólo se editan las existentes (por `id`); las variantes nuevas se crean únicamente al crear el producto. * `DELETE /products/:id` da de baja el producto. Si tiene reservas vigentes responde `409 conflict` con `details[0].reason = "has_reservations"`. ### Precios (`write_prices`) `PUT /prices/:productId` es el único camino para cambiar precios. Body: ```json theme={null} { "price": 13000, "variants": [{ "id": "6710c0d1e4a1f20012ab3501", "price": 13000 }], "expected": { "price": 12500 } } ``` Cambia el precio base del producto y/o de sus variantes (mayores a 0). Con `expected` hacés una escritura condicional: si el precio actual no es el esperado responde `409 conflict`. En productos con variantes no se acepta `price` a nivel producto: el precio base se deriva de las variantes. ### Stock (`write_stock`) `POST /stock/adjustments` (con `Idempotency-Key` obligatorio): ```json theme={null} { "shop": "6710b3c2e4a1f20012ab34cf", "product": "6710c0d1e4a1f20012ab3500", "variant": "6710c0d1e4a1f20012ab3501", "delta": -2, "reason": "Sincronización ERP" } ``` Usá `delta` (entero distinto de 0, suma o resta) **o** `set` (cantidad absoluta), nunca los dos. `variant` es obligatorio si el producto tiene variantes. Si el ajuste dejaría el stock en negativo responde `409 conflict` con `details[0].reason = "insufficient_stock"`. Devuelve la fila de stock actualizada. ### Ventas (`write_orders`) * `POST /orders/:id/status` con `{ "status": "...", "notes": "..." }` cambia el estado. Los estados permitidos son `confirmed`, `preparing`, `shipped` y `delivered`, y tienen que existir en el flujo de estados de la tienda. Nunca se puede cancelar, rechazar ni devolver una venta desde la API. Si la entrega exige confirmar el cobro (por ejemplo un retiro pagado en efectivo), responde `409 conflict` con `details[0].reason = "payment_confirmation_required"`: esa confirmación se hace desde Commercy. * `POST /orders/:id/fulfillment` con `{ "tracking_number": "...", "tracking_url": "..." }` carga los datos de seguimiento. Si la venta no tiene datos de envío responde `409 conflict` con `details[0].reason = "no_shipping"`. * `POST /orders/:id/payment` informa que tu plataforma cobró la venta. Ver [Informar un cobro](#informar-un-cobro). ### Informar un cobro `POST /orders/:id/payment` le avisa a Commercy que tu plataforma ya cobró una venta. Necesita el scope `write_orders` y el mismo permiso del comerciante que `POST /orders/:id/status`. Responde `200` con la venta, igual que el resto de las escrituras de ventas. ```json theme={null} { "amount": 25000, "external_id": "pay_8f3a21", "paid_at": "2026-10-09T12:04:00.000Z" } ``` | Campo | Qué es | | - | - | | `amount` | Obligatorio. Lo que cobraste, mayor a 0 y con hasta 2 decimales. Tiene que ser **igual al total** de la venta: no hay cobros parciales ni señas. | | `external_id` | Obligatorio, hasta 120 caracteres. El id del cobro en tu plataforma: lo elegís vos. Commercy lo guarda con tu app como prefijo y no lo expone en la API. | | `paid_at` | Opcional, fecha ISO 8601. Si no va, o si es futura, se usa el momento del request. | Qué hace Commercy: 1. **Registra el cobro** en la venta. En la API pública `payment.status` pasa a `approved`. 2. **Emite `order.paid`** con `paid_via: "app"` (ver [Webhooks](/webhooks)). Tu app no recibe su propio evento. 3. **Confirma la venta** si estaba `pending`: pasa al primer paso del flujo de estados de la tienda, que no es necesariamente `confirmed`. Si la venta estaba en otro estado, no se toca. Registrar el cobro **no mueve plata ni cambia el medio de pago de la venta**: Commercy sólo deja constancia de que tu app lo cobró. Lo mismo vale al revés: si la venta se cancela, la devolución la hacés vos en tu plataforma (recibís `order.cancelled`). Este endpoint no sirve para informar reembolsos: la API pública no los recibe. #### Respuestas | Código | Cuándo | Qué hacer | | - | - | - | | `200` | Cobro registrado y venta confirmada. | Nada. | | `200` con `status: "pending"` y `payment.status: "approved"` | El cobro quedó registrado, pero la venta no se pudo confirmar (por ejemplo, falta stock o el flujo de la tienda no lo permite). Commercy le avisa al comercio y la confirma a mano. | **No reintentes**: el cobro ya está. | | `200` en un reintento | Mandaste otra vez el mismo `external_id` para una venta ya cobrada por tu app. No se registra ni se emite nada de nuevo. Si la venta seguía `pending`, se vuelve a intentar confirmarla. | Nada. | | `409` | La venta no admite el cobro. El motivo viene en `details[0].reason` (ver abajo). | Corregí el dato o dejá de insistir. | | `5xx` | Falló algo transitorio (por ejemplo, otro request tuyo estaba confirmando la misma venta en ese momento). | Reintentá con **el mismo** `external_id`. | Motivos del `409 conflict`: | `reason` | Cuándo | | - | - | | `already_paid` | La venta ya tiene un cobro registrado con otro id, o el cobro ya se confirmó al entregarla. | | `online_payment_in_progress` | La venta tiene otro cobro online en curso (por ejemplo, un link de pago todavía vigente). Si el intento anterior venció, sí se acepta. | | `external_id_in_use` | Ese `external_id` ya se usó para el cobro de otra venta del comercio. | | `amount_mismatch` | `amount` no coincide con el total de la venta o, en un reintento, con el monto del cobro ya registrado con ese `external_id`. | | `payment_method_not_supported` | El medio de pago de la venta es efectivo, cobro con lector, cuenta corriente o pago dividido en varios medios. | | `shared_order` | La venta es compartida con otras tiendas y tu app no puede ver el total completo. | | `order_closed` | La venta está cancelada, rechazada o devuelta, o tiene una devolución parcial. | Para no cobrar dos veces por error, mandá siempre el mismo `external_id` para el mismo cobro. El header `Idempotency-Key` también sirve (ver la sección Idempotencia más arriba), pero es opcional y la protección no depende de él. Algunos detalles: * **Ventas del POS ya cobradas.** Una venta de mostrador que no es a cuenta corriente ya emitió `order.paid` (con `paid_via: "pos"`) al crearse. Sobre esas ventas el endpoint sólo atribuye el cobro a tu app, para conciliar, y no emite un evento nuevo. * **`payment.status` en ventas sin pago online.** Vale `pending` en toda venta que no tiene un pago online registrado, aunque se haya cobrado en el local. No lo uses para saber si una venta está cobrada si no fue por un medio online o por tu app. ### Clientes (`write_customers`) * `POST /customers` con `name` (obligatorio), `email`, `phone`, `document` (`{ "type": "DNI", "number": "..." }`) y `shipping_addresses`. * `PATCH /customers/:id` con los mismos campos, todos opcionales. Solo se admite `DNI` como documento: los datos fiscales no se gestionan por la API. ### `DELETE /installation` Sin scope. La app se desinstala a sí misma. Responde `{ "data": { "uninstalled": true } }` y el access token deja de valer en el siguiente request. # Errores Source: https://docs.commercy.com.ar/errores Formato de error de la API, códigos y qué hacer con cada uno. ## Formato Todos los errores de la API pública tienen la misma forma: ```json theme={null} { "error": { "code": "invalid_request", "message": "Request inválida", "details": [{ "field": "query.limit", "message": "limit must be less than or equal to 100" }], "request_id": "0b6f6d3e-6c0e-4c0b-9a5a-6a8f6d1c2a10" } } ``` | Campo | Detalle | | - | - | | `code` | Código estable en texto. Programá contra este campo, no contra `message`. | | `message` | Descripción para humanos. Puede cambiar. | | `details` | Lista con información extra (siempre es un arreglo, puede estar vacío). Su forma depende del código. | | `request_id` | Id del request. Es el mismo valor del header `X-Request-Id`. | **Mandá siempre el `request_id` a soporte** cuando reportes un problema: nos permite encontrar el request exacto. ## Códigos | Código | Status | Cuándo pasa | Qué hacer | | - | - | - | - | | `invalid_request` | 400 | El request está mal armado: JSON inválido, un campo o parámetro que no existe, un valor fuera de rango (por ejemplo `limit`), un cursor inválido o de otros filtros, un body de más de 1 MiB, o falta el `Idempotency-Key` donde es obligatorio. | Corregí el request. `details` trae `{ field, message }` por cada problema. No reintentes igual. | | `invalid_token` | 401 | Falta el header `Authorization: Bearer`, el access token es inválido o venció, la instalación ya no está activa (desinstalada o revocada), o la app no está disponible para esa tienda. | Renová el access token con el refresh token. Si el refresh también falla, la tienda tiene que volver a autorizar la app. | | `insufficient_scope` | 403 | La instalación no tiene el scope que pide el endpoint. `details` trae `{ "required_scope": "..." }`. | Pedí el scope en el portal y pedile al comerciante que vuelva a autorizar (ver [Scopes y permisos](/scopes)). | | `plan_required` | 403 | El plan actual del comercio no incluye apps, o llegó a un tope de su plan (por ejemplo la cantidad de productos). | Si el comercio bajó de plan, **la instalación sigue**, pero la API y los webhooks no obligatorios quedan pausados hasta que vuelva a un plan que incluya apps. Avisale al comerciante. | | `account_suspended` | 403 | La cuenta del comercio está suspendida o con el medio de pago pendiente. | No reintentes. La operatoria vuelve cuando el comercio regulariza su cuenta. | | `not_found` | 404 | El recurso no existe, **o pertenece a otro comercio** (nunca confirmamos que existe), o la ruta o la versión de la API no existe. | Revisá el id y la URL. Un id de otro comercio es indistinguible de uno inexistente. | | `conflict` | 409 | El request choca con el estado actual: un request con el mismo `Idempotency-Key` todavía en curso, o una regla de negocio (por ejemplo una entrega que exige confirmar el cobro). `details[0].reason` indica el motivo. | Leé `details`, resolvé el conflicto y reintentá. Si es un `Idempotency-Key` en curso, esperá unos segundos y reenviá lo mismo. | | `idempotency_conflict` | 422 | Reusaste un `Idempotency-Key` con un body distinto al del primer request. | Generá una clave nueva para cada operación distinta. | | `rate_limited` | 429 | Superaste el límite de requests de tu app para esa tienda. | Esperá lo que indica el header `Retry-After` y reintentá con backoff (ver [Límites de uso](/rate-limits)). | | `internal_error` | 500 | Falló algo de nuestro lado. | Reintentá con backoff exponencial. Si persiste, escribinos con el `request_id`. | ## Reintentos Reintentá sólo lo que tiene sentido: * **Sí**: `429` (respetando `Retry-After`), `500`, y `409` por idempotencia en curso. * **Sí, después de renovar el token**: `401 invalid_token`. * **No**: `400`, `403`, `404`, `422`. Repetir el mismo request da el mismo error. Para las escrituras, mandá un `Idempotency-Key` y reintentá con la misma clave: si el primer intento sí llegó a aplicarse, el reintento devuelve la respuesta original en lugar de repetir la operación (ver [Idempotencia](/api-reference#idempotencia)). ## Errores del endpoint de token Los endpoints de OAuth (`/oauth/apps/token` y `/oauth/apps/revoke`) **no** usan este formato. Siguen el RFC 6749: ```json theme={null} { "error": "invalid_grant" } ``` Los códigos son `invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_grant_type` y `server_error`. Los detalles están en [Autenticación OAuth](/oauth#errores-del-endpoint-de-token). # Desarrollá con IA Source: https://docs.commercy.com.ar/ia Cómo darle esta documentación a Claude, Codex o Cursor para armar tu app, con un prompt listo. Podés armar tu app de Commercy con un asistente de IA (Claude Code, Codex, Cursor u otro) dándole la documentación como contexto. La forma tradicional sigue igual: estas mismas páginas son la referencia, y la IA lee exactamente el mismo contenido en markdown plano. ## Qué le podés dar a la IA | Qué | URL | Para qué | | - | - | - | | Toda la documentación | `https://docs.commercy.com.ar/llms-full.txt` | Un solo archivo con el contexto, el checklist de punta a punta, todas las páginas y las reglas para asistentes. Es lo recomendado. | | Índice | `https://docs.commercy.com.ar/llms.txt` | Lista de páginas con una línea de descripción, en el formato llms.txt. | | Una página | `https://docs.commercy.com.ar/oauth.md` | Cualquier página de la documentación en markdown plano: cambiá `oauth` por el nombre de la página. | ## Cómo dárselo * **Pasale la URL.** En Claude Code, Codex o Cursor pegá `https://docs.commercy.com.ar/llms-full.txt` en el primer mensaje y pedile que la lea antes de escribir código. * **Copialo y pegalo.** En cualquier página de la documentación usá el menú **Copy page** (arriba a la derecha): deja la página que estás viendo en el portapapeles, lista para pegar en el chat. Para toda la documentación de una vez, usá la URL de `llms-full.txt`. * **Dejalo en el proyecto.** Si trabajás con un agente de código, guardá el archivo en tu repositorio (por ejemplo `docs/commercy.md`) y mencionalo en las instrucciones del agente. ## Prompt de arranque Copiá y completá lo que está entre corchetes: ```text theme={null} Vas a construir una app de Commercy. Antes de escribir código, leé la documentación completa en https://docs.commercy.com.ar/llms-full.txt y seguí sus reglas para asistentes de IA. Qué quiero construir: [descripción de la app, por ejemplo "sincronizar el stock con mi ERP"]. Scopes que necesita (los mínimos): [por ejemplo read_products, write_stock]. Stack: [lenguaje y framework del servidor]. Reglas: - Usá sólo los endpoints documentados; si algo no está en la documentación, preguntame. - OAuth con PKCE (S256) en el servidor, con el refresh token rotativo de un solo uso. - Los secretos (client secret, secreto de webhook, tokens) van en variables de entorno, nunca en el código. - Implementá los 4 webhooks obligatorios y verificá la firma con el cuerpo crudo y el timestamp. - Mandá Idempotency-Key en las escrituras y respetá Retry-After ante un 429. Empezá con un plan corto de los pasos y esperá mi OK antes de implementar. ``` ## Recomendaciones * **Probá en una tienda demo.** Creá una desde el portal (ver [Primeros pasos](/quickstart)) y apuntá ahí a la IA mientras desarrollás. Datos ficticios, cero riesgo para un comercio real. * **Pedí los scopes mínimos.** Decile a la IA exactamente qué scopes puede usar; cada scope extra se revisa y se le muestra al comerciante (ver [Scopes y permisos](/scopes)). * **Verificá la firma de los webhooks.** Es lo que más se omite o se hace mal: tiene que calcularse sobre el cuerpo crudo, con el timestamp, en tiempo constante (ver [Webhooks](/webhooks#verificar-la-firma)). Probala con el vector de prueba de esa página. * **No pegues secretos en el chat.** Ni el client secret ni el secreto de webhook ni tokens: pasalos por variables de entorno. * **Revisá lo que genera.** Contrastá los endpoints y los campos con la [referencia de la API](/api-reference) y corré el flujo completo (instalar, llamar a la API, recibir un webhook) antes de enviar a revisión. > [!NOTE] > > Si la IA inventa un endpoint o un campo, pedile que cite la página de la documentación de donde lo sacó. La API rechaza los parámetros desconocidos con `400 invalid_request`. # Introducción Source: https://docs.commercy.com.ar/introduction Armá apps para Commercy con la API pública, OAuth y webhooks. Las apps de Commercy se conectan a la tienda de un comerciante a través de la API pública y de los webhooks. Cada app se instala con OAuth 2.0 (authorization code con PKCE): el comerciante ve en una pantalla de consentimiento qué permisos (scopes) pide tu app y puede aceptar o rechazar la instalación. La API pública es una API REST en JSON, versionada por fecha en la URL. Según los scopes que tenga tu app, podés consultar y modificar datos de la tienda como sucursales, productos, precios, stock, ventas y clientes. Con los webhooks recibís un aviso cuando algo cambia en una tienda donde está instalada tu app, para no tener que consultar la API todo el tiempo. Antes de aparecer en la Tienda de apps de Commercy, cada app pasa por una revisión manual del equipo, que responde en hasta 10 días hábiles. Commercy no cobra comisión por las apps: 0 %. Todo lo que probás en borrador funciona en tu propia tienda y en tus tiendas demo, sin necesidad de un plan pago. ## Empezá > **[Primeros pasos](/quickstart)** > > Creá tu app, instalala en una tienda y hacé tu primer request en minutos. > **[Desarrollá con IA](/ia)** > > Cómo darle esta documentación a Claude, Codex o Cursor para armar tu app, con un prompt listo. ## Explorá > **[Autenticación OAuth](/oauth)** > > Authorization code con PKCE, tokens de acceso, refresh rotativo y revocación. > **[Scopes y permisos](/scopes)** > > Qué datos puede ver y modificar tu app, y cómo se piden. > **[Referencia de la API](/api)** > > Endpoints, convenciones, paginación e idempotencia de la API pública v1. > **[Webhooks](/webhooks)** > > Eventos, formato del cuerpo, reintentos y verificación de la firma. > **[Errores](/errores)** > > Formato de error de la API, códigos y qué hacer con cada uno. > **[Límites de uso](/rate-limits)** > > Cuántas requests podés hacer, cómo leer los headers y cómo reintentar. > **[Revisión y publicación](/revision)** > > Cómo revisamos las apps, el checklist y los términos del programa. # Autenticación OAuth Source: https://docs.commercy.com.ar/oauth Authorization code con PKCE, tokens de acceso, refresh rotativo y revocación. Las apps de Commercy usan OAuth 2.0 con **authorization code + PKCE (S256)** y un **cliente confidencial**: tu servidor guarda el `client_secret` y nunca lo expone a un navegador. | Qué | Valor | | - | - | | Endpoint de autorización | `GET https://api.commercy.com.ar/oauth/apps/authorize` | | Endpoint de token | `POST https://api.commercy.com.ar/oauth/apps/token` | | Endpoint de revocación | `POST https://api.commercy.com.ar/oauth/apps/revoke` | | Access token | JWT, dura **1 hora** | | Refresh token | Opaco, dura **90 días**, de **un solo uso** (rotativo) | | Code de autorización | Dura **60 segundos**, de un solo uso | > [!WARNING] > > Estos endpoints no tienen CORS a propósito: hay que llamarlos desde tu servidor. Un access token o un client secret nunca deben vivir en un navegador. ## Paso 1: mandá al comerciante a autorizar Generá un `code_verifier` aleatorio (43 a 128 caracteres de `A-Z a-z 0-9 - . _ ~`), calculá su `code_challenge` (SHA-256 en base64url, sin padding) y guardá el verifier y el `state` en la sesión del comerciante. Parámetros de `GET /oauth/apps/authorize`: | Parámetro | Obligatorio | Detalle | | - | - | - | | `client_id` | Sí | El `app_...` de tu app. | | `redirect_uri` | Sí | Debe coincidir **exactamente** con una de las registradas en tu app. | | `response_type` | Sí | Siempre `code`. | | `state` | Sí | Valor aleatorio tuyo; lo verificás al volver (protección CSRF). | | `code_challenge` | Sí | 43 caracteres base64url del SHA-256 del verifier. | | `code_challenge_method` | Sí | Siempre `S256`. | | `scope` | No | Scopes separados por espacios. Tienen que ser un subconjunto de los que pidió tu app. Si no lo mandás, se piden todos los de tu app. | Commercy muestra la pantalla de consentimiento (la solicitud vence a los 10 minutos). Quien autoriza tiene que ser el dueño de la tienda (el usuario con rol Admin de la cuenta principal), no un usuario de su equipo. Al terminar: * **Si acepta**, redirige a tu `redirect_uri` con `?code=...&state=...`. * **Si rechaza**, redirige con `?error=access_denied&state=...`. * **Si algún parámetro es inválido** pero el `client_id` y la `redirect_uri` son correctos, redirige a tu `redirect_uri` con `?error=...&error_description=...&state=...` (por ejemplo `invalid_request`, `invalid_scope` o `unsupported_response_type`). * **Si el `client_id` o la `redirect_uri` son inválidos**, nunca redirigimos a la URL recibida: el comerciante ve una pantalla de error en Commercy. Verificá siempre que el `state` de la vuelta sea el que mandaste. ## Paso 2: canjeá el code por tokens `POST /oauth/apps/token`. Autenticá tu app con `client_secret_basic` (header `Authorization: Basic base64(client_id:client_secret)`) o `client_secret_post` (`client_id` y `client_secret` en el cuerpo). El cuerpo puede ser `application/x-www-form-urlencoded` o JSON. | Campo | Detalle | | - | - | | `grant_type` | `authorization_code` | | `code` | El code recibido. | | `code_verifier` | El verifier original. | | `redirect_uri` | La misma que usaste en `/authorize`. | Respuesta `200`: ```json theme={null} { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "9f2c8a...", "scope": "read_shops read_products read_orders", "installation_id": "6710b3c2e4a1f20012ab34cd", "store": { "id": "6710b3c2e4a1f20012ab34ce", "name": "Mi Tienda" } } ``` El canje es lo que **crea la instalación**. `installation_id` identifica la instalación de tu app en esa tienda y `store.id` es el comercio. Guardalos junto con los tokens, cifrados. Si el comerciante vuelve a autorizar tu app mientras ya está instalada, la instalación conserva y amplía sus scopes (nunca se reducen en silencio) y los refresh tokens anteriores quedan invalidados. ## Paso 3: usá y renová el access token Mandá el access token en cada request a la API: `Authorization: Bearer `. Cuando venza (o recibas `401 invalid_token`), renovalo con el refresh token: | Campo | Detalle | | - | - | | `grant_type` | `refresh_token` | | `refresh_token` | El refresh token vigente. | La respuesta tiene el mismo formato que el canje y trae un **refresh token nuevo**. Guardalo y descartá el anterior. ### Refresh rotativo y reuso Cada refresh token se puede usar **una sola vez**. Si alguien presenta un refresh token que ya se usó, lo tratamos como una posible filtración: revocamos toda la familia de tokens y la instalación pasa a estar revocada, así que **el comerciante tiene que reinstalar la app**. Excepción: si el token usado se vuelve a presentar dentro de los **10 segundos**, respondemos `invalid_grant` pero **no** revocamos. Eso cubre el caso típico de dos workers tuyos refrescando a la vez. Aun así, serializá los refresh: un solo proceso renueva y los demás esperan. ## Revocar tokens `POST /oauth/apps/revoke` con la misma autenticación de cliente y el campo `token` (un refresh token o un access token). Sigue el RFC 7009: responde `200 {}` aunque el token no exista. Revocar invalida los tokens de la instalación (la API empieza a responder `401 invalid_token`), pero no la desinstala: para volver a operar el comerciante tiene que autorizar de nuevo. ## Desinstalación El comerciante puede desinstalar tu app desde su panel de Commercy. Tu app recibe el webhook obligatorio `app.uninstalled`, y a las 48 horas `store.redact` si no se reinstaló (ver [Webhooks](/webhooks)). ## Cuando necesitás más scopes Si ampliás los scopes de tu app, el cambio pasa por revisión (ver [Revisión y publicación](/revision)). Una vez aprobado, los comerciantes ya instalados siguen con los scopes anteriores hasta que vuelvan a pasar por `/authorize`, donde ven y aceptan los permisos nuevos. ## Errores del endpoint de token Estos endpoints responden con el formato de OAuth (RFC 6749), **no** con el envelope de la API: ```json theme={null} { "error": "invalid_grant", "error_description": "install_limit_reached" } ``` | `error` | Status | Cuándo | | - | - | - | | `invalid_request` | 400 | Falta un parámetro, o mandaste credenciales distintas por Basic y por cuerpo. | | `invalid_client` | 401 | `client_id` o `client_secret` incorrectos, app suspendida o developer suspendido. Si usaste Basic, la respuesta trae `WWW-Authenticate: Basic`. | | `invalid_grant` | 400 | Code vencido, ya usado o de otra app; `redirect_uri` o `code_verifier` que no coinciden; refresh token vencido, usado o revocado. Con `error_description: install_limit_reached`, el comercio ya alcanzó el máximo de apps instaladas de su plan. | | `unsupported_grant_type` | 400 | `grant_type` distinto de `authorization_code` y `refresh_token`. | | `server_error` | 500 | Error nuestro: reintentá. | Límites: 300 requests por minuto por `client_id` en `/token` y en `/revoke`, y 10 intentos fallidos de autenticación de cliente cada 10 minutos por combinación `client_id` + IP. Al excederlos recibís `429` con el header `Retry-After` y un cuerpo `{ "error": "Too many requests", "retryAfter": 30 }`. ## Ejemplo completo en Node ```js theme={null} const crypto = require("crypto"); const API = "https://api.commercy.com.ar"; const CLIENT_ID = process.env.COMMERCY_CLIENT_ID; const CLIENT_SECRET = process.env.COMMERCY_CLIENT_SECRET; const REDIRECT_URI = "https://miapp.example.com/callback"; const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); // 1) Armá la URL de autorización y guardá verifier + state en la sesión del comerciante. function startInstall() { const verifier = b64url(crypto.randomBytes(32)); // 43 caracteres const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); const state = b64url(crypto.randomBytes(16)); const url = new URL(`${API}/oauth/apps/authorize`); url.search = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: "code", state, code_challenge: challenge, code_challenge_method: "S256", }).toString(); return { url: url.toString(), verifier, state }; } async function tokenRequest(params) { const res = await fetch(`${API}/oauth/apps/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: "Basic " + Buffer.from(`${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`).toString("base64"), }, body: new URLSearchParams(params), }); const body = await res.json(); if (!res.ok) throw new Error(`OAuth ${res.status}: ${body.error} ${body.error_description ?? ""}`); return body; } // 2) En /callback: verificá el state y canjeá el code. function exchangeCode({ code, verifier }) { return tokenRequest({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: REDIRECT_URI, }); } // 3) Renová antes de que venza. Guardá SIEMPRE el refresh_token nuevo. function refresh(refreshToken) { return tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }); } module.exports = { startInstall, exchangeCode, refresh }; ``` Para verlo funcionando de punta a punta (instalación, refresh rotativo con refreshes serializados, llamadas a la API y webhooks), mirá la app de ejemplo [sales-dashboard](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/sales-dashboard). # Apps de pago Source: https://docs.commercy.com.ar/payment-apps Cómo una app cobra en el checkout de la tienda: sesión de pago, estado, webhook y vencimiento. Una app de pago le permite a un comercio cobrar en el checkout de su tienda online con tu servicio: el comprador elige tu medio de pago, lo mandamos a tu página de cobro y, cuando pagó, tu app nos avisa. La integración es **HTTP en los dos sentidos**: * **Commercy te llama** a dos rutas de tu servidor: una para abrir la sesión de cobro y otra para consultar su estado. Cada request va firmado. * **Vos le avisás a Commercy** con un webhook mínimo cuando el estado del pago cambia. El webhook sólo avisa: el estado lo volvemos a consultar nosotros. No guardamos credenciales de tu procesador de pagos: tu app identifica al comercio por la instalación y resuelve su cuenta en tu propio proceso de alta. ## Cómo se habilita 1. Guardá el **secreto de webhook** de tu app (el `whsec_...` que ves en el portal): es con el que firmamos nuestros requests y con el que tenés que firmar el webhook que nos mandás, el mismo de los [webhooks](/webhooks). **Si lo rotás, las sesiones en curso dejan de poder verificarse hasta que tu app use el secreto nuevo.** 2. Cargá en el portal la **URL de tu app** (`appUrl`): tiene que ser un dominio propio, no una IP ni `localhost`. Es la referencia para validar el `checkout_url` de cada sesión. 3. **Pedile a soporte que habilite tu app como medio de pago.** 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 y la prende en **Ajustes > Cobros** de su tienda. Desde ese momento la ofrecemos en el checkout, siempre que su plan incluya las apps. Desinstalar la app deja de ofrecerla. 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 ``. * 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: **8 segundos** para abrir la sesión y **5 segundos** para consultar el estado. * 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, ".")` en hexadecimal y con el prefijo `v1=`, con tu secreto completo (incluido `whsec_`). Verificala siempre, porque cualquiera podría llamar a tus rutas y abrir o consultar cobros: 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-payment 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/payments/session", express.raw({ type: "application/json" }), commercyAuth, (req, res) => { // req.commercy.order.id, req.commercy.amount: abrí la sesión de cobro en tu sistema. res.json({ payment_id: "pay_8f3a21", checkout_url: "https://pagos.tuapp.com/checkout/pay_8f3a21" }); }); ``` ## Abrir la sesión de cobro Cuando el comprador confirma la compra con tu medio de pago, creamos la venta y te pedimos la sesión. ```text theme={null} POST /commercy/payments/session ``` ```json theme={null} { "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0", "idempotency_key": "6710d1e0e4a1f20012ab3600", "order": { "id": "6710d1e0e4a1f20012ab3600", "number": "1042" }, "amount": 25000, "currency": "ARS", "items": [ { "name": "Remera lisa", "quantity": 2, "unit_price": 12500 } ], "back_url": "https://mitienda.com.ar/checkout/confirmacion?order=1042&status=processing&method=app", "expires_at": "2026-10-24T13:00:00.000Z" } ``` * `idempotency_key` es el id de la venta. Si recibís dos veces la misma, devolvé **la misma sesión**. * `amount` es el total de la venta en pesos, con envío y descuentos. `currency` es siempre `ARS`. * `back_url` es la página de la tienda adonde tenés que devolver al comprador cuando termina de pagar. Esa página consulta el estado de la venta y le muestra el resultado. * **`expires_at` lo fija Commercy** (60 minutos desde que el comprador confirma) y **tu app no tiene que aceptar pagos después de ese momento**. Cualquier vencimiento que devuelvas se ignora. Respondé: ```json theme={null} { "payment_id": "pay_8f3a21", "checkout_url": "https://pagos.tuapp.com/checkout/pay_8f3a21" } ``` | Campo | Regla | | - | - | | `payment_id` | Obligatorio, entre 1 y 200 caracteres. Identifica la **sesión**, no cada intento de pago (ver abajo). | | `checkout_url` | Obligatorio, hasta 2048 caracteres. A esta página mandamos al comprador. Tiene que ser `https` y de tu **mismo dominio registrable** (ver abajo). | Los campos de más se ignoran. **Si la respuesta no cumple las reglas, la compra no se arma**: la venta queda cancelada y el comprador ve «No se pudo iniciar el pago». Lo mismo si tu app tarda más de 8 segundos o responde un error. ### `payment_id` identifica la sesión El `payment_id` tiene que ser **estable durante toda la sesión**, incluidos los reintentos del comprador: si su primera tarjeta es rechazada y paga con otra, sigue siendo el mismo `payment_id`. Tu `/status` devuelve el estado **agregado** de la sesión: `approved` si algún intento se aprobó, y `rejected` sólo si ningún intento está aprobado ni en curso. Un webhook con un `payment_id` distinto al de la sesión se descarta. ### El `checkout_url` tiene que ser de tu dominio Por seguridad del comprador, el `checkout_url` debe ser `https` y compartir el **dominio registrable** de la URL de tu app (`appUrl`): con `appUrl = https://tuapp.com`, sirven `https://pagos.tuapp.com/...` y `https://tuapp.com/...`, y no sirve `https://otro.com/...`. Los dominios que comparten un proveedor de hosting (por ejemplo `*.vercel.app`) no cuentan como un mismo dominio: `a.vercel.app` y `b.vercel.app` son distintos. Si tu procesador de pagos te da una URL externa, redirigí desde una ruta de tu propio dominio. ## Consultar el estado ```text theme={null} POST /commercy/payments/status ``` ```json theme={null} { "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0", "payment_id": "pay_8f3a21" } ``` Respondé: ```json theme={null} { "payment_id": "pay_8f3a21", "order_id": "6710d1e0e4a1f20012ab3600", "status": "approved", "amount": 25000, "currency": "ARS" } ``` **Los cinco campos son obligatorios**: una respuesta sin alguno es inválida y no se aplica. | `status` | Qué hacemos | | - | - | | `pending` | Todavía no se pagó. No cambia nada. | | `in_process` | El pago fue aceptado pero no se resolvió (por ejemplo, una verificación del banco o un pago en efectivo pendiente). Mientras la venta tenga menos de 72 horas, no la cancelamos: esperamos y volvemos a consultar. Pasado ese tope, se cancela (ver más abajo, «Qué pasa con la venta»). | | `approved` | Cobrado. Registramos el cobro y confirmamos la venta. | | `rejected` | Ningún intento se aprobó. La venta sigue abierta hasta que venza la sesión, por si el comprador reintenta. | | `cancelled` | Lo tratamos igual que `rejected`. | | `refunded` | **Lo ignoramos.** Commercy no recibe reembolsos de las apps (ver [Reembolsos](#reembolsos)). | Antes de aplicar cualquier estado, Commercy comprueba que lo que respondiste corresponde a la venta: * **Binding:** `payment_id` es el de la sesión y `order_id` es el `order.id` de la sesión. Si no coinciden, ignoramos la respuesta y avisamos a nuestro equipo. * **Monto y moneda:** para aprobar, `amount` tiene que ser **igual al total de la venta** y `currency` tiene que ser `ARS`. Si no, no confirmamos la venta. * **Si no podemos verificar** (timeout, error `5xx`, respuesta inválida), no aplicamos nada y no cancelamos la venta: lo que no se pudo verificar no se da por no pagado. ## Avisarnos que el estado cambió Cuando el pago se aprueba, se rechaza o cambia de estado, mandanos un webhook. **El webhook no lleva el estado**: sólo nos dice qué sesión cambió, y nosotros la consultamos con tu `/status`. ```text theme={null} POST https:///api/v1/app-webhooks/ ``` ```json theme={null} { "type": "payment.updated", "payment_id": "pay_8f3a21" } ``` * `` es el mismo id de instalación que te mandamos en cada request. * **`Content-Type: application/json` es obligatorio.** * Va firmado igual que nuestros requests, con **tu secreto de webhook**: el header `X-Commercy-Signature` (`v1=...`) y el `X-Commercy-Timestamp` de ese mismo momento. La firma se hace sobre el cuerpo exacto que enviás y vale 5 minutos. ```js theme={null} // commercy-sign-webhook const crypto = require("crypto"); // Devuelve los headers para avisarle a Commercy que cambió el estado de una sesión. function signCommercyWebhook(rawBody, secret, nowSec = Math.floor(Date.now() / 1000)) { const timestamp = String(nowSec); const signature = "v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); return { "Content-Type": "application/json", "X-Commercy-Timestamp": timestamp, "X-Commercy-Signature": signature, }; } module.exports = { signCommercyWebhook }; ``` ```js theme={null} const { signCommercyWebhook } = require("./sign"); async function notifyCommercy(installationId, paymentId) { const raw = JSON.stringify({ type: "payment.updated", payment_id: paymentId }); const res = await fetch(`${process.env.COMMERCY_API_URL}/api/v1/app-webhooks/${installationId}`, { method: "POST", headers: signCommercyWebhook(raw, process.env.COMMERCY_WEBHOOK_SECRET), body: raw, }); if (res.status >= 500) throw new Error("Commercy no pudo procesarlo: reintentá"); } ``` | Código | Cuándo | Qué hacer | | - | - | - | | `200` | Lo procesamos, o el `payment_id` no corresponde a ninguna venta. | Nada. | | `401` | Firma inválida o fuera de los 5 minutos. | Revisá el secreto y el reloj. | | `404` | La instalación no existe. | Revisá el id del path. | | `500` | No pudimos verificar el estado con tu app. | **Reintentá**, con espera creciente. | **Respondemos el webhook después de consultar tu `/status`.** Tu app tiene que poder atender `/status` mientras espera la respuesta de su propio webhook (no bloquees las dos cosas en el mismo hilo), o el webhook va a dar timeout. Si recibís un `500`, reintentá. El webhook sirve para que la venta se confirme sin demora, pero **no es la única vía**: el comprador, al volver a la tienda, también dispara una consulta (como mucho una cada 10 segundos por venta), y al vencer la sesión consultamos de nuevo. Aun así mandá siempre el webhook. ## Qué pasa con la venta * **Aprobado:** la venta pasa a `confirmed`, se emite el evento [`order.paid`](/webhooks) con `paid_via: "app"` y, si el comercio tiene facturación automática, se factura. Tu app **no** recibe su propio `order.paid`. * **A los 60 minutos** (`expires_at`) consultamos tu `/status` una última vez: * `pending` o `rejected` verificados: **cancelamos la venta**. * `approved`: la confirmamos. * `in_process`: no la cancelamos, extendemos la sesión y volvemos a consultar. **El tope es de 72 horas desde que se creó la venta**: si pasado ese plazo sigue `in_process`, se cancela y avisamos al comercio. * No se pudo verificar: no la cancelamos y reintentamos. Con el mismo tope de **72 horas de creada**, se cancela y avisamos al comercio. * **Si tu medio de pago es lento** (un cupón de pago en efectivo que vence en 3 a 5 días, por ejemplo), resolvelo antes de las 72 horas: o tu `/status` informa `approved` o `rejected` dentro de ese plazo, o hacés vencer el cupón antes. Si el comprador paga después de la cancelación es un cobro tardío (ver abajo). * **Cobro tardío:** si tu app informa un cobro de una venta que ya se canceló (incluida una cancelada por el tope de 72 horas), lo registramos y avisamos al comercio, pero **no la reabrimos ni devolvemos la plata**: la devolución la hacés vos en tu plataforma. * **Si el comercio cancela la venta** antes de que se pague, escuchá el webhook [`order.cancelled`](/webhooks) y cerrá la sesión de tu lado. * **Si el comercio desinstala tu app o se suspende**, no se abren sesiones nuevas, pero **las que estaban abiertas se siguen reconciliando** con tu secreto hasta cerrarse. Tu `/status` y tus webhooks tienen que seguir funcionando. ## Reembolsos Un reembolso lo hace la app en su plataforma, con su propio flujo. Commercy **no tiene un endpoint para informar reembolsos** y **ignora** el estado `refunded` de `/status`. * Si el comercio cancela o devuelve la venta desde Commercy, la plata **no se mueve**: tu app recibe el webhook [`order.cancelled`](/webhooks) y tiene que devolverle al comprador de su lado. * Si el comercio intenta reembolsar desde Commercy un cobro de tu app, ve el aviso «Los pagos registrados por una app se reembolsan desde esa app» y no se hace nada. * No uses [Informar un cobro](/api-reference#informar-un-cobro) para esto: ese endpoint **registra cobros**, no devoluciones. ## Checklist de salida * [ ] Verificás la firma sobre el cuerpo crudo y que `installation_id` coincida con el header. * [ ] `/commercy/payments/session` devuelve la misma sesión para la misma `idempotency_key` y un `checkout_url` `https` de tu dominio. * [ ] El `payment_id` es el mismo durante toda la sesión, aunque el comprador reintente. * [ ] `/status` devuelve los cinco campos, con `order_id` y `amount` exactos. * [ ] No aceptás pagos después de `expires_at`. * [ ] Mandás el webhook `payment.updated` firmado, con `Content-Type: application/json`, y reintentás ante un `500`. * [ ] Atendés `/status` mientras esperás la respuesta de tu webhook. ## Ejemplo [`payment-provider`](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/payment-provider) es una app de pago de ejemplo, lista para copiar y adaptar. # Apps de cobro presencial Source: https://docs.commercy.com.ar/pos-payment-apps Cómo una app cobra en el POS y el kiosco: lectores, QR o transferencia, creación, estado, cancelación, aviso y declaración. Una app de cobro presencial le permite a un comercio cobrar en el **punto de venta (POS) y en el kiosco** con tu servicio: un lector de tarjetas, un QR o una transferencia. El cajero (o el cliente, en el kiosco) elige «Tu app presencial», nosotros te pedimos el cobro y, cuando se resuelve, tu app nos avisa. La integración es **HTTP en los dos sentidos**: * **Commercy te llama** a cuatro rutas de tu servidor: listar dispositivos, crear el cobro, consultar su estado y cancelarlo. Cada request va firmado. * **Vos le avisás a Commercy** con un aviso mínimo cuando el estado del cobro cambia. El aviso es opcional pero recomendado, y sólo avisa: el estado lo volvemos a consultar nosotros. Tu app **nunca recibe datos del comprador**: sólo el número de venta, el monto, las cuotas y lo que hace falta para cobrar. No guardamos credenciales de tu procesador: tu app identifica al comercio por la instalación y resuelve su cuenta en tu propio proceso de alta. ## Cómo se habilita Hacen falta cuatro cosas, y la última la decide el comercio: 1. **Declarar el cobro presencial** en la pestaña **Cobro presencial** de tu app en el portal: modos, si listás dispositivos, si mandás avisos, cómo se cancela, vencimientos, montos, cuotas y ticket (ver [La declaración](#la-declaración)). En una app publicada, el cambio pasa por revisión. 2. **Pedir el permiso `pos_payments`** («Cobrar en tu punto de venta») en la pestaña Scopes. Ver [Scopes y permisos](/scopes). El comerciante lo ve destacado en la pantalla de consentimiento al instalar. 3. **Pedile a soporte que habilite el cobro presencial de tu app.** Pasale el id de tu app 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. Tu `appUrl` tiene que ser un dominio propio, no una IP ni `localhost`. 4. El comercio instala la app, **acepta «Cobrar en tu punto de venta»** y, en **Ajustes > sucursal > Cobros**, configura la espera y, si la app usa dispositivos, vincula sus lectores. Después prende «Tu app presencial» en **Medios de pago del POS**. Mientras tu app esté en borrador, la habilitamos sólo en la tienda de desarrollo de tu cuenta, para que pruebes el flujo completo. Commercy puede apagar el cobro presencial por app en cualquier momento (y globalmente en una emergencia): no se ofrece ni se crean cobros nuevos, pero **los cobros en curso se siguen reconciliando** con tu app. ## La declaración La declaración le dice a Commercy qué ofrecer en el POS y el kiosco. Staff la revisa contra lo que tu app hace de verdad, así que tiene que ser fiel. ```json theme={null} { "displayName": "Mi Cobro", "modes": ["device", "qr"], "listsDevices": true, "sendsWebhooks": true, "cancel": "sync", "expirySeconds": { "min": 60, "max": 3600 }, "amount": { "min": "1.00", "max": "500000.00" }, "installments": { "mode": "pos", "counts": [3, 6, 12], "costChoice": true }, "printTicket": false, "contractVersion": "2026-10" } ``` | Campo | Regla | | - | - | | `displayName` | Entre 2 y 30 caracteres. Es el nombre que ve el cajero y que sale en el ticket. | | `modes` | Al menos uno, sin repetir: `device` (lector físico), `qr` (QR o link que muestra Commercy) o `none` (sin dispositivo ni QR: instrucciones o transferencia). | | `listsDevices` | `true` sólo si soportás `device`. Si es `true`, Commercy te llama a `/commercy/pos/devices`; si no, el comercio tipea el id del lector. | | `sendsWebhooks` | Si mandás el aviso `pos_payment.updated`. Si no, consultamos el estado cada pocos segundos. | | `cancel` | `unsupported`, `sync` (cancelás en el acto) o `async` (la cancelación se confirma después, con `cancel_requested`). | | `expirySeconds` | Rango de vencimiento que aceptás, entre 60 y 259200 segundos (72 horas). | | `amount` | Rango de monto que aceptás, como strings con dos decimales, de `"0.01"` a `"99999999.99"`. | | `installments` | `mode`: `none`, `device` (las elige el cliente en el lector) o `pos` (las elige el cajero, con `counts` entre 1 y 24 y, opcional, `costChoice` para elegir quién paga el recargo). | | `printTicket` | Si el dispositivo puede imprimir el comprobante del comercio. | | `contractVersion` | `2026-10`. | Se rechazan los campos que no están en la lista. El portal usa las mismas reglas que el server y marca el error en el campo. ## Cómo te llamamos Todas las operaciones son un `POST` con cuerpo JSON a ``. * 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. * Respondé `2xx` con un cuerpo JSON. Un `4xx` con `{ "error": { "code": "...", "message": "..." } }` es un error tipado (ver más abajo). * Los campos que no conocemos se ignoran, en los dos sentidos. * Tiempos máximos: **5 segundos** para listar los dispositivos, **8 segundos** para crear el cobro (si se corta, lo reenviamos una vez, con el mismo cuerpo y el mismo `payment_ref`), **5 segundos** para consultar el estado y **8 segundos** para cancelar. Un timeout nunca se interpreta como rechazo. ### 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=`. | | `X-Commercy-Contract` | Versión del contrato. Hoy: `X-Commercy-Contract: 2026-10`. | 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 y que las [apps de pago online](/payment-apps): `HMAC-SHA256(secreto, ".")` en hexadecimal y con el prefijo `v1=`, con tu secreto completo (incluido `whsec_`). Verificala siempre, porque cualquiera podría llamar a tus rutas y crear cobros: 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-payment const crypto = require("crypto"); const TOLERANCE_SECONDS = 300; // 5 minutos // headers: nombres en minúscula (como los entrega Node/Express). // secret: tu secreto, o un arreglo con el nuevo y el anterior mientras rotás (ver "Rotar el secreto"). 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 received = Buffer.from(String(signature)); return [].concat(secret).some((s) => { const expected = Buffer.from("v1=" + crypto.createHmac("sha256", s).update(`${timestamp}.${rawBody}`).digest("hex")); return expected.length === received.length && crypto.timingSafeEqual(expected, received); }); } 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(); // Secretos vigentes: el actual y, durante una rotación, el anterior. const SECRETS = [process.env.COMMERCY_WEBHOOK_SECRET, process.env.COMMERCY_WEBHOOK_SECRET_PREVIOUS].filter(Boolean); // 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, SECRETS)) 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(); } ``` ## Convenciones del contrato * **Montos como string con dos decimales**, por ejemplo `"1234.50"`, y moneda `ARS`. Los comparamos en centavos. Un monto que no es exactamente así (`amount_paid: 1234.5` como número, `"1234.5"`, `"-1.00"` o `"1e3"`) hace que la respuesta sea inválida y no la aplicamos. * **Ojo si también implementás una app de pago online.** En las [apps de pago online](/payment-apps), `amount` va como número; acá va como string. * `amount_paid` es el **total bruto cobrado al cliente**, con recargo y propina incluidos, antes de las comisiones de tu procesador. Nunca el neto que te liquidan. * **Fechas en ISO-8601 con zona**, por ejemplo `2026-10-27T15:30:00.000Z`. Una fecha sin hora o sin zona se rechaza. * **`payment_ref` lo genera Commercy**: 32 caracteres `[a-z0-9]`, único, **nuevo por cada intento** de cobro (nunca por venta). Aceptá hasta 36 caracteres `[A-Za-z0-9_-]`, para que el contrato pueda evolucionar, y usalo como referencia externa con tu procesador. * **Idempotencia por `payment_ref`.** Repetir la creación con el mismo `payment_ref` y el mismo cuerpo devuelve el mismo `payment_id`. Tenés que retener la clave al menos **72 horas** más el vencimiento del cobro. El mismo `payment_ref` con otro cuerpo se responde `idempotency_conflict`. Si tu procesador no tiene idempotencia, guardá el `payment_ref` **antes** de llamarlo. * **Un `status` o un `error.code` que no conocemos** se tratan como «no verificado» (volvemos a consultar), nunca como rechazo. * **Commercy nunca aplica el `status` de la respuesta de creación ni el cuerpo de un aviso:** sólo el de la operación `status` verifica un cobro. ## Errores La semántica es la misma en todas las operaciones: * Un `4xx` **con un código del contrato** quiere decir que no se creó nada (o que no se hizo lo pedido), con una excepción: `idempotency_conflict` significa que el cobro **ya existe** en tu app, así que lo resolvemos con `status`. El formato es `{ "error": { "code": "device_busy", "message": "El lector está ocupado" } }`. * Un `5xx`, un timeout o un `4xx` **sin código** quiere decir que no sabemos qué pasó: el cobro puede existir en tu app. No lo damos por no creado: lo resolvemos consultando `status` por `payment_ref`. Lo mismo para un código que no conocemos. * **Cuándo un `4xx` cuenta como error tipado.** Sólo si el cuerpo trae `error.code` (texto de 1 a 40 caracteres, sólo `a-z` y `_`, p. ej. `device_busy`) y `error.message` (texto de hasta 500 caracteres). Si falta alguno, si el `code` tiene mayúsculas, números u otros símbolos, o si el `message` pasa de 500 caracteres, lo tratamos como un resultado desconocido y el intento queda «sin confirmar» hasta que `status` lo resuelva. * Una respuesta `2xx` mal formada (o con un `display` inválido) tampoco es «no creado»: el cobro pudo haberse creado, y lo resolvemos con `status`. | Código | Cuándo respondés | Qué hace Commercy | | - | - | - | | `device_busy` | El lector ya tiene un cobro en curso. | Muestra «lector ocupado»; el cajero reintenta. | | `device_offline` | El lector no está conectado. | Muestra «lector desconectado». | | `invalid_device` | El `device_id` no pertenece a la cuenta de esa instalación. | Se rechaza el cobro. Un id mal tipeado no puede mandar la plata a otro comercio. | | `amount_out_of_range` | El monto está fuera del rango que declaraste. | Se rechaza el cobro. | | `not_available` | La cuenta del comercio no puede cobrar ahora (por ejemplo, un alta incompleta). | Se rechaza el cobro. | | `idempotency_conflict` | Mismo `payment_ref` con otro cuerpo. | El cobro puede existir en tu app: no lo damos por no creado y lo resolvemos consultando `status`. | | `payment_ref_consumed` | Te piden crear con un `payment_ref` por el que ya respondiste `payment_not_found`. | No se crea nada (ver abajo). | | `payment_not_found` | `status` o `cancel` con un `payment_ref` o `payment_id` que no conocés. Siempre con HTTP `404`. | Ver abajo. | ### `payment_not_found` y `payment_ref_consumed` Si no conocés el `payment_ref` (ni el `payment_id`), respondé `404` con `payment_not_found`. **Desde ese momento tu app rechaza para siempre** crear un cobro con ese `payment_ref` (`payment_ref_consumed`): así un reintento de creación que todavía viaja por la red no puede crear el cobro después de que Commercy lo dio por inexistente. Con `payment_not_found`, Commercy da el intento por no creado **sólo si ese intento nunca recibió un `status` válido ni un `payment_id`**. Si ya los tuvo, el `404` se trata como «sin confirmar» y se alerta a nuestro equipo: una app desinstalada o que perdió datos no puede habilitar un doble cobro. ## Listar dispositivos Sólo si declaraste `listsDevices`. Los lectores que el comercio vincula a su sucursal salen de esta lista. ```text theme={null} POST /commercy/pos/devices ``` ```json theme={null} { "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0" } ``` Respondé: ```json theme={null} { "devices": [ { "id": "reader_01", "label": "Caja 1", "status": "online" } ] } ``` | Campo | Regla | | - | - | | `id` | Obligatorio, entre 1 y 128 caracteres de `A-Z a-z 0-9 _ . : -`, y sin `..` (`v1.2` sirve; `..` y `a..b` no). Los repetidos se descartan. **Un lector con otro id se descarta en silencio de la lista** (no aparece para vincular, y tampoco se puede vincular a mano): evitá espacios, `/`, `?`, `#` y caracteres no ASCII. | | `label` | Opcional. El nombre se muestra hasta 80 caracteres; lo que pase de 80 se corta. | | `status` | `online`, `offline`, `busy` o `unknown`. Cualquier otro valor se toma como `unknown`. | Hasta 500 dispositivos. Un fallo de esta operación sólo impide listar: no hay errores tipados. ## Crear el cobro ```text theme={null} POST /commercy/pos/payments ``` ```json theme={null} { "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0", "payment_ref": "k3x9a1b7q2m5z8c4v6n0d2f5h7j9l1p3", "order": { "id": "6710d1e0e4a1f20012ab3600", "number": "1042" }, "amount": "1234.50", "currency": "ARS", "mode": "device", "device_id": "reader_01", "installments": { "count": 3, "cost": "buyer" }, "expires_at": "2026-10-27T15:35:00.000Z", "display_title": "Venta 1042", "print_ticket": false } ``` | Campo | Qué es | | - | - | | `payment_ref` | Clave de idempotencia, **nueva por cada intento**. Ver [Convenciones](#convenciones-del-contrato). | | `order` | Id y número de la venta. Es todo lo que la app sabe de la venta. | | `amount` | Total a cobrar, string con dos decimales. `currency` es siempre `ARS`. | | `mode` | `device`, `qr` o `none`, uno de los que declaraste. | | `device_id` | Sólo en modo `device`. Es uno de los lectores vinculados a esa sucursal y a tu app; si no pertenece a la cuenta de esa instalación, respondé `invalid_device`. | | `installments` | Sólo si declaraste cuotas desde el POS y el cajero eligió. `cost` es `buyer` o `seller`. | | `expires_at` | El vencimiento que pide Commercy, dentro del rango que declaraste. Tu app puede no poder respetarlo; informá el real. | | `display_title` | Hasta 20 caracteres, para la cola del lector. | | `print_ticket` | Sólo si lo declaraste y el comercio lo prendió: pide que el dispositivo imprima el comprobante del comercio. | Respondé: ```json theme={null} { "payment_id": "pay_8f3a21", "status": "pending", "expires_at": "2026-10-27T15:35:00.000Z", "display": { "qr_data": "https://pagos.tuapp.com/qr/pay_8f3a21", "instructions": "Escaneá el QR con tu app de pagos" } } ``` | Campo | Regla | | - | - | | `payment_id` | Obligatorio, entre 1 y 200 caracteres ASCII imprimibles, sin espacios. Identifica el cobro en tu app. | | `status` | Obligatorio, pero **nunca se aplica**: el estado lo verificamos con `status`. | | `expires_at` | Obligatorio, ISO-8601 con zona. Es el **vencimiento real**. | | `display` | Según el modo (ver [Qué mostramos](#qué-mostramos-el-display)). En modo `device` se ignora. | Si el `payment_id` ya está ligado a otro intento, Commercy rechaza la respuesta y alerta a nuestro equipo. Los errores tipados de la creación son `device_busy`, `device_offline`, `invalid_device`, `amount_out_of_range`, `not_available`, `idempotency_conflict` y `payment_ref_consumed`. En el modo `none`, cuando el POS ofrece «Dejar pendiente», `expires_at` puede llegar a 72 horas: el cajero sigue atendiendo y confirmamos el cobro cuando tu app avisa o cuando lo consultamos. ## Qué mostramos: el `display` El cliente final **mira** esto en el POS o en el kiosco, así que lo validamos con formato, no sólo con tipos. Lo mostramos siempre como **texto** o como un **QR que dibuja Commercy**: nunca como HTML ni como una imagen remota. Si un campo que vamos a mostrar no pasa la validación, **falla todo el `display`** y tratamos la respuesta como inválida (el cobro puede existir: lo resolvemos con `status`). * **Modo `device`:** no mostramos nada de tu app. Cualquier `display` se descarta. * **Modo `qr`:** `qr_data` es obligatorio. Puede ser: * un **QR EMVCo** con CRC válido: empieza con `000201`, es una cadena de campos bien formada que termina en el campo `63` de largo `04`, y ese CRC es el CRC-16/CCITT-FALSE (polinomio `0x1021`, valor inicial `0xFFFF`) de los bytes **UTF-8** de todo el texto hasta `6304` inclusive. Los nombres con «ñ» o acentos cuentan: calculá el CRC sobre los bytes UTF-8; * o una **URL `https` del mismo dominio registrable** que tu `appUrl`, sin credenciales ni espacios. Con `appUrl = https://tuapp.com` sirven `https://pagos.tuapp.com/...` y no sirve `https://otro.com/...`. Los dominios compartidos de hosting (`*.vercel.app`) no cuentan como un mismo dominio. En los dos casos, hasta 2048 caracteres y sin caracteres de control. `instructions` es opcional. * **Modo `none`:** `instructions`, `transfer` o los dos; tiene que haber al menos uno. * **`instructions`:** **texto plano** de hasta 280 caracteres, con saltos de línea (`\n`) si querés varias líneas. Sin otros caracteres de control, sin marcas de dirección de texto y sin HTML (se muestra literal, y las URLs no se convierten en links). * **`transfer`**, con al menos un CVU o un alias: * `cvu`: **22 dígitos** con dígito verificador válido (algoritmo del BCRA); * `alias`: entre 6 y 20 caracteres, letras, números, punto y guion; * `holder_name`: texto plano de hasta 80 caracteres; * `holder_cuit`: CUIT de 11 dígitos (con o sin guiones) con dígito verificador válido. Mostramos el titular bien visible y advertimos al cajero si el CUIT no es el de la sucursal. ```json theme={null} { "instructions": "Transferí el total y guardá el comprobante.\nEl cobro vence en 30 minutos.", "transfer": { "cvu": "0000003101000000000001", "alias": "tienda.pagos", "holder_name": "Tienda SRL", "holder_cuit": "30-71234567-1" } } ``` ## Consultar el estado ```text theme={null} POST /commercy/pos/payments/status ``` ```json theme={null} { "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0", "payment_ref": "k3x9a1b7q2m5z8c4v6n0d2f5h7j9l1p3", "payment_id": "pay_8f3a21" } ``` **Tu app tiene que poder resolver el estado sólo por `payment_ref`**: `payment_id` es opcional, porque la creación puede haberse cortado por timeout antes de que lo recibiéramos. Respondé: ```json theme={null} { "payment_id": "pay_8f3a21", "payment_ref": "k3x9a1b7q2m5z8c4v6n0d2f5h7j9l1p3", "status": "approved", "amount_requested": "1234.50", "amount_paid": "1300.00", "amount_extra": { "tip": "50.00", "surcharge": "15.50" }, "refunded_amount": "0.00", "currency": "ARS", "details": { "installments": 3, "card_last4": "4242", "card_brand": "visa", "auth_code": "A12345" } } ``` Son obligatorios `payment_id`, `payment_ref` (el que te mandamos), `status`, `amount_requested`, `amount_paid` y `currency`. Los demás son opcionales. Con `rejected`, agregá `"error": { "code": "declined", "message": "..." }`; los códigos son `declined`, `customer_canceled`, `device_busy`, `device_offline` y `other`. | `status` | Qué hacemos | | - | - | | `pending` | Todavía no se pagó. Seguimos esperando. | | `in_process` | El cobro está en marcha (tarjeta presentada, verificación pendiente). Seguimos esperando. | | `approved` | Cobrado. Aplicamos el cobro según el monto (ver [Montos](#montos-y-excedente)). | | `partial` | Cobraste menos que el total. **No aplicamos nada**, avisamos al comercio y la venta sigue pendiente. | | `rejected` | Rechazado. Cerramos el intento con el motivo de `error.code`; **la venta sigue pendiente** para reintentar o cambiar de medio. | | `cancel_requested` | La cancelación es asíncrona y todavía no terminó. El diálogo muestra «cancelando…». | | `cancelled` | Cancelado sin cobro. Cerramos el intento; la venta sigue pendiente. | | `expired` | Venció sin cobro. Igual que `cancelled`. | | `refunded` | Se devolvió el cobro. Registramos el monto de `refunded_amount` y avisamos al comercio (ver [Reembolsos](#reembolsos)). | | `partially_refunded` | Se devolvió una parte. Igual que `refunded`. | Cualquier otro `status` se toma como «no verificado» y volvemos a consultar. `details` se acota a lo conocido: `installments` (entero de 1 a 99), `card_last4` (**exactamente 4 dígitos**; si no, lo descartamos: nunca guardamos un número de tarjeta), `card_brand` y `auth_code` (hasta 40 caracteres). Para tu app son opcionales: sólo mostramos lo que llega. ### Reglas de coherencia Commercy las hace cumplir antes de aplicar nada: * **Binding:** `payment_ref` y `payment_id` tienen que ser los del intento. Si no coinciden, no aplicamos nada y avisamos a nuestro equipo. * `rejected`, `cancelled` y `expired` exigen `amount_paid` igual a `"0.00"`; si viene mayor, la tratamos como `partial`. * Un cobro `approved` sólo puede pasar a `refunded` o `partially_refunded`. Cualquier otra transición posterior (de vuelta a `pending` o a `rejected`, por ejemplo) se ignora y se alerta. La única excepción es `approved` a `approved` con un `amount_paid` mayor, si el anterior no se había aplicado. * La suma de `amount_extra` tiene que ser menor o igual a `amount_paid - amount_requested`. Si no, descartamos el desglose. * La moneda tiene que ser `ARS`; con otra no aplicamos el cobro. ### Montos y excedente | Caso | Qué hacemos | | - | - | | `approved` y `amount_paid` igual al total | Aplicamos el cobro. | | `approved` y `amount_paid` mayor al total | Aplicamos el cobro por el total de la venta y guardamos el excedente (propina o recargo). Se muestra como «Excedente cobrado por tu app» en la venta y en el arqueo. No entra a la factura ni a la contabilidad. | | `approved` y `amount_paid` menor al total | No aplicamos nada y alertamos «monto distinto». La venta queda pendiente y **nunca se cancela sola**. | | `partial` | No aplicamos nada y alertamos una vez. La venta queda pendiente y **nunca se cancela sola**: la resuelve el comercio. | La tolerancia de comparación es de un centavo. ## Cancelar ```text theme={null} POST /commercy/pos/payments/cancel ``` ```json theme={null} { "installation_id": "6710b3c2e4a1f20012ab34cd", "shop_id": "6710b3c2e4a1f20012ab34d0", "payment_ref": "k3x9a1b7q2m5z8c4v6n0d2f5h7j9l1p3", "payment_id": "pay_8f3a21", "cancel_ref": "c9f2e7a1b3" } ``` `cancel_ref` (hasta 36 caracteres `[A-Za-z0-9_-]`) es la clave de idempotencia de la cancelación. `payment_id` es opcional: `payment_ref` alcanza. Si no conocés el cobro, respondé `404` con `payment_not_found`. Respondé: ```json theme={null} { "result": "cancelled" } ``` | `result` | Qué hacemos | | - | - | | `cancelled` | Se canceló. Confirmamos con `status`. | | `cancel_requested` | La cancelación es asíncrona (por ejemplo, el cobro ya está en el lector). La confirmamos con `status`. | | `not_cancellable` | Ya hay un pago o el cobro avanzó. Consultamos `status` y seguimos desde ahí: **nunca** damos la venta por cancelada. | Cancelamos el **intento de cobro**, no la venta: la venta sigue pendiente y se puede cobrar de nuevo, con un `payment_ref` nuevo. ## Avisarnos que el estado cambió Si declaraste `sendsWebhooks`, mandanos un aviso cuando el cobro se aprueba, se rechaza o cambia de estado. **El aviso no lleva el estado**: sólo dice qué cobro cambió, y nosotros lo consultamos con tu `status`. Nunca aplicamos el contenido del aviso. ```text theme={null} POST https:///api/v1/app-webhooks/ ``` ```json theme={null} { "type": "pos_payment.updated", "installation_id": "6710b3c2e4a1f20012ab34cd", "payment_id": "pay_8f3a21", "payment_ref": "k3x9a1b7q2m5z8c4v6n0d2f5h7j9l1p3" } ``` * El `installation_id` del cuerpo tiene que ser el del path: la firma no cubre la URL, y lo comprobamos. * **`Content-Type: application/json` es obligatorio.** * Va firmado igual que nuestros requests, con **tu secreto de webhook**: el header `X-Commercy-Signature` (`v1=...`) y el `X-Commercy-Timestamp` de ese mismo momento. La firma vale 5 minutos. * Atendemos hasta **120 avisos por minuto por instalación** (más, devuelve `429`) y hacemos una sola consulta a la vez por `payment_ref`: mientras hay una consulta en curso para ese cobro (como máximo 10 segundos), un aviso repetido recibe `202` sin una consulta nueva. Terminada la consulta, el aviso siguiente se atiende. * **Ante un `202` reintentá en unos segundos.** Quiere decir que ya estábamos consultando ese cobro: esa consulta pudo haber leído el estado antes del cambio que estás avisando. Reintentá el aviso a los pocos segundos (con una espera corta y un tope de unos 3 reintentos); si no, el cambio lo levanta nuestro barrido, pero más tarde. ```js theme={null} // commercy-sign-webhook const crypto = require("crypto"); // Devuelve los headers para avisarle a Commercy que cambió el estado de un cobro. function signCommercyWebhook(rawBody, secret, nowSec = Math.floor(Date.now() / 1000)) { const timestamp = String(nowSec); const signature = "v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); return { "Content-Type": "application/json", "X-Commercy-Timestamp": timestamp, "X-Commercy-Signature": signature, }; } module.exports = { signCommercyWebhook }; ``` ```js theme={null} const { signCommercyWebhook } = require("./sign"); async function notifyCommercy(installationId, paymentId, paymentRef) { const raw = JSON.stringify({ type: "pos_payment.updated", installation_id: installationId, payment_id: paymentId, payment_ref: paymentRef }); const res = await fetch(`${process.env.COMMERCY_API_URL}/api/v1/app-webhooks/${installationId}`, { method: "POST", headers: signCommercyWebhook(raw, process.env.COMMERCY_WEBHOOK_SECRET), body: raw, }); if (res.status >= 500 || res.status === 429) throw new Error("Commercy no pudo procesarlo: reintentá con espera creciente"); } ``` El aviso adelanta la confirmación, pero **no es la única vía**: mientras el POS espera consultamos el estado cada pocos segundos, y un barrido del server sigue todo cobro que no terminó, aunque cierren el POS. Aun así conviene mandar siempre el aviso. ## Qué pasa con la venta * **Cobrado:** la venta se confirma como cualquier venta cobrada en el POS, con el monto del total. Tu app **no** recibe su propio `order.paid`. * **Vencimiento (modos `device` y `qr`):** antes de dar el cobro por vencido consultamos `status`; si sigue vivo en tu app, te pedimos `cancel`. Si no podés responder o la respuesta no se puede verificar, el intento queda **«sin confirmar»**: la venta sigue pendiente y no mandamos un cobro nuevo hasta que una consulta confirme que el anterior no cobró. * **Reintentar:** siempre consultamos primero el intento anterior. Si está `approved`, lo aplicamos y no cobramos de nuevo; si sigue vivo, lo cancelamos antes de crear otro. Cada intento nuevo lleva un `payment_ref` nuevo. Por eso `status` y `cancel` tienen que funcionar siempre, no sólo mientras el cobro está en curso. * **Cobro tardío o duplicado:** si tu app informa un cobro de una venta que ya se canceló o se cobró por otro medio, lo registramos y avisamos al comercio y a nuestro equipo, pero **no reabrimos la venta ni devolvemos la plata**: la devolución la hacés vos en tu plataforma. * **Desinstalar o suspender la app** saca el medio del POS y no se crean cobros nuevos, pero **los que estaban en curso se siguen reconciliando** con tu secreto hasta cerrarse. Tu `status` y tus avisos tienen que seguir funcionando. ## Reembolsos Los reembolsos los hace tu app, en su plataforma y con su propio flujo: **no pedimos devoluciones a la app** y no hay un endpoint para informarlos. Si tu `status` informa `refunded` o `partially_refunded`, con `refunded_amount`, registramos el monto y lo mostramos como «Devuelto por tu app» en la venta y en el arqueo. Avisamos al comercio para que, si corresponde, haga la devolución de la venta en Commercy (nota de crédito y stock) con el flujo de siempre. **No cambiamos el estado de la venta ni emitimos nada por nuestra cuenta.** Si la venta nunca se aplicó, no aplicamos nada y avisamos. ## Rotar el secreto El secreto de webhook (`whsec_...`) es uno por app y firma los requests en los dos sentidos. Rotarlo **no corta los cobros en curso**: durante **72 horas** Commercy acepta tus avisos firmados con cualquiera de los dos secretos, el nuevo o el secreto anterior. * **Lo que te mandamos se firma con el secreto nuevo desde el momento de la rotación.** Por eso, mientras desplegás el secreto nuevo, aceptá los dos secretos al verificar (el ejemplo de arriba lo hace con un arreglo). * Si tu app tarda en desplegar el secreto nuevo, nuestras consultas `status` te dan `401` y las tratamos como «no verificado»: no se pierde plata, se demora, y el barrido reintenta. * Pasadas las 72 horas, sólo vale el secreto nuevo. ## Checklist de salida * [ ] Verificás la firma sobre el cuerpo crudo, que `installation_id` coincida con el header, y aceptás los dos secretos mientras rotás. * [ ] Los montos van y vienen como string con dos decimales; `amount_paid` es el bruto cobrado. * [ ] La creación es idempotente por `payment_ref` (al menos 72 horas más el vencimiento) y responde `idempotency_conflict` ante otro cuerpo. * [ ] `status` resuelve por `payment_ref` aunque no haya `payment_id`, y devuelve `payment_not_found` (`404`) cuando no lo conoce, rechazando después la creación con `payment_ref_consumed`. * [ ] `cancel` es idempotente por `cancel_ref` y devuelve `not_cancellable` si el cobro ya avanzó. * [ ] El `display` cumple las validaciones: QR EMVCo con CRC UTF-8 o URL `https` de tu dominio, instrucciones de hasta 280 caracteres, transferencia con CVU, alias y CUIT válidos. * [ ] Tus errores son `4xx` con código del contrato cuando no se creó nada; ante la duda, `5xx`. * [ ] Declaraste el cobro presencial, pediste el permiso `pos_payments` y probaste el flujo completo en una sucursal de prueba. ## Ejemplo [`terminal-provider`](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/terminal-provider) es una app de cobro presencial de ejemplo, lista para copiar y adaptar. # Primeros pasos Source: https://docs.commercy.com.ar/quickstart Creá tu app, instalala en una tienda y hacé tu primer request en minutos. En esta guía creás una app de Commercy, la instalás en una tienda de prueba y hacés tu primer request a la API en unos minutos. Al final la enviás a revisión. > [!NOTE] > > Todo lo que probás antes de publicar funciona en tu propia tienda y en tus tiendas demo, sin necesidad de un plan pago. > [!NOTE] > > ¿Vas a armar la app con Claude, Codex o Cursor? Mirá [Desarrollá con IA](/ia): la documentación completa está en `https://docs.commercy.com.ar/llms-full.txt`, lista para pasarle como contexto. > [!NOTE] > > ¿Preferís partir de una app que ya funciona? [sales-dashboard](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/sales-dashboard) es una app de ejemplo completa en Node: OAuth con PKCE, refresh de tokens, API pública con paginación y webhooks firmados, incluidos los 4 obligatorios. Es server-rendered y sin build, para copiar y adaptar. ## 1. Creá tu cuenta y el alta de developer 1. Registrate en Commercy como cualquier comercio (el registro vive en el panel de Commercy). 2. Entrá al portal de developers con el mismo email y contraseña. 3. Completá el alta de developer (nombre público y aceptación de los [términos del programa](/revision#términos-del-programa)). ## 2. Creá la app En el portal elegí **Nueva app** y completá el nombre. Después, en **Credenciales**: * Cargá una **redirect URI**. Mientras la app está en borrador podés usar `http://localhost:3000/callback`. * Elegí los **scopes** que necesita tu app. Pedí sólo los que vas a usar: cada permiso extra se revisa y se le muestra al comerciante (ver [Scopes y permisos](/scopes)). ## 3. Generá el client secret En **Credenciales** generá un client secret. Empieza con `cs_` y **se muestra una sola vez**: copialo y guardalo en un lugar seguro (variable de entorno o gestor de secretos). Si lo perdés, generás otro y borrás el anterior. Cada app puede tener hasta 2 secretos a la vez para rotarlos sin cortar el servicio. El `client_id` (empieza con `app_`) no es secreto y lo ves siempre en el portal. ## 4. Instalá la app en una tienda En borrador, la app sólo se instala en tu propia tienda o en una tienda demo. Abrí esta URL en el navegador, con la sesión de la tienda donde querés instalarla: ```text theme={null} https://api.commercy.com.ar/oauth/apps/authorize ?client_id=TU_CLIENT_ID &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback &response_type=code &state=UN_VALOR_ALEATORIO &code_challenge=EL_CODE_CHALLENGE &code_challenge_method=S256 ``` Commercy te lleva a la pantalla de consentimiento. Al aceptar, te redirige a tu `redirect_uri` con `?code=...&state=...`. El `code` dura 60 segundos y sirve una sola vez. Cómo generar el `code_challenge` y canjear el code está en [Autenticación OAuth](/oauth). Canjeá el code por un access token: ```bash theme={null} curl -X POST https://api.commercy.com.ar/oauth/apps/token \ -u "TU_CLIENT_ID:TU_CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code=EL_CODE_RECIBIDO \ -d redirect_uri=http://localhost:3000/callback \ -d code_verifier=EL_CODE_VERIFIER ``` La respuesta trae el `access_token` (dura 1 hora) y un `refresh_token`: ```json theme={null} { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "9f2c...", "scope": "read_shops read_products", "installation_id": "6710b3c2e4a1f20012ab34cd", "store": { "id": "6710b3c2e4a1f20012ab34ce", "name": "Mi Tienda" } } ``` ## 5. Tu primer request ```bash theme={null} curl https://api.commercy.com.ar/api/public/2026-10/store \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```json theme={null} { "data": { "id": "6710b3c2e4a1f20012ab34ce", "name": "Mi Tienda", "currency": "ARS", "plan_tier": "professional", "shops": [{ "id": "6710b3c2e4a1f20012ab34cf", "name": "Sucursal Centro" }] } } ``` Si recibís `200` con ese formato, ya estás autenticado. Seguí con la [referencia de la API](/api-reference). ## 6. Configurá un webhook En **Webhooks** cargá la URL donde querés recibir los eventos. Tiene que ser `https` y pública: **no funciona con `localhost`**. Para desarrollar usá un túnel como ngrok o cloudflared: ```bash theme={null} cloudflared tunnel --url http://localhost:3000 ``` Desde el portal enviá un **ping de prueba** para verificar que tu endpoint responde `2xx`. Después implementá la [verificación de la firma](/webhooks#verificar-la-firma). Tu app tiene que responder `2xx` a los 4 webhooks obligatorios (`app.uninstalled`, `store.redact`, `customers.redact` y `customers.data_request`); sin eso no se publica. ## 7. Enviá la app a revisión Cuando todo funciona en tu tienda demo, completá la ficha (descripción, política de privacidad, soporte, video de demo) y usá **Enviar a revisión**. Respondemos en hasta 10 días hábiles. El detalle del checklist está en [Revisión y publicación](/revision). # Límites de uso Source: https://docs.commercy.com.ar/rate-limits Cuántas requests podés hacer, cómo leer los headers y cómo reintentar. Para que una app no degrade la tienda de un comerciante, limitamos cuántas requests puede hacer. El límite se calcula por la combinación **app × comercio**: tu app tiene un cupo propio en cada tienda donde está instalada, y el uso en una tienda no afecta a las demás. ## Cómo funciona Usamos un cubo que se vacía de a poco (*leaky bucket*): cada request llena el cubo una unidad, y el cubo se vacía a un ritmo constante. Mientras no se desborde, podés hacer ráfagas. | Plan del comercio | Capacidad | Goteo | | - | - | - | | Professional | capacidad de **40** requests | **2 requests por segundo** | | Enterprise | ×5: capacidad de 200 requests | ×5: 10 requests por segundo | | Tiendas demo, tu propia tienda de desarrollo | ×1: igual que Professional | ×1 | En la práctica, en una tienda Professional podés hacer una ráfaga de 40 requests y después sostener 2 por segundo. ## Headers Las respuestas a requests autenticados (incluidos los errores de la API) traen: | Header | Significado | | - | - | | `X-RateLimit-Limit` | Capacidad total de tu cubo. | | `X-RateLimit-Remaining` | Requests que te quedan antes de desbordar el cubo. | | `X-RateLimit-Reset` | Segundos hasta que el cubo se vacíe por completo. | Cuando te pasás, la respuesta es `429 rate_limited` e incluye además `Retry-After`, con los segundos que tenés que esperar antes de reintentar: ```http theme={null} HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 40 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 20 Retry-After: 1 Content-Type: application/json {"error":{"code":"rate_limited","message":"Superaste el límite de requests de esta app para esta tienda","details":[],"request_id":"..."}} ``` Los endpoints de OAuth tienen sus propios límites (ver [Autenticación OAuth](/oauth#errores-del-endpoint-de-token)). ## Recomendaciones * **Respetá `Retry-After`.** Es el tiempo mínimo antes de que haya lugar de nuevo. * **Reintentá con backoff exponencial** (1 s, 2 s, 4 s...) y algo de variación aleatoria, para que varios workers no reintenten todos a la vez. * **Mirá `X-RateLimit-Remaining`** y frená antes de llegar a cero, en lugar de esperar al `429`. * **No hagas polling.** Para enterarte de cambios, suscribite a [webhooks](/webhooks) y, para la sincronización de respaldo, pedí sólo lo nuevo con `updated_since` en lugar de recorrer todo el catálogo. * **Paginá con `limit` alto** (hasta 100) en vez de muchas páginas chicas. > [!WARNING] > > El polling agresivo es causal de rechazo en la revisión de tu app (ver [Revisión y publicación](/revision)). En la homologación te pedimos un diagrama de secuencia de cómo consultás la API. # Revisión y publicación Source: https://docs.commercy.com.ar/revision Cómo revisamos las apps, el checklist y los términos del programa. Antes de aparecer en la Tienda de apps de Commercy, cada app pasa por una revisión manual del equipo. Esta página explica cómo funciona, qué miramos y qué condiciones aceptás al participar del programa. ## Ciclo de vida de una app | Estado | Qué significa | | - | - | | `draft` (borrador) | Estás armando la app. Sólo se puede instalar en tu propia tienda y en tus tiendas demo. | | `in_review` (en revisión) | Enviaste la app y el equipo la está revisando. Mientras tanto no podés editarla. | | `published` (publicada) | Aprobada. Cualquier comercio con un plan que incluya apps puede instalarla desde la Tienda de apps. | | `rejected` (rechazada) | La revisión encontró algo para corregir. Te explicamos el motivo; al editarla vuelve a borrador y la podés reenviar. | | `suspended` (suspendida) | El equipo la frenó (por ejemplo por un incumplimiento). Los tokens dejan de validar y no se entregan webhooks nuevos hasta que se levante la suspensión. | ## Qué podés probar en borrador En borrador podés probar todo, sin necesidad de un plan pago, en dos lugares: * **Tu propia tienda** (la cuenta con la que te registraste como developer). * **Tus tiendas demo**: tiendas de ejemplo con productos, stock, clientes y ventas ficticias que creás desde el portal (hasta 3). En borrador podés usar `http://localhost` como redirect URI y URL de la app para desarrollar. Para enviar a revisión, esas URLs tienen que ser `https` y públicas. La URL del webhook **nunca** puede ser `localhost`, ni siquiera en borrador (usá un túnel). ## Plazo Revisamos cada envío en hasta **10 días hábiles** desde que lo enviás. En el portal ves el estado y los días que faltan. Si hay que corregir algo, te dejamos el motivo en la pestaña **Revisión** del portal. ## Qué miramos (checklist) La revisión verifica estos puntos. No todos aplican a todas las apps (por ejemplo, lo del permiso de costos sólo aplica si lo pedís). * **Se instala bien.** Se instala desde el flujo de autorización de Commercy, sin pasos manuales extra. * **Se reinstala bien.** Funciona si se desinstala y se vuelve a instalar. * **Responde a los 4 webhooks obligatorios.** `app.uninstalled`, `store.redact`, `customers.redact` y `customers.data_request` responden `2xx` (ver [Webhooks](/webhooks#los-4-webhooks-obligatorios)). * **Pide sólo los permisos que usa**, y los justifica (ver [Scopes y permisos](/scopes)). * **Justifica el permiso de costos.** Si pedís `read_costs`, tenés que explicar para qué lo necesitás. * **No consulta la API en exceso.** Adjuntás un diagrama de secuencia de cómo sincronizás datos. Preferimos webhooks más `updated_since` antes que polling (ver [Límites de uso](/rate-limits)). * **Política de privacidad y soporte válidos.** Los enlaces funcionan y hay un canal real de soporte. * **La ficha es honesta.** Sin promesas falsas ni marcas de terceros que no te pertenecen. * **Video de demo.** Muestra el flujo completo de la app. * **Probada en una tienda demo.** * **Si se abre dentro de Commercy** (modo embebido): sólo se deja enmarcar por el panel de Commercy y valida el token de sesión. * **Si ocupa lugares de la tienda online**: ningún lugar pide datos de pago ni contraseñas. * **Si agrega scripts a la tienda**: el hash coincide, el archivo se sirve sin redirects y con CORS (`Access-Control-Allow-Origin`) desde una URL versionada, la carga es diferida y sin `document.write`, no lee formularios ni credenciales, es necesario para la función (no es analítica ni publicidad) y no empeora el Lighthouse mobile en más de 10 puntos (ver [Scripts en la tienda](/store-scripts)). > [!NOTE] > > La lista exacta la mantiene el equipo de Commercy y puede ajustarse con el tiempo. En el portal, la pestaña **Revisión** muestra los puntos que aplican a tu app. ## Cambios en una app publicada Una vez publicada, algunos cambios los aplicás directo y otros vuelven a pasar por revisión: | Cambio | Cómo se aplica | | - | - | | URL del webhook y eventos a los que te suscribís | Directo. | | No listar la app en la Tienda | Directo, sin revisión. | | Ficha (nombre, descripción, imágenes, enlaces, precio informativo) | Pasa por revisión. | | Permisos (scopes) | Pasa por revisión. | | Redirect URIs y URL de la app | Pasa por revisión. | | Activar «Abrir dentro de Commercy» | Pasa por revisión. | | Cambiar los lugares de la tienda online | Pasa por revisión. | | Cambiar los scripts de la tienda | Pasa por revisión. | | Desactivar el modo embebido | Directo, salvo que haya una revisión en curso. | Mientras la revisión de un cambio está pendiente, tu app sigue funcionando con la configuración aprobada. Si ampliás los scopes, los comercios que ya la instalaron mantienen los permisos anteriores hasta que vuelvan a autorizarla. ## No listar en la Tienda Si no querés que tu app aparezca en el listado de la Tienda de apps (por ejemplo, porque es para tus propios clientes), activá **No listar en la Tienda** en la pestaña **Revisión** del portal. * **Qué saca:** la app deja de aparecer en el listado y en las búsquedas de la Tienda. Sigue publicada. * **Qué no cambia:** la ficha de la app sigue abierta para quien tenga el link, y las tiendas que ya la instalaron la siguen usando igual. * **Cómo la comparten:** pasales el link a la ficha en el panel, `https://app.commercy.com.ar/apps/`. El portal te muestra el link ya armado, para copiar. Desde ahí el comercio instala la app por el flujo normal: tu app inicia la autorización con PKCE, como siempre. No existe un link de autorización fijo para compartir. * **Sin revisión:** el cambio se aplica al instante, en cualquier estado de la app, tanto para dejar de listarla como para volver a listarla. * **Requisito del comercio:** los comercios con un plan pago de Commercy pueden instalar apps. Con el plan gratis no pueden instalarlas. El equipo de Commercy y quienes revisan las apps ven una etiqueta **No listada** en su pantalla, pero no pueden cambiarla. ## Scripts en la tienda Si tu app agrega scripts a la tienda online del comercio, la revisión es más estricta, porque un script corre con el mismo origen que la tienda. Commercy prende la capacidad de scripts de tu app al aprobarla, y puede apagarla en cualquier momento. Cómo declararlos, el hash SRI y el contrato del archivo están en [Scripts en la tienda](/store-scripts). La política de comportamiento es parte de los términos: * **Prohibido** leer campos de formularios, el `localStorage` o las cookies de Commercy (`auth-storage`, `cart-id`) y el DOM de las rutas excluidas (checkout, «Mi cuenta», login y registro). * **Prohibido** pedir credenciales o datos de pago. * **Obligatorio** envolver el código en un closure, no depender de jQuery ni del JavaScript del tema, y no usar `document.write`. Romper esta política es causal de suspensión de la app. ## Monetización Commercy no cobra comisión por las apps: **0 %**. Si tu app es paga, el cobro es directo entre vos y el comerciante, fuera de Commercy. En la ficha podés informar el modelo (gratis, pago o freemium) y el precio, sólo a modo informativo. ## Términos del programa Al registrarte como developer y publicar apps en Commercy aceptás que: * **Usás los datos sólo para la función de tu app.** Los datos de un comercio y de sus clientes que obtenés por la API o por webhooks se usan únicamente para prestar el servicio que el comerciante instaló. No los vendés, no los cedés y no los usás para otros fines. * **Cumplís los webhooks de privacidad.** Respondés y actuás ante `store.redact`, `customers.redact` y `customers.data_request`: borrás o entregás los datos en los plazos que correspondan. * **Guardás las credenciales de forma segura.** El client secret, los tokens y el secreto de webhook no se exponen en un navegador, en repositorios públicos ni en logs. * **Respondés por tu app.** Sos el responsable de su funcionamiento, su soporte y lo que informás en la ficha. * **Aceptás la suspensión por incumplimiento.** Commercy puede suspender una app o tu cuenta de developer si incumple estos términos, compromete la seguridad o los datos de un comercio, o abusa de la API. La suspensión frena los tokens y los webhooks de la app. # Scopes y permisos Source: https://docs.commercy.com.ar/scopes Qué datos puede ver y modificar tu app, y cómo se piden. Los scopes definen qué datos puede ver y modificar tu app en la tienda de un comerciante. El comerciante los ve, en lenguaje llano, en la pantalla de consentimiento y puede aceptar o rechazar la instalación. Pedí **sólo los scopes que tu app usa**. Cada scope extra se revisa en la homologación y baja la tasa de instalación. ## Catálogo | Scope | Qué habilita | Notas | | - | - | - | | `read_shops` | Ver las sucursales (nombre, dirección y horarios) y los datos básicos de la tienda. | Está **siempre incluido**: no hace falta pedirlo. | | `read_products` | Ver productos, variantes, categorías y marcas. | Habilita los webhooks `product.*` y `products.bulk_updated`. | | `write_products` | Crear, editar y dar de baja productos. | Incluye `read_products`. Los productos que crea la app no llevan precio ni costo: los precios van por `write_prices`. | | `read_prices` | Ver los precios de venta resueltos por sucursal y lista de precios. | | | `write_prices` | Cambiar los precios de venta. | Incluye `read_prices`. | | `read_stock` | Ver el stock de cada sucursal (existencias y disponible). | Habilita el webhook `stock.changed`. | | `write_stock` | Ajustar el stock. | Incluye `read_stock`. | | `read_orders` | Ver las ventas, con nombre, email, teléfono y dirección de cada comprador. | Habilita los webhooks `order.*`. | | `write_orders` | Cambiar el estado de las ventas, cargar los datos de envío e informar que una venta está cobrada. | Incluye `read_orders`. No permite cancelar, devolver ni reembolsar. | | `read_customers` | Ver los clientes y sus datos de contacto. | Habilita los webhooks `customer.*`. | | `write_customers` | Crear y editar clientes. | Incluye `read_customers`. | | `read_costs` | Ver costos y márgenes: `cost` en productos y variantes, y `cogs` en las líneas de las ventas. | **Scope sensible**, ver abajo. | | `storefront_scripts` | Agregar a la tienda online los scripts que la app declara en la pestaña «Scripts». | No da permisos de la API. Lo habilita staff al revisar la app y el comerciante lo acepta al instalar (y lo puede apagar). Ver [Scripts en la tienda online](/store-scripts). | | `pos_payments` | Cobrar en el POS y el kiosco con la app, según la declaración de la pestaña «Cobro presencial». | No da permisos de la API. Aparece destacado como «Cobrar en tu punto de venta» al instalar. Lo habilita staff al revisar la app y el comerciante lo prende en sus medios de pago. Ver [Apps de cobro presencial](/pos-payment-apps). | ## Reglas * **`write_x` implica `read_x`.** Si pedís `write_stock` ya podés leer el stock; no hace falta pedir también `read_stock`. * **`read_shops` siempre está.** Todas las instalaciones pueden listar las sucursales. * **`read_costs` no va solo.** Sólo se puede pedir junto con `read_products` o `read_orders` (o sus versiones `write_`). Aparece destacado en la pantalla de consentimiento con un aviso de que se trata de información sensible, y hay que justificarlo en la revisión. * **Los webhooks de un recurso exigen su `read_`.** Una instalación sin `read_orders` no recibe `order.created`, aunque esté suscripta. Los 4 webhooks obligatorios se entregan a todas las instalaciones. * **Nunca se exponen datos fiscales.** Ningún recurso de la API v1 devuelve CUIT, CUIL, razón social ni condición frente al IVA. Sólo se informa un DNI cuando el comerciante lo cargó como DNI. * **La instalación ve todas las sucursales del comercio**, no se puede acotar a algunas. * **Los scopes no se reducen en silencio.** Si ampliás los scopes de tu app, los comerciantes ya instalados mantienen los anteriores hasta que vuelvan a autorizar. ## Qué pasa si falta un scope Si tu token no tiene el scope que necesita un endpoint, la API responde `403 insufficient_scope` e indica cuál falta: ```json theme={null} { "error": { "code": "insufficient_scope", "message": "La instalación no tiene el scope read_orders", "details": [{ "required_scope": "read_orders" }], "request_id": "0b6f6d3e-6c0e-4c0b-9a5a-6a8f6d1c2a10" } } ``` Los scopes de una instalación están en el campo `scope` de la respuesta del token. Para pedir más, ampliá los scopes de tu app en el portal y pedile al comerciante que vuelva a autorizarla (ver [Autenticación OAuth](/oauth)). # Apps de envío Source: https://docs.commercy.com.ar/shipping-apps 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](/api-reference)** 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](/scopes). 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](/webhooks). 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 ``. * 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, ".")` 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 /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](/api-reference) (`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 /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 `:`. **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 /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](/api-reference#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](/errores). ## Qué pasa con la venta * **Si el comercio cancela la venta, no cancelamos el envío en tu sistema.** Escuchá el webhook [`order.cancelled`](/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. # Scripts en la tienda Source: https://docs.commercy.com.ar/store-scripts Cómo tu app agrega un widget o un script a la tienda online del comercio: declaración, SRI, carga y window.Commercy. Un script de tienda le permite a tu app agregar un widget o un fragmento de código a la tienda online de un comercio: un botón de chat, un botón de WhatsApp, un banner de cuotas, un popup que el comercio configuró. Declarás hasta **3** scripts en tu app, el equipo de Commercy los revisa, y cuando el comercio instala tu app los cargamos en su tienda. Un script corre con el mismo origen que la tienda, así que tiene mucho poder. Por eso es el permiso más controlado del marketplace: pasa por revisión, el navegador verifica que el archivo no cambió (SRI), el comercio lo ve y lo puede apagar cuando quiera, y nosotros podemos cortarlo al instante. ## Qué es y qué no es * **Es** código tuyo, servido desde tu dominio, que se carga en las páginas públicas de la tienda: inicio, catálogo, ficha de producto, carrito, búsqueda, páginas y contacto. * **No es** una forma de tocar el checkout. Nunca cargamos scripts de apps en el checkout ni en la confirmación de la compra, en «Mi cuenta», en el login ni el registro, en la recuperación de clave, en el botón de arrepentimiento, ni en la vista previa del editor de la tienda. Ver «Rutas excluidas». * **No es** una API: no recibís datos de clientes ni podés escribir en Commercy desde la tienda. Lo que sabe tu script es lo que ve el visitante, más el contexto de `window.Commercy` (más abajo). * **En esta versión** sólo se aceptan scripts **necesarios** para la función que el comercio instaló. Ver la sección «Qué cuenta como necesario». ## Las tres llaves Un script se carga en una tienda sólo si se cumplen las tres a la vez: 1. **Commercy habilitó los scripts en tu app.** La capacidad la prende el equipo de Commercy al aprobar tu app, y la puede apagar en cualquier momento. 2. **El comercio concedió el scope `storefront_scripts`** al instalar tu app (ver [Scopes y permisos](/scopes)). El comercio ve un aviso destacado: «Agregar código a tu tienda online». 3. **La declaración está aprobada.** Si tu app ya está publicada, lo que cambiás pasa por revisión y mientras tanto sigue corriendo la versión aprobada. Además valen las condiciones de siempre: la instalación tiene que estar activa, el plan del comercio tiene que incluir las apps y la tienda no puede estar suspendida. El comercio puede **apagar los scripts de tu app** desde el detalle de la app instalada, sin desinstalarla. Si la desinstala o revoca el acceso, también dejan de cargarse. Si tu app ya estaba instalada y le agregás el scope `storefront_scripts`, cada comercio tiene que volver a autorizarla: hasta que lo haga no cargamos nada en su tienda. ## Cómo declarar un script En el portal, abrí tu app y andá a la pestaña **Scripts**. Cada fila tiene: | Campo | Qué es | | - | - | | `handle` | Identificador estable del script dentro de tu app: 2 a 40 caracteres, minúsculas, números, `-` y `_`, empieza con letra o número. No se puede repetir. | | `name` | Nombre legible, hasta 40 caracteres. Es el que ve el comercio y el que aparece en los mensajes de la consola. | | `src` | URL del archivo. Ver «Dónde puede vivir el archivo». | | `integrity` | Hash SRI `sha384-...` del archivo. Ver «Hash SRI». | | `loading` | Cuándo se carga: `interaction` (por defecto) o `idle`. Ver «Cuándo se carga». | | `consent` | En esta versión tiene que ser `necessary`. | También podés declararlos por API, con el `PATCH /api/v1/developers/apps/:id` que usa el portal: ```json theme={null} { "storefrontScripts": [ { "handle": "chat-widget", "name": "Botón de chat", "src": "https://cdn.tuapp.com/widget.v3.js", "integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC", "loading": "interaction", "consent": "necessary" } ] } ``` Además tenés que pedir el scope `storefront_scripts` en la pestaña **Scopes**: sin él, no podés enviar la app a revisión. Los límites: | Límite | Valor | | - | - | | Scripts por app | 3 | | Scripts por tienda | 10 (si una tienda instala más, se cargan los de las apps instaladas primero) | | Tamaño del archivo | 256 KB sugerido | El tamaño no lo bloqueamos al guardar, pero el portal te avisa y la revisión lo mira. ## Dónde puede vivir el archivo * Tiene que ser `https`. * Tiene que estar en el **mismo dominio** que la **URL de tu app** (`appUrl`): `cdn.tuapp.com` sirve si tu app está en `app.tuapp.com`. Si tu `appUrl` no tiene un dominio propio, no podemos validar el script. * No puede ser un dominio de Commercy ni el dominio de una tienda. * No puede llevar usuario ni contraseña en la URL, ni usar un puerto menor a 1024, ni apuntar a una IP privada. Si después cambiás el `appUrl` y un script queda en otro dominio, deja de cargarse. ## Hash SRI Cada script lleva el hash `sha384` de su contenido. El navegador de cada visitante lo verifica antes de ejecutar el archivo: si alguien lo cambia, **no corre**. En el portal, el botón **Calcular hash** lo hace por vos. Desde una terminal: ```bash theme={null} echo "sha384-$(openssl dgst -sha384 -binary widget.v3.js | openssl base64 -A)" ``` El resultado tiene que ser `sha384-` seguido de 64 caracteres. ### El archivo se sirve así Para que el navegador pueda verificar el hash, tu servidor tiene que cumplir tres cosas: * **Sin redirects.** Una respuesta `301` o `302` rompe la verificación y es una forma de cambiar el contenido sin que nos enteremos. La `src` tiene que responder `200` directo. * **Con CORS.** La respuesta lleva `Access-Control-Allow-Origin: *`. Sin ese header, el navegador no ejecuta el script (cargamos con `crossorigin="anonymous"`). * **Con `Content-Type: text/javascript`.** Y en una **URL versionada** (por ejemplo `widget.v3.js`) cuyo contenido no cambia nunca. ### Cómo actualizar sin romper Como el hash está atado al contenido, **no pises el archivo en la misma URL**: todas las tiendas dejarían de cargarlo hasta que apruebe la revisión. 1. Publicá el archivo nuevo en una URL nueva (`widget.v4.js`). 2. Dejá **vivo el archivo viejo** (`widget.v3.js`): las tiendas siguen cargándolo hasta que se apruebe el cambio. 3. Calculá el hash del nuevo, y cambiá `src` e `integrity` en el portal. Si tu app está publicada, el cambio pasa por revisión y se aplica al aprobarlo. 4. Cuando el cambio esté aprobado y pasaron unos minutos, ya podés retirar el archivo viejo. ## Cuándo se carga Cargamos el script con `async`, así que nunca bloquea la página. * **`interaction`** (por defecto): después de la primera interacción del visitante (un toque, una tecla, un scroll o un movimiento de la rueda). Si el visitante no interactúa, el script no se carga. Es lo mejor para el rendimiento de la tienda. * **`idle`**: cuando termina de cargar la página y el navegador está libre. Se puede usar, pero la revisión te va a pedir que justifiques por qué no alcanza con `interaction`. Se carga una sola vez por documento. Si el archivo no se puede cargar (hash que no coincide, falta CORS, un redirect), el navegador lo bloquea y en la consola de la tienda aparece: `[Commercy] No se pudo cargar el script "" de . Revisá el hash SRI y que el archivo se sirva con CORS y sin redirects.` ## Qué cuenta como necesario En esta versión sólo aceptamos `consent: "necessary"`. Un script es necesario cuando **sin él no anda la función que el comercio instaló y no hace perfilado del visitante**: un chat, un botón de WhatsApp, un banner de cuotas, un popup que configuró el comercio. **No es necesario**, y por lo tanto todavía no se puede declarar: la medición de audiencia, los mapas de calor, la grabación de sesiones, los píxeles publicitarios, el remarketing y el envío de eventos a plataformas de anuncios. Esos casos llegan más adelante, con el consentimiento del visitante. Si declarás `analytics` o `marketing`, la API responde con el motivo `consent_unsupported`. ## `window.Commercy` Antes de cargar el primer script definimos `window.Commercy`: un objeto **congelado** (no lo podés modificar ni reemplazar) y **sin datos personales**. No incluye email, nombre, teléfono, dirección, documentos ni ids de clientes, ni tokens. | Propiedad o método | Qué devuelve | | - | - | | `Commercy.version` | `1` | | `Commercy.store` | `{ id, shopId, name, domain, currency, locale }`. `locale` es siempre `"es-AR"`; `shopId` puede ser `null`. | | `Commercy.getPage()` | `{ type, path, product }`. `type` es `home`, `product`, `catalog`, `search`, `cart`, `page`, `contact` u `other`. `path` no incluye la query. `product` es `{ id, name, price }` en una ficha de producto y `null` en el resto. | | `Commercy.getCart()` | `{ itemCount, subtotal, currency, items }`. Cada item es `{ productId, variantSku, name, quantity, unitPrice }`. | | `Commercy.getCustomer()` | `{ loggedIn }` y nada más. | | `Commercy.on(event, cb)` | Se suscribe a un evento. Devuelve una función para desuscribirse. | | `Commercy.off(event, cb)` | Se desuscribe. | Cada llamada a un `get` devuelve una foto nueva, congelada. No se puede emitir eventos desde un script: sólo escuchar. ### Eventos Son cuatro. Todos los payloads están congelados. | Evento | Cuándo | Payload | | - | - | - | | `page_viewed` | Al entrar a una página permitida y en cada cambio de página. | `{ type, path }` | | `product_viewed` | Al ver la ficha de un producto. | `{ productId, name, price, currency }` | | `product_added_to_cart` | Cuando el visitante agrega un producto y el server lo aceptó. | `{ productId, variantSku, name, price, quantity, currency }` (`quantity` es la cantidad **agregada**) | | `checkout_started` | Cuando el visitante pasa del carrito al checkout. | `{ itemCount, subtotal, currency, items }` (la forma de `getCart()`) | Como tu script se carga tarde, `page_viewed` y `product_viewed` se **reenvían** al suscribirte: recibís el último de la página actual. Los otros dos no se repiten. Si un callback tira un error, lo atrapamos y no afecta a los demás. Un `on()` con un evento desconocido, o con algo que no es una función, se ignora y deja un aviso en la consola. Hay un tope de 50 listeners por evento. ### Ejemplo ```js theme={null} // commercy-script-example (function () { 'use strict'; if (!window.Commercy) return; var C = window.Commercy; function send(name, data) { // Mandá lo que necesites a tu backend. Acá sólo lo mostramos. console.log('[mi-widget]', name, data); } C.on('page_viewed', function (p) { send('page', { type: p.type, path: p.path, store: C.store.domain }); }); C.on('product_viewed', function (p) { send('product', { id: p.productId, price: p.price, currency: p.currency }); }); C.on('product_added_to_cart', function (p) { send('add', { id: p.productId, quantity: p.quantity }); }); C.on('checkout_started', function (cart) { send('checkout', { items: cart.itemCount, total: cart.subtotal, currency: cart.currency }); }); })(); ``` ## Rutas excluidas Estas rutas nunca llevan scripts de apps, y `window.Commercy` no existe en ellas: `/checkout` (incluye la confirmación de la compra), `/cuenta`, `/login`, `/registro`, `/auth`, `/forgot-password`, `/reset-password`, `/arrepentimiento`, `/preview` y las rutas del Hub de pago (`/confirm`, `/hub`) y las rutas internas de desarrollo del editor. Si el visitante navega desde una página con tu script hacia una ruta excluida, **recargamos el documento** para que tu script no siga vivo ahí. Tampoco cargamos nada en la vista previa del editor ni en el borrador de la tienda. ## Política de comportamiento Con un script en la tienda tenés acceso a datos sensibles del visitante. Por eso: * **Prohibido** leer campos de formularios, y leer o escribir el `localStorage` o las cookies de Commercy (`auth-storage`, `cart-id`). * **Prohibido** leer o modificar el DOM de las rutas excluidas. * **Prohibido** pedir credenciales o datos de pago. * **Obligatorio** envolver tu código en un closure (como en el ejemplo), no depender de jQuery ni del JavaScript del tema de la tienda, y no usar `document.write`. * Cargá lo mínimo: la revisión mide el Lighthouse mobile de una tienda y tu script no puede empeorarlo en más de 10 puntos. Incumplir esta política es causal de suspensión de la app. ## Cómo probar Mientras tu app está en borrador, los scripts sólo corren en **tu tienda de desarrollo** y en tus **tiendas demo**. 1. Declará el script y pedí el scope `storefront_scripts`. 2. Pedile a soporte que habilite los scripts en tu app, para poder probarlos en borrador. Pasale el id de tu app. 3. Instalá la app en tu tienda de desarrollo aceptando el scope. 4. Abrí la tienda, interactuá con la página (un scroll alcanza) y mirá la consola. En borrador podés usar un `src` en `http://localhost` (también `127.0.0.1` y `::1`), que no necesita estar en el dominio de tu app. Para enviar la app a revisión el `src` tiene que ser `https` y público. Después del envío, la revisión mira que el hash coincida, que el archivo se sirva sin redirects y con CORS desde una URL versionada, que la carga sea diferida, que sea un script necesario y que no empeore el rendimiento. Ver [Revisión y publicación](/revision). ## Errores de validación Cuando algo de la declaración no es válido, la API responde `400` y `details` trae un item por cada problema, con `{ field, reason }`. El `field` indica qué script (por ejemplo `storefrontScripts[0].src`). | `reason` | Qué pasó | Qué hacer | | - | - | - | | `duplicate_handle` | Dos scripts con el mismo `handle`. | Usá un `handle` distinto para cada uno. | | `invalid_url` | La `src` no es una URL válida. | Revisá la URL. | | `not_https` | La `src` no es `https`. | Servila por `https`. | | `credentials` | La URL lleva usuario o contraseña. | Sacalos de la URL. | | `port` | La URL usa un puerto menor a 1024. | Usá el puerto estándar de `https`. | | `private_ip` | La URL apunta a una IP privada. | Usá un dominio público. | | `localhost` | La `src` apunta a `localhost` o a un host interno donde no se permite. | En borrador sólo se acepta `http://localhost`; para enviar a revisión o publicar usá `https` público. | | `commercy_domain` | La `src` es un dominio de Commercy. | Alojá el archivo en tu dominio. | | `store_domain` | La `src` es el dominio de una tienda. | Alojá el archivo en tu dominio. | | `app_url_required` | Tu app no tiene una `appUrl` con dominio propio. | Cargá la URL de tu app en el portal. | | `not_app_domain` | La `src` no está en el dominio de tu `appUrl`. | Usá el mismo dominio (o un subdominio) que tu `appUrl`. | | `consent_unsupported` | El `consent` no es `necessary`. | Sólo se aceptan scripts necesarios en esta versión. | | `scope_required:storefront_scripts` | Declaraste scripts pero no pediste el scope. Aparece al enviar a revisión. | Agregá `storefront_scripts` en la pestaña **Scopes**. | ## Ejemplos Dos scripts de ejemplo, listos para copiar y adaptar: * [`whatsapp-widget-script`](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/whatsapp-widget-script): un widget de WhatsApp para la tienda. * [`installments-banner-script`](https://github.com/KonixDev/commercy-app-examples/tree/main/examples/installments-banner-script): un banner de cuotas para la tienda. # Webhooks Source: https://docs.commercy.com.ar/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-reference). | | `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. ### 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`, `delivery_confirmation` o `app` (cobro informado por una app con `POST /orders/:id/payment`). La app que informa el cobro no recibe su propio `order.paid`. | | `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": "" }`. ## 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, ".") ``` 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). ```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} 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) ``` 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.