API de cobros

Crear cobros desde tu servidor con tu llave de API: autenticación, endpoints, errores y webhooks firmados.

Vigente desde20 de agosto de 2026
Última actualizaciónSin revisión posterior

Para quién es esto

Para quien va a integrar Criptapp Pay en un servidor. Si eres el comercio y le pasaste la llave a tu desarrollador o a tu agencia, esta página es la que hay que reenviarles: es pública y no hace falta entrar al panel.

Todo lo de aquí se hace con una llave de API, que el comercio saca él mismo desde Integraciones → Llaves de API. Puede tener dos vivas a la vez, para poder cambiar la del servidor sin cortar los cobros ni un segundo.

La dirección y la llave

https://api.criptapp.com.co

Cada petición lleva la llave en una cabecera:

X-Api-Key: cpk_live_...

La llave no viaja nunca en la URL ni en el cuerpo. Si la mandas por otro sitio, la petición se rechaza.

Hay dos rechazos distintos, y conviene tratarlos distinto:

  • Falta la cabecera401 con {"error":"falta_llave"}. Es un error tuyo de integración: la petición salió sin llave.
  • La llave no vale403 con {"error":"llave_invalida"}. Y no dice por qué: no distingue una llave que no existe de una revocada. Es a propósito — decirlo sería una forma de averiguar cuáles existen.

Si te llega un 403 en producción y no has tocado nada, lo primero que hay que mirar es si esa llave se revocó desde el panel.

Crear un cobro

POST /v1/cobros
CampoTipo
copnúmeroObligatorio. Los pesos que quieres RECIBIR.
conceptotextoOpcional. Lo ve tu cliente en la pantalla de pago.
contacto_pagadortextoOpcional. Correo del pagador, para avisarle cuando su pago se confirme.

cop es lo que tú recibes, no lo que paga tu cliente. La comisión se le suma a él por encima; a ti te llegan esos pesos completos.

curl -X POST https://api.criptapp.com.co/v1/cobros \
  -H "X-Api-Key: cpk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-4471" \
  -d '{ "cop": 4000000, "concepto": "Factura 118" }'

Contesta 201 con el cobro:

{
  "short_id": "K7M2P9X4QRTVWN",
  "estado": "creado",
  "url": "https://criptapp.com.co/c/K7M2P9X4QRTVWN",
  "monto_usdt": "1320.754346",
  "monto_usdt_micro": 1320754346,
  "cop_estimado_centavos": 400000000,
  "direccion": "TGnvmKaUaPpwk4xbMRhct62iUdyrsLL5Ko",
  "cadena": "tron",
  "activo": "USDT",
  "vence_a": "2026-08-21T17:00:00-05:00",
  "ventana": {
    "abierto": true,
    "cierra_a": "2026-08-21T22:00:00.000Z",
    "abre_a": null,
    "minutos_para_cerrar": 398,
    "texto": "Abierto hasta las 17:00, hora de Bogotá."
  },
  "confirmaciones": {
    "hechas": 0,
    "necesarias": 20,
    "porcentaje": 0,
    "texto": "Esperando el pago"
  }
}

ventana y confirmaciones traen texto ya escrito, y están para que no tengas que redactarlo tú. ventana dice si nuestro proveedor está trabajando y cuánto le queda para cerrar — útil para avisarle a tu cliente de que si paga ahora entra hoy. confirmaciones es por dónde va su pago de las veinte que hacen falta, y cambia según lo consultes.

No des por fijo el conjunto de campos: podemos añadir alguno. Lee los que necesites por su nombre y no te rompas si aparece uno nuevo.

Lo que le mandas a tu cliente es la url. No le mandes la dirección suelta: la pantalla es la que le dice cuánto enviar y le arranca su ventana de pago.

[object Object], y por qué te conviene usarla

Es una cabecera opcional y es lo que evita cobrar dos veces. Si tu servidor reintenta —porque se cayó la red, porque el cliente pulsó dos veces— y manda la misma llave, te devolvemos el cobro que ya se creó en vez de crear otro.

Usa algo tuyo y estable: el número de pedido, el id de la factura.

Si repites la llave con un cuerpo distinto, la petición se rechaza. Eso no es un reintento: es un error de tu lado, y devolverte lo anterior te acreditaría un cobro que no pediste.

Consultar

GET /v1/cobros/{short_id}      # uno
GET /v1/cobros                 # los tuyos

El primero es público —lo usa la pantalla del pagador— y devuelve lo mismo que el POST, con estado, ventana y confirmaciones al día. Nunca la cuenta del comercio, ni la dirección desde la que se pagó, ni nada de identidad. Un identificador que no existe contesta 404, y no distingue «no existe» de «no puedes verlo»: distinguirlo permitiría enumerar cobros ajenos.

El segundo pide tu llave y devuelve solo los tuyos, en { "cobros": [...] }, con short_id, estado, concepto, monto_solicitado_cop_centavos, monto_usdt_micro, created_at y vence_a. Es una lista, no lleva ventana ni direccion: para el detalle, el GET de uno.

Cuando algo sale mal

Los errores llegan con un código estable en error y una frase en mensaje. Enséñale el mensaje a quien esté delante: trae la cifra que hace falta para arreglarlo.

