Titanio Pay · API v1
Versión del núcleo: 2.0.0 (16 de octubre de 2026). Núcleo escrito en V++.
Base pública: https://ceoo.world/pago/api/v1 Base local (servicios del ecosistema en el mismo equipo): http://127.0.0.1:5905/api/v1
Todas las respuestas son JSON con "ok": true|false. Los errores traen "error" con el motivo en español y el código HTTP que corresponde (400 datos inválidos, 401 sin credencial, 403 sin permiso, 404 no existe, 409 conflicto de estado o de idempotencia, 413 cuerpo grande, 429 límite de solicitudes).
Principio de operación
Titanio Pay no recibe dinero de nadie. Cada comercio cobra a su propia CLABE o a su propio adquirente. Titanio registra el cobro, emite la ficha, concilia, lleva el libro mayor, calcula la comisión de plataforma y avisa con webhooks firmados. Por eso un comercio sin CLABE propia no puede emitir fichas SPEI (respuesta 409).
Autenticación
| Quién | Cómo |
|---|---|
| Comercio | Authorization: Bearer tp_<comercio>_<secreto> |
| Plataforma (p. ej. PRIVORA) | su propia llave tp_<plataforma>_… y, para operar un comercio hijo, la cabecera Titanio-Comercio: <id del hijo> |
| Ventas propias de CEOO | también se acepta el x-integration-token heredado (equivale al comercio ceoo) |
| Administración CEOO | Authorization: Bearer tpadm_… (solo rutas /admin/) |
Las llaves se entregan una vez al crearlas. Titanio guarda solo su sha256. Se pueden rotar con POST /comercio/llave/rotar.
Un comercio solo ve lo suyo. Pedir un cobro de otro comercio responde 404 (no se confirma que exista). Una plataforma solo opera a sus hijos; cualquier otro responde 403.
Montos
Siempre centavos enteros (monto_centavos). 125050 son $1,250.50 MXN. Un monto con decimales responde 400. Moneda: MXN. Máximo por cobro: 100,000,000 centavos (un millón de pesos).
Estados del cobro
creado ──► pendiente ──► pagado ──► reembolsado
│ ├──────► fallido
│ └──────► expirado
└──────► fallido / expirado / pagado (solo método «registro»)
Cualquier otra transición responde 409 y queda en la bitácora de seguridad. Repetir la misma transición (por ejemplo, dos notificaciones del mismo pago) responde 200 con "sin_cambio": true y no hace nada la segunda vez.
Idempotencia
POST /cobros exige la cabecera Idempotency-Key (8 a 120 caracteres). Con la misma llave y el mismo cuerpo se devuelve el mismo cobro (200, "repetido": true). Con la misma llave y otro cuerpo, 409. Probado con 12 peticiones simultáneas: un solo cobro.
Sugerencia de llave: <sistema>-<id interno>-<intento>, por ejemplo prv-8841-1.
Rutas
Públicas
| Método y ruta | Qué hace |
|---|---|
GET /salud | estado del núcleo y del libro mayor |
GET /metodos | métodos de pago y su estado real (activo, sandbox, requiere contrato…) |
POST /proveedores/<proveedor>/notificacion | notificación de un proveedor, autenticada por firma, no por llave |
Cobros
POST /cobros
{
"monto_centavos": 250000,
"concepto": "Cuota de mantenimiento octubre · Casa 14",
"metodo": "spei",
"referencia_externa": "PRV-12-8841",
"cliente": {"nombre": "Ana Ruiz", "email": "[email protected]", "referencia": "Casa 14"},
"metadatos": {"comunidad": "12", "predio": "14"},
"expira_en_horas": 72
}
metodo:spei(por omisión),registro(cobrado por fuera: caja, depósito) osandbox(solo si está encendido). También se aceptametodos: ["tarjeta","spei"]: se toma el primero que Titanio puede atender hoy.metadatos: hasta 20 claves[A-Za-z0-9_-], valores de hasta 200 caracteres.- Respuesta 201 con el cobro. Para SPEI incluye
referencia_spei(7 dígitos, el último es verificador),concepto_speieinstrucciones_url(ficha para quien paga).
GET /cobros?estado=pendiente&limite=50&referencia_externa=PRV-12-8841 lista los cobros del comercio (los más recientes primero).
GET /cobros/<id> relee un cobro. La verdad es esta consulta; el webhook es un aviso.
POST /cobros/<id>/cancelar pasa a fallido (solo desde creado o pendiente).
POST /cobros/<id>/reembolsar {"motivo": "...", "referencia_devolucion": "..."} registra un reembolso de un cobro pagado. El dinero lo devuelve el comercio desde su cuenta; Titanio lo asienta.
POST /cobros/<id>/marcar-pagado {"evidencia": "recibo 0042"} solo para el método registro. Un cobro SPEI no se declara pagado a mano: se concilia.
Campos del cobro: id, comercio, estado, monto_centavos, monto_vigente_centavos (lo que toca pagar hoy si hay tramos), moneda, concepto, metodo, creado, actualizado, expira (unix), cliente, metadatos, referencia_externa, referencia_spei, concepto_spei, tramos, pago (fecha, monto, rastreo, origen, evidencia), comision_centavos, reembolso, historial. Si el comercio tiene comision_a_cargo: "cliente", además comision_a_cargo, comision_base_centavos, comision_iva_centavos, total_cliente_centavos (lo que paga el cliente: monto + comisión + IVA) y monto_vigente_cliente_centavos; cada tramo trae su total_cliente_centavos.
Conciliación SPEI
POST /conciliacion/spei con el estado de cuenta del comercio en CSV (Content-Type: text/csv, máx. 2 MB, 5,000 renglones).
- Encabezado obligatorio. Columnas reconocidas (sin importar mayúsculas ni acentos):
fecha,abonoomonto/importe,referencia,concepto/descripción,clave de rastreo/rastreo/folio,ordenante,cargo. Separador coma o punto y coma. FechasAAAA-MM-DD,DD/MM/AAAAoDD-MM-AAAA. Importes$1,234.56o1234.56. - La referencia se toma de su columna o, si no viene, de cualquier número de 7 dígitos con verificador válido dentro del concepto.
- Cada abono se procesa una sola vez (por clave de rastreo). Reimportar el mismo archivo no aplica nada dos veces.
- Lo que no cuadra no se descarta ni se da por bueno: va a excepciones (
sin_referencia,referencia_desconocida,monto_insuficiente,pago_duplicado,pago_tardio_cobro_expirado,fecha_anterior_al_cobro,fecha_futura). - Se acepta pago igual o mayor al monto vigente en la fecha del abono; el excedente se anota.
GET /conciliacion/excepciones lista las excepciones del comercio.
CEP de Banxico
POST /cep/verificar
{"cobro": "cob_…", "fecha": "2026-10-15", "rastreo": "CLAVE123", "banco_emisor": "40012", "monto_centavos": 250000}
Consulta el Comprobante Electrónico de Pago en Banxico con la CLABE y el código de participante (banco_spei) del comercio. Si Banxico lo confirma, el cobro pasa a pagado y la clave de rastreo queda consumida (una transferencia no paga dos cobros). Límites: horario de consulta de Banxico 09:30 a 23:00; el formulario tiene un captcha que hoy está desactivado; el camino «encontrado» no se ha probado con un pago real. La conciliación por estado de cuenta sigue siendo la vía principal.
Recurrencia
POST /planes
{
"nombre": "Cuota de mantenimiento",
"monto_centavos": 150000,
"dia_vencimiento": 10,
"emitir_dias_antes": 10,
"dias_expira": 45,
"descuento": {"hasta_dia": 5, "centavos": 10000},
"recargo": {"dias_gracia": 5, "bps": 1000}
}
Descuento y recargo aceptan centavos fijos o bps (puntos base del monto; 1000 = 10 %). El cobro de cada periodo lleva tramos: pronto pago, normal (hasta vencimiento + gracia) y con recargo (hasta la expiración). La conciliación compara contra el tramo del día del abono.
POST /suscripciones {"plan": "pln_…", "cliente": {"nombre": "...", "email": "...", "referencia": "Casa 21"}, "inicio": "2026-10"}
GET /suscripciones/<id>, POST /suscripciones/<id>/pausar|reanudar|cancelar
El trabajador (cada minuto) emite un cobro por suscripción y periodo (llave sus-<id>-<periodo>: nunca dos), expira los vencidos y encola avisos.
GET /avisos lista avisos pendientes (emision, por_vencer, vencido) con texto, destinatario y enlace a la ficha. Titanio no los envía: los entrega al comercio (aquí y por webhook aviso.creado) para que su plataforma decida. POST /avisos/<id>/atendido los marca.
Webhooks
POST /comercio/webhook {"url": "https://…", "rotar_secreto": false} registra el destino y devuelve el secreto_webhook.
Cada evento llega por POST con:
| Cabecera | Valor |
|---|---|
Titanio-Firma y X-Titanio-Firma | t=<unix>,v1=<hex> |
Titanio-Evento-Id y X-Titanio-Evento | id único del evento (evt_…) |
Titanio-Evento | tipo (cobro.pagado…) |
v1 = HMAC-SHA256(secreto_webhook, "<t>.<cuerpo exacto>") en hexadecimal.
El receptor debe:
- Recalcular la firma sobre el cuerpo crudo y compararla en tiempo constante.
- Rechazar si
|ahora − t| > 300segundos (protección contra repetición). - Guardar el id del evento y no procesarlo dos veces (responder 200 al repetido).
- Releer el cobro con
GET /cobros/<id>antes de dar algo por pagado.
Responder 2xx en menos de 10 s. Si no, Titanio reintenta a los 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h (8 intentos en total). Todo queda en la bitácora de webhooks.
La plataforma madre recibe también los eventos de sus comercios hijos, firmados con su secreto.
Eventos: cobro.creado, cobro.pendiente, cobro.pagado, cobro.fallido, cobro.expirado, cobro.reembolsado, conciliacion.excepcion, suscripcion.creada, suscripcion.pausada, suscripcion.activa, suscripcion.cancelada, aviso.creado, prueba.ping.
POST /webhooks/verificar {"cabecera": "t=…,v1=…", "cuerpo": "…"} comprueba una firma con el secreto del comercio (útil para depurar; en producción se verifica localmente).
POST /webhooks/probar envía un prueba.ping.
GET /eventos?limite=50 lista los eventos del comercio, por si un webhook se perdió.
Ejemplo de verificación en V++:
funcion titanio_firma_valida(secreto, cabecera, cuerpo):
sea partes = dividir(cabecera, ",")
sea t = subcadena(lista_obtener(partes, 0), 2, longitud(lista_obtener(partes, 0)))
sea v1 = subcadena(lista_obtener(partes, 1), 3, longitud(lista_obtener(partes, 1)))
si iguales_seguro(hmac_sha256_hex(secreto, t + "." + cuerpo), v1) == 0:
regresa 0
fin
sea dif = entero(tiempo_unix()) - entero(t)
si dif > 300 o dif < -300:
regresa 0
fin
regresa 1
fin
La versión completa, con protección contra repetición, está en titanio/pruebas/receptor.vpp.
Comercios y libro mayor
GET /comercio datos del comercio (sin secretos).
POST /comercios (solo llave de plataforma) da de alta un comercio hijo: {"id": "fracc-lomas", "nombre": "...", "clabe": "...", "banco": "...", "banco_spei": "40012", "beneficiario": "...", "rfc": "...", "comision_bps": 300, "comision_fija_centavos": 0, "comision_iva_bps": 1600, "comision_a_cargo": "cliente"}. La CLABE se valida con su dígito verificador. Respuesta con llave y secreto_webhook (una sola vez).
GET /mayor últimos asientos del libro mayor del comercio, cada uno con su sello.
El libro mayor es de solo adición: cada asiento incluye el sha256 del anterior. Cambiar, borrar o reordenar un asiento se detecta (GET /admin/mayor/verificar dice en qué asiento se rompió).
Comisión de plataforma
Por comercio: comision_bps (puntos base) + comision_fija_centavos, y desde el 23-oct-2026 comision_iva_bps (IVA de la comisión, 1600 = 16 %; 0 = la comisión ya viene con IVA, como en los comercios anteriores). Base = monto × bps + fija, redondeada al centavo; IVA = base × iva_bps, redondeado al centavo; total = base + IVA, con tope en el monto. Se calcula al pasar a pagado sobre el monto efectivamente pagado y se asienta en el libro mayor como comision.plataforma con comision_base_centavos y comision_iva_centavos; el cobro los expone igual. GET /comisiones?periodo=AAAA-MM devuelve en por_facturar también base_centavos e iva_centavos para el CFDI. Ejemplo PRIVORA (3 % + IVA): cuota de $1,850 → base $55.50, IVA $8.88, total $64.38 (en tarjeta/OXXO es el application_fee_amount). Titanio registra la comisión; no la retiene de un dinero que nunca pasa por él.
Regla de redondeo (la misma en Titanio, PRIVORA, recibos y CFDI). Cada componente se redondea al centavo por separado, a la unidad más cercana con la mitad hacia arriba: comisión = redondeo(monto × bps / 10000) + fija; IVA de la comisión = redondeo(comisión × 16 %); total del cliente = monto + comisión + IVA (suma exacta); tarifa de Stripe = redondeo(total × porcentaje) + fijo, IVA de la tarifa = redondeo(tarifa × 16 %); neto del comercio = total − (comisión + IVA) − (tarifa + IVA). Como el neto se define por resta, neto + comisión + tarifa = total siempre, sin centavos perdidos (reparto_estimado, probado en S7d para $1, $99.99, $1,850, $9,999.99 y $50,000 con tarjeta nacional, internacional, OXXO y SPEI). La tarifa REAL se lee del balance_transaction del cargo (GET /v1/payment_intents/<pi>?expand[]=latest_charge.balance_transaction en la cuenta conectada) y queda en pago.reparto.tarifa_procesador_centavos con tarifa_fuente; si difiere de la estimada, alerta_tarifa y evento de seguridad stripe_tarifa_distinta.
Cuentas conectadas ADMINISTRADAS por la plataforma (24-oct-2026). POST /comercio/stripe/cuenta con {"modo":"administrada", tipo (company|individual|non_profit), nombre, rfc, telefono, domicilio{calle,colonia,ciudad,estado,cp}, url, descripcion, mcc, representante{nombres, apellidos, correo, telefono, nacimiento AAAA-MM-DD, id_number (CURP o RFC), cargo, domicilio{…}}, clabe, titular, tos{fecha (unix), ip, agente}, retencion_dias} crea en UNA llamada la cuenta conectada con controller[stripe_dashboard][type]=none, requirement_collection=application, losses[payments]=application, fees[payer]=application (la única combinación que Stripe acepta cuando la plataforma recoge los requisitos; comprobado por API el 23-oct-2026), capacidades card/transfers/oxxo, external_account = la CLABE, tos_acceptance, settings[payouts] (retención y débito de saldos negativos) y el representante en Persons. Respuesta: cuenta, tipo: administrada, estado (pendiente_kyc|activa), requisitos (en español), errores, retencion_dias. Si la cuenta ya existe, actualiza (retención, domicilio, url, representante). POST /comercio/stripe/documento {archivo_b64, nombre, tipo (image/jpeg|image/png|application/pdf), lado (frente|reverso)} sube la identificación a la API de Files (identity_document, multipart empalmado en base64 sin tocar bytes) y la liga al representante (verification.document). GET /comercio/stripe/cuenta devuelve además tipo, requisitos, errores, motivo, retencion_dias, revisado. GET /comercio/reparto?monto_centavos=&metodo= devuelve el reparto estimado al centavo (total_cliente_centavos, procesador_cliente_centavos, comision_aplicacion_centavos, neto_comercio_centavos, quien_paga_tarifa). Con cuenta administrada y método tarjeta/OXXO, el cobro nace con procesador_cliente_centavos (punto fijo procesador_sobre) dentro de total_cliente_centavos, el Checkout lleva un cuarto renglón «Cargo del procesador de pagos» y la application_fee_amount = comisión + IVA + procesador; al pagarse, la tarifa REAL se lee de la application_fee (plataforma) → reparto.tarifa_procesador_centavos, margen_procesador_centavos, alerta_tarifa si Stripe cobró más de lo cubierto; deposito_estimado viene del available_on de la cuenta conectada. Eventos account.updated, charge.dispute.* y payout.failed se asientan en el mayor y se emiten a la plataforma madre como cuenta.stripe_<estado>, disputa.<evento> y deposito.fallido. Ejemplo PRIVORA $1,850 administrada: tarjeta $2,001.44 (procesador $87.06), OXXO $2,011.18 ($96.80), SPEI $1,914.38; la comunidad recibe $1,850.00 en los tres casos.
Comisión de servicio única y métodos (24-oct-2026). Con cuenta administrada y comisión a cargo del cliente, el cobro nace con UN renglón para el cliente («Comisión de servicio <plataforma> (incluye IVA $x)»): base = comisión (bps) + recuperación de la tarifa de Stripe SIN IVA sobre el total (punto fijo, comision_servicio), IVA sobre toda la base; procesador_cliente_centavos guarda la recuperación. Métodos: tarjeta, oxxo, transferencia (SPEI vía Stripe: customer_balance/mx_bank_transfer, Customer creado por cobro, $7 fijos; exige la capacidad mx_bank_transfer_payments activa en la cuenta conectada → GET /comercio/stripe/cuenta expone transferencias), spei (ficha directa a la CLABE del comercio). retenciones en el cuerpo del cobro ({isr_centavos, iva_centavos, base_centavos, iva_cobrado_centavos, con_rfc, rfc}) se suman a la application fee, se descuentan al comercio y se asientan como retencion.isr/retencion.iva; GET /retenciones?periodo=AAAA-MM las resume por comercio. POST /cobros/<id>/reembolsar en cobros de Stripe ejecuta el reembolso en Stripe (cuenta conectada; devolver_comision por omisión true) antes de asentarlo. El reparto trae comision_servicio_centavos, comision_privora_centavos, recuperacion_centavos, retenciones_centavos, iva_trasladado_centavos, tarifa_real_sin_iva_centavos, iva_acreditable_centavos, margen_ceoo_sin_iva_centavos, deposito_estimado.
A cargo de quién (23-oct-2026, decisión de Ricardo para PRIVORA). comision_a_cargo del comercio: comercio (por omisión; la comisión se descuenta de lo que recibe) o cliente (la comisión se SUMA encima del monto). Con cliente: el cobro nace con total_cliente_centavos = monto + comisión + IVA; la ficha SPEI pide ese total y lo desglosa (cuota, comisión, IVA, total; un abono por solo el monto queda como excepción monto_insuficiente); el Checkout de Stripe lleva tres renglones (concepto, «Comisión por uso del servicio <plataforma> (3 %)», «IVA 16 % de la comisión») y application_fee_amount = comisión + IVA; al pagarse, la comisión se calcula sobre el monto del comercio (comision_sobre_centavos), no sobre el total, y el reparto lleva monto_comercio_centavos. Stripe cobra su tarifa a la cuenta conectada sobre el total cobrado. Ejemplo PRIVORA: cuota $1,850 → el residente paga $1,914.38; CEOO $64.38; con tarjeta Stripe ≈ $83.43 (3.6 % + $3 + IVA sobre $1,914.38) y la comunidad recibe ≈ $1,766.57; en OXXO Stripe ≈ $92.31 y la comunidad ≈ $1,757.69; por SPEI la comunidad recibe $1,914.38 y liquida $64.38 en el corte mensual (por_facturar). Se factura aparte o se retiene en el adquirente (cargo directo con comisión de plataforma) cuando exista ese contrato.
Límites y seguridad
- 240 solicitudes por minuto por IP y 600 por minuto por comercio. 20 intentos fallidos de credencial por minuto y por IP antes de responder 429.
- Credenciales fallidas, transiciones ilegales, firmas inválidas, accesos a comercios ajenos y rotaciones de llave quedan en la bitácora de seguridad.
- Secretos fuera del código:
~/.ceoo/titanio_pay.json(600) y llaves en la bóvedaBACKUP 2025/CLAVES_PAGOS. Datos en~/ceoo-pay/datos(700, archivos 600). - Titanio no toca datos de tarjeta. Cuando haya agregador, el cobro con tarjeta será con su checkout alojado (PCI DSS SAQ A).
Compatibilidad
Las rutas de la versión anterior siguen idénticas (POST /api/cobro, POST /api/cobro/<id>/refrescar, /api/salud, /login, /panel). Las atiende el servicio heredado detrás del núcleo; cada cobro heredado queda además reflejado en el libro mayor como leg-<id>.