xYago APIv1

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

  1. Expose a public https URL (private IPs and localhost are not allowed), e.g. https://erp.yourcompany.com.co/xyago/webhooks.
  2. In the panel → API Developer → Webhooks, choose the environment (Live or Test), add the URL and the events you want.
  3. Copy the environment's signing secret (whsec_...) to your server: you need it to verify every request.
  4. Click Send test: a signed ping event 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.createdAn employee was created.
employee.updatedAn employee's public data changed.
employee.deactivatedAn employee was deactivated.
vacation.requestedNew vacation request.
vacation.approvedRequest approved.
vacation.rejectedRequest rejected.
time_entry.createdNew closed time entry (also when an entry is edited: it arrives with a new id).
area.createdAn area was created.
area.updatedAn area changed (including activation/deactivation).
message.sentA message reached the recipient's chat (also one that was waiting for them to install xYago).
message.deliveredThe message reached one of the recipient's devices.
message.readThe recipient opened the chat.
message.failedThe message will not be delivered (see failure_code).
message.button_clickedThe 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_changedA sender's review status changed.
template.status_changedA template's approval status changed (also superseded when a new version is approved).
verification.deliveredThe code reached one of the user's devices.
verification.readThe user opened the chat with the code.
verification.verifiedThe user entered the correct code.
verification.expiredThe code expired without being verified (10 minutes).
verification.failedThe 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"
}
idUnique event id (same as X-Xyago-Event-Id). Repeated on retries.
typeEvent type.
environmentlive or test.
created_atWhen the change happened (UTC).
dataThe 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

Verifying the signature

Verify every request before trusting it. The header looks like:

X-Xyago-Signature: t=<unix timestamp>,v1=<hex signature>
  1. Split out t and the v1 values (there may be more than one).
  2. 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.
  3. Compare it in constant time with each v1; one match is enough.
  4. Reject the request if t is 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=&lt;timestamp&gt;,v1=&lt;firma&gt;[,v1=&lt;firma&gt;...].
    /// <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