Skip to content
Documentation
Get started

Webhooks and errors

Receive webhooks

A webhook announces that a payment or invoice changed. Verify its signature and store it before retrieving the current resource. This guide covers payments first, followed by an independent Invoices flow that does not require the Payments SDK.

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",
});

Store the endpoint secret on your server as TIMBRO_WEBHOOK_SECRET: it is returned only when issued. Keep the same idempotency key (Idempotency-Key), idempotencyKey in TypeScript or idempotency_key in Python, if you retry registration. Test and Live need their own endpoints and secrets.

The handler below uses saveAndSchedule (save_and_schedule in Python), a function from your application that saves the complete event and schedules its processing in one durable transaction. It deduplicates by event.id and preserves pending work if another delivery arrives.

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 needs only the signing secret, not an API key. Verify the original bytes before parsing the JSON. Return 2xx only after storing the event; if storage fails, return an error so Timbro retries. Keep your server clock in sync: the signature allows a five-minute difference.

A signed event whose type this SDK version does not know yet is still delivered as an unrecognized event. Store it and skip it when processing, using webhooks.isKnownWebhookEvent in Payments and isRecognizedWebhookEvent in Invoices, or webhooks.is_known_event in both Python packages, instead of rejecting it.

applyPaymentOnce (apply_payment_once in Python) also belongs to your application. It must check the reference, currency, and amount against the order, then record the payment once. External effects such as delivery or email must also be recoverable without duplication.

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);
}

Events can be repeated or arrive out of order. event.id identifies the event; event.data.id identifies the Payment or Invoice you retrieve. If the order does not exist yet, keep the work so you can retry it.

For an invoices-only integration, use @timbro/invoices or, in Python, timbro-invoices. Register a separate URL with an API key that has Invoicing access:

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",
});

Store the endpoint secret as TIMBRO_INVOICES_WEBHOOK_SECRET. Use a separate route for invoices because payment events have a different envelope.

Invoices verification is asynchronous: use await and pass the original text without parsing JSON. Export invoicePOST as the POST handler on that route.

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 });
}

Implement saveInvoiceAndSchedule (save_invoice_and_schedule in Python) using the same durable storage and duplicate rules as the payment example. In the queued worker, event.data.id is the canonical invoice ID. invoice.accepted announces acceptance, which can be accepted or acceptedWithObservations. Review any observations. invoice.ready lets you check file readiness:

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 in Python) stores the PDF in your application without duplication. If a file remains pending or a query fails, retain the task and retrieve the same invoice again. Follow Download and send the invoice to send the invoice to the customer or accountant. Also process rejected and operatorRequired outcomes so the responsible person can resolve them.

Add only the events you process to enabledEvents. When each one arrives, retrieve the resource that changed to learn its current state:

  • For subscription.canceled and subscription.status_changed, retrieve the subscription with timbro.subscriptions.retrieve(event.data.id).
  • For payment.succeeded, payment.declined, payment.canceled, payment.expired, and payment.operator_required, retrieve the payment with timbro.payments.retrieve(event.data.id).
  • For refund.succeeded, refund.declined, refund.operator_required, and refund.invoiceAttached, retrieve the refund with timbro.refunds.retrieve(event.data.id).
  • For invoice.accepted, invoice.rejected, invoice.operatorRequired, and invoice.ready, retrieve the invoice with invoices.invoices.retrieve(event.data.id).
  • For invoice.deliverySent and invoice.deliveryFailed, retrieve the email delivery with invoices.invoices.retrieveDelivery, passing event.data.id as invoiceId and event.data.deliveryId as deliveryId.

payment.declined reports each attempt the acquirer declined; the payment stays open and its lastDecline gives the reason. Do not cancel the order on this event: the customer can try again until the payment expires.

refund.succeeded reports that the acquirer confirmed the money was returned. refund.declined reports a definitive refusal; the amount becomes available again, and you may request another refund with a new refundReference. Each refund emits only one of these two events, and a refund that ends in operator_required emits refund.operator_required instead. Like any event, a refund event is only a notification: verify its signature, skip an event.id you already processed, and retrieve the refund before you update your system.

For the e-CF, retrieve its status and files separately. If a notification is missing, review your webhook’s deliveries with GET /v1/webhook-endpoints/{webhookEndpointId}/deliveries and ask Timbro to resend them with POST /v1/webhook-endpoints/{webhookEndpointId}/replays and an idempotency key (Idempotency-Key), sending the eventIds or a period of up to 7 days. Receiving an event again must not make your system fulfill the order twice.

Timbro attempts each delivery up to 8 times over one day. If your server will be down, you can suspend the webhook with POST /v1/webhook-endpoints/{webhookEndpointId}/suspend and reactivate it later with .../reactivate. To change the signing secret, use .../rotate-secret, also with an idempotency key: the previous secret keeps signing for 24 hours.