Ir al contenido
Documentación

Webhooks y errores

Recibir notificaciones

Timbro puede avisar a tu servidor cuando cambia un pago o una factura. Ese aviso se llama webhook. Primero verifica su firma y guarda el aviso; después consulta el recurso para conocer su estado actual. Esta guía empieza con los avisos de pagos y, más abajo, muestra los avisos de facturas con Invoices, sin instalar Payments.

import {
createTimbroPayments,
type Payment,
type VerifiedWebhookEvent,
webhooks,
} from "@timbro/payments";
const timbro = createTimbroPayments({ apiKey: process.env.TIMBRO_PAYMENTS_SECRET_KEY! });
const endpoint = await timbro.webhookEndpoints.create({
url: "https://merchant.example/webhooks/timbro",
enabledEvents: ["payment.succeeded"],
idempotencyKey: "orders-webhook-v1",
});

Usa una URL HTTPS y guarda el secret del endpoint en la variable TIMBRO_WEBHOOK_SECRET de tu servidor. Timbro muestra este secreto de firma del webhook una sola vez, al crearlo; lo usarás para comprobar que los avisos vienen de Timbro.

Si necesitas repetir el registro, usa la misma clave de idempotencia (Idempotency-Key): idempotencyKey en TypeScript o idempotency_key en Python. Debes registrar la URL y guardar su secreto por separado para Prueba y En vivo.

La ruta POST verifica la firma y guarda el aviso antes de responder. saveAndSchedule (save_and_schedule en Python) es una función que debes implementar en tu aplicación: guarda el evento completo y la tarea que lo procesará en una misma transacción de base de datos. Así, si tu servidor se reinicia, puede continuar el trabajo.

Usa event.id para reconocer un aviso que ya recibiste. Si llega de nuevo, conserva la tarea pendiente sin crear otra.

declare function saveAndSchedule(event: VerifiedWebhookEvent): Promise<void>;
export async function POST(request: Request) {
const webhookSecret = process.env.TIMBRO_WEBHOOK_SECRET;
if (!webhookSecret) return new Response(null, { status: 503 });
let event: VerifiedWebhookEvent;
try {
event = webhooks.verify({
body: new Uint8Array(await request.arrayBuffer()),
headers: Object.fromEntries(request.headers),
secret: webhookSecret,
});
} catch {
return new Response(null, { status: 401 });
}
await saveAndSchedule(event);
return new Response(null, { status: 204 });
}

webhooks.verify solo necesita el secreto de firma del webhook, no una clave de API. Pasa a verify el cuerpo original de la solicitud, sin convertirlo primero a JSON. Responde con 2xx solo cuando hayas guardado el evento y su tarea. Si no puedes guardarlos, responde con un error para que Timbro vuelva a enviarlo. Mantén el reloj de tu servidor sincronizado: la verificación de la firma admite 5 minutos de diferencia.

Un evento firmado cuyo tipo esta versión del SDK aún no conoce se entrega de todos modos como evento no reconocido: guárdalo y omítelo al procesar, con webhooks.isKnownWebhookEvent en Payments e isRecognizedWebhookEvent en Invoices, o webhooks.is_known_event en ambos paquetes de Python, en lugar de rechazarlo.

La tarea que guardaste consulta el pago para conocer su estado actual. applyPaymentOnce (apply_payment_once en Python) también es una función de tu aplicación: comprueba que la referencia, la moneda y el monto coincidan con el pedido, y márcalo como pagado una sola vez.

Si entregas el pedido o envías un correo, guarda también qué acciones se completaron. Debes poder continuar tras una falla sin repetirlas.

declare function applyPaymentOnce(payment: Payment): Promise<void>;
export async function processEvent(event: VerifiedWebhookEvent) {
if (!webhooks.isKnownWebhookEvent(event) || event.type !== "payment.succeeded") return;
const payment = await timbro.payments.retrieve(event.data.id);
if (payment.status === "succeeded") await applyPaymentOnce(payment);
}

Los avisos pueden repetirse o llegar en un orden distinto al de los cambios. event.id identifica el aviso; event.data.id es el identificador del pago o la factura que debes consultar. Si todavía no encuentras el pedido en tu sistema, deja la tarea pendiente y vuelve a procesarla después.

Si solo facturas, usa @timbro/invoices o, en Python, timbro-invoices. Registra una URL distinta para estos avisos usando una clave de API con acceso a Facturación:

import {
createTimbroInvoices,
isRecognizedWebhookEvent,
type ReceivedWebhookEvent,
} from "@timbro/invoices";
const invoices = createTimbroInvoices({ apiKey: process.env.TIMBRO_INVOICES_API_KEY! });
const invoiceEndpoint = await invoices.webhookEndpoints.create({
url: "https://merchant.example/webhooks/timbro-invoices",
enabledEvents: ["invoice.accepted", "invoice.ready", "invoice.rejected", "invoice.operatorRequired"],
idempotencyKey: "invoices-webhook-v1",
});

