Guía de integración

Una API para todos tus pagos por medio de Punto de Pago

Convierte tu tienda o negocio en el punto más importante del barrio. Recibe pagos, giros y servicios mientras ganas dinero por cada operación. Empezá en sandbox hoy mismo: sandbox.puntodepago.com.co

Bogotá, COL

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 Punto de Pago.

Obtené tu token

Canjeá el client_id y client_secret que recibís en el onboarding de Punto de Pago por un Bearer token. Dura 1 hora: cachealo y renovalo antes de que expire.

Reemplazá <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.

Ver la referencia completa →

Probalo
curl -X POST https://sandbox.puntodepago.com.co/oauth/token \
  -H "Content-Type: application/json" \
  -d '
{
  "client_id": "<CLIENT_ID>",
  "client_secret": "<CLIENT_SECRET>"
}
'
Respuesta · 200
{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 3600
}

Salí a producción

Validaste tus flujos en sandbox: pedile a Punto de Pago tus credenciales productivas y apuntá tu integración a https://api.puntodepago.com.co. Mismos endpoints, mismos contratos.

Ver la referencia completa →

¿Integrás con un asistente de IA? Pasale /llms-full.txt — toda esta doc en markdown, lista para que tu agente escriba la integración. Y para buscar acá, apretá /.

Base URL

AmbienteURL
Sandboxhttps://sandbox.puntodepago.com.co
Producciónhttps://api.puntodepago.com.co

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

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.

Probalo
curl -X POST https://sandbox.puntodepago.com.co/oauth/token \
  -H "Content-Type: application/json" \
  -d '
{
  "client_id": "<CLIENT_ID>",
  "client_secret": "<CLIENT_SECRET>"
}
'
Respuesta · 200
{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 3600
}
Ejemplo
curl -X POST https://sandbox.puntodepago.com.co/v1/payments \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

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.

