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

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

* Usamos `https` hacia una dirección pública. **No seguimos redirects.**
* Leemos como máximo **64 KB** de tu respuesta. Si es más larga la tratamos como error, nunca como JSON parcial.
* Tiempos máximos: **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, "<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 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 <apiBaseUrl>/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 <apiBaseUrl>/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 de Commercy>/api/v1/app-webhooks/<installation_id>
```

```json theme={null}
{ "type": "payment.updated", "payment_id": "pay_8f3a21" }
```

* `<installation_id>` 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`](https://docs.commercy.com.ar/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`](https://docs.commercy.com.ar/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`](https://docs.commercy.com.ar/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](https://docs.commercy.com.ar/api-reference.md#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.


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