Parlo · Guía de WebhooksWebhooks guide Guía de CSSCSS guide

Webhooks

Tu chat avisa a tu propio servidor, casi en tiempo real, cada vez que alguien escribe, entra, sale o es moderado. Sirve para guardar un historial, mandar avisos a Discord/Slack/Telegram, estadísticas, integraciones propias…

Dónde está y quién puede usarlo. Ajustes → Webhooks. Solo Owner y Manager pueden gestionarlos, y la función está incluida en el plan Business. Si el plan no lo incluye, la pestaña aparece con candado y no se envía nada.

Empezar en 4 pasos

  1. Prepara una URL pública en tu servidor que acepte peticiones POST con JSON (por ejemplo https://tu-servidor.com/webhook).
  2. En Ajustes → Webhooks, escribe la URL, marca los eventos que quieres recibir y, si quieres, pon un secreto para firmar. Pulsa «Añadir webhook».
  3. Pulsa «Probar» junto al webhook: se envía un evento de prueba (chat_action con action_type: "test"), firmado igual que los reales.
  4. Mira la línea de estado del webhook: «último HTTP 200» significa que tu servidor respondió bien.

Cada webhook tiene botones para Editar, Desactivar/Activar, Probar y Eliminar. Al editar, si dejas el secreto vacío se conserva el que ya tenía; para cambiarlo, escribe uno nuevo.

Cómo se envía

  • Es una petición POST con cuerpo JSON y siempre tiene esta forma: { "type": "…", "data": [ … ] }.
  • data es siempre una lista, aunque tenga un solo elemento. Los eventos se agrupan y se envían cada ~3 segundos; dentro de la lista van en orden cronológico.
  • Cada petición contiene un único tipo de evento. Si en esos 3 segundos hubo mensajes y entradas, recibirás dos peticiones (una chat_message y otra user_joined).
  • Si un webhook tiene marcados solo algunos eventos, solo recibe esos.

Cabeceras

CabeceraValor
Content-Typeapplication/json
User-AgentParlo-Webhook/1.0
X-Chat-EventEl tipo de evento: chat_message, user_joined, user_left o chat_action
X-Chat-SignatureSolo si configuraste un secreto: sha256=<firma> (ver más abajo)

Qué debe responder tu servidor

  • Responde con un código 2xx (200 o 204) lo antes posible: el chat espera como máximo 8 segundos.
  • Haz el trabajo pesado después de responder (guarda en una cola o procesa en segundo plano).
  • No hay reintentos. Si tu servidor no responde bien, esos eventos no se vuelven a enviar. Tampoco se siguen redirecciones (301/302): usa directamente la URL final.
  • Entre envíos se guardan como máximo 500 eventos por tipo; si tu servidor tarda mucho y el chat tiene muchísima actividad, los más antiguos se descartan.
  • Los eventos pendientes se guardan en memoria: si el chat se reinicia, los que aún no se habían enviado se pierden.

Reglas de la URL

  • Debe empezar por http:// o https:// (recomendamos https). Máximo 1000 caracteres.
  • No se admiten direcciones locales o privadas: localhost, 127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x ni sus equivalentes IPv6. Debe ser accesible desde internet.
  • Los usuario:contraseña dentro de la URL y el #fragmento se eliminan. Si necesitas autenticación, usa el secreto y comprueba la firma.

Comprobar la firma (muy recomendable)

Si pones un secreto (hasta 200 caracteres), cada petición lleva X-Chat-Signature: sha256=…, que es el HMAC-SHA256 en hexadecimal del cuerpo exacto de la petición, usando tu secreto como clave. Calcúlalo tú sobre el cuerpo sin modificar (antes de parsear el JSON) y compáralo con la cabecera. Así sabes que el mensaje viene de tu chat y no de otra persona que conozca tu URL.

const crypto = require('crypto');
const express = require('express');
const app = express();

// Importante: leer el cuerpo SIN parsear para calcular la firma
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.PARLO_WEBHOOK_SECRET)
    .update(req.body).digest('hex');
  const got = req.get('X-Chat-Signature') || '';
  const ok = got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  res.sendStatus(200);                        // responde enseguida
  const { type, data } = JSON.parse(req.body.toString('utf8'));
  for (const ev of data) console.log(type, ev);
});
app.listen(3000);
Guarda el secreto en una variable de entorno de tu servidor, nunca en código público. Si lo cambias en el chat, cámbialo también en tu servidor.

Eventos y campos

Los nombres de los campos van en inglés. Las fechas timestamp son segundos Unix. Los campos que no apliquen llegan como null o vacíos. Los mensajes privados (DM) no se envían.

chat_message — mensaje escrito

