Webhooks: eventos
La API te avisa de los cambios de estado de la operacion con un webhook: una
peticion HTTP POST a la URL que configures. Cada envio va firmado para que
puedas comprobar que es autentico.
Configura la URL y el secreto en el portal, seccion Webhooks. El secreto se muestra al crearlo; guardalo para validar la firma.
Entrega#
- Metodo
POST,Content-Type: application/json. - Cabecera de firma:
Ecuafact-Signature: t=<epoch_segundos>,v1=<hmac_sha256>. El campotes el momento del envio yv1la firma. - Reintentos con espera escalonada: 1, 2, 5, 15, 30, 60, 120 y 240 minutos, hasta 8 intentos.
- Agotados los 8 intentos, el evento queda registrado como no entregado.
- Tras 8 fallos consecutivos el destino se pausa; un
2xxno reanuda un destino pausado. - La reentrega de un mismo evento conserva
resourceIdy contenido; cambia el timestamp y la firma.
Responde
2xxsolo despues de persistir el evento. Un timeout se considera fallo y provoca reintento.
Garantias de entrega#
- Entrega al menos una vez. Un mismo evento puede entregarse mas de una vez
(reintento o reenvio manual). Deduplica por
eventType+resourceId; eloccurredAtUtcse conserva igual en las reentregas. - Sin orden garantizado. Los eventos pueden llegar desordenados. Si necesitas el estado actual, consulta el seguimiento en lugar de asumir el ultimo recibido.
- Reintento y reenvio. Hasta 8 intentos con la escalera de espera de
## Entrega. Un destino puede reenviar una entrega ya registrada; conservaresourceIdy cuerpo, y cambia el timestamp y la firma. - Responde rapido. Persiste y responde
2xx; procesa en segundo plano. Un timeout se trata como fallo y reintenta. - Un
2xxno reanuda un destino pausado. La pausa por fallos consecutivos se reanuda desde el portal.
Cuerpo#
El cuerpo es un objeto JSON con el tipo de evento, su identificacion y los datos del documento:
{
"eventType": "document.authorized",
"resourceId": "3f1c8c1e-6f2a-4b7e-9c3d-2a1b0c9d8e7f",
"occurredAtUtc": "2026-09-07T15:04:05.1234567Z",
"idOperacion": "3f1c8c1e-6f2a-4b7e-9c3d-2a1b0c9d8e7f",
"estado": "authorized",
"claveAcceso": "0709202601179212345600110010020000001231234567818"
}
Todos los valores del cuerpo son cadenas. document.authorized y
document.rejected incluyen ademas idOperacion, estado y claveAcceso;
document.failed agrega motivo y, si aplica, codigoError.
| Campo | Significado |
|---|---|
eventType |
Tipo de evento (ver catalogo) |
resourceId |
Identificador del recurso del evento |
occurredAtUtc |
Momento del hecho (UTC, ISO 8601) |
idOperacion |
Identificador de seguimiento de la operacion |
estado |
Resultado: authorized, rejected o failed |
claveAcceso |
Clave de acceso del comprobante |
motivo |
Solo en document.failed: estructura_invalida, entrega_no_posible o resultado_desconocido |
codigoError |
Solo en document.failed: codigo del error asociado |
El orden de las claves puede cambiar y pueden agregarse campos nuevos. Valida siempre la firma antes de interpretar el cuerpo.
Catalogo de eventos#
| Evento | Cuando | motivo |
|---|---|---|
document.authorized |
El SRI autorizo el comprobante | - |
document.rejected |
El SRI rechazo el comprobante | - |
document.failed |
No se pudo emitir: estructura invalida, entrega no posible o resultado desconocido | estructura_invalida, entrega_no_posible, resultado_desconocido |
Solo se entregan los eventos de esta tabla. Si recibes un eventType que no
conoces, ignoralo sin detener el procesamiento.
document.failed significa que el comprobante no llego a autorizarse por una
causa de la plataforma o del propio documento, no que el SRI lo haya rechazado.
Implementacion en tu servidor#
Recibe el POST, lee el cuerpo crudo (tal como llego), verifica la firma
y responde 2xx despues de persistir. Cada ejemplo incluye la verificacion y la
respuesta.
Node.js (Express)#
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/webhooks/ecuafact',
express.raw({ type: 'application/json' }), // cuerpo crudo, sin parsear
(req, res) => {
const cabecera = req.header('Ecuafact-Signature') || '';
const cuerpo = req.body.toString('utf8');
if (!verificar(process.env.ECUAFACT_WEBHOOK_SECRET, cabecera, cuerpo)) {
return res.sendStatus(401);
}
const evento = JSON.parse(cuerpo);
switch (evento.eventType) {
case 'document.authorized': /* persistir autorizacion */ break;
case 'document.rejected': /* persistir rechazo */ break;
default: /* no-op */ break;
}
res.sendStatus(200);
});
function verificar(secreto, cabecera, cuerpoCrudo, toleranciaSegundos = 300) {
const partes = Object.fromEntries(cabecera.split(',').map((p) => p.split('=')));
if (!partes.t || !partes.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(partes.t)) > toleranciaSegundos) return false;
const esperado = crypto.createHmac('sha256', secreto).update(`${partes.t}.${cuerpoCrudo}`, 'utf8').digest('hex');
const a = Buffer.from(esperado);
const b = Buffer.from(partes.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python (Flask)#
import hashlib, hmac, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRETO = os.environ["ECUAFACT_WEBHOOK_SECRET"]
def verificar(cabecera, cuerpo_crudo, tolerancia=300):
partes = dict(p.split("=", 1) for p in cabecera.split(",") if "=" in p)
t, v1 = partes.get("t"), partes.get("v1")
if not t or not v1 or abs(int(time.time()) - int(t)) > tolerancia:
return False
esperado = hmac.new(SECRETO.encode(), f"{t}.{cuerpo_crudo}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, v1)
@app.post("/webhooks/ecuafact")
def webhook():
cuerpo_crudo = request.get_data(as_text=True) # cuerpo crudo
if not verificar(request.headers.get("Ecuafact-Signature", ""), cuerpo_crudo):
abort(401)
evento = request.get_json()
if evento.get("eventType") == "document.authorized":
pass # persistir autorizacion
return "", 200
PHP#
<?php
$secreto = getenv('ECUAFACT_WEBHOOK_SECRET');
$cuerpoCrudo = file_get_contents('php://input'); // cuerpo crudo
$cabecera = $_SERVER['HTTP_ECUAFACT_SIGNATURE'] ?? '';
if (!verificar($secreto, $cabecera, $cuerpoCrudo)) {
http_response_code(401);
exit;
}
$evento = json_decode($cuerpoCrudo, true);
if (($evento['eventType'] ?? '') === 'document.authorized') {
// persistir autorizacion
}
http_response_code(200);
function verificar(string $secreto, string $cabecera, string $cuerpoCrudo, int $tolerancia = 300): bool
{
$partes = [];
foreach (explode(',', $cabecera) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) { $partes[$kv[0]] = $kv[1]; }
}
$t = $partes['t'] ?? null;
$v1 = $partes['v1'] ?? null;
if ($t === null || $v1 === null || abs(time() - (int) $t) > $tolerancia) { return false; }
$esperado = hash_hmac('sha256', $t . '.' . $cuerpoCrudo, $secreto);
return hash_equals($esperado, strtolower($v1));
}
Java (Spring Boot)#
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
@RestController
public class WebhookController {
@PostMapping("/webhooks/ecuafact")
public ResponseEntity<Void> recibir(
@RequestHeader("Ecuafact-Signature") String cabecera,
@RequestBody String cuerpoCrudo) throws Exception {
if (!verificar(System.getenv("ECUAFACT_WEBHOOK_SECRET"), cabecera, cuerpoCrudo, 300)) {
return ResponseEntity.status(401).build();
}
// procesar cuerpoCrudo segun eventType y persistir
return ResponseEntity.ok().build();
}
static boolean verificar(String secreto, String cabecera, String cuerpoCrudo, long tolerancia) throws Exception {
java.util.Map<String, String> partes = new java.util.HashMap<>();
for (String p : cabecera.split(",")) {
String[] kv = p.split("=", 2);
if (kv.length == 2) { partes.put(kv[0], kv[1]); }
}
String t = partes.get("t"), v1 = partes.get("v1");
if (t == null || v1 == null) { return false; }
if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > tolerancia) { return false; }
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal((t + "." + cuerpoCrudo).getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hash) { hex.append(String.format("%02x", b)); }
return java.security.MessageDigest.isEqual(
hex.toString().getBytes(StandardCharsets.US_ASCII),
v1.toLowerCase().getBytes(StandardCharsets.US_ASCII));
}
}
.NET (ASP.NET Core)#
using System.IO;
using System.Text;
using Ecuafact.Sdk;
using Microsoft.AspNetCore.Mvc;
[ApiController]
public sealed class WebhookController : ControllerBase
{
[HttpPost("/webhooks/ecuafact")]
public async Task<IActionResult> Recibir()
{
using var lector = new StreamReader(Request.Body, Encoding.UTF8);
string cuerpoCrudo = await lector.ReadToEndAsync();
string cabecera = Request.Headers["Ecuafact-Signature"].ToString();
// Opcion 1: helper del SDK.
bool valido = WebhookSignature.Verify(
Environment.GetEnvironmentVariable("ECUAFACT_WEBHOOK_SECRET")!,
cabecera, cuerpoCrudo, DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5));
// Opcion 2: verificacion propia (ver "Verificacion de firma").
if (!valido)
{
return Unauthorized();
}
// Deserializar cuerpoCrudo, procesar segun eventType y persistir.
return Ok();
}
}
En ASP.NET Core no uses el binding automatico del modelo para la verificacion: el HMAC se calcula sobre los bytes exactos recibidos. Lee siempre el cuerpo crudo (
Request.Body).
Los SDK oficiales incluyen el verificador de firma; usalo en lugar de reimplementar el HMAC. El detalle esta en Verificacion de firma.