Saltar al contenido
El Registro de Jornada .com

Portal del desarrollador · en preparación

Guías por caso de uso

Cinco tareas habituales, cada una con curl y con la librería de cada lenguaje. Dice también lo que la API no hace.

Alta de personas: lo que sí se puede hacer por API

La API no da de alta, modifica ni da de baja personas: no existe ninguna ruta para ello. Las altas se hacen en la aplicación o con la importación de personas; por API se lee la plantilla para mantener sincronizado tu programa de nóminas o de recursos humanos.

Ámbito de la clave: personas:leer (y personas:nif si necesitas el NIF completo).

  • Sin personas:nif, el NIF sale enmascarado (***5678Z).
  • Para dar de alta personas usa la aplicación, que admite el alta masiva desde una plantilla CSV; no hay fecha para altas por API.

curl

Rutas y parámetros comprobados contra el contrato; no se han lanzado contra el servicio real, que aún no existe.

export ERJ_CLAVE="erj_test_..."   # tu clave de pruebas
curl -H "Authorization: Bearer $ERJ_CLAVE" \
  "https://app.elregistrodejornada.com/api/v1/personas?estado=alta&limit=50"

# La respuesta trae "datos" y "siguiente" (cursor). Para la página siguiente, el mismo filtro más ese cursor:
curl -H "Authorization: Bearer $ERJ_CLAVE" \
  "https://app.elregistrodejornada.com/api/v1/personas?estado=alta&limit=50&cursor=EL_CURSOR_DE_SIGUIENTE"

TypeScript

Ejecutado contra un servidor local falso.

import { ClienteErj } from './sdk/typescript/src/index.ts';

const cliente = new ClienteErj({ clave: process.env.ERJ_CLAVE!, urlBase: process.env.ERJ_URL_BASE });
// iterar sigue el cursor hasta la última página
for await (const persona of cliente.personas.iterar({ estado: 'alta', limit: 50 })) {
  console.log(persona.id, persona.nombre);
}

Python

Ejecutado contra un servidor local falso.

import os
import sys
sys.path.insert(0, "sdk/python")
from erj import Cliente

opciones = {"url_base": os.environ["ERJ_URL_BASE"]} if os.environ.get("ERJ_URL_BASE") else {}
cliente = Cliente(os.environ["ERJ_CLAVE"], **opciones)
# iterar sigue el cursor hasta la última página
for persona in cliente.personas.iterar(estado="alta", limit=50):
    print(persona["id"], persona["nombre"])

C#

Escrito con la librería de C#, sin compilar ni ejecutar (no hay SDK de .NET en el equipo de desarrollo).

using ElRegistroJornada;

var cliente = new ClienteErj(new OpcionesCliente { Clave = Environment.GetEnvironmentVariable("ERJ_CLAVE")! });
// IterarAsync sigue el cursor hasta la última página
await foreach (var persona in cliente.Personas.IterarAsync(new Dictionary<string, string?> { ["estado"] = "alta", ["limit"] = "50" }))
    Console.WriteLine($"{persona["id"]} {persona["nombre"]}");

PHP

Escrito con la librería de PHP, sin ejecutar (no hay intérprete de PHP en el equipo de desarrollo).

<?php
require 'sdk/php/src/ClienteErj.php';

use ElRegistroJornada\ClienteErj;

$cliente = new ClienteErj(getenv('ERJ_CLAVE') ?: '');
// iterar sigue el cursor hasta la última página
foreach ($cliente->personas->iterar(['estado' => 'alta', 'limit' => 50]) as $persona) {
    echo $persona['id'], ' ', $persona['nombre'] ?? '', "\n";
}

Leer fichajes de un periodo

Los fichajes se leen por tramos de como mucho 31 días por llamada. Cada fichaje lleva su secuencia y su huella en la cadena de integridad. Sin parámetros, devuelve los últimos 7 días.

Ámbito de la clave: fichajes:leer (y fichajes:posicion para ver posición y nota).

  • Más de 31 días en una llamada da 422 rango_demasiado_amplio: pide varios tramos.
  • Sin fichajes:posicion no salen la posición ni la nota (minimización de datos).
  • La lista de la API no sustituye al verificador independiente: es una proyección de lectura. Para recalcular la cadena sin fiarte del servidor, usa el paquete pericial.

curl

