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…
Empezar en 4 pasos
- Prepara una URL pública en tu servidor que acepte peticiones
POSTcon JSON (por ejemplohttps://tu-servidor.com/webhook). - En Ajustes → Webhooks, escribe la URL, marca los eventos que quieres recibir y, si quieres, pon un secreto para firmar. Pulsa «Añadir webhook».
- Pulsa «Probar» junto al webhook: se envía un evento de prueba (
chat_actionconaction_type: "test"), firmado igual que los reales. - 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
POSTcon cuerpo JSON y siempre tiene esta forma:{ "type": "…", "data": [ … ] }. dataes 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_messagey otrauser_joined). - Si un webhook tiene marcados solo algunos eventos, solo recibe esos.
Cabeceras
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
User-Agent | Parlo-Webhook/1.0 |
X-Chat-Event | El tipo de evento: chat_message, user_joined, user_left o chat_action |
X-Chat-Signature | Solo 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://ohttps://(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.xni sus equivalentes IPv6. Debe ser accesible desde internet. - Los usuario:contraseña dentro de la URL y el
#fragmentose 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);
<?php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('PARLO_WEBHOOK_SECRET'));
$got = $_SERVER['HTTP_X_CHAT_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) { http_response_code(401); exit; }
$payload = json_decode($raw, true);
foreach ($payload['data'] as $ev) {
// $payload['type'] es chat_message, user_joined, user_left o chat_action
error_log($payload['type'] . ' ' . json_encode($ev));
}
http_response_code(200);
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/webhook')
def webhook():
raw = request.get_data() # cuerpo sin modificar
expected = 'sha256=' + hmac.new(
os.environ['PARLO_WEBHOOK_SECRET'].encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-Chat-Signature', '')):
abort(401)
payload = request.get_json(force=True)
for ev in payload['data']:
print(payload['type'], ev)
return '', 200
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).
| Campo | Contenido |
|---|---|
msg_id | Identificador del mensaje (texto) |
chat_id, chat_name | Identificador y nombre de tu chat |
channel | Sala donde se escribió; main es la sala principal |
user_id, username, nickname | Quién lo escribió: id, usuario y nick que se muestra |
profile_picture_url | Avatar (o null) |
message | El texto. Vacío si el mensaje es una imagen, vídeo o audio |
rank | Rango: owner, manager, moderator, member, guest… |
edited | true si el mensaje fue editado |
attachment | URL de la imagen/GIF enviada (o null) |
attachment_id | Identificador del archivo subido (imagen, vídeo o audio) (o null) |
attachment_type | image, upload-image, upload-video, audio o null |
reply_to_id | Id del mensaje al que responde (o null) |
ip | IP del autor (o null) |
timestamp | Momento 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:
| Campo | Contenido |
|---|---|
chat_id, chat_name | Tu chat |
channel | Sala en la que está |
user_id, username, nickname | Identificación del usuario |
profile_picture_url | Avatar (o null) |
rank | Su rango |
country_code | País (código de 2 letras) o null |
timestamp | Segundos 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:
| Campo | Contenido |
|---|---|
action_type | Qué se hizo (tabla de abajo) |
chat_id, chat_name, channel | Tu chat y la sala |
user_id, username, nickname, profile_picture_url, rank | Quien 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_rank | Quien recibe la acción (el usuario afectado). La «a» es de «afectado» |
timestamp | Segundos Unix |
Y según action_type, campos extra:
action_type | Qué ocurrió | Campos extra |
|---|---|---|
delete_message | Se borró un mensaje | msg_id, msg (texto), msg_attachment, msg_deleted: true, ip |
rank_change | Cambio de rango | old_rank, new_rank |
ban | Baneo | reason, duration ("HH:MM" o "forever"), all_msgs_deleted, ip |
unban | Se quitó un baneo | — |
warning | Advertencia | reason, ip |
kick | Expulsión | reason, ip |
mute | Silenciado | reason, expires_at (milisegundos Unix), duration_minutes |
unmute | Se quitó el silencio | — |
bot_… (p. ej. bot_kick) | Acción automática del bot de moderación | reason, ip. El «actor» es el bot |
test | Evento 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
}
]
}
Ideas
- Registro propio: guarda cada
chat_messageen tu base de datos. - Avisos al equipo: reenvía los
chat_actionde tipobanokicka un canal de Discord/Slack. - Estadísticas: cuenta entradas (
user_joined) por hora. - Alertas: detecta palabras clave en
messagey 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 ves | Qué 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 candado | Tu plan actual no incluye webhooks (plan Business) |
último HTTP 401 / 403 | Tu servidor rechazó la petición: revisa la comprobación de la firma |
último HTTP 404 | La ruta de tu servidor no existe: revisa la URL |
último HTTP 500 | Tu servidor falló al procesarla |
| Un mensaje de error en lugar de un número | No se pudo conectar (servidor caído, certificado no válido, más de 8 s de espera, o la URL redirige) |
| La firma no coincide | Calcula el HMAC sobre el cuerpo sin parsear, con el mismo secreto, y compara con el valor completo sha256=… |
| No llega nada | Comprueba que el webhook está Activo, que el evento está marcado y que tu servidor es accesible desde internet. Usa «Probar» |
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…
Get started in 4 steps
- Prepare a public URL on your server that accepts
POSTrequests with JSON (for examplehttps://your-server.com/webhook). - In Settings → Webhooks, enter the URL, tick the events you want and, optionally, set a secret for signing. Click “Add webhook”.
- Click “Test” next to the webhook: it sends a test event (
chat_actionwithaction_type: "test"), signed just like real ones. - 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
POSTrequest with a JSON body that always looks like this:{ "type": "…", "data": [ … ] }. datais 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_messageand oneuser_joined). - If a webhook has only some events ticked, it only receives those.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Parlo-Webhook/1.0 |
X-Chat-Event | The event type: chat_message, user_joined, user_left or chat_action |
X-Chat-Signature | Only 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://orhttps://(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.xor their IPv6 equivalents. It must be reachable from the internet. - User:password inside the URL and the
#fragmentare 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);
<?php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('PARLO_WEBHOOK_SECRET'));
$got = $_SERVER['HTTP_X_CHAT_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) { http_response_code(401); exit; }
$payload = json_decode($raw, true);
foreach ($payload['data'] as $ev) {
// $payload['type'] is chat_message, user_joined, user_left or chat_action
error_log($payload['type'] . ' ' . json_encode($ev));
}
http_response_code(200);
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/webhook')
def webhook():
raw = request.get_data() # unmodified body
expected = 'sha256=' + hmac.new(
os.environ['PARLO_WEBHOOK_SECRET'].encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-Chat-Signature', '')):
abort(401)
payload = request.get_json(force=True)
for ev in payload['data']:
print(payload['type'], ev)
return '', 200
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).
| Field | Content |
|---|---|
msg_id | Message identifier (string) |
chat_id, chat_name | Your chat’s identifier and name |
channel | Room where it was written; main is the main room |
user_id, username, nickname | Who wrote it: id, username and displayed nickname |
profile_picture_url | Avatar (or null) |
message | The text. Empty if the message is an image, video or audio |
rank | Rank: owner, manager, moderator, member, guest… |
edited | true if the message was edited |
attachment | URL of the image/GIF sent (or null) |
attachment_id | Identifier of the uploaded file (image, video or audio) (or null) |
attachment_type | image, upload-image, upload-video, audio or null |
reply_to_id | Id of the message it replies to (or null) |
ip | Author’s IP (or null) |
timestamp | Time 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:
| Field | Content |
|---|---|
chat_id, chat_name | Your chat |
channel | Room they are in |
user_id, username, nickname | User identification |
profile_picture_url | Avatar (or null) |
rank | Their rank |
country_code | Country (2-letter code) or null |
timestamp | Unix 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:
| Field | Content |
|---|---|
action_type | What was done (table below) |
chat_id, chat_name, channel | Your chat and the room |
user_id, username, nickname, profile_picture_url, rank | Who 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_rank | Who receives the action (the affected user). The “a” stands for “affected” |
timestamp | Unix seconds |
And depending on action_type, extra fields:
action_type | What happened | Extra fields |
|---|---|---|
delete_message | A message was deleted | msg_id, msg (text), msg_attachment, msg_deleted: true, ip |
rank_change | Rank changed | old_rank, new_rank |
ban | Ban | reason, duration ("HH:MM" or "forever"), all_msgs_deleted, ip |
unban | A ban was lifted | — |
warning | Warning | reason, ip |
kick | Kick | reason, ip |
mute | Muted | reason, expires_at (Unix milliseconds), duration_minutes |
unmute | Mute lifted | — |
bot_… (e.g. bot_kick) | Automatic action by the moderation bot | reason, ip. The “actor” is the bot |
test | Event from the “Test” button | reason. 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
}
]
}
Ideas
- Your own log: save every
chat_messagein your database. - Team alerts: forward
chat_actionevents of typebanorkickto a Discord/Slack channel. - Statistics: count joins (
user_joined) per hour. - Alerts: detect keywords in
messageand 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 see | What 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 lock | Your current plan doesn’t include webhooks (Business plan) |
last HTTP 401 / 403 | Your server rejected the request: check the signature verification |
last HTTP 404 | The route on your server doesn’t exist: check the URL |
last HTTP 500 | Your server failed while handling it |
| An error message instead of a number | Couldn’t connect (server down, invalid certificate, more than 8 s wait, or the URL redirects) |
| Signature doesn’t match | Compute the HMAC over the unparsed body, with the same secret, and compare against the full sha256=… value |
| Nothing arrives | Check the webhook is Active, the event is ticked and your server is reachable from the internet. Use “Test” |