Guarda el secret del endpoint en TIMBRO_INVOICES_WEBHOOK_SECRET. Usa una ruta separada de la de pagos, pues los eventos tienen formatos distintos.

El verificador de Invoices es asíncrono: usa await y pásale el texto original, sin convertirlo a JSON. Exporta invoicePOST como el manejador POST de esa ruta.

declare function saveInvoiceAndSchedule(event: ReceivedWebhookEvent): Promise<void>;
export async function invoicePOST(request: Request) {
const secret = process.env.TIMBRO_INVOICES_WEBHOOK_SECRET;
if (!secret) return new Response(null, { status: 503 });
let event: ReceivedWebhookEvent;
try {
event = await invoices.webhooks.verify({
body: await request.text(),
headers: Object.fromEntries(request.headers),
secret,
});
} catch {
return new Response(null, { status: 401 });
}
await saveInvoiceAndSchedule(event);
return new Response(null, { status: 204 });
}

Implementa saveInvoiceAndSchedule (save_invoice_and_schedule en Python) con las mismas reglas de guardado y duplicados del ejemplo de pagos. En la tarea pendiente, event.data.id es el ID canónico de la factura. invoice.accepted avisa de la aceptación, que puede ser accepted o acceptedWithObservations. Revisa las observaciones si existen. invoice.ready permite revisar si los archivos están listos:

declare function saveInvoicePdfOnce(invoiceId: string, bytes: Uint8Array): Promise<void>;
export async function processInvoiceEvent(event: ReceivedWebhookEvent) {
if (!isRecognizedWebhookEvent(event)) return;
if (event.type !== "invoice.accepted" && event.type !== "invoice.ready") return;
const invoice = await invoices.invoices.retrieve(event.data.id);
if (!invoice) return;
const accepted = invoice.status === "accepted" || invoice.status === "acceptedWithObservations";
if (!accepted || invoice.files.pdf.status !== "ready") return;
const pdf = await invoices.invoices.downloadPdf(invoice.id);
await saveInvoicePdfOnce(invoice.id, pdf.bytes);
}

saveInvoicePdfOnce (save_invoice_pdf_once en Python) guarda el PDF en tu aplicación sin repetirlo. Si el archivo sigue pendiente o falla una consulta, conserva la tarea y vuelve a consultar la misma factura. Para enviar la factura al cliente o al contador, sigue Descargar y enviar la factura. Procesa también rechazos y casos operatorRequired para avisar a la persona que debe resolverlos.

Agrega a enabledEvents solo los que procesas. Al recibir cada uno, consulta el recurso que cambió para conocer su estado actual:

  • Con subscription.canceled y subscription.status_changed, consulta la suscripción con timbro.subscriptions.retrieve(event.data.id).
  • Con payment.succeeded, payment.declined, payment.canceled, payment.expired y payment.operator_required, consulta el pago con timbro.payments.retrieve(event.data.id).
  • Con refund.succeeded, refund.declined, refund.operator_required y refund.invoiceAttached, consulta el reembolso con timbro.refunds.retrieve(event.data.id).
  • Con invoice.accepted, invoice.rejected, invoice.operatorRequired e invoice.ready, consulta la factura con invoices.invoices.retrieve(event.data.id).
  • Con invoice.deliverySent e invoice.deliveryFailed, consulta el envío por correo con invoices.invoices.retrieveDelivery, usando event.data.id como invoiceId y event.data.deliveryId como deliveryId.

payment.declined avisa de cada intento que el adquirente rechazó; el pago sigue abierto y su lastDecline indica el motivo. No canceles el pedido por este evento: el cliente puede volver a intentarlo hasta que el pago venza.

refund.succeeded avisa que el adquirente confirmó el reembolso. refund.declined avisa que lo rechazó de forma definitiva; el monto vuelve a estar disponible y puedes solicitar otro reembolso con una refundReference nueva. Cada reembolso emite uno solo de estos dos eventos, y uno que queda en operator_required emite refund.operator_required en su lugar. Como cualquier aviso, un evento de reembolso es solo una notificación: verifica su firma, descarta un event.id que ya procesaste y consulta el reembolso antes de actualizar tu sistema.

Si solicitaste un e-CF, consulta también su estado y sus archivos. Si falta una notificación, revisa los envíos de tu webhook con GET /v1/webhook-endpoints/{webhookEndpointId}/deliveries y pide que Timbro los reenvíe con POST /v1/webhook-endpoints/{webhookEndpointId}/replays y una clave de idempotencia (Idempotency-Key), indicando los eventIds o un periodo de hasta 7 días. Ese reenvío no debe hacer que tu sistema entregue el pedido otra vez.

Timbro intenta cada envío hasta 8 veces durante un día. Si tu servidor estará fuera de servicio, puedes suspender el webhook con POST /v1/webhook-endpoints/{webhookEndpointId}/suspend y reactivarlo después con .../reactivate. Para cambiar el secreto de firma, usa .../rotate-secret, también con una clave de idempotencia: el secreto anterior sigue firmando durante 24 horas.