# API de Pagos — Efecty > REST + JSON, un solo Bearer token. Generá links de pago, seguí tus pagos y resolvé reembolsos, cancelaciones y contracargos. ## Empezar ### Base URL | Ambiente | URL | |---|---| | Sandbox | `https://sandbox.payty.com` | | Producción | `https://apis.payty.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): `POST /oauth/token` ```json { "client_id": "", "client_secret": "" } ``` Respuesta · 200: ```json { "access_token": "eyJhbGciOi…", "token_type": "Bearer", "expires_in": 43200 } ``` El token dura 12 horas — 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`. ```bash curl -X POST https://sandbox.payty.com/v1/payments \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ ... }' ``` ## Pagos ### Obtener un pago `GET /v1/payments/{id}` 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. Respuesta · 200: ```json { "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" } ] } ``` | 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 | > Si el `id` no existe o es de otro cliente, la API responde `404` con `"message": "payment not found"`. ### Listar pagos `GET /v1/payments?page=1&page_size=50` 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 | Respuesta · 200: ```json { "data": [ { "id": "pay-…", "status": "CAPTURED" } ], "pagination": { "page": 1, "page_size": 50, "total": 94, "total_found": 94 } } ``` | 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 | > No pagines a ciegas contra `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. > Con `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. ### Reembolsar un pago `POST /v1/refunds` ```json { "payment_id": "pay-cu94vq1n8ql8u3v469gg", "amount": 1000, "currency": "COP" } ``` 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**. ### Cancelar / Capturar `POST /v1/payments/{id}/cancel` Anula una autorización aún no capturada (void). Sin body. `POST /v1/payments/{id}/capture` Solo para captura diferida — por defecto los pagos se capturan automáticamente. ### Contracargos `GET /v1/chargebacks` 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. Respuesta · 200: ```json { "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 } } ``` > Acá el tope de `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`. ## Links de pago ### Crear un link Generá una URL de checkout hosteada — tu cliente paga sin que manejes datos de tarjeta. Compartila por WhatsApp, email o QR. `POST /v1/links` ```json { "type": "payment", "expires_in": 3600, "payment_methods_source": "merchant", "data": { "amount": { "value": 25000, "currency": "COP" }, "description": "Orden #12345", "redirect_url": "https://mitienda.com/gracias", "cancel_url": "https://mitienda.com/carrito" } } ``` Respuesta · 201: ```json { "id": "lnk-a1b2c3d4", "url": "https://checkout.akua.la/links/lnk-a1b2c3d4", "status": "created", "expires_at": "2026-07-02T14:00:00Z" } ``` | Campo | Valor | Qué hace | |---|---|---| | `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 `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. Ciclo de vida: created → opened → used → expired > **Opciones útiles** — `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). ### Consultar links `GET /v1/links/{id}` Devuelve el link con su `status` y, una vez pagado, el `payment_id` asociado. `GET /v1/links` lista todos, paginado. ## Webhooks ### 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`. > Confirmar un pedido mirando solo el código HTTP es el error de integración más caro que se puede cometer. Leé `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. ```json { "type": "payment.capture.cleared", "data": { "trace_id": "order-34324366", "payment_id": "pay-d07u8e218e1elk12b92g", "transaction_id": "trx-d0gv1122dpo29a8d7l4g" } } ``` > La regla es fija: recibís el evento, verificás la firma y vas a buscar el recurso con `GET /v1/payments/{id}`. Ese `GET` devuelve el estado recomputado y es la única fuente de verdad. ### 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,` | 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. > Se firma el cuerpo **crudo**, byte por byte. Si tu framework parsea el JSON y lo volvés a serializar, cambia el espaciado y el orden de las claves, y la firma no va a coincidir nunca. Guardá los bytes originales antes de parsear. Una entrega real: ```bash 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"}} ``` Node.js, sin dependencias: ```bash 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)); } ``` 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. ### 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. > Si actualizás inventario, cobrás impuestos y mandás un correo antes de responder, tarde o temprano vas a superar los 15 segundos y el evento va a volver a entrar como si nunca hubiera llegado. | 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. > Si un webhook falla de forma continua durante **24 horas**, sin ninguna entrega exitosa que reinicie el contador, se desactiva y deja de recibir eventos. Cualquier entrega exitosa reinicia la ventana. Los eventos de ese período se recuperan consultando los pagos. 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 | > En sandbox no corre el ciclo de compensación. `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. > No decidas nada con el nombre del evento. Decidí con el `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. Reconciliar ante cada evento: ```bash 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); } ``` 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. > No hagas polling agresivo sobre `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. ## Usuarios ### Crear un usuario Un usuario representa al comprador final. Sirve para agrupar sus pagos y consultarlos después. El body es opcional: si no mandás `id`, el sistema genera uno. `POST /v1/users` ```json { "id": "usr-mitienda-00123", "metadata": { "email": "ana@example.com" } } ``` | Campo | Tipo | Req. | Notas | |---|---|---|---| | `id` | string | — | Tu propio identificador. Si lo omitís, Akua genera uno | | `metadata` | object | — | Pares clave-valor libres que te devolvemos tal cual | Respuesta · 201: ```json { "id": "usr-mitienda-00123", "metadata": { "email": "ana@example.com" }, "instruments": [], "created_at": "2026-09-18T14:02:11Z", "updated_at": "2026-09-18T14:02:11Z" } ``` ### Obtener un usuario `GET /v1/users/{id}` Devuelve el usuario con su `metadata` y los instrumentos asociados. Si el `id` no existe, la API responde `404`. Respuesta · 200: ```json { "id": "usr-mitienda-00123", "metadata": { "email": "ana@example.com" }, "instruments": [], "created_at": "2026-09-18T14:02:11Z", "updated_at": "2026-09-18T14:02:11Z" } ``` ## Referencia ### 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 | > No existen los estados `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. ```json { "error_code": "resource_not_found", "error_type": "not_found", "message": "payment not found", "trace_id": "req-dampr99ma4dqhvj1d1p0" } ``` > Un parámetro mal escrito **no se ignora**: devuelve `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. ### Tarjetas de prueba (sandbox) | Número | Resultado | |---|---| | `4111 1111 1111 1111` | Aprobada (Visa) | | `5186 1700 7000 1108` | Aprobada (Mastercard) |