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.
Register the URL
Section titled “Register the URL”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",});import osfrom collections.abc import Mapping
from timbro_payments import TimbroPayments, WebhookVerificationError, webhooksfrom timbro_payments.types import Payment
timbro = TimbroPayments(api_key=os.environ["TIMBRO_PAYMENTS_SECRET_KEY"])endpoint = timbro.webhook_endpoints.create( {"url": "https://merchant.example/webhooks/timbro", "enabledEvents": ["payment.succeeded"]}, idempotency_key="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.
Receive and store the event
Section titled “Receive and store the event”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 });}Call receive_webhook from your application’s POST route with the original body, as bytes, and the request headers. Answer with the HTTP status it returns.
def save_and_schedule(event: webhooks.WebhookEvent) -> None: """Your application: save the event and its task in one transaction.""" raise NotImplementedError
def receive_webhook(body: bytes, headers: Mapping[str, str]) -> int: webhook_secret = os.environ.get("TIMBRO_WEBHOOK_SECRET") if not webhook_secret: return 503 try: event = webhooks.verify(body=body, headers=headers, secret=webhook_secret) except WebhookVerificationError: return 401 save_and_schedule(event) return 204webhooks.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.
Confirm the payment in the background
Section titled “Confirm the payment in the background”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);}def apply_payment_once(payment: Payment) -> None: """Your application: check the order and record the payment once.""" raise NotImplementedError
def process_event(event: webhooks.WebhookEvent) -> None: if webhooks.is_known_event(event) and event["type"] == "payment.succeeded": payment = timbro.payments.retrieve(event["data"]["id"]) if payment["status"] == "succeeded": apply_payment_once(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.
Receive invoice notifications
Section titled “Receive invoice notifications”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",});Register the URL once with webhook_endpoints.create on the timbro-invoices client; you do not need timbro-payments:
import osfrom collections.abc import Mapping
from timbro_invoices import TimbroInvoices, WebhookVerificationErrorfrom timbro_invoices import webhooks as invoice_webhooks
invoices = TimbroInvoices(api_key=os.environ["TIMBRO_INVOICES_API_KEY"])invoice_endpoint = invoices.webhook_endpoints.create( { "url": "https://merchant.example/webhooks/timbro-invoices", "enabledEvents": [ "invoice.accepted", "invoice.ready", "invoice.rejected", "invoice.operatorRequired", ], }, idempotency_key="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 });}Verify the original bytes with the endpoint’s secret, which proves the delivery comes from Timbro. Call receive_invoice_webhook from the POST route and answer with the status it returns.
def save_invoice_and_schedule(event: invoice_webhooks.WebhookEvent) -> None: """Your application: save the event and its task in one transaction.""" raise NotImplementedError
def receive_invoice_webhook(body: bytes, headers: Mapping[str, str]) -> int: secret = os.environ.get("TIMBRO_INVOICES_WEBHOOK_SECRET") if not secret: return 503 try: event = invoice_webhooks.verify(body=body, headers=headers, secret=secret) except WebhookVerificationError: return 401 save_invoice_and_schedule(event) return 204Implement 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);}def save_invoice_pdf_once(invoice_id: str, content: bytes) -> None: """Your application: store the PDF once.""" raise NotImplementedError
def process_invoice_event(event: invoice_webhooks.WebhookEvent) -> None: if not invoice_webhooks.is_known_event(event): return if event["type"] not in ("invoice.accepted", "invoice.ready"): return invoice = invoices.invoices.retrieve(event["data"]["id"]) accepted = invoice["status"] in ("accepted", "acceptedWithObservations") if not accepted or invoice["files"]["pdf"]["status"] != "ready": return pdf = invoices.invoices.download_pdf(invoice["id"]) save_invoice_pdf_once(invoice["id"], pdf.content)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.
Other events
Section titled “Other events”Add only the events you process to enabledEvents. When each one arrives, retrieve the resource that changed to learn its current state:
- For
subscription.canceledandsubscription.status_changed, retrieve the subscription withtimbro.subscriptions.retrieve(event.data.id). - For
payment.succeeded,payment.declined,payment.canceled,payment.expired, andpayment.operator_required, retrieve the payment withtimbro.payments.retrieve(event.data.id). - For
refund.succeeded,refund.declined,refund.operator_required, andrefund.invoiceAttached, retrieve the refund withtimbro.refunds.retrieve(event.data.id). - For
invoice.accepted,invoice.rejected,invoice.operatorRequired, andinvoice.ready, retrieve the invoice withinvoices.invoices.retrieve(event.data.id). - For
invoice.deliverySentandinvoice.deliveryFailed, retrieve the email delivery withinvoices.invoices.retrieveDelivery, passingevent.data.idasinvoiceIdandevent.data.deliveryIdasdeliveryId.
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.