Rutas y parámetros comprobados contra el contrato; no se han lanzado contra el servicio real, que aún no existe.

curl -G -H "Authorization: Bearer $ERJ_CLAVE" \
  --data-urlencode "persona=per_1" \
  --data-urlencode "desde=2026-10-01" \
  --data-urlencode "hasta=2026-10-08" \
  --data-urlencode "limit=100" \
  "https://app.elregistrodejornada.com/api/v1/fichajes"

TypeScript

Ejecutado contra un servidor local falso.

import { ClienteErj, ErrorApi } from './sdk/typescript/src/index.ts';

const cliente = new ClienteErj({ clave: process.env.ERJ_CLAVE!, urlBase: process.env.ERJ_URL_BASE });
try {
  for await (const f of cliente.fichajes.iterar({ persona: 'per_1', desde: '2026-10-01', hasta: '2026-10-08', limit: 100 })) {
    console.log(f.secuencia, f.tipo, f.ocurridoEn);
  }
} catch (e) {
  if (e instanceof ErrorApi && e.codigo === 'rango_demasiado_amplio') console.error('Pide tramos de 31 días como mucho.');
  else throw e;
}

Python

Ejecutado contra un servidor local falso.

import os
import sys
sys.path.insert(0, "sdk/python")
from erj import Cliente, ErrorApi

opciones = {"url_base": os.environ["ERJ_URL_BASE"]} if os.environ.get("ERJ_URL_BASE") else {}
cliente = Cliente(os.environ["ERJ_CLAVE"], **opciones)
try:
    for f in cliente.fichajes.iterar(persona="per_1", desde="2026-10-01", hasta="2026-10-08", limit=100):
        print(f["secuencia"], f["tipo"], f["ocurridoEn"])
except ErrorApi as e:
    if e.codigo != "rango_demasiado_amplio":
        raise
    print("Pide tramos de 31 días como mucho.")

C#

Escrito con la librería de C#, sin compilar ni ejecutar (no hay SDK de .NET en el equipo de desarrollo).

using ElRegistroJornada;

var cliente = new ClienteErj(new OpcionesCliente { Clave = Environment.GetEnvironmentVariable("ERJ_CLAVE")! });
try
{
    var filtros = new Dictionary<string, string?> { ["persona"] = "per_1", ["desde"] = "2026-10-01", ["hasta"] = "2026-10-08", ["limit"] = "100" };
    await foreach (var f in cliente.Fichajes.IterarAsync(filtros))
        Console.WriteLine($"{f["secuencia"]} {f["tipo"]} {f["ocurridoEn"]}");
}
catch (ErrorApi e) when (e.Codigo == "rango_demasiado_amplio")
{
    Console.Error.WriteLine("Pide tramos de 31 días como mucho.");
}

PHP

Escrito con la librería de PHP, sin ejecutar (no hay intérprete de PHP en el equipo de desarrollo).

<?php
require 'sdk/php/src/ClienteErj.php';

use ElRegistroJornada\ClienteErj;
use ElRegistroJornada\ErrorApi;

$cliente = new ClienteErj(getenv('ERJ_CLAVE') ?: '');
try {
    foreach ($cliente->fichajes->iterar(['persona' => 'per_1', 'desde' => '2026-10-01', 'hasta' => '2026-10-08', 'limit' => 100]) as $f) {
        echo $f['secuencia'], ' ', $f['tipo'], ' ', $f['ocurridoEn'], "\n";
    }
} catch (ErrorApi $e) {
    if ($e->codigo !== 'rango_demasiado_amplio') { throw $e; }
    fwrite(STDERR, "Pide tramos de 31 días como mucho.\n");
}

Añadir un fichaje desde tu sistema (apagado por defecto)

POST /fichajes añade un asiento de entrada o salida con el mismo sellador que la aplicación. Está apagado en todas las empresas y sin fecha: hace falta una opción de la empresa y un ámbito de la clave. Estos ejemplos no lo activan.

Ámbito de la clave: fichajes:escribir (no se concede por defecto ni por OAuth).

  • Sin la opción de la empresa, 403 escritura_no_habilitada; sin el ámbito, 403 ambito_insuficiente; con una clave erj_test_, 403 escritura_no_disponible_en_pruebas.
  • Idempotency-Key es obligatoria. Con la misma clave y los mismos datos se devuelve el mismo asiento (cabecera Idempotent-Replayed: true) y no se duplica nada.
  • La hora la pone el servidor. Los asientos solo se añaden: no hay ruta para modificar ni borrar uno. Una corrección es una propuesta (POST /modificaciones) que la persona acepta o rechaza en la aplicación.

