Webhooks de comprobantes
Cuando un comprobante de producción cambia de destino —
document.authorized
,
document.rejected
o
document.failed
— la plataforma lo entrega por POST a cada
endpoint
suscrito del libro.
El contrato de entrega#
- El cuerpo se congela en el momento del evento: un reintento entrega la verdad de ese instante, nunca una relectura posterior.
-
X-Datil-Signature: sha256=<hex>— HMAC-SHA256 del cuerpo exacto, con elsecretdel endpoint como llave. -
Tu
2xxconfirma la entrega. Cualquier otra respuesta reintenta con retroceso exponencial, hasta 8 intentos; después el evento queda marcado como fallido y visible en Contabilidad → Configuración → API. - A lo sumo una entrega por combinación endpoint · documento · evento — pero procesa idempotente de todos modos: la red puede duplicar.
Verificación de la firma#
Calcula el HMAC sobre los bytes crudos del cuerpo — antes de cualquier parseo o re-serialización — y compara en tiempo constante. El secreto es el que definiste al crear el endpoint (se muestra una sola vez).
POST https://erp.acme.ec/webhooks/datil
Content-Type: application/json
User-Agent: Datil-Webhooks/1.0
X-Datil-Signature: sha256=0b4e5d…
{
"event": "document.authorized",
"sent_at": "2026-07-03T14:07:00Z",
"document": {
"id": "9f2c6e1a-4b3d-4e9a-8d2f-5a7c1b9e0d43",
"doc_type": "factura",
"numero": "001-002-000000123",
"access_key": "0307202601179001234500110010020000001231234567811",
"status": "authorized",
"issue_date": "2026-07-03",
"currency": "USD",
"grand_total": "115.00",
"authorization": {
"status": "AUTORIZADO",
"number": "0307202601179001234500110010020000001231234567811",
"date": "2026-07-03T14:07:00Z"
},
"failure_reason": null
}
}
# Phoenix/Plug — verifica la firma contra el cuerpo EXACTO recibido.
# Lee el cuerpo crudo ANTES de cualquier parser de JSON.
def verify(conn, secret) do
["sha256=" <> theirs] = Plug.Conn.get_req_header(conn, "x-datil-signature")
{:ok, raw, conn} = Plug.Conn.read_body(conn)
ours =
:crypto.mac(:hmac, :sha256, secret, raw)
|> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(ours, theirs),
do: {:ok, Jason.decode!(raw), conn},
else: {:error, :bad_signature}
end
# Pseudocódigo — cualquier lenguaje
raw = request.raw_body # bytes exactos, sin re-serializar
header = request.headers["X-Datil-Signature"] # "sha256=<hex>"
expected = hex(hmac_sha256(secret, raw))
if not constant_time_equal(expected, header.removeprefix("sha256=")):
return 401 # firma inválida: descarta
event = json.parse(raw)
process(event) # idempotente por event.document.id + event.event
return 200 # 2xx confirma la entrega