Titanio PayDocumentación

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énCómo
ComercioAuthorization: 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 CEOOtambién se acepta el x-integration-token heredado (equivale al comercio ceoo)
Administración CEOOAuthorization: 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 rutaQué hace
GET /saludestado del núcleo y del libro mayor
GET /metodosmétodos de pago y su estado real (activo, sandbox, requiere contrato…)
POST /proveedores/<proveedor>/notificacionnotificació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
}

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).

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:

CabeceraValor
Titanio-Firma y X-Titanio-Firmat=<unix>,v1=<hex>
Titanio-Evento-Id y X-Titanio-Eventoid único del evento (evt_…)
Titanio-Eventotipo (cobro.pagado…)

v1 = HMAC-SHA256(secreto_webhook, "<t>.<cuerpo exacto>") en hexadecimal.

El receptor debe:

  1. Recalcular la firma sobre el cuerpo crudo y compararla en tiempo constante.
  2. Rechazar si |ahora − t| > 300 segundos (protección contra repetición).
  3. Guardar el id del evento y no procesarlo dos veces (responder 200 al repetido).
  4. 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

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