xYago APIv1

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

  1. Publica una URL https accesible desde Internet (no se admiten IPs privadas ni localhost), p. ej. https://erp.tuempresa.com.co/xyago/webhooks.
  2. En el panel → API Developer → Webhooks, elige el entorno (Live o Test), añade la URL y los eventos que quieres recibir.
  3. Copia el secreto de firma del entorno (whsec_...) y guárdalo en tu servidor: lo necesitas para verificar cada petición.
  4. Pulsa Enviar prueba: llega un evento ping firmado.

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.createdAlta de un empleado.
employee.updatedCambio en los datos públicos de un empleado.
employee.deactivatedUn empleado pasó a inactivo.
vacation.requestedNueva solicitud de vacaciones.
vacation.approvedSolicitud aprobada.
vacation.rejectedSolicitud rechazada.
time_entry.createdNuevo tramo de horas cerrado (también al editar un tramo: llega con id nuevo).
area.createdAlta de un área.
area.updatedCambio en un área (incluye activar/desactivar).
message.sentUn mensaje llegó al chat del destinatario (también uno que estaba en espera de que instalara xYago).
message.deliveredEl mensaje llegó a un dispositivo del destinatario.
message.readEl destinatario abrió el chat.
message.failedEl mensaje no se entregará (ver failure_code).
message.button_clickedEl 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_changedCambió el estado de revisión de un remitente.
template.status_changedCambió el estado de aprobación de una plantilla (también superseded al aprobarse una versión nueva).
verification.deliveredEl código llegó a un dispositivo del usuario.
verification.readEl usuario abrió el chat con el código.
verification.verifiedEl usuario escribió el código correcto.
verification.expiredEl código caducó sin verificarse (10 minutos).
verification.failedNo 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"
}
idId único del evento (igual que X-Xyago-Event-Id). Se repite en los reintentos.
typeTipo de evento.
environmentlive o test.
created_atCuándo ocurrió el cambio (UTC).
dataEstado 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

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>
  1. Separa t y los valores v1 (puede haber más de uno).
  2. 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á.
  3. Compáralo en tiempo constante con cada v1; basta con que coincida uno.
  4. Rechaza la petición si t se 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=&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;
    }
}

// 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