curl

Rutas y parámetros comprobados contra el contrato; no se han lanzado contra el servicio real, que aún no existe.

curl -X POST -H "Authorization: Bearer $ERJ_CLAVE" -H "Content-Type: application/json" \
  -H "Idempotency-Key: fichaje-per_1-2026-10-12-entrada" \
  -d '{"personaId":"per_1","tipo":"entrada"}' \
  "https://app.elregistrodejornada.com/api/v1/fichajes"

# Con la escritura apagada (hoy, en todas las empresas) responde 403 con "code": "escritura_no_habilitada".

TypeScript

Ejecutado contra un servidor local falso.

import { ClienteErj, ErrorApi } from './sdk/typescript/src/index.ts';

const cliente = new ClienteErj({ clave: process.env.ERJ_CLAVE!, urlBase: process.env.ERJ_URL_BASE });
const clave = 'fichaje-per_1-2026-10-12-entrada'; // una por intento; la MISMA al reintentar tras un corte
try {
  const asiento = await cliente.crearFichaje({ personaId: 'per_1', tipo: 'entrada' }, clave);
  console.log(asiento.id, asiento.secuencia);
} catch (e) {
  if (e instanceof ErrorApi && e.codigo === 'escritura_no_habilitada') console.log('La escritura está apagada en esta empresa.');
  else throw e;
}

Python

Ejecutado contra un servidor local falso.

import os
import sys
sys.path.insert(0, "sdk/python")
from erj import Cliente, ErrorApi

opciones = {"url_base": os.environ["ERJ_URL_BASE"]} if os.environ.get("ERJ_URL_BASE") else {}
cliente = Cliente(os.environ["ERJ_CLAVE"], **opciones)
clave = "fichaje-per_1-2026-10-12-entrada"  # una por intento; la MISMA al reintentar tras un corte
try:
    asiento = cliente.crear_fichaje({"personaId": "per_1", "tipo": "entrada"}, clave)
    print(asiento["id"], asiento.get("secuencia"))
except ErrorApi as e:
    if e.codigo != "escritura_no_habilitada":
        raise
    print("La escritura está apagada en esta empresa.")

C#

Escrito con la librería de C#, sin compilar ni ejecutar (no hay SDK de .NET en el equipo de desarrollo).

using System.Text.Json.Nodes;
using ElRegistroJornada;

var cliente = new ClienteErj(new OpcionesCliente { Clave = Environment.GetEnvironmentVariable("ERJ_CLAVE")! });
var clave = "fichaje-per_1-2026-10-12-entrada"; // una por intento; la MISMA al reintentar tras un corte
try
{
    var asiento = await cliente.CrearFichajeAsync(new JsonObject { ["personaId"] = "per_1", ["tipo"] = "entrada" }, clave);
    Console.WriteLine($"{asiento["id"]} {asiento["secuencia"]}");
}
catch (ErrorApi e) when (e.Codigo == "escritura_no_habilitada")
{
    Console.WriteLine("La escritura está apagada en esta empresa.");
}

PHP

Escrito con la librería de PHP, sin ejecutar (no hay intérprete de PHP en el equipo de desarrollo).

<?php
require 'sdk/php/src/ClienteErj.php';

use ElRegistroJornada\ClienteErj;
use ElRegistroJornada\ErrorApi;

$cliente = new ClienteErj(getenv('ERJ_CLAVE') ?: '');
$clave = 'fichaje-per_1-2026-10-12-entrada'; // una por intento; la MISMA al reintentar tras un corte
try {
    $asiento = $cliente->crearFichaje(['personaId' => 'per_1', 'tipo' => 'entrada'], $clave);
    echo $asiento['id'], ' ', $asiento['secuencia'] ?? '', "\n";
} catch (ErrorApi $e) {
    if ($e->codigo !== 'escritura_no_habilitada') { throw $e; }
    echo "La escritura está apagada en esta empresa.\n";
}

Informes: consultar los generados y pedir una exportación asíncrona

