Central One/api/v1Documentación de la APIVersión 1
Todo lo necesario para conectar tu propia página, bot o punto de venta con Central One. Servidor a servidor, JSON de ida y de vuelta.
URL base
https://portal.centraloneglobal.com/api/v1
Todas las rutas de abajo cuelgan de esta base. Los ejemplos de esta página ya usan la dirección desde la que la estás leyendo.
https://portal.centraloneglobal.com/docs/api
Integración mínima
Esta API deja que tu propio sistema lea tu catálogo y tu saldo, cree pedidos y consulte su estado, sin que nadie tenga que abrir el portal. Tiene la misma forma que las API de proveedores que Central One ya consume: una llave en una cabecera, JSON de ida y JSON de vuelta.
- 1GET /health¿Hay servicio?
- 2GET /ping¿Mi llave sirve?
- 3GET /catalogGuarda una copia en tu sistema
- 4GET /balanceMuestra lo disponible
- 5POST /ordersCon Idempotency-Key. Devuelve 201 y el id del pedido
- 6GET /orders/{id}Consulta hasta completed o failed. Revisa delivered_count en cada línea
- 7GET /orders/{id}/codesSolo si delivered_count es mayor que 0. Aquí obtienes los códigos
Sobre el último paso: consulta cada 30 segundos los primeros 5 minutos, y después cada 5 minutos. No consultes en bucle cerrado: vas a alcanzar el límite.
Autenticación
Cada petición lleva la llave en la cabecera Authorization. La emites tú desde tu pantalla de llaves y se muestra una sola vez: guardamos únicamente una huella suya, igual que una contraseña, así que nadie puede volver a mostrártela, ni nosotros. Si la pierdes, revócala y crea otra.
Authorization: Bearer co_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Esto no es una recomendación, es un requisito. La llave autoriza a gastar todo el saldo de tu cuenta. Si aparece en el JavaScript de tu página, en el HTML, o dentro de una aplicación móvil, cualquier persona puede abrirla con el inspector y comprar con tu dinero.
Navegador del comprador -> Tu backend -> API de Central One
(nunca ve la llave) (la llave vive aquí) (co_live_...)- Llamadas desde un navegador. La API no habilita CORS, y es deliberado.
- La llave en el query string (?api_key=...). Nunca se lee, ni siquiera para rechazarla con un mensaje distinto: los query strings quedan en los registros de cualquier proxy intermedio.
- Cualquier esquema de Authorization que no sea Bearer.
Permisos
Cada llave se emite con un conjunto explícito de permisos. Pedir un endpoint fuera del scope devuelve 403 insufficient_scope.
| Permiso | Habilita | Si se filtra |
|---|---|---|
| catalog:read | GET /catalog | Quedan a la vista tus precios. Los tuyos, no nuestro costo: el catálogo nunca lleva a cuánto compramos ni a quién. |
| balance:read | GET /balance | Queda a la vista tu saldo. Con este permiso solo no se puede gastar nada. |
| orders:read | GET /orders, GET /orders/{id} | Queda a la vista tu historial: qué compraste, cuándo y por cuánto. No muestra los códigos entregados: para eso hace falta codes:read. |
| orders:write | POST /orders | GASTA TU DINERO. Quien la tenga puede comprar contra tu saldo hasta agotarlo. Es la que hay que cuidar. |
| codes:read | GET /orders/{id}/codes | Revela los códigos de gift card que ya compraste. Un código es valor al portador: quien lo lee puede canjearlo, y después no se puede anular. Pídelo solo si tu sistema de verdad entrega códigos a clientes finales. |
Pide lo mínimo que necesites. Si tu integración solo consulta precios, pide catalog:read y nada más. Una llave de lectura filtrada es un incidente; una con orders:write es una pérdida, y una con codes:read entrega mercancía que ya pagaste.
codes:read es el único permiso que NO viene marcado al emitir una llave. Pídelo explícitamente y di para qué lo necesitas.
Límites de uso
Ventanas fijas de 60 segundos, contadas por llave y por IP de forma independiente.
| Límite | Alcance |
|---|---|
| 60 peticiones / 60 s | por llave, endpoints de lectura |
| 20 peticiones / 60 s | por llave, POST /orders |
| 30 peticiones / 60 s | por IP, antes de autenticar |
Los contadores de lectura y de escritura son separados: consultar el estado de tus pedidos nunca te consume la capacidad de comprar.
X-RateLimit-Limit: 60 X-RateLimit-Remaining: 57
Al pasarte recibes 429 rate_limited con Retry-After en segundos. Espera ese tiempo; no reintentes en bucle.
Errores
Todas las respuestas de error usan el mismo sobre.
{
"error": {
"code": "invalid_request",
"message": "The request could not be processed as specified.",
"request_id": "8f3a1c2e-..."
}
}| HTTP | code | Qué pasó | ¿Reintentar? |
|---|---|---|---|
| 400 | invalid_request | El cuerpo o los parámetros no cumplen el contrato | No, sin corregir |
| 401 | invalid_api_key | Llave ausente, inválida, suspendida, revocada o cuenta inactiva | No |
| 403 | insufficient_scope | La llave es válida pero no tiene el permiso | No |
| 404 | not_found | El recurso no existe, o no es tuyo | No |
| 409 | insufficient_balance | El saldo disponible no cubre el pedido | Sí, después de recargar |
| 409 | insufficient_stock | No hay inventario suficiente | Sí, más tarde |
| 429 | rate_limited | Superaste el límite | Sí, tras Retry-After |
| 500 | internal_error | Falla interna | Sí, con espera creciente |
| 503 | service_unavailable | Servicio no disponible temporalmente | Sí, con espera creciente |
- Todos los fallos de credencial son idénticos. Una llave inexistente, una suspendida, una revocada, una cuenta inactiva y una cabecera mal formada devuelven exactamente el mismo 401 invalid_api_key, con el mismo cuerpo y las mismas cabeceras. Es intencional: evita usar la API como oráculo para adivinar llaves. No intentes distinguirlos, no puedes.
- 404 no distingue “no existe” de “no es tuyo”. El pedido de otro revendedor devuelve la misma respuesta que un id inventado.
- Los mensajes son fijos y genéricos. Nunca contienen datos de tu petición, nombres de tablas, ni errores de la base de datos.
Toda respuesta incluye X-Request-Id. Puedes enviarlo tú (se respeta si es un token corto y seguro) o lo genera el servidor. Guárdalo en tu log: es lo que permite que soporte encuentre tu petición exacta.
Endpoints
Comprobación de vida, sin autenticar. Sirve para monitoreo.
curl https://portal.centraloneglobal.com/api/v1/health
{ "status": "ok", "request_id": "..." }Prueba autenticada. Confirma que tu llave sirve. No devuelve ningún dato de negocio.
curl https://portal.centraloneglobal.com/api/v1/ping \ -H "Authorization: Bearer $CO_API_KEY"
{ "ok": true, "request_id": "..." }Los productos habilitados para ti, con tu precio. Un producto que no tienes habilitado no aparece.
- product_id es el valor que mandas como catalog_item_id al crear un pedido. Es el mismo identificador con dos nombres: uno nombra el producto y el otro la línea que estás comprando.
- Guarda el catálogo en tu sistema y actualízalo cada cierto tiempo; cada 15 minutos alcanza. No lo pidas en cada visita de un comprador: vas a alcanzar el límite de 60 peticiones por minuto.
- Los precios siguen el costo del proveedor, así que cambian durante el día, a veces varias veces. Una copia de hace un día puede estar lejos del precio real. price_updated_at dice cuándo se fijó el precio actual; si tu copia es anterior, actualízala. updated_at es la fecha del registro del producto, no la del precio. En cualquier caso, lo que se cobra es el total_sale_amount que devuelve POST /orders.
- in_stock dice si el producto se puede comprar ahora. available_stock es cuántas unidades hay; null significa que el proveedor no informa cantidad: no se sabe, que no es lo mismo que cero. Usa in_stock para decidir si ofreces el producto, y available_stock sólo como tope cuando trae un número. El stock sale de la última lectura del proveedor, así que un pedido igual puede recibir 409 insufficient_stock.
- Agrupa por product_family_id, nunca parseando el nombre ni el SKU. Todos los productos de Free Fire traen el mismo product_family_id, así que puedes mostrar un juego con sus denominaciones debajo. El nombre lo escribe el proveedor y cambia; el formato del SKU a propósito no es un contrato.
- product_family_id se deriva del nombre de la familia, no es una clave permanente. Si un proveedor renombra la familia, el id cambia con ella. Úsalo para agrupar el catálogo que acabas de pedir; no lo guardes como clave fija de tu lado.
- requires_target y target_fields te dicen qué necesita el producto para poder entregarse. false con la lista vacía es que no necesita nada: una gift card se entrega sin datos del comprador. true significa que cada nombre de la lista es una clave OBLIGATORIA dentro de items[].target_payload al crear el pedido. target_schema es esa misma lista con lo que cada campo acepta de verdad: el tipo "text" es texto libre, y el tipo "select" significa que el proveedor sólo acepta los valores de sus options — se manda el value de la opción, no su label. Cualquier otro valor falla en el proveedor, con el pedido ya cobrado. target_fields sigue funcionando igual que siempre; target_schema se agrega.
- Léelos por producto, no los escribas fijos. Hoy conviven varias formas en el catálogo vivo — game_user_id sola, game_user_id más game_zone_id, player_id, player_id más server, user_id más region — las define cada proveedor al publicar y cambian producto por producto. Una lista copiada en tu código se desactualiza en silencio y te enteras cuando una compra falla.
- Si mandas un target_payload al que le falta un campo obligatorio, el pedido se rechaza antes de que se mueva dinero: no se retiene ni se cobra nada.
- Los precios traen CUATRO decimales y viajan como texto. No los redondees a dos: el margen vive en el tercer y cuarto decimal, así que 0,7010 redondeado a 0,70 es un error de 0,14% — más que el margen entero de ese producto.
curl https://portal.centraloneglobal.com/api/v1/catalog \ -H "Authorization: Bearer $CO_API_KEY"
{
"items": [
{
"product_id": "11111111-2222-3333-4444-555555555555",
"sku": "STEAM-GC-10",
"name": "Steam Gift Card 10 USD",
"category": "gift_card",
"product_type": "gift_card",
"market": "GLOBAL",
"region": "GLOBAL",
"currency": "USD",
"reseller_price": "24.9975",
"status": "active",
"updated_at": "2026-08-28T14:03:11.482913Z",
"product_family_id": "steam",
"product_family_name": "Steam",
"requires_target": false,
"target_fields": [],
"target_schema": [],
"in_stock": true,
"available_stock": 152,
"price_updated_at": "2026-10-01T20:16:17.160858Z"
},
{
"product_id": "22222222-3333-4444-5555-666666666666",
"sku": "ML-100-DIAMONDS",
"name": "Mobile Legends 100 Diamonds",
"category": "gaming_topup",
"product_type": "game_topup",
"market": "GLOBAL",
"region": "GLOBAL",
"currency": "USD",
"reseller_price": "1.9013",
"status": "active",
"updated_at": "2026-09-17T11:20:04.118273Z",
"product_family_id": "mobile-legends",
"product_family_name": "Mobile Legends",
"requires_target": true,
"target_fields": ["player_id", "server"],
"target_schema": [
{ "key": "player_id", "label": "Player ID", "type": "text", "options": null },
{
"key": "server",
"label": "Server",
"type": "select",
"options": [
{ "value": "os_usa", "label": "America" },
{ "value": "os_euro", "label": "Europe" },
{ "value": "os_asia", "label": "Asia" }
]
}
],
"in_stock": true,
"available_stock": null,
"price_updated_at": "2026-10-02T09:00:34.222519Z"
}
]
}Tu saldo, en exactamente cuatro campos.
- held_balance es el dinero retenido por pedidos en curso. Un pedido nuevo se paga contra available_balance, no contra total_balance.
- Todos los importes de la API son strings con cuatro decimales, nunca números de punto flotante. Parsea con una librería decimal, no con parseFloat. Cuatro y no dos: los precios se guardan con cuatro decimales y ahí vive el margen — un precio de "0.7010" devuelto como "0.70" se va por 0,14%, que es más que el margen completo del producto.
curl https://portal.centraloneglobal.com/api/v1/balance \ -H "Authorization: Bearer $CO_API_KEY"
{
"available_balance": "470.00",
"held_balance": "30.00",
"total_balance": "500.00",
"currency": "USD"
}Crea un pedido, retiene el saldo y reserva el inventario en una sola transacción.
- La cabecera Idempotency-Key es obligatoria. Ver la sección de Idempotencia más abajo.
- No existe ningún campo de precio. El importe lo calcula el servidor leyendo el catálogo. Una línea que traiga un campo desconocido (price, total, reseller_id) se rechaza entera con 400, en vez de ignorarlo en silencio: si lo ignorara, creerías que compraste a un precio que nunca cotizaste.
- Límites de la petición: cuerpo máximo 16 KB, Content-Type: application/json.
| Campo | Tipo | Obligatorio | Reglas |
|---|---|---|---|
| items | arreglo | sí | de 1 a 20 elementos |
| items[].catalog_item_id | texto | sí | El product_id que devuelve GET /catalog (un UUID) |
| items[].quantity | entero | sí | de 1 a 1000. Entero de verdad: 1.5 y "2" se rechazan |
| items[].target_payload | objeto | según el producto | Obligatorio cuando el producto trae requires_target: true en GET /catalog, y entonces tiene que llevar exactamente las claves de su target_fields. Si no, no hace falta. Máx. 2 KB |
| note | texto | no | Máx. 500 caracteres. Tu referencia interna |
curl -X POST https://portal.centraloneglobal.com/api/v1/orders \
-H "Authorization: Bearer $CO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-2026-08-28-000123" \
-d '{
"items": [
{ "catalog_item_id": "11111111-2222-3333-4444-555555555555",
"quantity": 2 },
{ "catalog_item_id": "22222222-3333-4444-5555-666666666666",
"quantity": 1,
"target_payload": { "game_user_id": "123456789",
"game_zone_id": "2001" } }
],
"note": "pedido del cliente 42"
}'{
"order": {
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"reference_code": "CO-ORD-20260828-A1B2C3",
"status": "confirmed",
"total_sale_amount": "50.00",
"currency": "USD",
"created_at": "2026-08-28T14:03:11.482913Z"
}
}Tus pedidos, del más reciente al más antiguo.
- Paginación: si next_cursor es una cadena, pide la siguiente página con ?cursor=<ese valor>. Cuando llega null, terminaste. El cursor es opaco y se valida byte a byte: no lo construyas ni lo modifiques.
- Un status inválido, un limit fuera de rango o un cursor manipulado invalidan TODA la petición con 400. No se ignoran en silencio.
| Campo | Tipo | Obligatorio | Reglas |
|---|---|---|---|
| limit | entero | no | de 1 a 50. Por defecto 20 |
| status | texto | no | Uno o varios estados de pedido separados por coma |
| cursor | texto | no | Opaco. Devuélvelo tal cual vino |
curl "https://portal.centraloneglobal.com/api/v1/orders?limit=10&status=confirmed,processing" \ -H "Authorization: Bearer $CO_API_KEY"
{
"orders": [
{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"reference_code": "CO-ORD-20260828-A1B2C3",
"status": "confirmed",
"total_sale_amount": "50.00",
"currency": "USD",
"line_count": 1,
"reseller_note": "pedido del cliente 42",
"created_at": "2026-08-28T14:03:11.482913Z",
"updated_at": "2026-08-28T14:03:11.482913Z"
}
],
"pagination": { "limit": 10, "next_cursor": "eyJjcmVhdGVkX2F0Ijoi..." }
}Un pedido con sus líneas. Este es el endpoint de estado: el que consultas después de crear un pedido.
- delivered_count te dice si esta línea tiene códigos esperando. 0 significa que no entrega ninguno — una recarga directa al ID del jugador no deja nada que revelar. Mayor que 0 significa que hay esa cantidad esperando en GET /orders/{id}/codes.
- Los códigos NO vienen en esta respuesta, a propósito. Un código es valor al portador: quien lo lee puede canjearlo. Vive detrás de su propio permiso y cada revelación queda registrada.
curl https://portal.centraloneglobal.com/api/v1/orders/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee \ -H "Authorization: Bearer $CO_API_KEY"
{
"order": {
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"reference_code": "CO-ORD-20260828-A1B2C3",
"status": "confirmed",
"total_sale_amount": "50.00",
"currency": "USD",
"line_count": 1,
"reseller_note": "pedido del cliente 42",
"created_at": "2026-08-28T14:03:11.482913Z",
"updated_at": "2026-08-28T14:03:11.482913Z",
"items": [
{
"id": "cccccccc-dddd-eeee-ffff-000000000000",
"product_sku": "STEAM-GC-10",
"product_name": "Steam Gift Card 10 USD",
"category": "gift_card",
"product_type": "gift_card",
"region": "GLOBAL",
"quantity": 2,
"unit_sale_price": "25.00",
"total_sale_price": "50.00",
"status": "reserved",
"created_at": "2026-08-28T14:03:11.482913Z",
"delivered_count": 0
}
]
}
}Los códigos que compraste: códigos de gift card y PIN. Aquí es donde se entrega el producto.
- Pídelo cuando GET /orders/{id} muestre delivered_count mayor que 0 en una línea. Solo las líneas completed tienen códigos; una que sigue en reserved o processing todavía no tiene nada.
- Necesita el scope codes:read, que es el ÚNICO permiso que no viene marcado al emitir una llave. Si vendes gift cards o PIN lo necesitas — pídelo y di para qué. Sin él este endpoint responde 403 insufficient_scope.
- Cada llamada queda en la auditoría, con qué llave leyó qué pedido. Es a propósito: es el registro de quién manipuló un código que se canjea una sola vez.
- Algunos proveedores devuelven el PIN junto con un serial. En ese caso el código llega como "PIN - SERIAL": la primera parte es la que canjea tu cliente, la segunda es el número de control del proveedor.
curl https://portal.centraloneglobal.com/api/v1/orders/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/codes -H "Authorization: Bearer $CO_API_KEY"
{
"order": {
"order_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"reference_code": "CO-ORD-20260828-A1B2C3",
"items": [
{
"id": "cccccccc-dddd-eeee-ffff-000000000000",
"product_sku": "FF-110-DIAMONDS-PIN",
"product_name": "Free Fire 110 Diamonds (PIN)",
"product_type": "game_topup",
"status": "completed",
"quantity": 1,
"delivered_at": "2026-09-18T22:04:42.312387Z",
"codes": ["A995E04D-6980-42AA-9488-BF88039B1C3D - 80980592"]
}
]
}
}Idempotencia
POST /orders exige la cabecera Idempotency-Key. Formato: de 8 a 255 caracteres de A-Z a-z 0-9 _ -.
Idempotency-Key: pedido-2026-08-28-000123
Reenviar la misma llave devuelve el mismo pedido, sin crear uno nuevo y sin cobrar dos veces. Esa es toda la razón por la que es obligatoria: si tu conexión se cae después de que el servidor retuvo el saldo, no sabes si compraste o no. Con la llave, reintentar es seguro; sin ella, reintentar gasta el saldo dos veces.
- Genera una llave por intento de compra, no una por petición HTTP. El reintento tiene que usar la misma.
- La llave es única por revendedor. La tuya no colisiona con la de nadie.
- Un reintento devuelve 201 igual que la creación original, con el mismo cuerpo. La API no distingue una creación de una repetición, y no debería importarte: lo que garantiza es que el pedido es uno solo.
Estados del pedido
| Estado | Significa |
|---|---|
| created | Creado, sin saldo retenido todavía (transitorio) |
| confirmed | Saldo retenido y líneas reservadas. Estado normal de un pedido recién creado |
| processing | En ejecución |
| completed | Entregado completo |
| partially_completed | Algunas líneas entregadas y otras no |
| failed | Falló; el saldo retenido se libera |
| cancelled | Cancelado |
| Estado | Significa |
|---|---|
| pending | Aceptada, todavía sin reservar. Transitorio: casi nunca lo verás |
| reserved | Tu saldo está retenido por esta línea y está en cola para entregarse. Todavía no se cobró nada |
| processing | Se está comprando al proveedor ahora mismo. No reintentes: el pedido ya existe |
| completed | Entregada. Es cuando se te cobra de verdad y el importe sale de retenido |
| failed | No se entregó, y la retención se libera completa. No se te cobró: tu saldo queda igual que antes |
| refunded | SÍ se entregó y se cobró, y después se devolvió el importe como un asiento aparte. No es lo mismo que failed: el cargo original queda en los libros |
| cancelled | Cancelada antes de comprarle nada al proveedor; la retención se libera completa |
Los cuatro estados sobre los que conviene ramificar son completed, failed, refunded y cancelled. Los otros tres significan que la línea sigue avanzando sola y va a llegar a uno de esos sin que hagas nada.
- 1Creas el pedido: el importe pasa de available_balance a held_balance.
- 2Se entrega: se debita de verdad y sale de retenido.
- 3Falla: se libera y vuelve a disponible. No es un reembolso, es una retención que se suelta.
- failed: no hay nada que pedir. La retención se libera sola y el importe completo vuelve a available_balance. Nunca se te cobró, así que no hay nada que reembolsar. Si quieres intentarlo de nuevo, crea un pedido nuevo con una cabecera Idempotency-Key NUEVA (con la misma te devolvemos el pedido fallido).
- refunded: la línea SÍ se entregó y se cobró, y después Central One te devolvió el importe como un crédito aparte (por ejemplo, cuando el proveedor revirtió la entrega). No se pide: ocurre de nuestro lado y te llega order.refunded.
- Sigue en proceso mucho rato: a veces el proveedor no confirma enseguida. Central One le pregunta hasta saber con certeza y recién ahí cierra la línea: completed (cobrada) o failed (liberada). Suele resolverse en minutos y puede tardar hasta un par de horas. Mientras tanto tu dinero queda retenido, no cobrado. No vuelvas a crear el mismo pedido mientras está en proceso: podrías recibirlo dos veces.
- Gift cards y PIN: algunos proveedores emiten el código uno o dos minutos después de la compra. La línea sigue en proceso hasta que el código existe; cuando cierra, el código llega en el aviso order.delivered y en GET /orders/{id}/codes.
- Se entregó pero al jugador no le llegó, o el código no funciona: escribe a soporte con el reference_code. Es el único caso que necesita a una persona.
Webhooks
En lugar de consultar, registra una URL en Mis llaves de API → Webhooks y te hacemos un POST cuando una de tus órdenes se cierra. Consultar GET /orders/{reference_code} sigue funcionando y queda como respaldo.
| order.delivered | La orden se entregó completa. |
| order.failed | No se entregó nada. El saldo se devolvió. |
| order.partially_delivered | Se entregaron unas líneas y otras no. Las no entregadas se devolvieron a tu saldo. |
| order.refunded | Una línea que ya estaba entregada se reembolsó: el importe volvió a tu saldo. Se envía la primera vez que se reembolsa una línea de la orden; GET /orders/{reference_code} da el estado de cada línea. |
| webhook.test | Se envía desde el portal para comprobar tu endpoint. No pertenece a ninguna orden. |
{
"event_id": "8fec58d6-8202-4ca3-9dd4-53168ead4a24",
"event": "order.partially_delivered",
"occurred_at": "2026-09-25T18:04:11Z",
"order": {
"reference_code": "CO-ORD-20260925-1A2B3C",
"idempotency_key": "pedido-2026-09-25-000123",
"status": "partially_completed",
"total_sale_amount": "20.0000",
"currency": "USD",
"created_at": "2026-09-25T18:03:52Z",
"items": { "total": 2, "delivered": 1, "not_delivered": 1 },
"codes": [
{
"id": "3f2c9a10-6b1e-4c4d-9a77-2d5e8f0b1c22",
"product_sku": "STEAM-10-USD",
"product_name": "Steam 10 USD",
"product_type": "gift_card",
"quantity": 1,
"codes": ["XXXX-XXXX-XXXX"]
}
]
}
}order.codes llega en order.delivered y order.partially_delivered: una entrada por cada línea entregada que tenga códigos (gift cards y PIN; una recarga directa no tiene, así que no aparece). Viene activado y puedes apagarlo en Mis llaves de API → Webhooks. Un código es dinero: el mismo aviso puede llegar más de una vez, así que deduplica por event_id y protege los registros de tu servidor. Si lo apagas, pídelos con GET /orders/{reference_code}/codes. El aviso no lleva IDs de jugador.
| X-CentralOne-Event-Id | El mismo valor que event_id. Deduplica por él. |
| X-CentralOne-Timestamp | Segundos Unix del envío. Es parte de lo firmado. |
| X-CentralOne-Signature | sha256=HMAC-SHA256(secreto, timestamp + "." + cuerpo crudo), en hexadecimal. |
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function esDeCentralOne(secreto, timestamp, cuerpoCrudo, firma) {
const esperada = "sha256=" + createHmac("sha256", secreto)
.update(`${timestamp}.${cuerpoCrudo}`)
.digest("hex");
const a = Buffer.from(esperada);
const b = Buffer.from(firma);
return a.length === b.length && timingSafeEqual(a, b);
}- Firma el cuerpo CRUDO tal como llega, antes de interpretarlo. Volver a serializar el JSON cambia los bytes y la firma deja de coincidir.
- Rechaza los avisos con un timestamp de más de 5 minutos. El timestamp va dentro de la firma para que un aviso capturado no se pueda reenviar después.
- Responde cualquier 2xx en menos de 10 segundos. Haz el trabajo después de responder: un endpoint lento cuenta como fallo.
- La entrega es AL MENOS UNA vez, no exactamente una. El mismo event_id puede llegar dos veces: procésalo una sola.
- Si tu endpoint falla, reintentamos con esperas crecientes (alrededor de 5 y 25 minutos, después 2 horas, y luego 10 horas dos veces) y abandonamos a los 6 intentos. Puedes reenviar cualquier aviso desde el portal.
- La URL tiene que ser https y con un dominio público. Las redirecciones no se siguen, y las direcciones de redes privadas se rechazan.
Lo que esta versión no hace
Dicho de frente para que nadie lo descubra a mitad de una integración.
- No hay modo sandbox. No existe todavía una llave de prueba con saldo ficticio: cualquier pedido contra esta API mueve saldo real.
- La entrega es automática, pero no siempre instantánea. Compramos al proveedor en cuanto llega el pedido: en la primera semana de octubre la mitad de los pedidos por API se entregó en menos de 4 segundos y el 90 % en menos de 10. Algunos tardan más cuando el proveedor demora, así que muestra "en proceso" y actualízalo cuando llegue el aviso de pedido cerrado, en lugar de prometer un plazo.
- No hay endpoint de cancelación ni de reembolso. Un pedido fallido no lo necesita: su retención se libera sola. Cualquier otro caso se gestiona por soporte (ver "Si un pedido falla" en Estados).
- No hay widget ni snippet para incrustar. La integración es servidor a servidor.
Soporte
Al reportar un problema, incluye: el X-Request-Id de la respuesta, la hora aproximada con zona horaria, el endpoint, y el reference_code si es sobre un pedido.
Ni completa ni parcial, ni a soporte ni a nadie que te la pida. Soporte no la necesita y de todos modos no puede leerla: solo se guarda su huella. Quien te la pida no es Central One.