CódigoHTTPQué pasó
cop_invalido400Falta cop o no es un número mayor que cero.
monto_fuera_de_rango422Por debajo del mínimo por cobro (hoy 90 USD) o por encima del tope. Trae la cifra.
tope_operacion422Se pasa del tope por cobro de tu cuenta.
tope_dia422Se pasa de lo que puedes emitir hoy. Vuelve mañana o escríbenos.
falta_llave401La petición salió sin la cabecera X-Api-Key.
llave_invalida403La llave no vale, o está revocada.
404Ese short_id no existe (o no es tuyo).
503La emisión está pausada. Reintenta con espera creciente.

Que tu servidor se entere solo

En vez de preguntar cada rato, configura una URL en Ajustes → Cómo te avisamos y te mandamos un POST cuando pase algo.

Los eventos

EventoCuándo
cobro.creadoSe emitió el cobro.
pago.detectadoLlegó a la cadena. Todavía sin confirmar.
pago.confirmadoConfirmado y acreditado. Este es el que marca el pedido como pagado.
pago.incompletoLlegó menos de lo pedido, fuera de tolerancia.
pago.excedidoLlegó de más por encima del tope de excedente.
cobro.liquidadoLos pesos salieron hacia tu cuenta.
cobro.vencidoMurió la ventana sin pago.

El cuerpo

{
  "evento": "pago.confirmado",
  "id": "b1e4…",
  "creado_en": "2026-08-20T15:04:05.000Z",
  "datos": {
    "short_id": "K7M2P9X4QRTVWN",
    "estado": "confirmado",
    "monto_usdt_micro": 1320754346,
    "cop_estimado_centavos": 400000000,
    "concepto": "Factura 118"
  }
}

El campo id es del envío, no del cobro. Un reintento nuestro llega con el mismo id, así que guárdalo y descarta los repetidos: es lo que evita que proceses dos veces el mismo aviso.

Lo que NO viaja, y es deliberado: la dirección desde la que pagó tu cliente, tu cuenta bancaria, el hash de la transacción, y nada de identidad. Un webhook acaba en los registros de quien lo recibe, y esos registros no los controlamos.

La firma

Si configuras un secreto —y deberías—, cada aviso lleva:

X-Criptapp-Signature: t=1755712345,v1=5d41402abc4b2a76b9719d911017c592...

v1 es un HMAC-SHA256 sobre la cadena <t>.<cuerpo>, con tu secreto como clave.

Verifícalo sobre los BYTES QUE RECIBES, no sobre el objeto ya parseado. Si nosotros firmamos un texto y tú verificas otro —aunque digan lo mismo— la firma nunca cuadra.

const crypto = require('crypto')
 
function esNuestro(cuerpoCrudo, cabecera, secreto) {
  const [t, v1] = cabecera.split(',').map((p) => p.split('=')[1])
 
  // La marca de tiempo va DENTRO de lo firmado: sin comprobarla, alguien que
  // capture un aviso bueno puede reenviarlo mañana y la firma seguirá valiendo.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
 
  const esperado = crypto.createHmac('sha256', secreto)
    .update(`${t}.${cuerpoCrudo}`).digest('hex')
 
  // En tiempo constante. Un `===` filtra, por lo que tarda, en qué carácter falla.
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado))
}

Toleramos 300 segundos de desfase de reloj entre tu servidor y el nuestro.

Sin secreto configurado, la cabecera no viaja. No mandamos una firma vacía: una firma con clave vacía la puede recalcular cualquiera, y verificaría bien sin probar nada. Si no la ves, es que no has puesto secreto — y entonces tu endpoint acepta cualquier POST de quien sepa tu dirección.

Si tu servidor no contesta

Reintentamos ocho veces con espera creciente: el último intento sale algo más de siete horas después del primero. Si para entonces sigue sin contestar, el aviso se va a nuestra cola de excepciones y lo mira una persona.

Un aviso no sobrevive a un despliegue nuestro. Los reintentos viven en el proceso que los manda: si lo reiniciamos en mitad de la tanda, los que quedaran no salen. No es lo habitual, pero puede pasar — y por eso no construyas tu sistema sobre la idea de que un aviso siempre acaba llegando. Guarda el id de los que recibas y, para lo que de verdad importe, consulta el cobro con GET /v1/cobros/{short_id} en vez de esperar sentado.

Cada intento espera como mucho diez segundos. Contesta 2xx en cuanto recibas y haz el trabajo después: si tardas más de diez segundos en procesar, lo contamos como fallo y reintentamos.

Las cifras de hoy

Mínimo por cobro90 USD
Tope por cobro2.000 USD
Tope al día5.000 USD
Ventana de pago20 minutos desde que tu cliente abre el enlace
Moneda y redUSDT sobre TRON (TRC-20)
Horario de consignación7:00 – 17:00, hora de Bogotá, días hábiles

Los topes suben con historial: se piden y se suben, sin rellenar nada.

Rotar la llave sin cortar cobros

Como puedes tener dos vivas a la vez:

  1. Saca la segunda desde el panel.
  2. Ponla en tu servidor y despliega.
  3. Comprueba que un cobro se crea bien con la nueva.
  4. Revoca la vieja.

Revocar no toca los cobros en curso. Los que ya están abiertos siguen su camino y se cobran normal. Hay una casilla aparte, apagada por defecto, para anularlos también — es para el caso de robo, no para una rotación.

Del mismo tema

Más de Para desarrolladores

Este tema tiene un solo documento.

Si algo no te cuadra

Pregúntanos antes. No después

Estos documentos son densos por necesidad, no para esconder nada. Si hay algo que no entiendes, es mejor resolverlo ahora que cuando ya tengas plata de por medio.

Respondemos de 7:00 a 17:00, hora de Bogotá. Fuera de ese horario, a primera hora del día siguiente.