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 cabecera →
401con{"error":"falta_llave"}. Es un error tuyo de integración: la petición salió sin llave. - La llave no vale →
403con{"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
| Campo | Tipo | |
|---|---|---|
cop | número | Obligatorio. Los pesos que quieres RECIBIR. |
concepto | texto | Opcional. Lo ve tu cliente en la pantalla de pago. |
contacto_pagador | texto | Opcional. 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ódigo | HTTP | Qué pasó |
|---|---|---|
cop_invalido | 400 | Falta cop o no es un número mayor que cero. |
monto_fuera_de_rango | 422 | Por debajo del mínimo por cobro (hoy 90 USD) o por encima del tope. Trae la cifra. |
tope_operacion | 422 | Se pasa del tope por cobro de tu cuenta. |
tope_dia | 422 | Se pasa de lo que puedes emitir hoy. Vuelve mañana o escríbenos. |
falta_llave | 401 | La petición salió sin la cabecera X-Api-Key. |
llave_invalida | 403 | La llave no vale, o está revocada. |
| — | 404 | Ese short_id no existe (o no es tuyo). |
| — | 503 | La 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
| Evento | Cuándo |
|---|---|
cobro.creado | Se emitió el cobro. |
pago.detectado | Llegó a la cadena. Todavía sin confirmar. |
pago.confirmado | Confirmado y acreditado. Este es el que marca el pedido como pagado. |
pago.incompleto | Llegó menos de lo pedido, fuera de tolerancia. |
pago.excedido | Llegó de más por encima del tope de excedente. |
cobro.liquidado | Los pesos salieron hacia tu cuenta. |
cobro.vencido | Murió 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 cobro | 90 USD |
| Tope por cobro | 2.000 USD |
| Tope al día | 5.000 USD |
| Ventana de pago | 20 minutos desde que tu cliente abre el enlace |
| Moneda y red | USDT sobre TRON (TRC-20) |
| Horario de consignación | 7: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:
- Saca la segunda desde el panel.
- Ponla en tu servidor y despliega.
- Comprueba que un cobro se crea bien con la nueva.
- 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.
