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 campo t es el momento del envio y v1 la 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 2xx no reanuda un destino pausado.
  • La reentrega de un mismo evento conserva resourceId y contenido; cambia el timestamp y la firma.

Responde 2xx solo 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; el occurredAtUtc se 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; conserva resourceId y 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 2xx no 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.

Siguientes pasos#

No se pudo completar la operacion. Recargar ✕