Webhooks
Cómo Timbre te avisa, y cómo verificar que fue Timbre.
Cuando Hacienda resuelve, Timbre llama a tu servidor. Es la forma correcta de enterarte: no dependés de consultar y no perdés el veredicto si tu proceso estaba caído, la entrega se reintenta.
Todo lo de esta página (registrar un endpoint, probarlo, rotar el secreto, darlo de baja, ver y reenviar las entregas) también se hace desde el panel, en Integración → Webhooks, sin escribir una sola petición. Lo de abajo es para cuando lo vas a manejar por código.
Registrar un endpoint
Un endpoint es la URL de tu servidor donde Timbre manda los avisos. Se registra una vez por proyecto:
curl -X POST https://api.timbrecr.com/core/v1/webhooks/endpoints \
-H "Authorization: Bearer timbre_sk_…" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://tu-servidor.cr/timbre"
}'La URL debe ser https:. Un aviso lleva la clave y la identificación del
contribuyente, y eso no viaja en claro.
La respuesta trae el secreto, una sola vez. Guardalo al recibirlo.
Verificar la firma
Cada entrega llega con tres encabezados:
Timbre-Delivery-Id: …
Timbre-Event-Type: invoice.accepted
Timbre-Signature: t=1712345678,v1=…La firma es HMAC-SHA256 sobre esta cadena exacta:
v1:{deliveryId}:{t}:{cuerpo crudo}Tres cosas importan al verificarla. Usá el cuerpo crudo, tal cual llegó: si
lo parseás y lo volvés a serializar, la firma no va a coincidir. Compará en
tiempo constante, no con ==. Y rechazá lo que tenga t viejo; 300
segundos de tolerancia es razonable.
Sin lo último, cualquiera que haya capturado una entrega válida puede reproducirla mañana.
Timbre-Signature puede traer más de un v1 (t=…,v1=abc…,v1=def…) mientras
estés en la ventana de solapamiento de una rotación. Aceptá la entrega si
cualquiera de los v1 coincide con tu secreto; no asumas que es uno solo
ni que el primero es el vigente.
En Node, con solo node:crypto, la verificación completa queda así:
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyTimbreSignature({
rawBody, // el cuerpo crudo, como string, antes de parsearlo
deliveryId, // el encabezado Timbre-Delivery-Id
signatureHeader, // el encabezado Timbre-Signature
secret,
toleranceSeconds = 300,
}) {
const parts = signatureHeader.split(",")
const t = Number(parts.find((part) => part.startsWith("t="))?.slice(2))
const signatures = parts
.filter((part) => part.startsWith("v1="))
.map((part) => part.slice(3))
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) {
return false
}
const expected = createHmac("sha256", secret)
.update(`v1:${deliveryId}:${t}:${rawBody}`, "utf8")
.digest()
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex")
return given.length === expected.length && timingSafeEqual(given, expected)
})
}Qué cuenta como entregada
Una entrega queda delivered cuando tu servidor responde un 2xx dentro de 10
segundos. Cualquier otra respuesta cuenta como intento fallido: un 4xx, un
5xx, que no responda a tiempo y también una redirección (3xx). Timbre no
sigue redirecciones; si tu endpoint cambió de dirección, registrá la nueva.
Tras un intento fallido, Timbre reintenta cada dos minutos, hasta seis intentos
en total: la entrega tiene unos diez minutos para llegar. Si ninguno llega,
queda failed, Timbre te avisa con
webhook_endpoint.failing y la podés
reenviar cuando quieras.
Repetidos y orden
La entrega es al menos una vez: un mismo evento te puede llegar más de una
vez, por ejemplo si tu servidor lo procesó pero la respuesta no llegó a Timbre.
Para reconocer un repetido usá Timbre-Delivery-Id, que es el mismo valor que
el id del cuerpo. No cambia entre reintentos ni cuando reenviás la entrega a
mano. Lo que sí cambia en cada intento es t, y con él la firma: verificá cada
intento, no guardes firmas.
Cada evento es su propia entrega, con sus propios reintentos, así que el
orden de llegada no está garantizado: un invoice.processing reintentado
puede llegar después del invoice.accepted del mismo documento. Dos formas de
no equivocarse:
- Compará
data.occurredAtantes de pisar un estado que ya guardaste. - O, al recibir cualquier evento, consultá el documento
(
GET /core/v1/invoices/{id}) y quedate con lo que diga.
accepted, rejected y permanent_error son finales: si ya guardaste uno de
esos, ningún evento posterior del mismo documento lo cambia.
El cuerpo
Todos los eventos comparten el mismo sobre. Un invoice.accepted:
{
"specVersion": 1,
"id": "3f6c2a1e-8b4d-4c55-9a51-2f0e7c1d9b42",
"type": "invoice.accepted",
"environment": "production",
"data": {
"invoiceId": "5a60ffbb-b48f-4912-9f0f-291ecf4f9315",
"eventId": "b1d0c2a4-6f3e-4b8a-9c7d-1e2f3a4b5c6d",
"eventType": "accepted",
"status": "accepted",
"previousStatus": "processing",
"documentType": "FE",
"environment": "production",
"clave": "50601102600310112345600100001010000000042199999999",
"numeroConsecutivo": "00100001010000000042",
"totalComprobante": "11300.00000",
"currency": "CRC",
"occurredAt": "2026-10-01T15:04:05.000Z",
"detail": null
}
}id identifica la entrega; data.invoiceId el documento. Los eventos del PDF
agregan data.artifacts, y webhook_endpoint.failing lleva en data sus
propios campos, descritos abajo.
Los eventos
Cada evento tiene un tipo, que llega en el encabezado Timbre-Event-Type y en el type del cuerpo:
| Evento | Cuándo |
|---|---|
invoice.finalized | Numerado y encaminado |
invoice.processing | Hacienda lo recibió |
invoice.accepted | Aceptado |
invoice.rejected | Rechazado |
invoice.permanent_error | Falló definitivamente |
invoice.representation_ready | El PDF de un documento aceptado ya está |
invoice.representation_failed | El PDF no se pudo generar; los XML están |
webhook_endpoint.failing | Otro de tus endpoints dejó de recibir |
invoice.accepted llega antes de que exista el PDF, porque el PDF se dibuja
después de la aceptación. Si le reenviás los documentos a tu cliente, esperá
invoice.representation_ready: trae en data.artifacts el enlace y el
SHA-256 de cada archivo guardado, así que con ese aviso tenés todo lo que
hay que entregar. Los dos eventos del PDF llegan una sola vez por documento y
no cambian el estado: status viene en null, porque el documento ya estaba
aceptado. Más detalle en Artefactos.
webhook_endpoint.failing avisa que una entrega a otro de tus endpoints agotó
sus reintentos. Llega una sola vez por interrupción, a tus demás endpoints
activos (el que falla no lo puede recibir), y trae en data cuál es
(endpointId, url), desde cuándo (failingSince) y la entrega que no
llegó (failedDeliveryId, lastStatusCode, lastError). Cuando una entrega
a ese endpoint vuelve a llegar, el aviso se rearma para la próxima vez. En
producción, además, les llega un correo a los dueños y administradores de
la organización, aunque tengas un solo endpoint.
Probar sin emitir
Para probar tu endpoint y tu verificación de firma no hace falta emitir un documento: pedí una entrega de prueba.
curl -X POST https://api.timbrecr.com/core/v1/webhooks/endpoints/{id}/test \
-H "Authorization: Bearer timbre_sk_…"Manda un webhook.test real, firmado igual que los demás, para que verifiques
tu verificación antes de que haya un documento de por medio.
Rotar el secreto
Si un secreto se pudo haber filtrado, o lo rotás por costumbre, pedí uno nuevo:
curl -X POST https://api.timbrecr.com/core/v1/webhooks/endpoints/{id}/secrets \
-H "Authorization: Bearer timbre_sk_…"El anterior sigue siendo válido durante un solapamiento, para que puedas
desplegar el nuevo sin perder entregas en el medio. Sin más parámetros el
solapamiento dura 24 horas. Lo podés cambiar con ?overlapSeconds=, hasta
2 592 000 (30 días), y 0 es corte inmediato: el secreto anterior deja de
firmar ya.
Administrar tus endpoints
Para ver qué endpoints tiene el proyecto:
curl https://api.timbrecr.com/core/v1/webhooks/endpoints \
-H "Authorization: Bearer timbre_sk_…"Lista los endpoints del proyecto, nunca con el secreto, que solo se ve al crearlos o al rotarlos. Para dar de baja uno:
curl -X DELETE https://api.timbrecr.com/core/v1/webhooks/endpoints/{id} \
-H "Authorization: Bearer timbre_sk_…"Deja de recibir eventos nuevos. Las entregas que ya existían quedan como evidencia y no se reintentan mientras esté deshabilitado.
Ver qué se entregó
Cada entrega deja registro de sus intentos, para que veas qué llegó y qué no:
curl https://api.timbrecr.com/core/v1/webhooks/deliveries \
-H "Authorization: Bearer timbre_sk_…"Cada entrega queda con su estado (pending, delivered o failed) y la
evidencia de cada intento.
Reenviar una entrega fallida
Si una entrega no llegó en los diez minutos de
reintentos, termina en failed. Cuando lo tengas de vuelta, pedí que se reenvíe:
curl -X POST https://api.timbrecr.com/core/v1/webhooks/deliveries/{id}/redeliver \
-H "Authorization: Bearer timbre_sk_…"Vuelve a pending con todos sus reintentos y se intenta de inmediato. Es el
mismo evento, con el mismo id en el cuerpo, así que si ya lo habías procesado
lo reconocés como repetido. Solo se puede con una entrega failed: una
pending ya va en camino (409), y tampoco si el endpoint está deshabilitado.