Se envía por cada mensaje nuevo en cualquier sala (también la de staff: usa channel para filtrar), por los mensajes que un moderador aprueba si el chat tiene aprobación previa (en ese momento, no antes), por los mensajes del bot, y de nuevo cuando un mensaje se edita (mismo msg_id con edited: true).

CampoContenido
msg_idIdentificador del mensaje (texto)
chat_id, chat_nameIdentificador y nombre de tu chat
channelSala donde se escribió; main es la sala principal
user_id, username, nicknameQuién lo escribió: id, usuario y nick que se muestra
profile_picture_urlAvatar (o null)
messageEl texto. Vacío si el mensaje es una imagen, vídeo o audio
rankRango: owner, manager, moderator, member, guest…
editedtrue si el mensaje fue editado
attachmentURL de la imagen/GIF enviada (o null)
attachment_idIdentificador del archivo subido (imagen, vídeo o audio) (o null)
attachment_typeimage, upload-image, upload-video, audio o null
reply_to_idId del mensaje al que responde (o null)
ipIP del autor (o null)
timestampMomento del mensaje (segundos Unix)
{
  "type": "chat_message",
  "data": [
    {
      "msg_id": "1042", "chat_id": "mi-chat", "chat_name": "Mi chat", "channel": "main",
      "user_id": "7", "username": "ana", "nickname": "Ana", "profile_picture_url": null,
      "message": "Hola a todos", "rank": "member", "edited": false,
      "attachment": null, "attachment_id": null, "attachment_type": null,
      "reply_to_id": null, "ip": "203.0.113.5", "timestamp": 1760000000
    }
  ]
}

user_joined y user_left — entradas y salidas

user_joined se envía cuando un usuario pasa de desconectado a conectado; user_left cuando se cierra su última conexión (si tenía varias pestañas abiertas, solo cuando cierra la última). Mismos campos en ambos:

CampoContenido
chat_id, chat_nameTu chat
channelSala en la que está
user_id, username, nicknameIdentificación del usuario
profile_picture_urlAvatar (o null)
rankSu rango
country_codePaís (código de 2 letras) o null
timestampSegundos Unix

chat_action — moderación

Cuando un moderador, un manager, un owner o el bot automático hace algo. Todos los eventos de este tipo comparten estos campos, y el tipo concreto está en action_type:

CampoContenido
action_typeQué se hizo (tabla de abajo)
chat_id, chat_name, channelTu chat y la sala
user_id, username, nickname, profile_picture_url, rankQuien ejecuta la acción (el moderador). Si lo hace el sistema, rank es system
a_user_id, a_username, a_nickname, a_profile_picture_url, a_rankQuien recibe la acción (el usuario afectado). La «a» es de «afectado»
timestampSegundos Unix

Y según action_type, campos extra:

action_typeQué ocurrióCampos extra
delete_messageSe borró un mensajemsg_id, msg (texto), msg_attachment, msg_deleted: true, ip
rank_changeCambio de rangoold_rank, new_rank
banBaneoreason, duration ("HH:MM" o "forever"), all_msgs_deleted, ip
unbanSe quitó un baneo—
warningAdvertenciareason, ip
kickExpulsiónreason, ip
muteSilenciadoreason, expires_at (milisegundos Unix), duration_minutes
unmuteSe quitó el silencio—
bot_… (p. ej. bot_kick)Acción automática del bot de moderaciónreason, ip. El «actor» es el bot
testEvento del botón «Probar»reason. Los campos a_… vienen a null
{
  "type": "chat_action",
  "data": [
    {
      "action_type": "mute", "chat_id": "mi-chat", "chat_name": "Mi chat", "channel": "main",
      "user_id": "2", "username": "mod1", "nickname": "Mod 1", "profile_picture_url": null, "rank": "moderator",
      "a_user_id": "7", "a_username": "ana", "a_nickname": "Ana", "a_profile_picture_url": null, "a_rank": "member",
      "reason": "spam", "expires_at": 1760003600000, "duration_minutes": 60,
      "timestamp": 1760000000
    }
  ]
}
Ojo con la privacidad: los mensajes y las acciones incluyen la IP de los usuarios. Si guardas esos datos, trátalos según tu política de privacidad y la normativa que te aplique (por ejemplo RGPD).

Ideas

  • Registro propio: guarda cada chat_message en tu base de datos.
  • Avisos al equipo: reenvía los chat_action de tipo ban o kick a un canal de Discord/Slack.
  • Estadísticas: cuenta entradas (user_joined) por hora.
  • Alertas: detecta palabras clave en message y avisa a tu equipo.

