Skip to main content
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). 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. 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.
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

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: 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.
Con Express, usá el parser raw en esas rutas para conservar el cuerpo crudo:

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

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.
Respondé:
Hasta 500 dispositivos. Un fallo de esta operación sólo impide listar: no hay errores tipados.

Crear el cobro

Respondé:
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.

Consultar el estado

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é:
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. 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

La tolerancia de comparación es de un centavo.

Cancelar

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é:
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.
  • 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.
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 es una app de cobro presencial de ejemplo, lista para copiar y adaptar.