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

# Apps de cobro presencial

> 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](https://docs.commercy.com.ar/scopes.md). 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 `<apiBaseUrl><ruta>`.

* Usamos `https` hacia una dirección pública. **No seguimos redirects.**
* Leemos como máximo **64 KB** de tu respuesta. Si es más larga la tratamos como error, nunca como JSON parcial.
* 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](https://docs.commercy.com.ar/payment-apps.md): `HMAC-SHA256(secreto, "<X-Commercy-Timestamp>.<cuerpo crudo>")` en hexadecimal y con el prefijo `v1=`, con tu secreto completo (incluido `whsec_`). Verificala siempre, porque cualquiera podría llamar a tus rutas 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](https://docs.commercy.com.ar/payment-apps.md), `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 <apiBaseUrl>/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 <apiBaseUrl>/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 <apiBaseUrl>/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 <apiBaseUrl>/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 de Commercy>/api/v1/app-webhooks/<installation_id>
```

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


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