GET /informes lista los informes y exportaciones de la empresa con su estado y los metadatos del archivo. POST /informes pide una exportación de asientos como trabajo asíncrono (responde 202 sin esperar); se consulta hasta que termina y se descarga el CSV con un enlace firmado. Pedirla exige el ámbito informes:generar, que solo se concede en los planes de pago de la API (en el resto da 403 plan_sin_informes), con un tope diario por empresa; el informe lo hace el servidor en su pasada diaria, así que puede tardar hasta un día.

Ámbito de la clave: informes:leer (listar y consultar), informes:generar más fichajes:leer y personas:leer (generar y descargar; solo planes de pago de la API, desde el panel y nunca por OAuth).

  • El ámbito informes:generar solo existe en los planes de pago de la API. Y las escrituras de la API (POST /fichajes, /ausencias y /modificaciones) siguen apagadas en todas las empresas. Este caso de uso no las activa; los ejemplos de generar valen para cuando tu plan lo incluya.
  • POST /informes no modifica ningún dato (lee lo que la clave ya podría leer), pero Idempotency-Key es obligatoria: con la misma clave y los mismos datos se devuelve el mismo informe (200 e Idempotent-Replayed: true); con otros datos, 409 idempotency_key_reutilizada. La librería genera una si no la das y la mantiene en los reintentos.
  • Hoy solo existe tipo exportacion: los asientos entre desde y hasta (como mucho 366 días), en CSV y sin el documento de identidad. Una clave erj_test_ no genera (403 generacion_no_disponible_en_pruebas).
  • Estados: en_cola, en_proceso, terminado, fallido y caducado. La librería espera con intervalos crecientes (1 s, 2 s, 4 s... hasta 15 s) y un plazo (5 minutos por defecto); si acaba fallido o caducado lanza un error de informe, y si pasa el plazo, uno de espera agotada (el informe sigue en marcha). Cada consulta cuenta contra tus límites de uso.
  • Límite de informes sin terminar: 2 por clave en el plan Esencial y 3 en el Ampliada (y 3 entre todas las claves de la empresa); si no, 429 demasiados_informes_abiertos, que no trae Retry-After y la librería no reintenta.
  • El enlace de descarga vale 15 minutos como mucho, solo para la clave que generó el informe y solo contra la URL de la API, con su cabecera Authorization. Otra clave de tu empresa recibe 404 informe_no_encontrado. La librería lo usa solo contra su URL base.
  • Para comprobar el cumplimiento de un cuadrante o de unas jornadas sin guardar nada, existe POST /cumplimiento/validar (solo calcula): ver la referencia.

curl

Rutas y parámetros comprobados contra el contrato; no se han lanzado contra el servicio real, que aún no existe.

# 1) Los informes que ya existen (informes:leer):
curl -G -H "Authorization: Bearer $ERJ_CLAVE" \
  --data-urlencode "estado=terminado" \
  --data-urlencode "desde=2026-10-01" \
  "https://app.elregistrodejornada.com/api/v1/informes"

# 2) Pedir una exportación (informes:generar: hoy, 403 ambito_insuficiente). Responde 202 con "estado": "en_cola":
curl -X POST -H "Authorization: Bearer $ERJ_CLAVE" -H "Content-Type: application/json" \
  -H "Idempotency-Key: exportacion-2026-10" \
  -d '{"tipo":"exportacion","desde":"2026-10-01","hasta":"2026-10-31"}' \
  "https://app.elregistrodejornada.com/api/v1/informes"

# 3) Consultar hasta que "estado" sea "terminado"; entonces trae "descarga" (enlace firmado, 15 minutos):
curl -H "Authorization: Bearer $ERJ_CLAVE" "https://app.elregistrodejornada.com/api/v1/informes/inf_1"

# 4) Descargar el CSV con ese enlace y la MISMA clave (otra clave recibe 404):
curl -H "Authorization: Bearer $ERJ_CLAVE" -o exportacion-2026-10.csv \
  "https://app.elregistrodejornada.com<valor del campo descarga>"

TypeScript

Ejecutado contra un servidor local falso.

import { writeFile } from 'node:fs/promises';
import { ClienteErj, ErrorApi, ErrorEsperaAgotada, ErrorInforme } from './sdk/typescript/src/index.ts';

const cliente = new ClienteErj({ clave: process.env.ERJ_CLAVE!, urlBase: process.env.ERJ_URL_BASE });