Los webhooks son de un solo sentido (el chat te avisa a ti). Para que algo reaccione dentro del chat, usa el bot del chat.

Si algo no funciona

Lo que vesQué suele ser
«URL de webhook inválida o no permitida»La URL no es http/https o apunta a una dirección local/privada
La pestaña tiene candadoTu plan actual no incluye webhooks (plan Business)
último HTTP 401 / 403Tu servidor rechazó la petición: revisa la comprobación de la firma
último HTTP 404La ruta de tu servidor no existe: revisa la URL
último HTTP 500Tu servidor falló al procesarla
Un mensaje de error en lugar de un númeroNo se pudo conectar (servidor caído, certificado no válido, más de 8 s de espera, o la URL redirige)
La firma no coincideCalcula el HMAC sobre el cuerpo sin parsear, con el mismo secreto, y compara con el valor completo sha256=…
No llega nadaComprueba que el webhook está Activo, que el evento está marcado y que tu servidor es accesible desde internet. Usa «Probar»

← Volver al chat

Webhooks

Your chat notifies your own server, almost in real time, every time someone writes, joins, leaves or gets moderated. Use it to keep a history, send alerts to Discord/Slack/Telegram, build statistics, make your own integrations…

Where it is and who can use it. Settings → Webhooks. Only Owners and Managers can manage them, and the feature is included in the Business plan. If your plan doesn’t include it, the tab shows a lock and nothing is sent.

Get started in 4 steps

  1. Prepare a public URL on your server that accepts POST requests with JSON (for example https://your-server.com/webhook).
  2. In Settings → Webhooks, enter the URL, tick the events you want and, optionally, set a secret for signing. Click “Add webhook”.
  3. Click “Test” next to the webhook: it sends a test event (chat_action with action_type: "test"), signed just like real ones.
  4. Check the webhook’s status line: “last HTTP 200” means your server answered correctly.

Each webhook has Edit, Disable/Enable, Test and Delete buttons. When editing, leaving the secret empty keeps the existing one; to change it, type a new one.

How it is delivered

  • It is a POST request with a JSON body that always looks like this: { "type": "…", "data": [ … ] }.
  • data is always a list, even with a single item. Events are batched and sent about every 3 seconds; inside the list they are in chronological order.
  • Each request contains a single event type. If messages and joins happened in those 3 seconds, you get two requests (one chat_message and one user_joined).
  • If a webhook has only some events ticked, it only receives those.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentParlo-Webhook/1.0
X-Chat-EventThe event type: chat_message, user_joined, user_left or chat_action
X-Chat-SignatureOnly if you set a secret: sha256=<signature> (see below)

What your server should answer

  • Reply with a 2xx status (200 or 204) as soon as possible: the chat waits 8 seconds at most.
  • Do the heavy work after replying (put it in a queue or process in the background).
  • There are no retries. If your server doesn’t answer properly, those events are not sent again. Redirects (301/302) are not followed either: use the final URL directly.
  • At most 500 events per type are held between sends; if your server is very slow and the chat is extremely busy, the oldest ones are dropped.
  • Pending events are kept in memory: if the chat restarts, those not yet sent are lost.

URL rules

  • It must start with http:// or https:// (https recommended). 1000 characters maximum.
  • Local or private addresses are not allowed: localhost, 127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x or their IPv6 equivalents. It must be reachable from the internet.
  • User:password inside the URL and the #fragment are removed. If you need authentication, use the secret and check the signature.

Verify the signature (strongly recommended)

If you set a secret (up to 200 characters), every request carries X-Chat-Signature: sha256=…, which is the hex HMAC-SHA256 of the exact request body, using your secret as the key. Compute it yourself over the unmodified body (before parsing the JSON) and compare it with the header. That proves the message comes from your chat and not from someone who just knows your URL.

const crypto = require('crypto');
const express = require('express');
const app = express();

// Important: read the body UNPARSED to compute the signature
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.PARLO_WEBHOOK_SECRET)
    .update(req.body).digest('hex');
  const got = req.get('X-Chat-Signature') || '';
  const ok = got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  res.sendStatus(200);                        // reply right away
  const { type, data } = JSON.parse(req.body.toString('utf8'));
  for (const ev of data) console.log(type, ev);
});
app.listen(3000);
Keep the secret in an environment variable on your server, never in public code. If you change it in the chat, change it on your server too.

Events and fields

Field names are in English. timestamp values are Unix seconds. Fields that don’t apply arrive as null or empty. Private messages (DMs) are not sent.

chat_message — a message was written

Sent for every new message in any room (including the staff room: use channel to filter), for messages a moderator approves when the chat uses pre-approval (at that moment, not before), for bot messages, and again when a message is edited (same msg_id with edited: true).

