Qué es y qué resuelve
Este puente mantiene las conexiones WebSocket con tus usuarios para que tu servidor no tenga que hacerlo. Tú sigues hablando HTTP, como siempre.
TU FRONTEND EL PUENTE TU SERVIDOR
(llave pública) (llave secreta)
┌───────────┐ ws:// ┌──────────────┐
│ navegador │◄───────────►│ mantiene │ ① POST firmado
│ app móvil │ │ las ├──────────────────►┌──────────┐
└───────────┘ │ conexiones │ lo que envía │ tu API │
▲ │ abiertas │ el usuario │ REST │
│ │ │ └────┬─────┘
│ │ │ ② POST /publicar │
└────────────────────┤ reparte │◄───────────────────────┘
lo que tú publicas └──────────────┘ a quién va: conexión,
cliente, grupo o todos
Dos flujos, y ninguno te obliga a mantener sockets abiertos:
- Hacia arriba: lo que un usuario envía por el WebSocket, el puente te lo
entrega en tu API como un
POSTnormal, firmado. - Hacia abajo: cuando quieras avisar a un usuario, llamas a
POST /api/v1/publicary el puente lo entrega a quien digas.
ana» y llega a
todas sus ventanas abiertas. Si no está conectada, la respuesta te lo dice
(entregados: 0) y decides qué hacer.
Tus dos llaves
Al darte de alta recibes un par. No son intercambiables, y esa asimetría es la que protege a tus usuarios.
| Llave | Dónde va | Puede | No puede |
|---|---|---|---|
pk_…pública |
en tu frontend: navegador, app móvil | abrir conexiones y enviar datos hacia arriba | publicar a los clientes, listar conexiones, cerrar nada |
sk_…secreta |
solo en tu servidor | publicar, listar, mover de grupo y cerrar conexiones | salir de tu propia aplicación |
La pública sí es visible, y no pasa nada: no puede publicar. Lo peor que puede hacer quien la copie es abrir una conexión más, y eso está acotado por los límites por IP.
Cómo se envían
Siempre por cabecera:
X-Public-Key: pk_… # tu frontend, al abrir el WebSocket
X-Secret-Key: sk_… # tu servidor, en cada llamada REST
Si pierdes una llave, pide una rotación: se generan nuevas
conservando tu app_id y tu histórico. Las antiguas dejan de valer
al instante.
Restringir por IP (recomendado)
Puedes exigir que las llamadas con tu llave secreta vengan de las IPs de tu servidor. Así, aunque la llave se filtrara, no serviría desde fuera:
# 1. Autoriza la IP desde la que llamas (no necesitas saber cuál es)
curl -X POST /api/v1/apps/TU_APP_ID/ips/esta \
-H "X-Secret-Key: sk_…"
# 2. Actívalo
curl -X PATCH /api/v1/apps/TU_APP_ID \
-H "X-Secret-Key: sk_…" -H "Content-Type: application/json" \
-d '{"exigir_ip_api": true}'
Si tu IP cambia, vuelve a llamar al primer comando (o sustituye la lista entera con
PUT /api/v1/apps/TU_APP_ID/ips). Admite rangos:
203.0.113.0/24.
exigir_ip_conexiones, que aplica lo mismo a las
conexiones WebSocket. Solo tiene sentido si quien conecta son servidores o
una red conocida: si tus clientes son navegadores de usuarios finales, cada uno
llega con una IP distinta y los dejarías fuera a todos.
Los identificadores
| Campo | Quién lo pone | Para qué |
|---|---|---|
app_id | el puente | tu aplicación. Publicar a todos alcanza solo a tus clientes. |
cliente_id | tú | quién es el usuario. El id de tu base de datos, por ejemplo. |
grupo_id | tú | salas, segmentos, roles… lo que necesites agrupar. |
connection_id | el puente | una ventana concreta. Cambia en cada reconexión. |
connection_id distintos pero
el mismo cliente_id. Publicar a ese cliente_id llega
a las dos. Por eso, para casi todo, el destino natural es
cliente_id o grupo_id: sobreviven a recargas y
reconexiones. connection_id solo cuando de verdad quieras una ventana.
WS Conectar desde tu frontend
El navegador no puede enviar cabeceras al abrir un WebSocket, así que la llave pública va en la URL:
const ws = new WebSocket(
'/ws'
+ '?llave=pk_TU_LLAVE_PUBLICA'
+ '&cliente_id=' + encodeURIComponent(idDelUsuario) // opcional pero recomendable
+ '&grupo_id=ventas' // opcional
);
Desde una app móvil o un backend, mejor por cabecera:
X-Public-Key: pk_…
X-Cliente-Id: usuario-42
X-Grupo-Id: ventas
Nada más conectar recibes la bienvenida:
{
"tipo": "bienvenida",
"connection_id": "6f1c8a2e-…",
"app_id": "app_9f3c…",
"cliente_id": "usuario-42",
"grupo_id": "ventas",
"heartbeat_secs": 25
}
Si no envías cliente_id, el puente asigna uno anónimo
(anon-…). Funciona, pero no podrás dirigirte a ese usuario por su id.
Mensajes y eventos
De tu frontend al puente
ws.send(JSON.stringify({
tipo: 'mensaje',
evento: 'carrito_actualizado', // te llegará en X-Bridge-Event
data: { producto: 42, cantidad: 2 }
}));
Eso se convierte en un POST a tu API. También admite
{"tipo":"ping"}, que responde pong. Si envías texto que no
sea JSON válido, no es un error: se envuelve como
{"raw":"…"} y se dispara igual.
Del puente a tu frontend
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
switch (m.tipo) {
case 'bienvenida':
console.log('conectado como', m.connection_id);
break;
case 'evento': // lo que tú publicaste desde tu servidor
manejar(m.evento, m.data);
break;
case 'grupo_actualizado':
console.log('ahora estoy en el grupo', m.grupo_id);
break;
case 'error':
console.warn(m.codigo, m.mensaje);
break;
}
};
Un evento tiene siempre esta forma:
{
"tipo": "evento",
"evento": "pedido_confirmado",
"data": { "id": 42 },
"origen": "api",
"ts": "2026-08-10T15:36:10Z"
}
Reconexión
Las conexiones se caen: cambios de red, suspensión del portátil, un despliegue del puente. Implementa siempre reconexión con espera creciente.
function conectar() {
const ws = new WebSocket('/ws?llave=pk_…&cliente_id=' + idDelUsuario);
let espera = 1000;
ws.onopen = () => { espera = 1000; }; // reiniciar al lograr conectar
ws.onmessage = manejarMensaje;
ws.onclose = () => {
setTimeout(conectar, espera);
// Duplicar la espera evita que, si el puente se reinicia, TODOS tus clientes
// vuelvan a la vez y lo tumben otra vez.
espera = Math.min(espera * 2, 30000);
};
return ws;
}
connection_id es nuevo. El
cliente_id no cambia (lo pones tú), así que los mensajes dirigidos a
ese cliente le siguen llegando sin que tengas que hacer nada.
Latido
El puente envía un ping cada 25 segundos y cierra la conexión si no hay respuesta. Los navegadores responden solos; en otros clientes, asegúrate de que tu librería lo haga.
| Código de cierre | Motivo |
|---|---|
1000 | cierre normal (el motivo va en el reason) |
1001 | timeout: no respondió al latido |
1008 | rate_limit: demasiados mensajes por segundo |
POST Recibir los disparos en tu API
Nos das una URL destino y ahí llega, como POST, todo lo que
envíen tus clientes:
POST https://tu-servidor.com/webhook/puente
Content-Type: application/json
X-Bridge-App-Id: app_9f3c…
X-Bridge-Event: carrito_actualizado
X-Bridge-Connection-Id: 6f1c8a2e-…
X-Bridge-Cliente-Id: usuario-42
X-Bridge-Grupo-Id: ventas
X-Bridge-Ip: 203.0.113.9
X-Bridge-Timestamp: 1786462560
X-Bridge-Signature: 3a7f…
{
"app_id": "app_9f3c…",
"evento": "carrito_actualizado",
"connection_id": "6f1c8a2e-…",
"cliente_id": "usuario-42",
"grupo_id": "ventas",
"ip": "203.0.113.9",
"ts": "2026-08-10T15:36:00Z",
"data": { "producto": 42, "cantidad": 2 }
}
Puedes responder en el acto
Si contestas 200 con este JSON, te ahorras la llamada a
/publicar:
{
"reply": { "recibido": true },
"publicar": [
{ "grupo_id": "ventas", "evento": "carrito_de_otro", "data": { } }
]
}
replyvuelve a la ventana que envió el mensaje.publicarse reparte como cualquier publicación, siempre dentro de tu aplicación.
Un cuerpo vacío, o un JSON con otra forma, se ignora sin error.
Un 4xx no se reintenta; un 5xx o un fallo de red sí,
con espera creciente.
Verificar la firma
Cada disparo va firmado con el secreto que te dimos
(whsec_…). Verifícalo siempre: sin eso, cualquiera que conozca
tu URL puede inventarse mensajes de tus usuarios.
firma = HMAC-SHA256( whsec_…, "<X-Bridge-Timestamp>.<cuerpo crudo>" ) → hex
Node.js (Express)
app.post('/webhook/puente',
express.raw({ type: 'application/json' }), // ← crudo
(req, res) => {
const ts = req.get('X-Bridge-Timestamp');
const firma = req.get('X-Bridge-Signature');
const cuerpo = req.body.toString('utf8');
const esperada = crypto
.createHmac('sha256', process.env.WHSEC)
.update(`${ts}.${cuerpo}`)
.digest('hex');
const ok = firma?.length === esperada.length &&
crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperada));
if (!ok) return res.sendStatus(401);
// Anti-replay: descarta lo que llegue muy desfasado.
if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.sendStatus(401);
const datos = JSON.parse(cuerpo);
// … tu lógica …
res.json({ reply: { recibido: true } });
});
PHP (también WordPress)
$cuerpo = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_BRIDGE_TIMESTAMP'] ?? '';
$firma = $_SERVER['HTTP_X_BRIDGE_SIGNATURE'] ?? '';
$esperada = hash_hmac('sha256', $ts . '.' . $cuerpo, WHSEC);
if (!hash_equals($esperada, $firma)) {
http_response_code(401);
exit;
}
if (abs(time() - (int) $ts) > 300) { // anti-replay
http_response_code(401);
exit;
}
$datos = json_decode($cuerpo, true);
// … tu lógica …
header('Content-Type: application/json');
echo json_encode(['reply' => ['recibido' => true]]);
POST Enviar a tus clientes
Una llamada, y el puente reparte. Requiere la llave secreta.
curl -X POST /api/v1/publicar \
-H "X-Secret-Key: sk_TU_LLAVE_SECRETA" \
-H "Content-Type: application/json" \
-d '{
"grupo_id": "ventas",
"evento": "pedido_confirmado",
"data": { "id": 42 }
}'
A quién va
| Cuerpo | Llega a |
|---|---|
{"connection_id": "6f1c…"} | una ventana concreta |
{"cliente_id": "usuario-42"} | todas las ventanas de ese usuario |
{"grupo_id": "ventas"} | todos los usuarios de ese grupo |
{"todos": true} | todos los clientes de tu llave |
También puedes usar la forma larga:
{"destino": {"tipo": "grupo", "valor": "ventas"}}.
La respuesta te dice si llegó
{
"ok": true,
"resultado": {
"destino_tipo": "grupo",
"destino_valor": "ventas",
"entregados": 12,
"fallidos": 0,
"connection_ids": ["6f1c…", "8a2d…"]
}
}
entregados: 0 no es un error: significa que ese usuario no está
conectado ahora mismo. Si el aviso es importante, guárdalo en tu base de datos y
entrégalo cuando vuelva a conectar.
GET Consultar y gestionar
Todo con la llave secreta, y siempre acotado a tu aplicación.
| Petición | Para qué |
|---|---|
GET /api/v1/conexiones |
quién está conectado. Filtros: cliente_id,
grupo_id, ip. Paginado con
limite y desplazamiento. |
GET /api/v1/clientes | conteo por usuario |
GET /api/v1/grupos | conteo por grupo |
GET /api/v1/historial/conexiones | sesiones pasadas |
PATCH /api/v1/conexiones/{id}/grupo |
mover una conexión de grupo |
POST /api/v1/conexiones/{id}/cerrar |
echar una conexión |
PATCH /api/v1/apps/{tu_app_id} |
cambiar tu url_destino sin pedírnoslo |
# ¿Está conectada esta usuaria?
curl -s "/api/v1/conexiones?cliente_id=usuario-42" \
-H "X-Secret-Key: sk_…"
POST Tareas programadas
Un despertador para tu API. A la hora que marques, el puente te dispara un aviso firmado — el mismo formato y la misma firma que los mensajes de tus clientes.
wp-cron. El cron de WordPress solo corre cuando
alguien visita la página: de madrugada, que es cuando toca validar pagos o hacer
copias, no hay visitas y no corre nada. El puente está siempre encendido, así que
salta a su hora aunque tu web no reciba una sola visita.
Crear una
Con tu llave secreta. La pública no puede: una tarea se ejecuta sola contra tu servidor, y esa llave vive en el navegador de tus usuarios.
curl -X POST /api/v1/tareas \
-H "X-Secret-Key: sk_…" -H "Content-Type: application/json" \
-d '{
"nombre": "Validar pagos",
"evento": "cron.validar_pagos",
"horario": {
"tipo": "diaria",
"zona_horaria": "America/Bogota",
"hora": "03:00"
},
"datos": { "origen": "nocturno" }
}'
Cuándo quieres que suene
| tipo | Qué indicas | Ejemplo |
|---|---|---|
diaria | hora |
{"tipo":"diaria","hora":"03:00"} |
semanal | hora y dias_semana |
{"tipo":"semanal","hora":"03:00","dias_semana":[1,3,5]} |
mensual | hora y dias_mes |
{"tipo":"mensual","hora":"06:00","dias_mes":[1,15]} |
intervalo | cada_minutos |
{"tipo":"intervalo","cada_minutos":30} |
una_vez | fecha_unica (ISO, en UTC) |
{"tipo":"una_vez","fecha_unica":"2026-09-01T04:00:00Z"} |
cron | expresion, si la prefieres |
{"tipo":"cron","expresion":"0 3 * * 1"} |
dias_semana va de 1 = lunes a 7 = domingo. La
zona_horaria es tuya: si pones America/Bogota y las 3:00,
suena a las 3:00 ahí, y si tu zona cambia con el horario de verano, la hora
local se mantiene.
Comprobar antes de crearla
curl -X POST /api/v1/horarios/validar \
-H "X-Secret-Key: sk_…" -H "Content-Type: application/json" \
-d '{"horario":{"tipo":"semanal","zona_horaria":"America/Bogota",
"hora":"03:00","dias_semana":[1,3,5]}}'
# → { "cuando": "Cada semana: lunes, miércoles y viernes, a las 03:00",
# "proximas_ejecuciones": ["2026-08-12T08:00:00Z", …] }
Lo que recibe tu API
{
"app_id": "app_…",
"evento": "cron.validar_pagos",
"tarea": {
"id": "tsk_…", "nombre": "Validar pagos",
"cuando": "Todos los días a las 03:00",
"zona_horaria": "America/Bogota",
"programada_para": "2026-08-11T08:00:00Z",
"manual": false
},
"data": { "origen": "nocturno" },
"ts": "2026-08-11T08:00:01Z"
}
Con las cabeceras de siempre —X-Bridge-Timestamp y
X-Bridge-Signature— más X-Bridge-Tarea-Id y
X-Bridge-Programada-Para. La firma se verifica exactamente igual:
el código que ya escribiste para los mensajes te sirve tal cual.
Gestionarlas
| Petición | Para qué |
|---|---|
GET /api/v1/tareas | las tuyas, con su próxima ejecución |
PATCH /api/v1/tareas/{id} | cambiar hora, días o pausarla |
POST /api/v1/tareas/{id}/ejecutar |
dispararla ahora para probar, sin tocar su programación |
GET /api/v1/tareas/{id}/historial |
qué pasó en cada disparo: código HTTP, duración y error |
DELETE /api/v1/tareas/{id} | borrarla |
Si el servidor estuvo caído a la hora de la tarea, al volver la dispara
una sola vez (no acumula las que se perdieron). Se puede desactivar por tarea
con "recuperar_atrasadas": false.
Errores
Todos tienen la misma forma:
{ "ok": false, "error": { "codigo": "prohibido", "mensaje": "…" } }
| Código | HTTP | Qué revisar |
|---|---|---|
falta_llave | 401 | no enviaste la cabecera |
llave_invalida | 401 | llave mal copiada, o rotada |
prohibido | 403 | casi siempre: intentar publicar con la llave pública. Usa la secreta. |
no_encontrado | 404 | no existe, o pertenece a otra aplicación |
peticion_invalida | 400 | falta un campo o tiene mal formato |
limite_alcanzado | 429 | demasiadas conexiones desde esa IP |
ip_bloqueada | 403 | esa IP está vetada en el servidor |
Por el WebSocket
{ "tipo": "error", "codigo": "sin_destino",
"mensaje": "esta llave no tiene url_destino configurada" }
Si ves sin_destino, es que no nos has dado la URL a la
que disparar. Se arregla con PATCH /api/v1/apps/{tu_app_id}.
Límites
bienvenida al conectar,
y también en GET /api/v1/apps/TU_APP_ID.
| Límite | Por defecto | Se cuenta | Al superarlo |
|---|---|---|---|
| Mensajes por segundo | — | por cada conexión, por separado | error y cierre con código 1008 |
| Conexiones desde una misma IP | — | solo las de tu aplicación | 429 en el handshake |
| Conexiones simultáneas | según tu contrato | en toda tu aplicación | 429 en el handshake |
| Tamaño de un mensaje | — | por mensaje | se cierra la conexión |
| Cuerpo de una petición REST | — | del servidor, igual para todos | 413 |
Lo mismo con las conexiones por IP: se cuentan solo las de tu aplicación. Que otro cliente del puente tenga muchas conexiones desde la misma IP no te afecta.
Ver los tuyos
ws.onmessage = ev => {
const m = JSON.parse(ev.data);
if (m.tipo === 'bienvenida') {
console.log(m.limites);
// {
// mensajes_por_segundo_por_conexion: 30,
// conexiones_por_ip: 50,
// max_conexiones: 0, // 0 = sin límite propio
// max_mensaje_bytes: 262144,
// propios: { … } // true donde el valor es tuyo y no el general
// }
}
};
Si tu caso necesita más, dilo: se ajustan por llave sin tocar a nadie más.
Buenas prácticas
- Agrupa: mejor un mensaje con diez cambios que diez mensajes.
- Publica a
cliente_idogrupo_id, no aconnection_id: sobreviven a las reconexiones. - No uses el puente como base de datos: si un aviso no puede perderse, guárdalo tú y usa el puente para la entrega inmediata.
- Reconexión con espera creciente, siempre.
Probar tu llave pública
Abre una conexión real desde este navegador para comprobar que tu llave funciona antes de tocar tu código. Solo admite la llave pública.