Webhooks
Con los webhooks xYago avisa a tu sistema, con una petición HTTPS firmada, cada vez que cambia un empleado, un área, una solicitud de vacaciones o un registro de horas. Así no tienes que consultar la API cada pocos minutos.
Registrar un endpoint
- Publica una URL https accesible desde Internet (no se admiten IPs
privadas ni
localhost), p. ej.https://erp.tuempresa.com.co/xyago/webhooks. - En el panel → API Developer → Webhooks, elige el entorno (Live o Test), añade la URL y los eventos que quieres recibir.
- Copia el secreto de firma del entorno (
whsec_...) y guárdalo en tu servidor: lo necesitas para verificar cada petición. - Pulsa Enviar prueba: llega un evento
pingfirmado.
En live recibes los cambios hechos desde cualquier sitio (panel, app de xYago o la propia API). En test recibes los eventos de las escrituras que hagas en el sandbox.
Eventos
employee.created | Alta de un empleado. |
employee.updated | Cambio en los datos públicos de un empleado. |
employee.deactivated | Un empleado pasó a inactivo. |
vacation.requested | Nueva solicitud de vacaciones. |
vacation.approved | Solicitud aprobada. |
vacation.rejected | Solicitud rechazada. |
time_entry.created | Nuevo tramo de horas cerrado (también al editar un tramo: llega con id nuevo). |
area.created | Alta de un área. |
area.updated | Cambio en un área (incluye activar/desactivar). |
message.sent | Un mensaje llegó al chat del destinatario (también uno que estaba en espera de que instalara xYago). |
message.delivered | El mensaje llegó a un dispositivo del destinatario. |
message.read | El destinatario abrió el chat. |
message.failed | El mensaje no se entregará (ver failure_code). |
message.button_clicked | El destinatario pulsó un botón del mensaje (solo el primer clic por botón; incluye el payload de las respuestas rápidas y el texto del botón en el idioma del envío). |
sender.status_changed | Cambió el estado de revisión de un remitente. |
template.status_changed | Cambió el estado de aprobación de una plantilla (también superseded al aprobarse una versión nueva). |
verification.delivered | El código llegó a un dispositivo del usuario. |
verification.read | El usuario abrió el chat con el código. |
verification.verified | El usuario escribió el código correcto. |
verification.expired | El código caducó sin verificarse (10 minutos). |
verification.failed | No se pudo enviar el código (ver failure_code) o se agotaron los 5 intentos (too_many_attempts). |
Previstos para v2 (aún no suscribibles): contract.signed, contract.expiring.
La petición
POST /xyago/webhooks HTTP/1.1
Host: erp.tuempresa.com.co
Content-Type: application/json
User-Agent: xYago-Webhooks/1.0
X-Xyago-Event: employee.created
X-Xyago-Event-Id: evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59
X-Xyago-Signature: t=1772463604,v1=5f0c4e...9a1b
El cuerpo es un sobre JSON; data es el mismo objeto que devuelve la
API para ese recurso (empleado, área, solicitud o tramo):
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"id": "2c9a4e71-5b3d-4f8e-a6c2-7d1e0f9b3a54",
"external_id": "SIESA-000123",
"first_name": "Valentina Rojas Castaño",
"last_name": null,
"email": "valentina.rojas@example.com",
"phone_number": "+573005550123",
"document_number": "1020304050",
"job": {
"id": "9d4f6a28-3b1c-4e7d-b5a9-0e2f4c6d8a13",
"name": "Auxiliar de bodega"
},
"area_ids": [
"8b3e9f12-7c4d-4e5a-9b6c-1d2e3f405162"
],
"status": "active",
"contract_type": "Full-time fixed-term",
"hire_date": "2026-02-01",
"contract_end_date": "2027-01-31",
"created_at": null,
"updated_at": null
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "employee.created"
}
id | Id único del evento (igual que X-Xyago-Event-Id). Se repite en los reintentos. |
type | Tipo de evento. |
environment | live o test. |
created_at | Cuándo ocurrió el cambio (UTC). |
data | Estado del objeto tras el cambio. |
Ejemplo de vacation.approved
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"id": "65f1a2b3c4d5e6f708192a3b",
"employee_id": "2c9a4e71-5b3d-4f8e-a6c2-7d1e0f9b3a54",
"start_date": "2026-12-21",
"end_date": "2027-01-08",
"days": 13,
"status": "approved",
"reason": "Vacaciones de fin de año",
"created_at": "2026-11-03T13:20:00Z",
"decided_at": "2026-11-04T15:02:00Z"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "vacation.approved"
}Ejemplo de time_entry.created
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"id": "c4e2a6f8-1b3d-4a5c-8e7f-9a0b1c2d3e4f",
"employee_id": "2c9a4e71-5b3d-4f8e-a6c2-7d1e0f9b3a54",
"started_at": "2026-03-02T12:00:00Z",
"ended_at": "2026-03-02T21:00:00Z",
"duration_minutes": 540,
"source": "api",
"note": "Turno registrado desde Siesa",
"created_at": "2026-03-02T21:05:00Z"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "time_entry.created"
}Ejemplo de area.updated
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"id": "8b3e9f12-7c4d-4e5a-9b6c-1d2e3f405162",
"public_id": "AR-0004-02",
"name": "Bodega Medellín",
"parent_id": "6f1d2c3b-4a5e-4f60-8a71-0b2c3d4e5f60",
"status": "active",
"minimum_employees": 3,
"maximum_employees": 15,
"skills": [
"montacargas",
"inventarios"
],
"created_at": "2026-03-02T14:05:00Z",
"updated_at": "2026-03-02T14:05:00Z"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "area.updated"
}Ejemplo de message.button_clicked
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"message": {
"id": "7a1c9e35-2b4d-4f6a-8c0e-1d3f5a7b9c21",
"status": "read",
"to_masked": "+57 ••• ••• 0123",
"sender_id": "tiendaexpress",
"template": "pedido_enviado",
"language": "es",
"reference": "CLI-000123",
"failure_code": null,
"created_at": "2026-11-04T15:00:00Z",
"sent_at": "2026-11-04T15:00:01Z",
"delivered_at": "2026-11-04T15:02:10Z",
"read_at": "2026-11-04T15:07:42Z",
"expires_at": null
},
"button": {
"index": 1,
"id": "b1",
"type": "quick_reply",
"text": "No lo pedí",
"language": "es",
"payload": "order_not_mine",
"url": null
},
"clicked_at": "2026-11-04T15:08:03Z"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "message.button_clicked"
}Ejemplo de verification.verified
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"id": "3e8b1f6a-9c2d-4a7e-b5f0-6d1c2a3b4e59",
"object": "verification",
"sender_id": "tiendaexpress",
"to_masked": "+57 ••• ••• 0123",
"channel": "xyago_chat",
"language": "es",
"reference": "login-CLI-000123",
"status": "verified",
"delivery_status": "read",
"failure_code": null,
"attempts": 1,
"max_attempts": 5,
"expires_at": "2026-11-04T15:10:00Z",
"verified_at": "2026-11-04T15:01:12Z",
"created_at": "2026-11-04T15:00:00Z"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "verification.verified"
}Ejemplo de ping
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"message": "Test webhook from xYago"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "ping"
}Responder y reintentos
- Responde con cualquier
2xxen menos de 10 segundos (en total, incluida la conexión). Si tienes trabajo pesado, guarda el evento en una cola y procésalo después. - El cuerpo de la respuesta se ignora: se leen como mucho 64 KB y se cierra la conexión. Responde con un cuerpo vacío o corto.
- Cualquier otra respuesta (incluidas las redirecciones
3xx, que no se siguen), un timeout o un error de conexión cuentan como fallo. - Tras un fallo se reintenta a los 1 min, 5 min, 30 min, 2 h y 6 h (6 envíos en total). Si los 6 fallan, el endpoint queda como failing en el panel; sigue recibiendo eventos nuevos y vuelve a active con la primera entrega correcta.
- Un reintento lleva el mismo
id: guarda los ids procesados y descarta los repetidos. - El orden de llegada no está garantizado. Usa
created_ato, si necesitas el estado actual, consulta el objeto en la API.
Verificar la firma
Verifica todas las peticiones antes de confiar en ellas. La cabecera tiene la forma:
X-Xyago-Signature: t=<timestamp unix>,v1=<firma hex>
- Separa
ty los valoresv1(puede haber más de uno). - Calcula
HMAC-SHA256(secreto, "<t>." + cuerpo_crudo)en hexadecimal (minúsculas). El cuerpo debe ser el crudo, byte a byte: si lo parseas y vuelves a serializar el JSON, la firma no coincidirá. - Compáralo en tiempo constante con cada
v1; basta con que coincida uno. - Rechaza la petición si
tse aleja más de 5 minutos de tu hora actual (protección contra reenvíos). Mantén el reloj del servidor sincronizado (NTP).
Rotación del secreto: al rotarlo en el panel, durante 24 h cada
evento llega firmado con el secreto nuevo y con el anterior
(t=...,v1=<nueva>,v1=<anterior>). Despliega el secreto nuevo en ese
plazo y no se perderá ningún evento.
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_xyago_signature(payload: bytes, header: str, secret: str) -> bool:
"""
Verifica X-Xyago-Signature: t=<timestamp>,v1=<firma>[,v1=<firma>...].
`payload` es el cuerpo CRUDO de la peticion (bytes), antes de parsear el JSON.
"""
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isascii() and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False # firma antigua: posible reenvio (replay)
signed = f"{timestamp}.".encode() + payload
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# Tras rotar el secreto llegan dos v1 durante 24 h: basta con que coincida una.
return any(hmac.compare_digest(expected, sig) for sig in signatures)
# Ejemplo con Flask
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["XYAGO_WEBHOOK_SECRET"] # whsec_...
@app.post("/xyago/webhooks")
def xyago_webhook():
payload = request.get_data() # cuerpo crudo
if not verify_xyago_signature(payload, request.headers.get("X-Xyago-Signature", ""), SECRET):
abort(400)
event = request.get_json()
# descarta duplicados por event["id"] y encola el procesamiento
return "", 204
Node.js
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300;
/**
* Verifica X-Xyago-Signature: t=<timestamp>,v1=<firma>[,v1=<firma>...].
* `payload` es el cuerpo CRUDO (Buffer o string), antes de parsear el JSON.
*/
function verifyXyagoSignature(payload, header, secret) {
let timestamp = null;
const signatures = [];
for (const part of String(header || "").split(",")) {
const [key, ...rest] = part.trim().split("=");
const value = rest.join("=");
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1" && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(payload)
.digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
// Tras rotar el secreto llegan dos v1 durante 24 h: basta con que coincida una.
return signatures.some((sig) => {
const sigBuf = Buffer.from(sig, "utf8");
return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
});
}
module.exports = { verifyXyagoSignature };
// Ejemplo con Express: express.raw conserva el cuerpo crudo
const express = require("express");
const app = express();
const SECRET = process.env.XYAGO_WEBHOOK_SECRET; // whsec_...
app.post("/xyago/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyXyagoSignature(req.body, req.get("X-Xyago-Signature"), SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// descarta duplicados por event.id y encola el procesamiento
res.sendStatus(204);
});
PHP
<?php
const XYAGO_TOLERANCE_SECONDS = 300;
/**
* Verifica X-Xyago-Signature: t=<timestamp>,v1=<firma>[,v1=<firma>...].
* $payload es el cuerpo CRUDO: file_get_contents('php://input').
*/
function verify_xyago_signature(string $payload, string $header, string $secret): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || count($signatures) === 0) {
return false;
}
if (abs(time() - $timestamp) > XYAGO_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// Tras rotar el secreto llegan dos v1 durante 24 h: basta con que coincida una.
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// Uso
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_XYAGO_SIGNATURE'] ?? '';
if (!verify_xyago_signature($payload, $header, getenv('XYAGO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// descarta duplicados por $event['id'] y encola el procesamiento
http_response_code(204);
C# / .NET
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
public static class XyagoWebhook
{
private const long ToleranceSeconds = 300;
/// <summary>
/// Verifica X-Xyago-Signature: t=<timestamp>,v1=<firma>[,v1=<firma>...].
/// <paramref name="payload"/> es el cuerpo CRUDO (bytes), antes de deserializar el JSON.
/// </summary>
public static bool VerifySignature(byte[] payload, string header, string secret)
{
long? timestamp = null;
var signatures = new List<string>();
foreach (var part in (header ?? "").Split(','))
{
var kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t" && long.TryParse(kv[1], out var ts)) timestamp = ts;
else if (kv[0] == "v1" && kv[1].Length > 0) signatures.Add(kv[1]);
}
if (timestamp is null || signatures.Count == 0) return false;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - timestamp.Value) > ToleranceSeconds) return false;
var prefix = Encoding.UTF8.GetBytes($"{timestamp.Value}.");
var signed = new byte[prefix.Length + payload.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(payload, 0, signed, prefix.Length, payload.Length);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant());
// Tras rotar el secreto llegan dos v1 durante 24 h: basta con que coincida una.
foreach (var signature in signatures)
{
var candidate = Encoding.ASCII.GetBytes(signature);
if (CryptographicOperations.FixedTimeEquals(expected, candidate)) return true;
}
return false;
}
}
// Uso en ASP.NET Core (minimal API, .NET 6+)
app.MapPost("/xyago/webhooks", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // cuerpo crudo
var payload = buffer.ToArray();
var header = request.Headers["X-Xyago-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("XYAGO_WEBHOOK_SECRET")!;
if (!XyagoWebhook.VerifySignature(payload, header, secret)) return Results.BadRequest();
using var evt = System.Text.Json.JsonDocument.Parse(payload);
// descarta duplicados por evt.RootElement.GetProperty("id") y encola el procesamiento
return Results.NoContent();
});
Estos verificadores se prueban automáticamente contra firmas generadas por xYago.
Buenas prácticas
- Verifica la firma y la antigüedad antes de leer el JSON.
- Responde rápido (
2xx) y procesa en segundo plano. - Deduplica por
id: puedes recibir el mismo evento más de una vez. - Si te pierdes eventos (caída larga), resincroniza leyendo los listados de la API.
- Usa un endpoint distinto (o al menos distingue
environment) para test y live.