CampoPara qué
statusEl estado recomputado del pago. Es la verdad (ver Estados)
current_amountEl 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_idEl 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".
Probalo
curl -X GET https://sandbox.puntodepago.com.co/v1/payments/{id} \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Respuesta · 200
{
  "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

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ámetroDefaultNotas
page1Número de página
page_size10Registros por página. No pidas más de 200 (ver abajo)
sortcreated_at:descTambié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_sizeLos valores efectivos de esta respuesta
totalCantidad de resultados. Se topea en 10000, y puede venir -1 cuando no se pudo contar
total_foundMismo 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.

Probalo
curl -X GET https://sandbox.puntodepago.com.co/v1/payments?page=1&page_size=50 \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Respuesta · 200
{
  "data": [ { "id": "pay-…", "status": "CAPTURED" } ],
  "pagination": {
    "page": 1,
    "page_size": 50,
    "total": 94,
    "total_found": 94
  }
}

Reembolsar un pago

POST/v1/refunds

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.

Probalo
curl -X POST https://sandbox.puntodepago.com.co/v1/refunds \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '
{
  "payment_id": "pay-cu94vq1n8ql8u3v469gg",
  "amount": 1000,
  "currency": "COP"
}
'

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.

Probalo
curl -X POST https://sandbox.puntodepago.com.co/v1/payments/{id}/cancel \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Probalo
curl -X POST https://sandbox.puntodepago.com.co/v1/payments/{id}/capture \
  -H "Authorization: Bearer $ACCESS_TOKEN"

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.

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.

Probalo
curl -X GET https://sandbox.puntodepago.com.co/v1/chargebacks \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Respuesta · 200
{
  "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 }
}

Listar bancos (PSE)

Para PSE mostrás una lista de bancos y el cliente elige el suyo. Obtené la lista dinámicamente en cada checkout — no la hardcodees, cambia con el tiempo.

GET/api/banks?country=COL&rail_id=PSE
ParámetroReq.Notas
country✓ISO 3166-1 alpha-3 — COL
rail_id✓Rail para el que se listan las entidades — PSE

Usá el id del banco elegido como customer.bank_id al crear el intento.

El id es opaco: un UUID que se manda de vuelta tal cual. No es un código numérico corto ni el código de la Superintendencia — no lo parsees, no lo acortes y no lo guardes hardcodeado. Pedí la lista y pasá el valor verbatim.
Probalo
curl -X GET https://sandbox.puntodepago.com.co/api/banks?country=COL&rail_id=PSE \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Respuesta
[
  { "id": "9957817c-f17e-472b-b531-e3d1490bf757", "name": "BANCOLOMBIA" },
  { "id": "3f2b91ad-5c04-4e7a-9f18-2d6c8a1e4b03", "name": "DAVIVIENDA" }
]

Crear un pago APM

Además de tarjeta aceptás pagos alternativos en Colombia: el cliente paga desde su banco o billetera. Los cuatro rails se crean con el mismo endpoint y se distinguen por rail_id.

RailMecanismoCómo confirma el clienteVigencia
PSERedirección al portal del bancoSe autentica en su banco (rail.payment_link)~21 min
NEQUINotificación push a la appAprueba con su PIN en Nequi45 min
DAVIPLATAOTP por SMS (sin app)Ingresa el OTP en tu checkout (paso confirm)15 min
BRE_BQR / alias interoperableEscanea el QR desde cualquier app bancaria15 min
Los APMs son asíncronos. El POST devuelve el estado inicial (IN_PROGRESS), no el resultado. El resultado final llega siempre por webhook — nunca confirmes el pedido con la respuesta del POST. En PSE, además, hacé polling del estado.
Solo COP. Los pagos APM son irrevocables (sin contracargos); las devoluciones se procesan como un desembolso aparte. No hay tokenización ni cobros recurrentes. Sandbox: un monto cuyo entero termine en 00 (ej. 50000) activa el mock del rail.
POST/v1/payments/intents

Nequi — billetera, cobro push. Solo el celular del cliente:

PSE — débito bancario con redirección. Requiere return_url, el banco elegido (ver Listar bancos) y los datos del pagador:

Bre-B — QR interoperable. Body mínimo; la respuesta trae el QR y el alias:

CampoTipoReq.Notas
order_idstring✓Tu referencia única (idempotencia)
amountnumber✓Monto en COP, mayor a 0
currencystring✓Debe ser COP
rail_idstring✓PSE · NEQUI · DAVIPLATA · BRE_B
rail_typestring✓WALLET (Nequi/Daviplata) · BANK_TRANSFER (PSE/Bre-B)
descriptionstring—Aparece en el comprobante del cliente
return_urlstringPSEURL pública a la que vuelve el cliente tras el banco
customer.phone_numberstringNequi/DaviplataCelular colombiano registrado en la billetera (3001234567)
customer.bank_idstringPSEBanco elegido — el id opaco (UUID) que devuelve GET /api/banks, tal cual
customer.documentstringPSENúmero de documento del pagador
customer.document_typestringPSECC · CE · NIT · PASSPORT
customer.first_namestringPSENombre del pagador
customer.last_namestringPSEApellido del pagador
customer.emailstringPSEACH Colombia envía el comprobante a este correo
Campo de la respuestaNotas
payment_idGuardalo — es el id del pago (para consultar estado y para el confirm de Daviplata)
transaction.status / status_detailEstado inicial — ver la tabla de estados abajo
rail.payment_linkPSE/Bre-B: URL a la que redirigís al cliente
rail.qr.image / rail.qr.contentBre-B: QR para mostrar (imagen data-URI + contenido EMV)
rail.aliasBre-B: alias corto que el cliente puede tipear si no escanea
Según el rail: redirigí al cliente a rail.payment_link (PSE), mostrale rail.qr/rail.alias (Bre-B), o esperá que apruebe en la app (Nequi). Daviplata necesita un paso extra de OTP — ver su sección.
`status` / `status_detail`SignificadoAcción
IN_PROGRESS / PENDINGEl cliente aún no completóMostrar "procesando"
IN_PROGRESS / PENDING_PROVIDEREl banco (PSE) está procesandoPolling cada 15–30s
APPROVED / SUCCESSAprobado; fondos en tránsitoConfirmar el pedido
DECLINED / REJECTEDRechazado o expiradoNotificar; ofrecer alternativa
FAILEDError técnicoRegistrar; no reintentar sin verificar

El resultado final se entrega por webhook (al menos una vez — hacé tu handler idempotente por el campo id; respondé 200 en <5s). Consultá el estado con GET /v1/payments/{payment_id}.

Evento del webhookSignificado
payment.purchase.pendingIntento creado; esperando al cliente
payment.purchase.succeededAprobado → confirmar el pedido
payment.purchase.rejectedRechazado o expirado
payment.purchase.failedError técnico
payment.purchase.confirm.processedDaviplata: OTP confirmado, débito ejecutado
payment.purchase.confirm.failed.retryableDaviplata: OTP incorrecto, reintentable
Probalo
curl -X POST https://sandbox.puntodepago.com.co/v1/payments/intents \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Respuesta
{
  "order_id": "orden-83790",
  "amount": 50000,
  "currency": "COP",
  "rail_id": "NEQUI",
  "rail_type": "WALLET",
  "description": "Orden 83790 - mitienda.com",
  "customer": { "phone_number": "3001234567" }
}
Respuesta
{
  "order_id": "orden-83791",
  "amount": 200000,
  "currency": "COP",
  "rail_id": "PSE",
  "rail_type": "BANK_TRANSFER",
  "return_url": "https://mitienda.com/pagos/resultado",
  "customer": {
    "bank_id": "9957817c-f17e-472b-b531-e3d1490bf757",
    "document": "1234567890",
    "document_type": "CC",
    "first_name": "Juan",
    "last_name": "Pérez",
    "email": "juan@ejemplo.com"
  }
}
Respuesta
{
  "order_id": "orden-83792",
  "amount": 150000,
  "currency": "COP",
  "rail_id": "BRE_B",
  "rail_type": "BANK_TRANSFER"
}
Respuesta · 201 (PSE — redirección)
{
  "payment_id": "pay-xxxxxxxxxxxx",
  "transaction": {
    "id": "trx-xxxxxxxxxxxx",
    "type": "PURCHASE",
    "status": "IN_PROGRESS",
    "status_detail": "PENDING",
    "amount": 200000
  },
  "rail": {
    "id": "PSE",
    "payment_link": "https://pse.onepay.com/pay/session-abc123"
  }
}
Respuesta · 201 (Bre-B — QR)
{
  "payment_id": "pay-xxxxxxxxxxxx",
  "transaction": { "status": "IN_PROGRESS", "status_detail": "PENDING", "amount": 150000 },
  "rail": {
    "id": "BRE_B",
    "alias": "@STORE7X",
    "qr": { "image": "data:image/png;base64,...", "content": "00020101021226..." },
    "payment_link": "https://pay.breb.com.co/t/abc123"
  }
}
Payload del webhook
{
  "id": "evt-xxxxxxxxxxxx",
  "type": "payment.purchase.succeeded",
  "time": "2025-10-15T15:10:00Z",
  "data": {
    "payment": {
      "id": "pay-xxxxxxxxxxxx",
      "currency": "COP",
      "current_amount": 200000,
      "transaction": { "status": "APPROVED", "status_detail": "SUCCESS" }
    }
  }
}

Daviplata (OTP en 2 pasos)

Daviplata funciona por SMS, sin app. Es un flujo de dos pasos: creás el intento (dispara un OTP por SMS) y luego confirmás el código que el cliente ingresa en tu checkout.

Paso 1 — crear el intento (dispara el SMS). Devuelve el payment_id:

POST/v1/payments/intents
CampoTipoReq.Notas
rail_idstring✓Debe ser DAVIPLATA
rail_typestring✓Debe ser WALLET
customer.phone_numberstring✓Celular colombiano registrado en Daviplata
customer.documentstring—Recomendado: sube la tasa de aprobación
customer.document_typestring—CC · CE · TI

Paso 2 — confirmar el OTP que el cliente recibió por SMS (válido 15 min):

POST/v1/payments/intents/{payment_id}/confirm
El 200 del confirm solo indica que se recibió el OTP — no garantiza la aprobación. El resultado final llega por webhook (payment.purchase.confirm.processed = aprobado). Si el OTP es incorrecto (confirm.failed.retryable), el intento sigue activo y el cliente puede reintentar con el mismo payment_id.
Probalo
curl -X POST https://sandbox.puntodepago.com.co/v1/payments/intents \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '
{
  "order_id": "orden-83793",
  "amount": 75000,
  "currency": "COP",
  "rail_id": "DAVIPLATA",
  "rail_type": "WALLET",
  "customer": {
    "phone_number": "3001234567",
    "document": "1234567890",
    "document_type": "CC"
  }
}
'
Probalo
curl -X POST https://sandbox.puntodepago.com.co/v1/payments/intents/{payment_id}/confirm \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '
{
  "otp": "123456"
}
'

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ásQué trae la respuesta HTTPQué esperás por evento
Link de pagoEl link creado, todavía sin pagarTodo el resultado del cobro
CapturaQue la captura se registróQue completó la compensación en la red
CancelaciónQue la anulación se registróQue los fondos quedaron liberados
ReembolsoQue 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.

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.
Respuesta
{
  "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.

EncabezadoQué contiene
akua-wh-idIdentificador único del mensaje, con prefijo msg_. Sirve además para deduplicar
akua-wh-timestampMomento del intento, en segundos Unix
akua-wh-signatureUna 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.

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.

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.

Una entrega real
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
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ésQué hacemos
2xxEntrega exitosa. No hay más intentos
5xxEscalera completa: hasta 8 intentos a lo largo de unas 27 horas
408 o 429Escalera completa, con un piso de 60 segundos entre intentos
400, 401, 403, 404Escalera corta: 3 intentos en unas 6 horas
Resto de 4xxTerminal. 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.
IntentoEspera desde el anteriorAcumulado
1inmediato0
25 segundos5 s
35 minutos5 min
430 minutos35 min
52 horas2 h 35 min
65 horas7 h 35 min
710 horas17 h 35 min
810 horas27 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.

IdentificadorPara qué sirve
Idempotency-KeyProtege los requests que vos nos mandás
akua-wh-idDeduplica los eventos que nosotros te mandamos
payment_idIdentifica 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.

EventoQué significaQué hacer
payment.authorization.processedEl emisor aprobó la autorización. Los fondos quedaron reservadosConsultar el pago y avanzar el pedido. Todavía no se cobró nada
payment.authorization.rejectedEl emisor o la red rechazaron la autorizaciónConsultar el pago para leer el código de red y ofrecer otro medio
payment.capture.manual.processedRecibimos tu captura y sale a la red en el ciclo siguienteMarcar el pedido como cobrado a la espera de compensación
payment.capture.automatic.processedLo mismo, para una captura automáticaIgual que la manual
payment.capture.clearedLa captura completó el ciclo de compensaciónCerrar el pedido. Es el final del ciclo de red
payment.cancel.processedSe anuló una autorización. Los fondos quedaron liberadosLiberar el pedido y avisar al cliente
payment.cancel.rejectedLa anulación fue rechazada, por ejemplo porque ya se capturóConsultar el pago. Si ya está capturado, el camino es un reembolso
payment.refund.clearedEl reembolso completó el ciclo de compensaciónCerrar el caso de devolución
payment.refund.rejectedLa red rechazó el reembolsoConsultar 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 manualauthorization.processed, authorization.rejected, capture.manual.processed, capture.cleared
Captura automáticaauthorization.processed, authorization.rejected, capture.automatic.processed, capture.cleared
Devolucionesrefund.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.

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.
Reconciliar ante cada evento
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.

EstadoSignificado
CREATEDCreado, todavía sin procesar
PENDING_CUSTOMEREsperando que el cliente complete el pago
FRAUD_REVIEWEn revisión antifraude
AUTHORIZEDAutorizado: fondos reservados, pendiente de captura
REJECTEDRechazado por el emisor o la red
CANCELLEDAnulado antes de la captura
CAPTURE_IN_PROGRESSCaptura en curso
CAPTURE_PRESENTEDCaptura presentada a la red, esperando compensación
CAPTUREDCapturado
SETTLEDLiquidado
COMPLETEDCompletado
REFUND_AUTHORIZED · REFUND_PRESENTED · REFUND_CAPTUREDEl reembolso avanzando en la red hasta acreditarse
REFUND_REJECTED · REFUND_FAILEDEl reembolso no prosperó
FAILEDError 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ódigoCausa
400 · INVALID_QUERY_PARAMUn parámetro de query que el endpoint no soporta. El mensaje dice cuál
400 · BAD_PARAMETERSUn valor fuera de rango, por ejemplo page_size mayor al máximo
401Falta el token, está vencido o la firma no cierra
404Recurso inexistente (payment not found) o ruta inexistente (route not found)
429Rate limit — reintentá con backoff
5xxError 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.

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.
Respuesta
{
  "error_code": "resource_not_found",
  "error_type": "not_found",
  "message": "payment not found",
  "trace_id": "req-dampr99ma4dqhvj1d1p0"
}

Tarjetas de prueba (sandbox)

NúmeroResultado
4111 1111 1111 1111Aprobada (Visa)
5186 1700 7000 1108Aprobada (Mastercard)