Timbre

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:

Petición
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.occurredAt antes 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:

Respuesta
{
  "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:

EventoCuándo
invoice.finalizedNumerado y encaminado
invoice.processingHacienda lo recibió
invoice.acceptedAceptado
invoice.rejectedRechazado
invoice.permanent_errorFalló definitivamente
invoice.representation_readyEl PDF de un documento aceptado ya está
invoice.representation_failedEl PDF no se pudo generar; los XML están
webhook_endpoint.failingOtro 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.

Petición
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:

Petición
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:

Petición
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:

Petición
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:

Petición
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:

Petición
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.