// 1) Los informes que ya existen (informes:leer).
for await (const informe of cliente.informes.iterar({ estado: 'terminado', desde: '2026-10-01' })) {
  console.log(informe.id, informe.tipo, informe.estado);
}

// 2) Pedir una exportación, esperar a que termine y descargar el CSV (informes:generar; solo planes de pago).
const clave = 'exportacion-2026-10'; // una por informe que quieres; la MISMA al reintentar tras un corte
try {
  const creado = await cliente.generarInforme({ tipo: 'exportacion', desde: '2026-10-01', hasta: '2026-10-31' }, clave);
  const listo = await cliente.esperarInforme(creado, { plazoMs: 120_000 });
  await writeFile('exportacion-2026-10.csv', await cliente.descargarInforme(listo));
  console.log('CSV guardado:', listo.archivo?.nombre);
} catch (e) {
  if (e instanceof ErrorApi && e.codigo === 'ambito_insuficiente') console.log('Esta clave no tiene informes:generar (solo en planes de pago de la API).');
  else if (e instanceof ErrorApi && e.codigo === 'demasiados_informes_abiertos') console.log('Hay demasiados informes sin terminar: espera a que acabe alguno.');
  else if (e instanceof ErrorEsperaAgotada) console.log('Sigue en marcha; vuelve a esperarlo con el id', e.informe.id);
  else if (e instanceof ErrorInforme) console.log('No se puede entregar:', e.codigo);
  else throw e;
}

Python

Ejecutado contra un servidor local falso.

import os
import sys
sys.path.insert(0, "sdk/python")
from erj import Cliente, ErrorApi, ErrorEsperaAgotada, ErrorInforme

opciones = {"url_base": os.environ["ERJ_URL_BASE"]} if os.environ.get("ERJ_URL_BASE") else {}
cliente = Cliente(os.environ["ERJ_CLAVE"], **opciones)

# 1) Los informes que ya existen (informes:leer).
for informe in cliente.informes.iterar(estado="terminado", desde="2026-10-01"):
    print(informe["id"], informe["tipo"], informe["estado"])

# 2) Pedir una exportación, esperar a que termine y descargar el CSV (informes:generar; solo planes de pago).
clave = "exportacion-2026-10"  # una por informe que quieres; la MISMA al reintentar tras un corte
try:
    creado = cliente.generar_informe({"tipo": "exportacion", "desde": "2026-10-01", "hasta": "2026-10-31"}, clave)
    listo = cliente.esperar_informe(creado, plazo=120)
    with open("exportacion-2026-10.csv", "w", encoding="utf-8", newline="") as f:
        f.write(cliente.descargar_informe(listo))
    print("CSV guardado:", listo["archivo"]["nombre"])
except ErrorApi as e:
    if e.codigo == "ambito_insuficiente":
        print("Esta clave no tiene informes:generar (solo en planes de pago de la API).")
    elif e.codigo == "demasiados_informes_abiertos":
        print("Hay demasiados informes sin terminar: espera a que acabe alguno.")
    else:
        raise
except ErrorEsperaAgotada as e:
    print("Sigue en marcha; vuelve a esperarlo con el id", e.informe["id"])
except ErrorInforme as e:
    print("No se puede entregar:", e.codigo)

C#

Escrito con la librería de C#, sin compilar ni ejecutar (no hay SDK de .NET en el equipo de desarrollo).

using System.Text.Json.Nodes;
using ElRegistroJornada;

var cliente = new ClienteErj(new OpcionesCliente { Clave = Environment.GetEnvironmentVariable("ERJ_CLAVE")! });

// 1) Los informes que ya existen (informes:leer).
await foreach (var informe in cliente.Informes.IterarAsync(new Dictionary<string, string?> { ["estado"] = "terminado", ["desde"] = "2026-10-01" }))
    Console.WriteLine($"{informe["id"]} {informe["tipo"]} {informe["estado"]}");

