Webhooks y sincronización
Un webhook es un aviso HTTP que Boletura envía a tu sistema cuando cambia el estado de un comprobante, para que no tengas que preguntar por cada uno. También puedes trabajar sin webhooks, sincronizando por consulta, y conviene hacerlo de respaldo.
Sincronizar por consulta
Sección titulada «Sincronizar por consulta»Para muchos RUC, sincroniza de forma incremental en vez de consultar cada UUID: GET /v1/documents con order=updated_at, updated_from y, si quieres, updated_to y company_id. Guarda los UUID ya vistos y avanza la marca solo después de procesar todas las páginas. Solapa dos minutos para absorber cambios concurrentes: en tu sistema, updated_from es tu último updated_at menos dos minutos. Con la boleta de Primeros pasos basta su creación:
UPDATED_FROM=$(campo boleta-respuesta.json created_at)curl -sS -G -o cambios.json -w '%{http_code}\n' \ -H "Authorization: Bearer $BOLETURA_API_KEY" \ --data-urlencode 'order=updated_at' --data-urlencode "updated_from=$UPDATED_FROM" \ --data-urlencode 'limit=200' "$BOLETURA_BASE_URL/v1/documents"python -m json.tool cambios.jsonEspera 200. Sigue next_cursor con los mismos filtros y el mismo orden; no reutilices el cursor de otro listado. Deduplica por id, actualizando solo con un updated_at igual o posterior. Tolera campos, códigos y estados nuevos en las respuestas.
Registrar un endpoint
Sección titulada «Registrar un endpoint»En el back office, en Configuración → Webhooks, con el modo pruebas activo, registra una URL HTTPS pública de tu servidor y los eventos que quieres (sin ninguno marcado, recibe todos). No se aceptan localhost, redes privadas ni la propia plataforma (app.boletura.com y api.boletura.com). La API key no crea endpoints: lo hace un admin en el back office. Guarda el secreto whsec_… cuando aparece: no se vuelve a mostrar y no es la API key. Los webhooks de test y de live se registran por separado, cada uno con su secreto.
Eventos: document.accepted, document.accepted_with_observations, document.rejected, document.voided, document.void_rejected y document.deadline_missed.
Para comprobar el receptor sin emitir, pulsa Probar en la pantalla del endpoint: llega un webhook.test firmado igual que los demás, con data vacío y sin comprobante, y la pantalla muestra el código y la duración de la respuesta. Respóndele 2xx como a cualquier evento, sin procesarlo como un comprobante; sirve también en live. Para probar el recorrido completo, emite después una factura con SIMULAR OBSERVACIONES. Cada entrega se lista con su comprobante; una que sigue intentándose figura como «Reintentando», con el tipo de error en Respuesta, y Ver intentos muestra cada intento con su hora, su duración, su resultado y el cuerpo enviado. Una entrega fallida se puede Reenviar: sale una vez, con el mismo cuerpo y el mismo X-Boletura-Delivery. Probar y reenviar suman hasta 10 por minuto por cuenta.
Qué llega
Sección titulada «Qué llega»POST /tu-ruta HTTP/1.1Content-Type: application/jsonX-Boletura-Event: document.acceptedX-Boletura-Delivery: 0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0dX-Boletura-Timestamp: 1793433600X-Boletura-Environment: testX-Boletura-Signature: v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1bEl cuerpo tiene id (el de la entrega), event, created_at y data.document, el documento completo, como lo devuelve GET /v1/documents/{id}. data.document es el documento al publicarse el aviso, segundos después del evento: si en ese lapso volvió a cambiar, el aviso ya lo muestra con el estado nuevo, y el segundo cambio llega en su propio aviso. El estado vigente es siempre el de data.document, y event dice qué cambio originó el aviso.
Qué hace tu receptor
Sección titulada «Qué hace tu receptor»- Conserva los bytes originales del cuerpo y verifica
X-Boletura-Timestamp(hasta 5 minutos de diferencia de reloj) yX-Boletura-Signatureantes de leer el JSON. La firma esv1=seguido del HMAC-SHA256 en hexadecimal detimestamp + "." + cuerpo, con el secreto completo en UTF-8 como clave. Compara en tiempo constante. - Comprueba
X-Boletura-Environmenty guarda el aviso de forma duradera, deduplicando porX-Boletura-Delivery. Responde 2xx en menos de 10 segundos y procésalo después. Un duplicado ya guardado también responde 2xx. - Aplica el estado de
data.document, sin suponer que coincide con el nombre del evento ni que los avisos llegan en orden. No sobrescribas un estado más reciente; ante la duda, consulta el UUID.
Cada reintento conserva el cuerpo y el identificador de entrega, y lleva un timestamp firmado nuevo. Toda respuesta que no sea 2xx, un tiempo de espera o una falla de red se reintenta durante 24 horas (1, 5, 15 y 30 minutos; 1, 2 y 4 horas; luego cada 6 horas); no se siguen redirecciones. Desde que una entrega llega a su séptimo intento, unas 4 horas después del primero, y hasta que se agota, cada intento fallido puede avisar a los owner y admin de la cuenta con un correo, como máximo uno por endpoint al día. Pasado ese plazo, la sincronización por consulta recupera los cambios, y una entrega concreta se puede reenviar. Un reenvío repite el estado que tenía el documento cuando se generó la entrega: por eso el paso 3 no sobrescribe un estado más reciente.
delivery_status del documento resume las entregas de su último evento: pending mientras alguna sigue en curso, failed si alguna se agotó o su endpoint se deshabilitó, delivered si todas llegaron, y none mientras no haya ninguna.
Verificar la firma en tu lenguaje
Sección titulada «Verificar la firma en tu lenguaje»Cada programa trae una función para tu receptor y se prueba con un aviso de ejemplo y su firma real: la firma válida pasa y se rechazan un cuerpo alterado, otro secreto y una firma vencida. Al ejecutarlo imprime Todo correcto. El reloj fijo es solo para que la prueba se pueda repetir: en tu receptor usa el reloj actual, tu secreto y las cabeceras que llegaron. Solo usan la biblioteca estándar de su lenguaje.
#!/bin/sh# Calcula la firma de un aviso con openssl, para depurar un receptor desde la terminal. No sirve como receptor: la# comparación no es de tiempo constante y no revisa la diferencia de reloj.set -eu
# Aviso de ejemplo con su firma real. En tu caso: el cuerpo tal como llegó, tu secreto whsec_… y las cabeceras# X-Boletura-Timestamp y X-Boletura-Signature.body='{"id":"0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0d","event":"document.accepted","created_at":"2026-11-09T15:00:00.000000Z","data":{"document":{"customer":{"name":"José Pérez"}}}}'secret='whsec_ejemplo_solo_para_pruebas'timestamp='1793433600'signature='v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1b'
expected="v1=$(printf '%s.%s' "$timestamp" "$body" | openssl dgst -sha256 -hmac "$secret" | sed 's/.*= //')"if [ "$expected" != "$signature" ]; then echo "La firma no coincide: se esperaba $expected" >&2 exit 1fiecho "Todo correcto: la firma coincide"<?php// Verifica la firma de un webhook de Boletura y se prueba con un aviso de ejemplo.// PHP 8.1 o superior: php verify-webhook.php. En tu receptor, llama a verifySignature con el cuerpo tal como llegó// (file_get_contents('php://input')), tu secreto whsec_… y las cabeceras X-Boletura-Timestamp y X-Boletura-Signature,// antes de leer el JSON.
const TOLERANCE_SECONDS = 300;
function verifySignature(string $body, string $secret, string $timestamp, string $signature, ?int $now = null): bool{ if (!preg_match('/^[0-9]{1,12}$/D', $timestamp) || !preg_match('/^v1=[0-9a-f]{64}$/D', $signature)) { return false; } if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) { return false; } $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret); return hash_equals($expected, $signature);}
// Aviso de ejemplo con su firma real; el reloj fijo es solo para que la prueba se pueda repetir.$body = '{"id":"0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0d","event":"document.accepted",' . '"created_at":"2026-11-09T15:00:00.000000Z","data":{"document":{"customer":{"name":"José Pérez"}}}}';$secret = 'whsec_ejemplo_solo_para_pruebas';$timestamp = '1793433600';$signature = 'v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1b';$now = (int) $timestamp;
$checks = [ 'la firma válida pasa' => verifySignature($body, $secret, $timestamp, $signature, $now), 'un cuerpo alterado no pasa' => !verifySignature($body . "\n", $secret, $timestamp, $signature, $now), 'otro secreto no pasa' => !verifySignature($body, 'whsec_otro', $timestamp, $signature, $now), 'una firma vencida no pasa' => !verifySignature($body, $secret, $timestamp, $signature, $now + 301),];$failed = array_keys(array_filter($checks, fn (bool $passed): bool => !$passed));if ($failed !== []) { fwrite(STDERR, 'Falló: ' . implode(', ', $failed) . "\n"); exit(1);}echo 'Todo correcto: ' . json_decode($body, true)['event'] . "\n";"""Verifica la firma de un webhook de Boletura y se prueba con un aviso de ejemplo.
Python 3.9 o superior: python verify_webhook.py. En tu receptor, llama a verify_signature con el cuerpo tal como llegó(bytes), tu secreto whsec_… y las cabeceras X-Boletura-Timestamp y X-Boletura-Signature, antes de leer el JSON."""
import hashlibimport hmacimport jsonimport reimport sysimport time
TOLERANCE_SECONDS = 300
def verify_signature(body, secret, timestamp, signature, now=None): if not re.fullmatch(r"[0-9]{1,12}", timestamp) or not re.fullmatch(r"v1=[0-9a-f]{64}", signature): return False current = time.time() if now is None else now if abs(current - int(timestamp)) > TOLERANCE_SECONDS: return False signed = timestamp.encode("ascii") + b"." + body expected = "v1=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)
if __name__ == "__main__": # Aviso de ejemplo con su firma real; el reloj fijo es solo para que la prueba se pueda repetir. body = ( '{"id":"0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0d","event":"document.accepted",' '"created_at":"2026-11-09T15:00:00.000000Z","data":{"document":{"customer":{"name":"José Pérez"}}}}' ).encode("utf-8") secret = "whsec_ejemplo_solo_para_pruebas" timestamp = "1793433600" signature = "v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1b" now = int(timestamp)
assert verify_signature(body, secret, timestamp, signature, now), "la firma válida no pasó" assert not verify_signature(body + b"\n", secret, timestamp, signature, now), "pasó un cuerpo alterado" assert not verify_signature(body, "whsec_otro", timestamp, signature, now), "pasó otro secreto" assert not verify_signature(body, secret, timestamp, signature, now + 301), "pasó una firma vencida" print("Todo correcto:", json.loads(body)["event"])// Verifica la firma de un webhook de Boletura y se prueba con un aviso de ejemplo.// .NET 10: dotnet run verify-webhook.cs. En tu receptor, llama a VerifySignature con el cuerpo tal como llegó (bytes),// tu secreto whsec_… y las cabeceras X-Boletura-Timestamp y X-Boletura-Signature, antes de leer el JSON.using System.Security.Cryptography;using System.Text;using System.Text.Json;using System.Text.RegularExpressions;
// Aviso de ejemplo con su firma real; el reloj fijo es solo para que la prueba se pueda repetir.byte[] body = Encoding.UTF8.GetBytes( """{"id":"0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0d","event":"document.accepted","created_at":"2026-11-09T15:00:00.000000Z","data":{"document":{"customer":{"name":"José Pérez"}}}}""");string secret = "whsec_ejemplo_solo_para_pruebas";string timestamp = "1793433600";string signature = "v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1b";long now = long.Parse(timestamp);
Dictionary<string, bool> checks = new(){ ["la firma válida pasa"] = WebhookSignature.Verify(body, secret, timestamp, signature, now), ["un cuerpo alterado no pasa"] = !WebhookSignature.Verify([.. body, (byte)'\n'], secret, timestamp, signature, now), ["otro secreto no pasa"] = !WebhookSignature.Verify(body, "whsec_otro", timestamp, signature, now), ["una firma vencida no pasa"] = !WebhookSignature.Verify(body, secret, timestamp, signature, now + 301),};string[] failed = [.. checks.Where(check => !check.Value).Select(check => check.Key)];if (failed.Length > 0){ Console.Error.WriteLine($"Falló: {string.Join(", ", failed)}"); return 1;}Console.WriteLine($"Todo correcto: {JsonDocument.Parse(body).RootElement.GetProperty("event")}");return 0;
static class WebhookSignature{ private const long ToleranceSeconds = 300;
public static bool Verify(byte[] body, string secret, string timestamp, string signature, long? now = null) { // \z y no $: en .NET, $ también acepta un salto de línea al final. if (!Regex.IsMatch(timestamp, @"^[0-9]{1,12}\z") || !Regex.IsMatch(signature, @"^v1=[0-9a-f]{64}\z")) { return false; } long current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(current - long.Parse(timestamp)) > ToleranceSeconds) { return false; } byte[] signed = [.. Encoding.ASCII.GetBytes(timestamp + "."), .. body]; string expected = "v1=" + Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed)); return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature)); }}// Verifica la firma de un webhook de Boletura y se prueba con un aviso de ejemplo.// Java 11 o superior: java VerifyWebhook.java. En tu receptor, llama a verifySignature con el cuerpo tal como llegó// (bytes), tu secreto whsec_… y las cabeceras X-Boletura-Timestamp y X-Boletura-Signature, antes de leer el JSON.import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.Instant;import java.util.LinkedHashMap;import java.util.Map;import java.util.stream.Collectors;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public class VerifyWebhook { private static final long TOLERANCE_SECONDS = 300;
public static boolean verifySignature(byte[] body, String secret, String timestamp, String signature, long now) throws Exception { if (!timestamp.matches("[0-9]{1,12}") || !signature.matches("v1=[0-9a-f]{64}")) { return false; } if (Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) { return false; } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.US_ASCII)); String expected = "v1=" + toHex(mac.doFinal(body)); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.US_ASCII), signature.getBytes(StandardCharsets.US_ASCII)); }
public static boolean verifySignature(byte[] body, String secret, String timestamp, String signature) throws Exception { return verifySignature(body, secret, timestamp, signature, Instant.now().getEpochSecond()); }
private static String toHex(byte[] bytes) { StringBuilder hex = new StringBuilder(bytes.length * 2); for (byte value : bytes) { hex.append(String.format("%02x", value)); } return hex.toString(); }
public static void main(String[] args) throws Exception { // Aviso de ejemplo con su firma real; el reloj fijo es solo para que la prueba se pueda repetir. byte[] body = ("{\"id\":\"0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0d\",\"event\":\"document.accepted\"," + "\"created_at\":\"2026-11-09T15:00:00.000000Z\"," + "\"data\":{\"document\":{\"customer\":{\"name\":\"José Pérez\"}}}}").getBytes(StandardCharsets.UTF_8); String secret = "whsec_ejemplo_solo_para_pruebas"; String timestamp = "1793433600"; String signature = "v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1b"; long now = Long.parseLong(timestamp); byte[] altered = (new String(body, StandardCharsets.UTF_8) + "\n").getBytes(StandardCharsets.UTF_8);
Map<String, Boolean> checks = new LinkedHashMap<>(); checks.put("la firma válida pasa", verifySignature(body, secret, timestamp, signature, now)); checks.put("un cuerpo alterado no pasa", !verifySignature(altered, secret, timestamp, signature, now)); checks.put("otro secreto no pasa", !verifySignature(body, "whsec_otro", timestamp, signature, now)); checks.put("una firma vencida no pasa", !verifySignature(body, secret, timestamp, signature, now + 301)); String failed = checks.entrySet().stream() .filter(check -> !check.getValue()) .map(Map.Entry::getKey) .collect(Collectors.joining(", ")); if (!failed.isEmpty()) { System.err.println("Falló: " + failed); System.exit(1); } System.out.println("Todo correcto: document.accepted"); }}// Verifica la firma de un webhook de Boletura y se prueba con un aviso de ejemplo.// Node 24 o superior: node verify-webhook.ts. En tu receptor, llama a verifySignature con el cuerpo tal como llegó// (Buffer), tu secreto whsec_… y las cabeceras X-Boletura-Timestamp y X-Boletura-Signature, antes de leer el JSON.import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifySignature( body: Buffer, secret: string, timestamp: string, signature: string, now: number = Date.now() / 1000,): boolean { if (!/^[0-9]{1,12}$/.test(timestamp) || !/^v1=[0-9a-f]{64}$/.test(signature)) return false if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false const expected = `v1=${createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')}` return timingSafeEqual(Buffer.from(expected), Buffer.from(signature))}
// Aviso de ejemplo con su firma real; el reloj fijo es solo para que la prueba se pueda repetir.const body = Buffer.from( '{"id":"0192f2c4-7d1e-7a3b-9c4d-5e6f7a8b9c0d","event":"document.accepted",' + '"created_at":"2026-11-09T15:00:00.000000Z","data":{"document":{"customer":{"name":"José Pérez"}}}}', 'utf8',)const secret = 'whsec_ejemplo_solo_para_pruebas'const timestamp = '1793433600'const signature = 'v1=64f3ebf3712824645dc472174ac6470783f6a7a0e778f208f10307a35f5ada1b'const now = Number(timestamp)
const checks: Record<string, boolean> = { 'la firma válida pasa': verifySignature(body, secret, timestamp, signature, now), 'un cuerpo alterado no pasa': !verifySignature(Buffer.concat([body, Buffer.from('\n')]), secret, timestamp, signature, now), 'otro secreto no pasa': !verifySignature(body, 'whsec_otro', timestamp, signature, now), 'una firma vencida no pasa': !verifySignature(body, secret, timestamp, signature, now + 301),}const failed = Object.entries(checks) .filter(([, passed]) => !passed) .map(([name]) => name)if (failed.length > 0) { console.error(`Falló: ${failed.join(', ')}`) process.exit(1)}console.log(`Todo correcto: ${JSON.parse(body.toString('utf8')).event}`)La versión de terminal solo calcula la firma con openssl, para depurar: no compara en tiempo constante ni revisa la diferencia de reloj. La prueba verifica la firma y su vencimiento; no reemplaza recibir una entrega real en tu servidor.