🔌 Puente WebSocket

Guía de integración Panel de administración

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:

  1. Hacia arriba: lo que un usuario envía por el WebSocket, el puente te lo entrega en tu API como un POST normal, firmado.
  2. Hacia abajo: cuando quieras avisar a un usuario, llamas a POST /api/v1/publicar y el puente lo entrega a quien digas.
No necesitas guardar nada. El puente sabe quién está conectado en cada momento. Tú solo dices «esto es para el cliente 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.

LlaveDónde vaPuedeNo 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 llave secreta nunca va al navegador. Ni en el HTML, ni en el JavaScript, ni en una variable «oculta». Si aparece en el frontend, cualquiera puede enviar mensajes a todos tus usuarios haciéndose pasar por ti.

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.

No puedes dejarte fuera sin querer: no se activa con la lista vacía, no se puede quitar la última IP con la comprobación activa, y activarla exige que tu IP ya esté dentro. El error te dice tu IP y qué hacer.
Existe también 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

CampoQuién lo ponePara qué
app_idel puente tu aplicación. Publicar a todos alcanza solo a tus clientes.
cliente_id quién es el usuario. El id de tu base de datos, por ejemplo.
grupo_id salas, segmentos, roles… lo que necesites agrupar.
connection_idel puente una ventana concreta. Cambia en cada reconexión.
Un usuario, varias ventanas. Si alguien abre tu aplicación en el portátil y en el móvil, son dos conexiones con 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;
}
Al reconectar, el 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 cierreMotivo
1000cierre normal (el motivo va en el reason)
1001timeout: no respondió al latido
1008rate_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": { } }
  ]
}
  • reply vuelve a la ventana que envió el mensaje.
  • publicar se 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
Usa el cuerpo crudo, tal y como llegó. Si lo parseas y lo vuelves a serializar, cambia un espacio o el orden de una clave y la firma ya no cuadra.

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

CuerpoLlega 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ónPara qué
GET /api/v1/conexiones quién está conectado. Filtros: cliente_id, grupo_id, ip. Paginado con limite y desplazamiento.
GET /api/v1/clientesconteo por usuario
GET /api/v1/gruposconteo por grupo
GET /api/v1/historial/conexionessesiones 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.

Esto no es 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

tipoQué indicasEjemplo
diariahora {"tipo":"diaria","hora":"03:00"}
semanalhora y dias_semana {"tipo":"semanal","hora":"03:00","dias_semana":[1,3,5]}
mensualhora y dias_mes {"tipo":"mensual","hora":"06:00","dias_mes":[1,15]}
intervalocada_minutos {"tipo":"intervalo","cada_minutos":30}
una_vezfecha_unica (ISO, en UTC) {"tipo":"una_vez","fecha_unica":"2026-09-01T04:00:00Z"}
cronexpresion, 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ónPara qué
GET /api/v1/tareaslas 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
Hazla idempotente. Si el servidor se reinicia justo mientras tu tarea se ejecuta, esa vuelta se pierde y no se repite — preferimos no ejecutar a ejecutar dos veces, porque «validar pagos» dos veces puede cobrar dos veces. Lo verás en el historial y podrás relanzarla a mano.

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ódigoHTTPQué revisar
falta_llave401no enviaste la cabecera
llave_invalida401llave mal copiada, o rotada
prohibido403 casi siempre: intentar publicar con la llave pública. Usa la secreta.
no_encontrado404 no existe, o pertenece a otra aplicación
peticion_invalida400falta un campo o tiene mal formato
limite_alcanzado429demasiadas conexiones desde esa IP
ip_bloqueada403esa 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

Los límites son de tu llave, no del servidor. Los valores de abajo son los que este servidor trae por defecto; tu aplicación puede tener los suyos. Los tuyos, ya resueltos, te llegan en el mensaje de bienvenida al conectar, y también en GET /api/v1/apps/TU_APP_ID.
LímitePor defectoSe cuentaAl 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áneassegún tu contrato en toda tu aplicación 429 en el handshake
Tamaño de un mensaje por mensajese cierra la conexión
Cuerpo de una petición REST del servidor, igual para todos413
Los mensajes por segundo son POR CONEXIÓN, no de toda tu aplicación. Si tienes 1.000 usuarios conectados, cada uno tiene su propio presupuesto: son 1.000 × 30 mensajes por segundo en total, no 30 repartidos entre todos. El límite existe para frenar a un cliente que se descontrole, no para limitar tu tráfico.

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_id o grupo_id, no a connection_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.

Esto no guarda tu llave en ningún sitio: vive solo en esta pestaña y se pierde al cerrarla.