// 2) Pedir una exportación, esperar a que termine y descargar el CSV (informes:generar; solo planes de pago).
var clave = "exportacion-2026-10"; // una por informe que quieres; la MISMA al reintentar tras un corte
try
{
    var creado = await cliente.GenerarInformeAsync(new JsonObject { ["tipo"] = "exportacion", ["desde"] = "2026-10-01", ["hasta"] = "2026-10-31" }, clave);
    var listo = await cliente.EsperarInformeAsync(creado, new OpcionesEspera { Plazo = TimeSpan.FromMinutes(2) });
    await File.WriteAllTextAsync("exportacion-2026-10.csv", await cliente.DescargarInformeAsync(listo));
    Console.WriteLine($"CSV guardado: {listo["archivo"]?["nombre"]}");
}
catch (ErrorApi e) when (e.Codigo == "ambito_insuficiente")
{
    Console.WriteLine("Esta clave no tiene informes:generar (solo en planes de pago de la API).");
}
catch (ErrorApi e) when (e.Codigo == "demasiados_informes_abiertos")
{
    Console.WriteLine("Hay demasiados informes sin terminar: espera a que acabe alguno.");
}
catch (ErrorEsperaAgotada e)
{
    Console.WriteLine($"Sigue en marcha; vuelve a esperarlo con el id {e.Informe["id"]}");
}
catch (ErrorInforme e)
{
    Console.WriteLine($"No se puede entregar: {e.Codigo}");
}

PHP

Escrito con la librería de PHP, sin ejecutar (no hay intérprete de PHP en el equipo de desarrollo).

<?php
require 'sdk/php/src/ClienteErj.php';

use ElRegistroJornada\ClienteErj;
use ElRegistroJornada\ErrorApi;
use ElRegistroJornada\ErrorEsperaAgotada;
use ElRegistroJornada\ErrorInforme;

$cliente = new ClienteErj(getenv('ERJ_CLAVE') ?: '');

// 1) Los informes que ya existen (informes:leer).
foreach ($cliente->informes->iterar(['estado' => 'terminado', 'desde' => '2026-10-01']) as $informe) {
    echo $informe['id'], ' ', $informe['tipo'], ' ', $informe['estado'], "\n";
}

// 2) Pedir una exportación, esperar a que termine y descargar el CSV (informes:generar; solo planes de pago).
$clave = 'exportacion-2026-10'; // una por informe que quieres; la MISMA al reintentar tras un corte
try {
    $creado = $cliente->generarInforme(['tipo' => 'exportacion', 'desde' => '2026-10-01', 'hasta' => '2026-10-31'], $clave);
    $listo = $cliente->esperarInforme($creado, 120.0);
    file_put_contents('exportacion-2026-10.csv', $cliente->descargarInforme($listo));
    echo 'CSV guardado: ', $listo['archivo']['nombre'] ?? '', "\n";
} catch (ErrorApi $e) {
    if ($e->codigo === 'ambito_insuficiente') { echo "Esta clave no tiene informes:generar (solo en planes de pago de la API).\n"; }
    elseif ($e->codigo === 'demasiados_informes_abiertos') { echo "Hay demasiados informes sin terminar: espera a que acabe alguno.\n"; }
    else { throw $e; }
} catch (ErrorEsperaAgotada $e) {
    echo 'Sigue en marcha; vuelve a esperarlo con el id ', $e->informe['id'], "\n";
} catch (ErrorInforme $e) {
    echo 'No se puede entregar: ', $e->codigo, "\n";
}

Recibir webhooks y verificar la firma

Las suscripciones se crean en la aplicación (Más → Integraciones); la API no tiene rutas para gestionarlas. Cada entrega lleva ERJ-Timestamp y ERJ-Signature (v1= más el HMAC-SHA256 de «timestamp.cuerpo» con el secreto del webhook). Verifica siempre sobre el cuerpo exacto recibido.

Ámbito de la clave: Ninguno: es el servidor quien llama a tu URL (https, puerto 443).

  • Rechaza lo que tenga más de 300 segundos y deduplica por ERJ-Event-Id: la entrega es «al menos una vez».
  • Responde 2xx enseguida. Cualquier otra respuesta se reintenta hasta siete veces dentro de las 24 horas siguientes.
  • El secreto va en una variable de entorno, nunca en el repositorio. Los eventos llevan solo identificadores y fechas.

curl

Rutas y parámetros comprobados contra el contrato; no se han lanzado contra el servicio real, que aún no existe.