FieldContent
msg_idMessage identifier (string)
chat_id, chat_nameYour chat’s identifier and name
channelRoom where it was written; main is the main room
user_id, username, nicknameWho wrote it: id, username and displayed nickname
profile_picture_urlAvatar (or null)
messageThe text. Empty if the message is an image, video or audio
rankRank: owner, manager, moderator, member, guest…
editedtrue if the message was edited
attachmentURL of the image/GIF sent (or null)
attachment_idIdentifier of the uploaded file (image, video or audio) (or null)
attachment_typeimage, upload-image, upload-video, audio or null
reply_to_idId of the message it replies to (or null)
ipAuthor’s IP (or null)
timestampTime of the message (Unix seconds)
{
  "type": "chat_message",
  "data": [
    {
      "msg_id": "1042", "chat_id": "my-chat", "chat_name": "My chat", "channel": "main",
      "user_id": "7", "username": "ana", "nickname": "Ana", "profile_picture_url": null,
      "message": "Hello everyone", "rank": "member", "edited": false,
      "attachment": null, "attachment_id": null, "attachment_type": null,
      "reply_to_id": null, "ip": "203.0.113.5", "timestamp": 1760000000
    }
  ]
}

user_joined and user_left — joins and leaves

user_joined is sent when a user goes from offline to online; user_left when their last connection closes (with several tabs open, only when the last one closes). Same fields in both:

FieldContent
chat_id, chat_nameYour chat
channelRoom they are in
user_id, username, nicknameUser identification
profile_picture_urlAvatar (or null)
rankTheir rank
country_codeCountry (2-letter code) or null
timestampUnix seconds

chat_action — moderation

When a moderator, manager, owner or the automatic bot does something. All events of this type share these fields, and the specific kind is in action_type:

FieldContent
action_typeWhat was done (table below)
chat_id, chat_name, channelYour chat and the room
user_id, username, nickname, profile_picture_url, rankWho performs the action (the moderator). If the system does it, rank is system
a_user_id, a_username, a_nickname, a_profile_picture_url, a_rankWho receives the action (the affected user). The “a” stands for “affected”
timestampUnix seconds

And depending on action_type, extra fields:

action_typeWhat happenedExtra fields
delete_messageA message was deletedmsg_id, msg (text), msg_attachment, msg_deleted: true, ip
rank_changeRank changedold_rank, new_rank
banBanreason, duration ("HH:MM" or "forever"), all_msgs_deleted, ip
unbanA ban was lifted—
warningWarningreason, ip
kickKickreason, ip
muteMutedreason, expires_at (Unix milliseconds), duration_minutes
unmuteMute lifted—
bot_… (e.g. bot_kick)Automatic action by the moderation botreason, ip. The “actor” is the bot
testEvent from the “Test” buttonreason. The a_… fields are null
{
  "type": "chat_action",
  "data": [
    {
      "action_type": "mute", "chat_id": "my-chat", "chat_name": "My chat", "channel": "main",
      "user_id": "2", "username": "mod1", "nickname": "Mod 1", "profile_picture_url": null, "rank": "moderator",
      "a_user_id": "7", "a_username": "ana", "a_nickname": "Ana", "a_profile_picture_url": null, "a_rank": "member",
      "reason": "spam", "expires_at": 1760003600000, "duration_minutes": 60,
      "timestamp": 1760000000
    }
  ]
}
Mind privacy: messages and actions include users’ IP addresses. If you store that data, handle it according to your privacy policy and the regulations that apply to you (for example GDPR).

Ideas

  • Your own log: save every chat_message in your database.
  • Team alerts: forward chat_action events of type ban or kick to a Discord/Slack channel.
  • Statistics: count joins (user_joined) per hour.
  • Alerts: detect keywords in message and notify your team.

Webhooks are one-way (the chat notifies you). To make something react inside the chat, use the chat bot.

If something doesn’t work

What you seeWhat it usually is
“Invalid or not allowed webhook URL”The URL isn’t http/https or points to a local/private address
The tab has a lockYour current plan doesn’t include webhooks (Business plan)
last HTTP 401 / 403Your server rejected the request: check the signature verification
last HTTP 404The route on your server doesn’t exist: check the URL
last HTTP 500Your server failed while handling it
An error message instead of a numberCouldn’t connect (server down, invalid certificate, more than 8 s wait, or the URL redirects)
Signature doesn’t matchCompute the HMAC over the unparsed body, with the same secret, and compare against the full sha256=… value
Nothing arrivesCheck the webhook is Active, the event is ticked and your server is reachable from the internet. Use “Test”

← Back to the chat