- 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.
Cómo se habilita
Hacen falta cuatro cosas, y la última la decide el comercio:- 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.
- 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. - 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 serhttpsy pública, por ejemplohttps://api.tuapp.com. Esa base se concatena con las rutas de esta página. TuappUrltiene que ser un dominio propio, no una IP nilocalhost. - 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.
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 unPOST con cuerpo JSON a <apiBaseUrl><ruta>.
- Usamos
httpshacia 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é
2xxcon un cuerpo JSON. Un4xxcon{ "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:
- Leé el cuerpo crudo, antes de parsear el JSON.
- Comparala con
X-Commercy-Signatureen tiempo constante. - Rechazá timestamps que difieran más de 5 minutos de tu reloj.
- Comprobá que el
installation_iddel cuerpo sea el deX-Commercy-Installation. La firma cubre el cuerpo, no la URL, así que el recurso viaja en el cuerpo.
raw en esas rutas para conservar el cuerpo crudo:
Convenciones del contrato
- Montos como string con dos decimales, por ejemplo
"1234.50", y monedaARS. Los comparamos en centavos. Un monto que no es exactamente así (amount_paid: 1234.5como 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,
amountva como número; acá va como string.
- Ojo si también implementás una app de pago online. En las apps de pago online,
amount_paides 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_reflo 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 mismopayment_refy el mismo cuerpo devuelve el mismopayment_id. Tenés que retener la clave al menos 72 horas más el vencimiento del cobro. El mismopayment_refcon otro cuerpo se respondeidempotency_conflict. Si tu procesador no tiene idempotencia, guardá elpayment_refantes de llamarlo. - Un
statuso unerror.codeque no conocemos se tratan como «no verificado» (volvemos a consultar), nunca como rechazo. - Commercy nunca aplica el
statusde la respuesta de creación ni el cuerpo de un aviso: sólo el de la operaciónstatusverifica un cobro.
Errores
La semántica es la misma en todas las operaciones:- Un
4xxcon un código del contrato quiere decir que no se creó nada (o que no se hizo lo pedido), con una excepción:idempotency_conflictsignifica que el cobro ya existe en tu app, así que lo resolvemos constatus. El formato es{ "error": { "code": "device_busy", "message": "El lector está ocupado" } }. - Un
5xx, un timeout o un4xxsin código quiere decir que no sabemos qué pasó: el cobro puede existir en tu app. No lo damos por no creado: lo resolvemos consultandostatusporpayment_ref. Lo mismo para un código que no conocemos. - Cuándo un
4xxcuenta como error tipado. Sólo si el cuerpo traeerror.code(texto de 1 a 40 caracteres, sóloa-zy_, p. ej.device_busy) yerror.message(texto de hasta 500 caracteres). Si falta alguno, si elcodetiene mayúsculas, números u otros símbolos, o si elmessagepasa de 500 caracteres, lo tratamos como un resultado desconocido y el intento queda «sin confirmar» hasta questatuslo resuelva. - Una respuesta
2xxmal formada (o con undisplayinválido) tampoco es «no creado»: el cobro pudo haberse creado, y lo resolvemos constatus.
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 declarastelistsDevices. Los lectores que el comercio vincula a su sucursal salen de esta lista.
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. Cualquierdisplayse descarta. -
Modo
qr:qr_dataes obligatorio. Puede ser:- un QR EMVCo con CRC válido: empieza con
000201, es una cadena de campos bien formada que termina en el campo63de largo04, y ese CRC es el CRC-16/CCITT-FALSE (polinomio0x1021, valor inicial0xFFFF) de los bytes UTF-8 de todo el texto hasta6304inclusive. Los nombres con «ñ» o acentos cuentan: calculá el CRC sobre los bytes UTF-8; - o una URL
httpsdel mismo dominio registrable que tuappUrl, sin credenciales ni espacios. ConappUrl = https://tuapp.comsirvenhttps://pagos.tuapp.com/...y no sirvehttps://otro.com/.... Los dominios compartidos de hosting (*.vercel.app) no cuentan como un mismo dominio.
instructionses opcional. - un QR EMVCo con CRC válido: empieza con
-
Modo
none:instructions,transfero 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
payment_ref: payment_id es opcional, porque la creación puede haberse cortado por timeout antes de que lo recibiéramos. Respondé:
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_refypayment_idtienen que ser los del intento. Si no coinciden, no aplicamos nada y avisamos a nuestro equipo. rejected,cancelledyexpiredexigenamount_paidigual a"0.00"; si viene mayor, la tratamos comopartial.- Un cobro
approvedsólo puede pasar arefundedopartially_refunded. Cualquier otra transición posterior (de vuelta apendingo arejected, por ejemplo) se ignora y se alerta. La única excepción esapprovedaapprovedcon unamount_paidmayor, si el anterior no se había aplicado. - La suma de
amount_extratiene que ser menor o igual aamount_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 declarastesendsWebhooks, 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_iddel cuerpo tiene que ser el del path: la firma no cubre la URL, y lo comprobamos. Content-Type: application/jsones obligatorio.- Va firmado igual que nuestros requests, con tu secreto de webhook: el header
X-Commercy-Signature(v1=...) y elX-Commercy-Timestampde 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 porpayment_ref: mientras hay una consulta en curso para ese cobro (como máximo 10 segundos), un aviso repetido recibe202sin una consulta nueva. Terminada la consulta, el aviso siguiente se atiende. - Ante un
202reintentá 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.
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
deviceyqr): antes de dar el cobro por vencido consultamosstatus; si sigue vivo en tu app, te pedimoscancel. 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 unpayment_refnuevo. Por esostatusycanceltienen 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
statusy 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 tustatus 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
statuste dan401y 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_idcoincida con el header, y aceptás los dos secretos mientras rotás. - Los montos van y vienen como string con dos decimales;
amount_paides el bruto cobrado. - La creación es idempotente por
payment_ref(al menos 72 horas más el vencimiento) y respondeidempotency_conflictante otro cuerpo. -
statusresuelve porpayment_refaunque no hayapayment_id, y devuelvepayment_not_found(404) cuando no lo conoce, rechazando después la creación conpayment_ref_consumed. -
canceles idempotente porcancel_refy devuelvenot_cancellablesi el cobro ya avanzó. - El
displaycumple las validaciones: QR EMVCo con CRC UTF-8 o URLhttpsde tu dominio, instrucciones de hasta 280 caracteres, transferencia con CVU, alias y CUIT válidos. - Tus errores son
4xxcon código del contrato cuando no se creó nada; ante la duda,5xx. - Declaraste el cobro presencial, pediste el permiso
pos_paymentsy 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.