Procobro
Comenza a aceptar todo tipo de pagos Empezá en sandbox hoy mismo: quickconnect-sandbox.pro-cobro.com
Empezá en 3 pasos
De cero a tu primera operación en minutos, directo desde la terminal. Solo necesitás las credenciales de sandbox que te da Scanntech.
Obtené tu token
Canjeá el client_id y client_secret que recibís en el onboarding de Scanntech por un Bearer token. Dura 1 hora: cachealo y renovalo antes de que expire.
<CLIENT_ID> y <CLIENT_SECRET> por tus credenciales de sandbox. Guardá el access_token de la respuesta — va en el header Authorization de todos los pasos siguientes.curl -X POST https://quickconnect-sandbox.pro-cobro.com/oauth/token \
-H "Content-Type: application/json" \
-d '
{
"client_id": "<CLIENT_ID>",
"client_secret": "<CLIENT_SECRET>"
}
'{
"access_token": "eyJhbGciOi…",
"token_type": "Bearer",
"expires_in": 3600
}Generá un link de pago
Si preferís no tocar datos de tarjeta, creá una URL de checkout hosteada y compartila por WhatsApp, email o QR.
url de la respuesta es lo que le mandás a tu cliente. Cuando paga, el link te devuelve el payment_id asociado. Al cerrar el checkout lo redirigimos a success_url, cancel_url o error_url según cómo haya terminado.curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/links \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '
{
"type": "payment",
"data": {
"amount": {
"value": 25000,
"currency": "COP"
},
"description": "Orden #12345",
"success_url": "https://mitienda.com/success",
"cancel_url": "https://mitienda.com/cancel",
"error_url": "https://mitienda.com/error"
}
}
'{
"id": "lnk-a1b2c3d4",
"url": "https://checkout.akua.la/links/lnk-a1b2c3d4",
"status": "created"
}Salí a producción
Validaste tus flujos en sandbox: pedile a Scanntech tus credenciales productivas y apuntá tu integración a https://quickconnect.pro-cobro.com. Mismos endpoints, mismos contratos.
/llms-full.txt — toda esta doc en markdown, lista para que tu agente escriba la integración. Y para buscar acá, apretá /.Base URL
| Ambiente | URL |
|---|---|
| Sandbox | https://quickconnect-sandbox.pro-cobro.com |
| Producción | https://quickconnect.pro-cobro.com |
Todas las peticiones usan Content-Type: application/json.
Autenticación
Obtené un access token con las credenciales que recibís en el onboarding (un par por ambiente):
El token dura 1 hora — cachealo y renovalo antes de que expire. Todas las demás llamadas lo llevan en el header Authorization; sin token válido la API devuelve 401.
curl -X POST https://quickconnect-sandbox.pro-cobro.com/oauth/token \
-H "Content-Type: application/json" \
-d '
{
"client_id": "<CLIENT_ID>",
"client_secret": "<CLIENT_SECRET>"
}
'{
"access_token": "eyJhbGciOi…",
"token_type": "Bearer",
"expires_in": 3600
}curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/payments \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ ... }'Obtener un pago
Devuelve el pago con su estado recomputado y el detalle de todas sus transacciones. Es la fuente de verdad: consultá acá después de recibir un evento, en vez de decidir por el nombre del evento.
| Campo | Para qué |
|---|---|
status | El estado recomputado del pago. Es la verdad (ver Estados) |
current_amount | El monto vigente, ya con capturas parciales y reembolsos aplicados |
transactions[] | Cada movimiento con su fecha, para saber cómo se llegó hasta ahí |
external_client_trace_id | El identificador que mandaste vos al crear el pago |
id no existe o es de otro cliente, la API responde 404 con "message": "payment not found".curl -X GET https://quickconnect-sandbox.pro-cobro.com/v1/payments/{id} \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"id": "pay-dak3bkvudovnsbia9pj0",
"intent": "authorization",
"status": "REFUND_CAPTURED",
"status_detail": "SUCCESS",
"currency": "USD",
"country": "URY",
"initial_amount": 100,
"current_amount": 0,
"created_at": "2026-09-14T17:58:43.830183Z",
"external_client_trace_id": "orden-12345",
"transactions": [ { "id": "trx-…", "type": "AUTHORIZATION", "status": "APPROVED" } ]
}Listar pagos
Devuelve un listado paginado, ordenado por fecha de creación descendente. Se pagina con page y page_size.
| Parámetro | Default | Notas |
|---|---|---|
page | 1 | Número de página |
page_size | 10 | Registros por página. No pidas más de 200 (ver abajo) |
sort | created_at:desc | También acepta created_at:asc |
status | — | Filtra por estado del pago (ver Estados) |
created_at_from / created_at_to | — | Rango de fechas, formato RFC3339 |
external_client_trace_id | — | Tu propio identificador de pedido |
| Campo de `pagination` | Qué esperar |
|---|---|
page / page_size | Los valores efectivos de esta respuesta |
total | Cantidad de resultados. Se topea en 10000, y puede venir -1 cuando no se pudo contar |
total_found | Mismo valor que total. No aparece cuando no hay resultados |
total: con muchos resultados llega topeado en 10000 o en -1. Cortá cuando una página vuelva vacía, que es la señal confiable de fin de listado. Más allá del registro 10000 no se puede paginar.page_size por encima de ~200 la respuesta se corta en 1 MB y el JSON llega inválido. Pedí de a 200 como máximo. Un parámetro que no exista devuelve 400 INVALID_QUERY_PARAM — no se ignora en silencio.Los filtros de fecha se mandan en RFC3339 (2026-09-01T00:00:00Z). Ojo con las fechas de la respuesta: en este listado created_at y updated_at vuelven como Unix epoch en string ("1789408723"), mientras que GET /v1/payments/{id} las devuelve en RFC3339. Es el mismo pago con dos formatos según el endpoint.
curl -X GET https://quickconnect-sandbox.pro-cobro.com/v1/payments?page=1&page_size=50 \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"data": [ { "id": "pay-…", "status": "CAPTURED" } ],
"pagination": {
"page": 1,
"page_size": 50,
"total": 94,
"total_found": 94
}
}Reembolsar un pago
Reembolsos parciales: enviá un amount menor al original. Solo aplica a pagos capturados (la captura automática tarda unos minutos); para deshacer un pago recién autorizado usá cancelar.
curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/refunds \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '
{
"payment_id": "pay-cu94vq1n8ql8u3v469gg",
"amount": 1000,
"currency": "COP"
}
'Cancelar / Capturar
Anula una autorización aún no capturada (void). Sin body.
Solo para captura diferida — por defecto los pagos se capturan automáticamente.
curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/payments/{id}/cancel \
-H "Authorization: Bearer $ACCESS_TOKEN"curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/payments/{id}/capture \
-H "Authorization: Bearer $ACCESS_TOKEN"Contracargos
Se pagina igual que los pagos, con page y page_size, y la respuesta trae el detalle completo de cada disputa — no hace falta una segunda consulta.
page_size es 25, y pedir más devuelve 400 BAD_PARAMETERS. Es distinto del listado de pagos — no asumas el mismo límite en los dos.Mirá expires_at para el plazo de respuesta y reason_id para saber qué evidencia hace falta. Los estados que vas a ver son PENDING, INFO_REQUESTED y REPRESENTED.
curl -X GET https://quickconnect-sandbox.pro-cobro.com/v1/chargebacks \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"data": [
{
"id": "cbk-dak2l26kfdel09fpl9rg",
"payment_id": "pay-dahebpuuf97kkem2n3fg",
"transaction_id": "trx-dahebpuuf97kkem2n3f0",
"status": "PENDING",
"reason_id": "10_4",
"reason_description": "Otros Fraudes – Ambiente de Tarjeta Ausente",
"amount": 100,
"currency": "URY",
"created_at": "2026-09-14T17:10:32.26776Z",
"expires_at": "2026-10-04T05:00:00Z"
}
],
"pagination": { "page": 1, "page_size": 25, "total": 3, "total_found": 3 }
}Crear un link
Generá una URL de checkout hosteada — tu cliente paga sin que manejes datos de tarjeta. Compartila por WhatsApp, email o QR.
| Campo de `data` | Cuándo vuelve el cliente ahí |
|---|---|
success_url | El pago se aprobó |
cancel_url | El cliente abandonó el checkout |
error_url | El pago falló o fue rechazado |
Las tres son URLs tuyas a las que redirigimos al cliente al cerrar el checkout, según cómo haya terminado. Definilas por separado: si mandás una sola para todo, no vas a poder distinguir un pago aprobado de uno abandonado.
| Campo de `data` | Valor | Qué hace |
|---|---|---|
data.payment_methods_source | "merchant" | El checkout ofrece todos los medios de pago habilitados para el comercio, tal como estén configurados en el hub |
Con data.payment_methods_source: "merchant" no enumerás medios de pago link por link: el checkout toma los que el comercio tenga habilitados. Si más adelante se le habilita uno nuevo, aparece solo — sin tocar tu integración ni volver a generar los links.
multi_use: true + max_uses para links reutilizables; data.amount.type: "custom" para que el cliente elija el monto (con min_amount/max_amount); expires_in en segundos (default 12 h, máximo 24 h).curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/links \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '
{
"type": "payment",
"expires_in": 3600,
"data": {
"amount": {
"value": 25000,
"currency": "COP"
},
"description": "Orden #12345",
"payment_methods_source": "merchant",
"success_url": "https://mitienda.com/success",
"cancel_url": "https://mitienda.com/cancel",
"error_url": "https://mitienda.com/error"
}
}
'{
"id": "lnk-a1b2c3d4",
"url": "https://checkout.akua.la/links/lnk-a1b2c3d4",
"status": "created",
"expires_at": "2026-07-02T14:00:00Z"
}Consultar links
Devuelve el link con su status y, una vez pagado, el payment_id asociado. GET /v1/links lista todos, paginado.
curl -X GET https://quickconnect-sandbox.pro-cobro.com/v1/links/{id} \
-H "Authorization: Bearer $ACCESS_TOKEN"Sandbox: probá el formulario
Antes de escribir una línea de backend, jugá con la sesión de captura acá mismo. Cambiá el layout, los labels, un color de variables o el submit_button y mirá cómo se redibuja el formulario de tarjeta. Es el mismo body que después le vas a mandar a POST /v1/instruments/collect-sessions.
Editá el JSON y mirá cómo se redibuja el formulario. Es una maqueta que corre en tu navegador: no es el iframe real y no sale una sola request. El formulario de producción lo sirve Akua desde su propio origen y no le podés pasar CSS ni clases tuyas: lo único que viaja es el objeto appearance, con un vocabulario cerrado que Akua valida y traduce a CSS de su lado. Probá romperlo —un valor fuera de rango, una clave que no existe— y vas a ver los mismos details[] que te devolvería un 400 invalid_appearance.
Config válido. El Idempotency-Key va en el header, no acá. Una vez creada la sesión su look queda congelado: para cambiarlo, creá una sesión nueva.
Fuera del iframe · submit_button.mode: "external" — este botón es tuyo, lo estilás vos y llama a submit().
trace_id: orden-83779 · layout two-row · labels floating
La maqueta dibuja base, empty, complete, focus e invalid. placeholder, disabled, autofill y hover se validan igual, pero acá no se ven. El ancho y el alto del iframe nunca los ponés vos.
appearance, que Lo validamos y lo traducimos a CSS de nuestro lado. Probá meterle un valor fuera de rango o una propiedad que no esté en la tabla: vas a ver el mismo 400 invalid_appearance que te devolvería la API. El contrato completo está en Apariencia del formulario.Cómo funcionan los campos de tarjeta
Akua Fields te deja cobrar con tarjeta sin que el cliente salga de tu sitio y sin que vos toques el número de tarjeta. Un iframe servido desde otro origen captura los datos, los guarda en la bóveda y te devuelve un instrument_id (ins-…). Tu backend cobra ese id.
| Paso | Dónde corre | Qué pasa |
|---|---|---|
| 1 | Tu backend | Pedís un token con tus credenciales de comercio |
| 2 | Tu backend | Creás una sesión de captura y guardás su id |
| 3 | Browser | El iframe captura la tarjeta y la tokeniza |
| 4 | Tu backend | Leés la sesión y obtenés el ins-… |
| 5 | Tu backend | Cobrás contra ese instrumento |
| 6 | Tu backend | Leés el pago para tu pantalla de resultado |
localhost y 127.0.0.1 se aceptan con cualquier puerto en sandbox. En producción se rechazan, igual que un dominio parecido como localhost.evil.com.El browser nunca le habla a la API de pagos. Le habla a dos rutas tuyas, que construís vos en tu backend y que son las que llevan el token de comercio:
| Ruta | Quién la construye | Qué hace por dentro |
|---|---|---|
POST /api/session | Vos | Crea la sesión de captura y le devuelve al browser solo la url |
POST /api/pay | Vos | Lee la sesión, saca el ins-… y cobra |
| El iframe | Nosotros | Lo monta el script. Va directo, sin pasar por tu backend |
/api/session y /api/pay son de ejemplo: poneles los que quieras. Lo que no cambia es que el secreto de comercio vive solo en tu backend — si el browser pudiera pedir un token, cualquiera podría cobrar en tu nombre.Las llamadas de tu backend salen contra tu base URL, con Authorization: Bearer y nada más — las mismas de cualquier otro endpoint de esta doc.
| Qué | Contra qué host va | Por qué |
|---|---|---|
| Tus llamadas de backend | Tu base URL | Es la integración: corre sobre tu dominio |
El <script> y el iframe | Otro origen | Ahí viaja la tarjeta. Si se sirviera desde tu dominio, entraría en tu alcance PCI |
La url de la sesión | Otro origen | La devuelve la API ya armada; apunta a la página que captura la tarjeta |
Crear una sesión
Esto es lo que hace tu POST /api/session por dentro: una sesión por checkout, desde tu backend. Guardá el id con tu pedido y mandale al browser solo la `url`.
| Campo | Req. | Notas |
|---|---|---|
trace_id | — | Tu identificador de pedido. Vuelve en la sesión y en el pago |
appearance | — | El look del formulario: layout, labels, locale, show_holder, submit_button, placeholders, variables y styles. Ver Apariencia del formulario |
Idempotency-Key | ✓ | Va en el header. Sin él la API responde 400 invalid_request |
#s= de la url es un secreto: es la capability que habilita esa sesión. No la loguees ni la guardes más allá del checkout. Las sesiones vencen a los 15 minutos.url. El id de sesión te lo guardás vos: es lo que vas a usar en /api/pay para leer el instrumento, y no tiene por qué viajar al browser.El appearance del ejemplo es el mínimo. Acá es el único momento en que podés definir cómo se ve el formulario —después queda congelado—, así que si querés acercarlo a tu marca, mandalo completo: el vocabulario entero está en Apariencia del formulario.
curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/instruments/collect-sessions \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '
{
"trace_id": "orden-83779",
"appearance": {
"locale": "es"
}
}
'{
"id": "ise-davvj6sv88n403qvlp70jicabrmbtv3p5gep",
"url": "https://sandbox.akua.la/v1/pci-views/collect/ise-davvj6…#s=<capability>",
"status": "OPEN",
"expires_at": "2026-10-02T20:37:11Z",
"view_version": "1.2.0",
"trace_id": "orden-83779"
}// Tu base URL. Sale del onboarding, no se hardcodea por ambiente.
const BASE = "https://quickconnect-sandbox.pro-cobro.com";
// El browser llama a ESTA ruta, no a la API de pagos.
app.post("/api/session", async (req, res) => {
const token = await getMerchantToken(); // cacheado, credencial mer-
const r = await fetch(`${BASE}/v1/instruments/collect-sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({ trace_id: order.id, appearance: { locale: "es" } }),
});
const s = await r.json();
await guardarSesion(order.id, s.id); // el id queda de tu lado
res.json({ url: s.url }); // al browser va SOLO la url
});Apariencia del formulario
El look del formulario se define al crear la sesión, en el objeto appearance del body de arriba. No le pasás CSS ni clases tuyas: mandás opciones de un vocabulario cerrado, Validamos cada valor y armamos el CSS de nuestro lado. Eso es lo que te deja acercarlo a tu marca sin que el iframe deje de ser de nosotros —y sin que tu sitio salga de PCI SAQ A.
Layout y comportamiento — las perillas de estructura:
| Clave | Valores | Qué hace |
|---|---|---|
layout | one-row · two-row · stacked | Cómo se acomodan los campos |
labels | above · floating · hidden | Dónde va la etiqueta de cada campo |
locale | es (default) · pt · en | Idioma de etiquetas y mensajes |
show_holder | true · false | Agrega el campo de nombre del titular |
submit_button.mode | external · inside | external: el botón es tuyo y llama a submit(). inside: el botón va adentro del frame |
submit_button.text | String, hasta 32 caracteres | Texto del botón de adentro |
placeholders | number · expiry · cvc · holder | Texto de ejemplo de cada input, hasta 40 caracteres cada uno |
`variables`: el tema. Se aplican a todo el formulario de una, y son lo primero que conviene tocar: con estas siete líneas el formulario ya se parece al tuyo.
| Variable | Valor |
|---|---|
colorText · colorPrimary · colorDanger · colorSuccess | Un color: #hex, rgb() o hsl() |
colorPlaceholder · colorBackground · colorBorder | Un color: #hex, rgb() o hsl() |
fontSizeBase | 12–24px |
borderRadius | 0–24px |
spacingUnit | 0–8px |
fontFamily | Un font ID, no una lista CSS de fuentes. Hoy existe solo system; las self-hosted están planificadas |
`styles`: el control fino. La forma es siempre styles.<elemento>.<estado>.<propiedad>:
| Nivel | Valores |
|---|---|
| Elementos | input · label · button · error |
| Estados | base · focus · invalid · complete · empty · placeholder · disabled · autofill · hover |
Y estas son las propiedades permitidas, con su rango. Lo que no está en la tabla no existe:
| Propiedad | Rango |
|---|---|
color · background-color · border-color · caret-color | Un color (#hex, rgb() o hsl()) |
font-size | 12–24px |
font-weight | 100–900, de a 100 |
line-height | 1.00–2.00, sin unidad |
letter-spacing | 0–2px |
text-transform | none · uppercase · lowercase · capitalize |
padding (de 1 a 4 valores) · padding-top · padding-right · padding-bottom · padding-left · margin-top | 0–32px |
border-width | 0–4px |
border-radius | 0–24px |
Claves desconocidas. Cualquier clave o propiedad CSS que no esté en las tablas de arriba hace fallar la creación con 400 invalid_appearance, y details[] te lista todos los problemas de una, no el primero. Ahí caen url(), width, height, box-shadow, las transiciones y cualquier otra cosa que no figure.
Defaults por cliente. La configuración de tu cliente puede traer un appearance_defaults. Lo que mandás al crear la sesión pisa esos defaults, y lo que no define ninguno de los dos lo completan los defaults de la plataforma.
Lo que no controlás, por más que lo mandes:
| Qué | Por qué |
|---|---|
| El ancho del iframe | Ocupa el contenedor que vos le des |
| El alto del iframe | El frame reporta su propio alto (entre 60 y 800px) y embed.js lo redimensiona |
| Todo lo que está afuera del iframe | El espaciado alrededor, o la tarjeta que lo envuelve, son tu página y tu CSS |
{
"trace_id": "orden-83779",
"appearance": {
"layout": "two-row",
"labels": "floating",
"locale": "es",
"show_holder": true,
"submit_button": { "mode": "external" },
"placeholders": {
"number": "1234 1234 1234 1234",
"expiry": "MM/AA",
"cvc": "CVC",
"holder": "Nombre en la tarjeta"
},
"variables": {
"colorBackground": "#1d232a",
"colorText": "#e6e9ed",
"colorBorder": "#7d8b99",
"colorPlaceholder": "#98a3af",
"colorPrimary": "#5aa7f0",
"colorDanger": "#ff8b8f",
"borderRadius": "8px",
"fontSizeBase": "16px",
"spacingUnit": "8px",
"fontFamily": "system"
},
"styles": {
"input": {
"base": { "font-size": "18px", "padding": "12px 14px", "border-width": "1px", "letter-spacing": "1px" },
"focus": { "border-color": "#5aa7f0" },
"invalid": { "border-color": "#ff8b8f" }
},
"label": { "base": { "font-weight": 600, "text-transform": "uppercase" } },
"button": { "base": { "border-radius": "12px" } }
}
}
}{
"styles": {
"input": {
"base": { "font-size": "18px", "padding": "12px 14px" },
"focus": { "border-color": "#5aa7f0" },
"invalid": { "border-color": "#ff8b8f" }
},
"label": { "base": { "font-weight": 600, "text-transform": "uppercase" } }
}
}Montar el formulario
Cargá el script con su hash SRI, montá el campo de tarjeta y pasá el submit de tu formulario por handleSubmit(payAction). El src del script y el iframe son lo único que apunta al origen de otro origen: ahí viaja la tarjeta.
Si preferís el build ES module, es el mismo recurso con otro nombre y su propio hash — el integrity es del archivo, así que no se puede reusar el del otro build.
integrity. Sin SRI, cualquier cambio en el archivo servido entra a tu página de checkout sin que te enteres.| Lo que ves en el JS | A dónde va realmente |
|---|---|
fetch("/api/session") | Tu backend — el que creás vos (ver Crear una sesión) |
fetch("/api/pay") | Tu backend — lee la sesión y cobra (ver las dos secciones siguientes) |
AkuaCardEntry.mount(...) | Monta el iframe, que habla directo con Akua |
/api/session y /api/pay son tuyas, y el token de comercio no sale nunca de tu servidor. El browser solo mueve el sessionId.<script src="https://sandbox.akua.la/v1/pci-views/1.2.0/embed.js"
integrity="sha384-PWjrkxk2wTdMffNYwDW4Ir0+WKiANjvdEwbTVvzZfYVXn7dCawHNSfkGrfwSYfol"
crossorigin="anonymous"></script>
<form id="checkout">
<input name="email" type="email" required>
<div id="card"></div>
<button type="submit">Pagar</button>
</form>const card = AkuaCardEntry.mount("#card", {
// Tu endpoint llama al paso 2 y devuelve { url }.
// Se vuelve a llamar solo si la sesion vence.
session: async () => (await fetch("/api/session", { method: "POST" })).json(),
});
const form = document.getElementById("checkout");
form.onsubmit = card.handleSubmit(async ({ sessionId }) => {
// La tarjeta ya esta en la boveda. Mandas solo el id de sesion:
// tu backend hace los pasos 4 y 5.
const r = await fetch("/api/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
const out = await r.json();
if (!r.ok) throw new Error("declined");
location.assign(`/resultado/${out.order_id}`);
});
card.on("error", (e) => console.warn(e.code, e.source, e.retryable));<script type="module"
src="https://sandbox.akua.la/v1/pci-views/1.2.0/embed.mjs"
integrity="sha384-JQyHgvWEyxR9dSCYmKI9MGzQMH3DXZxl/UBRKtsoaAq8n/3HaDyDIPeYsclJ/5wW"
crossorigin="anonymous"></script>Leer la sesión
Primer paso de tu POST /api/pay. El browser te manda el sessionId; con él leés la sesión. Cobrá solo si status es COMPLETED, y tomá el instrumento de esta respuesta — nunca del browser.
| `status` | Qué significa |
|---|---|
OPEN | Todavía no hay tarjeta cargada |
COMPLETED | La tarjeta quedó en la bóveda: podés cobrar |
EXPIRED · REVOKED | Creá una sesión nueva |
instrument.id que venga del browser no se cobra. El browser solo manda el sessionId; el ins-… sale de esta respuesta, del lado del servidor. Si confiaras en el id del cliente, cualquiera podría mandarte el instrumento de otro.curl -X GET https://quickconnect-sandbox.pro-cobro.com/v1/instruments/collect-sessions/{id} \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"id": "ise-davvj6…",
"status": "COMPLETED",
"trace_id": "orden-83779",
"merchant_id": "mer-d6g518rbl6sg4fe3a7b0",
"instrument": {
"id": "ins-dacercdni7pdrioheibg",
"type": "CARD",
"card": {
"last_four_digits": "1111",
"expiration_month": "12",
"expiration_year": "30",
"bin_data": { "card_brand": "VISA", "card_type": "DEBIT" }
}
}
}Cobrar el instrumento
El monto y la moneda salen de tu pedido, nunca del browser. Usá una Idempotency-Key por intento de pago, guardada junto al pedido.
amount.value va en unidades mínimas y como string: "1000" son 10,00 USD.Cuotas. Para cobrar en cuotas mandá installments.quantity. La cantidad tiene que estar entre las habilitadas para tu comercio: cualquier otra se rechaza con invalid number of installments. Con quantity: 1 no hay plan de cuotas.
Si no mandás installments.type, se resuelve en este orden: el modo del producto del rail para esa cantidad, después el modo configurado para tu comercio, y si no hay ninguno, average-payment. En Uruguay los dos modos de comercio son plazo promedio ponderado (una sola presentación, cobrada al plazo promedio) y cuota a cuota (el comercio cobra una cuota por mes).
| `type` | Qué significa |
|---|---|
average-payment | Plazo promedio ponderado: una presentación, al plazo promedio del plan |
merchant-financed | Cuota a cuota — el comercio financia. Exige `capture.mode: AUTOMATIC` |
issuer-financed | Financia el banco emisor |
issuer-approved-zero-interest | Cuotas sin interés aprobadas por el emisor |
acquirer-financed | Financia el adquirente |
type fuera de esa lista se rechaza con invalid installment payment type. Y si mandás merchant-financed sin capture.mode: AUTOMATIC, la autorización se rechaza.Ojo al leer el pago: el GET lo devuelve en singular y con un flag aparte — installment: { quantity, type } y with_installment: true.
La respuesta es sincrónica: 201 con la transacción aprobada, o un rechazo. Después, leé el pago con GET /v1/payments/{id} para tu pantalla de resultado.
5xx, reintentá con la misma `Idempotency-Key`: te devolvemos el resultado original en vez de cobrar dos veces. Solo un intento nuevo lleva clave nueva.curl -X POST https://quickconnect-sandbox.pro-cobro.com/v1/payments/authorizations \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '
{
"instrument": {
"id": "ins-dacercdni7pdrioheibg"
},
"amount": {
"value": "1000",
"currency": "USD"
},
"intent": "authorization",
"entry_mode": "not-present",
"initiator": "customer",
"order_type": "purchase",
"capture": {
"mode": "AUTOMATIC"
},
"installments": {
"quantity": 12
},
"trace_id": "orden-83779"
}
'{
"payment_id": "pay-davvj781lhpi4eppkbt0",
"instrument_id": "ins-dacercdni7pdrioheibg",
"response_code": "00",
"response_code_description": "Approval and completed successfully",
"transaction": { "id": "trx-…", "type": "AUTHORIZATION", "status": "APPROVED" }
}// Mismo BASE que en /api/session.
const BASE = "https://quickconnect-sandbox.pro-cobro.com";
// El browser manda { sessionId } y nada mas. El monto sale de TU pedido.
app.post("/api/pay", async (req, res) => {
const { sessionId } = req.body;
const order = await buscarPedidoPorSesion(sessionId);
const token = await getMerchantToken();
const h = { Authorization: `Bearer ${token}` };
// 1. Leer la sesion: de aca sale el instrumento.
const s = await (await fetch(
`${BASE}/v1/instruments/collect-sessions/${sessionId}`, { headers: h }
)).json();
if (s.status !== "COMPLETED") return res.status(409).json({ error: "sesion incompleta" });
// 2. Cobrar. Una Idempotency-Key por intento, guardada con el pedido.
const r = await fetch(`${BASE}/v1/payments/authorizations`, {
method: "POST",
headers: { ...h, "Idempotency-Key": order.payKey, "Content-Type": "application/json" },
body: JSON.stringify({
instrument: { id: s.instrument.id },
amount: { value: order.amountMinor, currency: order.currency },
intent: "authorization",
entry_mode: "not-present",
initiator: "customer",
order_type: "purchase",
capture: { mode: "AUTOMATIC" },
trace_id: order.id,
}),
});
const out = await r.json();
res.status(r.status).json({ order_id: order.id, payment_id: out.payment_id });
});Errores de Akua Fields
Los de la API de sesiones (pasos 2 y 4):
| Código | `error_code` | Qué hacer |
|---|---|---|
400 | validation_error · invalid_appearance · invalid_request | Corregí el campo que nombra details[]. Falta la Idempotency-Key → invalid_request |
401 | — | Token ausente o vencido: pedí uno nuevo |
403 | merchant_token_required | Necesitás una credencial de comercio |
403 | authorization_denied · origin_not_allowed | Tu origen no está habilitado. Pedilo a tu proveedor |
404 | resource_not_found | Id de sesión inexistente, o de otro comercio |
409 | idempotency_conflict | Reusaste una Idempotency-Key con otro body |
429 | rate_limited | Esperá lo que diga Retry-After |
503 | upstream_unavailable | Reintentá con la misma clave |
Y los del formulario, que llegan por card.on("error"):
| `code` | Qué significa |
|---|---|
invalid_number · invalid_expiry · invalid_cvc | El iframe ya le muestra el mensaje al cliente; lo corrige él |
session_expired · session_inactive | Con session el script reemplaza la sesión una vez solo. Si no, remontá |
frame_load_timeout | Casi siempre: tu origen no está habilitado, la URL de sesión ya se usó, o red |
payment_failed | Tu payAction lanzó una excepción. El error original está en error.cause |
invalid_usage | Llamaste a card.handleSubmit sin pasarle el payAction |
Desde la 1.0.0 el error que te llega por on("error") trae el trace_id del server. Guardalo: es el identificador con el que soporte encuentra ese request exacto, y te ahorra tener que describir el síntoma.
Authorization: Bearer (más Content-Type y la Idempotency-Key donde aplique). No mandes `Client-Id` ni `Merchant-Id`: la ruta matchea por tu base URL y el comercio sale del token, porque la credencial está atada a él. Por lo mismo tampoco hace falta merchant_id en el body de la autorización.Cómo funcionan los webhooks
Un 201 significa que recibimos y procesamos tu solicitud, no que cobraste. El desenlace del emisor viaja adentro de esa respuesta, en transaction.status. Un rechazo también llega con 201.
transaction.status, nunca el 201 a secas.Hay resultados que todavía no existen cuando tu request termina: la compensación en la red, el reembolso acreditado, un contracargo. Todo eso llega por otro canal — te hacemos POST a una URL tuya cada vez que algo cambia, con el cuerpo firmado.
| Cómo cobrás | Qué trae la respuesta HTTP | Qué esperás por evento |
|---|---|---|
| Link de pago | El link creado, todavía sin pagar | Todo el resultado del cobro |
| Captura | Que la captura se registró | Que completó la compensación en la red |
| Cancelación | Que la anulación se registró | Que los fondos quedaron liberados |
| Reembolso | Que el reembolso se aceptó | Que se acreditó al tarjetahabiente |
Los payloads son delgados a propósito: traen identificadores, no el pago entero. Un cuerpo con el recurso completo llegaría desactualizado ante cualquier reintento o entrega tardía.
GET /v1/payments/{id}. Ese GET devuelve el estado recomputado y es la única fuente de verdad.{
"type": "payment.capture.cleared",
"data": {
"trace_id": "order-34324366",
"payment_id": "pay-d07u8e218e1elk12b92g",
"transaction_id": "trx-d0gv1122dpo29a8d7l4g"
}
}Configurar tu webhook
La configuración se hace desde el panel de comercios: ahí definís la URL HTTPS que recibe las entregas y los eventos a los que te suscribís. No hace falta llamar a ninguna API.
Al crearlo se genera el secreto de firma con el que vas a verificar cada entrega. Tratalo como una credencial.
Verificar la firma
Antes de mirar el contenido de un evento, comprobá que salió de nosotros. Cada entrega lleva tres encabezados que juntos son la prueba de origen.
| Encabezado | Qué contiene |
|---|---|
akua-wh-id | Identificador único del mensaje, con prefijo msg_. Sirve además para deduplicar |
akua-wh-timestamp | Momento del intento, en segundos Unix |
akua-wh-signature | Una o varias firmas separadas por espacio, con el formato v1,<base64> |
La firma es un HMAC-SHA256 sobre los tres elementos concatenados con puntos: id + "." + timestamp + "." + cuerpo_crudo. La clave es el secreto sin el prefijo whsec_, decodificado desde Base64.
Que el encabezado admita varias firmas no es decorativo: es lo que permite rotar el secreto sin cortar la entrega. Durante la rotación llegan dos y basta con que una cierre.
POST /webhooks HTTP/1.1
Content-Type: application/json
User-Agent: akua-webhooks/1.0
akua-wh-id: msg_p5jXN8AQM9LWM0D4loKWxJek
akua-wh-timestamp: 1614265330
akua-wh-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
{"type":"payment.authorization.processed","data":{"trace_id":"order-34324366","payment_id":"pay-d07u8e218e1elk12b92g","transaction_id":"trx-d0gv1122dpo29a8d7l4g"}}import crypto from "crypto";
const TOLERANCIA = 5 * 60;
export function verificarFirma(secret, headers, rawBody) {
const id = headers["akua-wh-id"];
const ts = headers["akua-wh-timestamp"];
const firmas = headers["akua-wh-signature"];
if (!id || !ts || !firmas) return false;
// Ventana temporal, contra ataques de repeticion.
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(ts, 10)) > TOLERANCIA) {
return false;
}
const clave = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const esperada = crypto
.createHmac("sha256", clave)
.update(`${id}.${ts}.${rawBody}`)
.digest();
// Comparacion en tiempo constante contra cada firma v1.
return firmas
.split(" ")
.filter((p) => p.startsWith("v1,"))
.map((p) => Buffer.from(p.slice(3), "base64"))
.some((f) => f.length === esperada.length && crypto.timingSafeEqual(f, esperada));
}Entrega y reintentos
La entrega es al menos una vez: es preferible que un evento llegue dos veces a que se pierda. Tu receptor tiene que tolerar duplicados, y el código con el que respondés decide qué pasa después.
| Qué respondés | Qué hacemos |
|---|---|
2xx | Entrega exitosa. No hay más intentos |
5xx | Escalera completa: hasta 8 intentos a lo largo de unas 27 horas |
408 o 429 | Escalera completa, con un piso de 60 segundos entre intentos |
400, 401, 403, 404 | Escalera corta: 3 intentos en unas 6 horas |
Resto de 4xx | Terminal. Se descarta en el primer intento |
| Nada (timeout) | Escalera completa, con piso de 60 segundos |
El timeout de entrega es de 15 segundos. Tu endpoint tiene dos trabajos: verificar la firma y acusar recibo rápido. Todo lo demás va después del 200, en una cola tuya.
| Intento | Espera desde el anterior | Acumulado |
|---|---|---|
| 1 | inmediato | 0 |
| 2 | 5 segundos | 5 s |
| 3 | 5 minutos | 5 min |
| 4 | 30 minutos | 35 min |
| 5 | 2 horas | 2 h 35 min |
| 6 | 5 horas | 7 h 35 min |
| 7 | 10 horas | 17 h 35 min |
| 8 | 10 horas | 27 h 35 min |
Sobre esas esperas se aplica un ruido aleatorio de ±20 %, para que mil comercios que caen a la vez no reintenten en el mismo instante. Si tu respuesta trae Retry-After, lo respetamos, acotado entre la espera base y el doble de esa espera.
El mismo evento puede llegar más de una vez. La defensa es una sola: guardá el akua-wh-id de cada mensaje procesado y descartá el que ya viste. Que el candado viva en la base de datos, con una restricción de unicidad — un chequeo en memoria falla en cuanto tenés dos instancias del receptor.
| Identificador | Para qué sirve |
|---|---|
Idempotency-Key | Protege los requests que vos nos mandás |
akua-wh-id | Deduplica los eventos que nosotros te mandamos |
payment_id | Identifica el recurso, no el mensaje. No sirve para deduplicar |
Eventos de pago
Los eventos del ciclo de vida de un pago. Cada nombre sigue el patrón recurso.accion.resultado y todos comparten el mismo payload de tres campos: trace_id, payment_id y transaction_id.
| Evento | Qué significa | Qué hacer |
|---|---|---|
payment.authorization.processed | El emisor aprobó la autorización. Los fondos quedaron reservados | Consultar el pago y avanzar el pedido. Todavía no se cobró nada |
payment.authorization.rejected | El emisor o la red rechazaron la autorización | Consultar el pago para leer el código de red y ofrecer otro medio |
payment.capture.manual.processed | Recibimos tu captura y sale a la red en el ciclo siguiente | Marcar el pedido como cobrado a la espera de compensación |
payment.capture.automatic.processed | Lo mismo, para una captura automática | Igual que la manual |
payment.capture.cleared | La captura completó el ciclo de compensación | Cerrar el pedido. Es el final del ciclo de red |
payment.cancel.processed | Se anuló una autorización. Los fondos quedaron liberados | Liberar el pedido y avisar al cliente |
payment.cancel.rejected | La anulación fue rechazada, por ejemplo porque ya se capturó | Consultar el pago. Si ya está capturado, el camino es un reembolso |
payment.refund.cleared | El reembolso completó el ciclo de compensación | Cerrar el caso de devolución |
payment.refund.rejected | La red rechazó el reembolso | Consultar el pago y revisar el motivo antes de reintentar |
payment.capture.cleared y payment.refund.cleared no se pueden probar ahí: si los estás esperando, no están tardando.Suscribite solo a lo que vas a procesar. Un webhook que recibe todo y descarta la mayoría gasta capacidad de tu receptor y hace más difícil leer tus logs.
| Si cobrás así | Suscribite a |
|---|---|
| Captura manual | authorization.processed, authorization.rejected, capture.manual.processed, capture.cleared |
| Captura automática | authorization.processed, authorization.rejected, capture.automatic.processed, capture.cleared |
| Devoluciones | refund.cleared, refund.rejected |
Reconstruir el estado
Los eventos no llegan en orden. Un capture.cleared puede llegarte antes que el capture.manual.processed que lo precedió, porque el primero salió limpio y el segundo tuvo que reintentar. Una integración que trate el último evento recibido como el estado actual va a marcar como rechazados pedidos que se cobraron.
status que devuelve el GET. El evento es el disparador; el pago es el dato.El estado de un pago no es un campo que se sobrescribe: se recalcula ordenando todas sus transacciones por timestamp. Guardá la marca temporal de lo que aplicaste y rechazá cualquier escritura más vieja. Y una regla que no cuesta nada: un estado terminal no se revisa.
Ningún receptor tiene 100 % de disponibilidad. Un barrido periódico cierra la brecha sin depender de que la entrega haya funcionado: consultá los pagos que quedaron en un estado no terminal dentro de una ventana de tiempo. Cada 15 minutos sobre la última hora cubre la mayoría de los casos, más una pasada diaria más ancha.
GET /v1/payments/{id} como reemplazo de los eventos. Consultar cada segundo por cada pago abierto te va a costar cuota y no te va a enterar antes: el evento sale en cuanto el estado cambia.async function reconciliar(paymentId) {
const pago = await api.get(`/v1/payments/${paymentId}`);
// La transaccion mas reciente marca hasta donde llega esta foto del pago.
const marca = pago.transactions.map((t) => t.timestamp).sort().at(-1);
// UPDATE ... WHERE marca_aplicada < $marca. Una foto vieja no pisa una nueva.
await guardarSiEsMasNuevo(paymentId, pago.status, marca);
}Estados de un pago
El status del pago. Es el valor que devuelve GET /v1/payments/{id} y el que acepta el filtro status del listado.
| Estado | Significado |
|---|---|
CREATED | Creado, todavía sin procesar |
PENDING_CUSTOMER | Esperando que el cliente complete el pago |
FRAUD_REVIEW | En revisión antifraude |
AUTHORIZED | Autorizado: fondos reservados, pendiente de captura |
REJECTED | Rechazado por el emisor o la red |
CANCELLED | Anulado antes de la captura |
CAPTURE_IN_PROGRESS | Captura en curso |
CAPTURE_PRESENTED | Captura presentada a la red, esperando compensación |
CAPTURED | Capturado |
SETTLED | Liquidado |
COMPLETED | Completado |
REFUND_AUTHORIZED · REFUND_PRESENTED · REFUND_CAPTURED | El reembolso avanzando en la red hasta acreditarse |
REFUND_REJECTED · REFUND_FAILED | El reembolso no prosperó |
FAILED | Error técnico durante el procesamiento |
APPROVED, PENDING ni REFUNDED. APPROVED es el estado de una transacción dentro de transactions[], no del pago. Filtrar por un estado que no existe devuelve 200 con la lista vacía, sin ningún error que te avise.Errores
| Código | Causa |
|---|---|
400 · INVALID_QUERY_PARAM | Un parámetro de query que el endpoint no soporta. El mensaje dice cuál |
400 · BAD_PARAMETERS | Un valor fuera de rango, por ejemplo page_size mayor al máximo |
401 | Falta el token, está vencido o la firma no cierra |
404 | Recurso inexistente (payment not found) o ruta inexistente (route not found) |
429 | Rate limit — reintentá con backoff |
5xx | Error transitorio — reintentá con backoff |
Los errores vienen con la misma forma. Guardá el trace_id: es lo que hace falta para que soporte encuentre tu request.
400 y la llamada no se ejecuta. En cambio un valor no numérico en page o page_size sí se ignora y se usa el default.{
"error_code": "resource_not_found",
"error_type": "not_found",
"message": "payment not found",
"trace_id": "req-dampr99ma4dqhvj1d1p0"
}Tarjetas de prueba (sandbox)
| Número | Resultado |
|---|---|
4111 1111 1111 1111 | Aprobada (Visa) |
5186 1700 7000 1108 | Aprobada (Mastercard) |