Webhooks
With webhooks xYago notifies your system, with a signed HTTPS request, whenever an employee, area, vacation request or time entry changes, so you don't have to poll the API.
The verification snippets below keep their comments in Spanish (they are the exact files tested in our CI); the logic is identical for every language.
Registering an endpoint
- Expose a public https URL (private IPs and
localhostare not allowed), e.g.https://erp.yourcompany.com.co/xyago/webhooks. - In the panel → API Developer → Webhooks, choose the environment (Live or Test), add the URL and the events you want.
- Copy the environment's signing secret (
whsec_...) to your server: you need it to verify every request. - Click Send test: a signed
pingevent arrives.
In live you receive changes made anywhere (panel, xYago app or the API itself). In test you receive the events of your sandbox writes.
Events
employee.created | An employee was created. |
employee.updated | An employee's public data changed. |
employee.deactivated | An employee was deactivated. |
vacation.requested | New vacation request. |
vacation.approved | Request approved. |
vacation.rejected | Request rejected. |
time_entry.created | New closed time entry (also when an entry is edited: it arrives with a new id). |
area.created | An area was created. |
area.updated | An area changed (including activation/deactivation). |
message.sent | A message reached the recipient's chat (also one that was waiting for them to install xYago). |
message.delivered | The message reached one of the recipient's devices. |
message.read | The recipient opened the chat. |
message.failed | The message will not be delivered (see failure_code). |
message.button_clicked | The recipient tapped a button of the message (first tap per button only; includes the payload of quick replies and the button text in the language sent). |
sender.status_changed | A sender's review status changed. |
template.status_changed | A template's approval status changed (also superseded when a new version is approved). |
verification.delivered | The code reached one of the user's devices. |
verification.read | The user opened the chat with the code. |
verification.verified | The user entered the correct code. |
verification.expired | The code expired without being verified (10 minutes). |
verification.failed | The code could not be sent (see failure_code) or the 5 attempts ran out (too_many_attempts). |
Planned for v2 (not subscribable yet): contract.signed, contract.expiring.
The request
POST /xyago/webhooks HTTP/1.1
Host: erp.yourcompany.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
The body is a JSON envelope; data is the same object the API returns
for that resource (employee, area, request or entry):
{
"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 | Unique event id (same as X-Xyago-Event-Id). Repeated on retries. |
type | Event type. |
environment | live or test. |
created_at | When the change happened (UTC). |
data | The object's state after the change. |
vacation.approved example
{
"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"
}time_entry.created example
{
"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"
}area.updated example
{
"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"
}message.button_clicked example
{
"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"
}verification.verified example
{
"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"
}ping example
{
"created_at": "2026-03-02T15:00:04Z",
"data": {
"message": "Test webhook from xYago"
},
"environment": "live",
"id": "evt_4f9c2b7a1d3e4c5b8a6f0e1d2c3b4a59",
"type": "ping"
}Responding and retries
- Answer with any
2xxwithin 10 seconds (in total, connection included). For heavy work, store the event in a queue and process it later. - The response body is ignored: we read at most 64 KB of it and then close the connection. Reply with an empty body or a short one.
- Any other response (including
3xxredirects, which are not followed), a timeout or a connection error counts as a failure. - After a failure we retry after 1 min, 5 min, 30 min, 2 h and 6 h (6 attempts in total). If all 6 fail the endpoint is marked failing in the panel; it still receives new events and returns to active on the first successful delivery.
- A retry carries the same
id: store processed ids and drop repeats. - Delivery order is not guaranteed. Use
created_ator, if you need the current state, fetch the object from the API.
Verifying the signature
Verify every request before trusting it. The header looks like:
X-Xyago-Signature: t=<unix timestamp>,v1=<hex signature>
- Split out
tand thev1values (there may be more than one). - Compute
HMAC-SHA256(secret, "<t>." + raw_body)as lowercase hex. Use the raw body, byte for byte: if you parse and re-serialize the JSON the signature won't match. - Compare it in constant time with each
v1; one match is enough. - Reject the request if
tis more than 5 minutes away from your current time (replay protection). Keep the server clock in sync (NTP).
Secret rotation: when you rotate the secret in the panel, for 24 h
every event is signed with both the new and the previous secret
(t=...,v1=<new>,v1=<previous>). Deploy the new secret within that window and
no event is lost.
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)
# Flask example
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() # raw body
if not verify_xyago_signature(payload, request.headers.get("X-Xyago-Signature", ""), SECRET):
abort(400)
event = request.get_json()
# drop duplicates by event["id"] and queue the processing
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 };
// Express example: express.raw keeps the raw body
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"));
// drop duplicates by event.id and queue the processing
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;
}
// Usage
$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);
// drop duplicates by $event['id'] and queue the processing
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;
}
}
// Usage in ASP.NET Core (minimal API, .NET 6+)
app.MapPost("/xyago/webhooks", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // raw body
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);
// drop duplicates by evt.RootElement.GetProperty("id") and queue the processing
return Results.NoContent();
});
These verifiers are tested automatically against signatures produced by xYago.
Best practices
- Verify signature and age before reading the JSON.
- Answer fast (
2xx) and process in the background. - Deduplicate by
id: you may receive the same event more than once. - If you miss events (long outage), resync by reading the API lists.
- Use a different endpoint for test and live (or at least check
environment).