# Simula una entrega a TU receptor local (no a nuestro servidor) con una firma válida:
export ERJ_WEBHOOK_SECRET="whsec_ejemplo_no_usar_en_produccion"
CUERPO='{"id":"evt_1","tipo":"fichaje.creado","empresaId":"emp_1","creadoEn":"2026-10-12T08:00:00.000Z","datos":{}}'
TS=$(date +%s)
FIRMA="v1=$(printf '%s.%s' "$TS" "$CUERPO" | openssl dgst -sha256 -hmac "$ERJ_WEBHOOK_SECRET" -hex | sed 's/^.* //')"
curl -X POST http://localhost:8080/webhooks/erj \
  -H "Content-Type: application/json" -H "ERJ-Timestamp: $TS" -H "ERJ-Signature: $FIRMA" \
  -d "$CUERPO"

TypeScript

Ejecutado contra un servidor local falso.

import { verificarWebhook } from './sdk/typescript/src/index.ts';

// `cuerpo` es el texto EXACTO recibido; `cabeceras` las de la petición, con los nombres en minúsculas (Node).
export function comprobarEntrega(cuerpo: string, cabeceras: Record<string, string | undefined>) {
  const r = verificarWebhook(process.env.ERJ_WEBHOOK_SECRET!, { timestamp: cabeceras["erj-timestamp"], firma: cabeceras["erj-signature"] }, cuerpo);
  if (!r.ok) throw new Error(`Entrega rechazada: ${r.motivo}`); // sin_cabeceras | timestamp_invalido | fuera_de_tolerancia | firma_no_valida
  return JSON.parse(cuerpo); // deduplica por evento.id y responde 200 enseguida
}

Python

Ejecutado contra un servidor local falso.

import json
import os
import sys
sys.path.insert(0, "sdk/python")
from erj import verificar_webhook


def comprobar_entrega(cuerpo: bytes, cabeceras: dict):
    """`cuerpo` son los bytes EXACTOS recibidos; `cabeceras` las de la petición."""
    r = verificar_webhook(os.environ["ERJ_WEBHOOK_SECRET"], cabeceras.get("ERJ-Timestamp"), cabeceras.get("ERJ-Signature"), cuerpo)
    if not r.ok:
        raise ValueError(f"Entrega rechazada: {r.motivo}")  # sin_cabeceras | timestamp_invalido | fuera_de_tolerancia | firma_no_valida
    return json.loads(cuerpo)  # deduplica por evento["id"] y responde 200 enseguida

C#

Escrito con la librería de C#, sin compilar ni ejecutar (no hay SDK de .NET en el equipo de desarrollo).

using ElRegistroJornada;

// En una API mínima de ASP.NET Core; `cuerpo` es el texto EXACTO recibido, sin deserializar antes.
var r = Webhooks.Verificar(Environment.GetEnvironmentVariable("ERJ_WEBHOOK_SECRET")!, Request.Headers["ERJ-Timestamp"], Request.Headers["ERJ-Signature"], cuerpo);
if (!r.Ok) return Results.BadRequest(r.Motivo); // sin_cabeceras | timestamp_invalido | fuera_de_tolerancia | firma_no_valida
// deduplica por el id del evento y responde 200 enseguida

PHP

Escrito con la librería de PHP, sin ejecutar (no hay intérprete de PHP en el equipo de desarrollo).

<?php
require 'sdk/php/src/ClienteErj.php';

use ElRegistroJornada\Webhooks;

$cuerpo = file_get_contents('php://input'); // el texto EXACTO, sin json_decode antes
$r = Webhooks::verificar(getenv('ERJ_WEBHOOK_SECRET') ?: '', $_SERVER['HTTP_ERJ_TIMESTAMP'] ?? null, $_SERVER['HTTP_ERJ_SIGNATURE'] ?? null, $cuerpo);
if (!$r->ok) { http_response_code(400); exit($r->motivo); } // sin_cabeceras | timestamp_invalido | fuera_de_tolerancia | firma_no_valida
// deduplica por el id del evento y responde 200 enseguida
http_response_code(200);

Qué falta

Lo que todavía no existe

  • Servicio abierto y entorno de pruebas con dominio propio.
  • Alta, modificación y baja de personas por API.
  • Librerías publicadas en los gestores de paquetes; la de C# y la de PHP, sin ejecutar.
  • Generar informes y descargar su archivo por API con una clave real: el ámbito informes:generar existe, pero no se concede todavía a ninguna clave (es decisión del titular). Las librerías ya traen los métodos y se prueban contra un servidor falso.