A webhook endpoint in your framework
Mount the SDK's webhook router on one route. It verifies, parses, dispatches and answers for you.
const router = webhooks . router( process. env. QBITFLOW_WEBHOOK_SECRET!, { // Every 400 (bad signature or body: event null) and 500 (a handler threw). The router // answers without the details: log them here. onError: (event, err) => console.error('webhook rejected:', event?.id, err.message), }) .on('payment.completed', async (payment, event) => { await fulfilOrder(payment.reference, event.id); // idempotent: deduplicates on event.id }); // nodeHandler() reads the raw body itself (at most 1 MiB): mount it before any body parser.const handleWebhook = router.nodeHandler();createServer((req, res) => { if (req.url === '/webhooks/qbitflow') void handleWebhook(req, res); else res.writeHead(404).end();}).listen(8080);router = qbitflow. WebhookRouter( os. environ["QBITFLOW_WEBHOOK_SECRET"]) @router.on("payment.completed") # the handler gets the typed data, then the eventdef fulfil(data: qbitflow.PaymentCompleted, event: qbitflow.Event) -> None: # At least once: the same event can arrive twice, deduplicate on event.id. mark_order_paid(data.reference or data.uuid, event_id=event.id) app = FastAPI()# Verifies the QBitFlow-Signature header on the raw body, parses the event, runs the handler# for its type, and answers the status QBitFlow expects (200, 400 or 500).app. add_api_route( "/webhooks/qbitflow", router.fastapi_endpoint(), methods=["POST"])webhook_handler/main.go
var fulfilled sync.Map // stands for your database: deliveries are at least oncerouter := qbitflow. NewWebhookRouter( os. Getenv( "QBITFLOW_WEBHOOK_SECRET"), qbitflow. WithWebhookErrorHandler( func( event *qbitflow.Event, err error) { log.Printf("webhook refused or failed: %v", err) // event is nil when the body could not be parsed }),). OnPaymentCompleted(func(ctx context.Context, payment *qbitflow.PaymentCompleted, event *qbitflow.Event) error { if _, done := fulfilled.LoadOrStore(event.ID, true); done { return nil // a retry of an event already handled } log.Printf("fulfil order %q: %s USD (%s, %v)", payment.Reference, payment.AmountUSD, payment.UUID, payment.Metadata) return nil // an error answers 500, and QBitFlow delivers the event again }). OnCheckoutExpired(func(ctx context.Context, expired *qbitflow.CheckoutExpired, event *qbitflow.Event) error { if expired.Payment != nil { log.Printf("release order %q", expired.Payment.Reference) } return nil }) // The router is an http.Handler: it verifies the QBitFlow-Signature header on the raw body,// parses the event, runs the handlers of its type and answers 200, 400 or 500.http.Handle("POST /webhooks/qbitflow", router)$router = (new \QBitFlow\Webhooks\WebhookRouter( ( string) getenv( 'QBITFLOW_WEBHOOK_SECRET'))) ->on( \QBitFlow\Enums\EventType::PAYMENT_COMPLETED, function ( \QBitFlow\Models\PaymentCompleted $payment, \QBitFlow\Events\Event $event, ): void { // Deliveries are at least once: skip an $event->id you already processed. error_log("event {$event->id}: fulfil order {$payment->reference} ({$payment->uuid}), {$payment->amountUsd} USD"); }) ->on( \QBitFlow\Enums\EventType::SUBSCRIPTION_STATUS_CHANGED, function ( \QBitFlow\Models\SubscriptionStatusChanged $subscription, ): void { error_log("{$subscription->uuid} is {$subscription->status}, access: " . ($subscription->hasAccess() ? 'yes' : 'no')); }) ->onError(fn (?\QBitFlow\Events\Event $event, \Throwable $error) => error_log("webhook refused or failed: {$error->getMessage()}")); // Reads php://input and the QBitFlow-Signature header, runs the handler of the event's type, and// answers 200 (ignored types too), 400 (bad signature) or 500 (a handler threw: QBitFlow retries).$router->handleGlobals();The router checks the signature on the raw body, runs your handler for the event's type and
answers 200, 400 for a bad signature, or 500 when your handler throws, so the delivery comes
again. It needs only the endpoint's secret (QBITFLOW_WEBHOOK_SECRET here): no API key, no call to
QBitFlow.
Your framework
Each tab above runs one framework. The same router mounts in the others:
TypeScript
The block above: Node's own node:http server (router.) · examples/webhook-handler.ts (opens in a new tab)
- README.md (opens in a new tab)
Express
router.on the route, behindnodeHandler() express.and registered beforeraw({ type: 'application/ json' }) express.json(): a body already parsed can't be verified. - README.md (opens in a new tab)
Next.js (App Router, the Node.js runtime), Hono, Bun, Deno, Cloudflare Workers
router., afetchHandler() (request: Request) => Promise<Response>(Workers with thenodejs_compatflag). The Edge runtime has nonode:crypto: the package can't load there.
Python
The block above: FastAPI (router.) · examples/webhook_fastapi.py (opens in a new tab)
- README.md (opens in a new tab)
Flask
router.(POST only).flask_ view() - README.md (opens in a new tab)
Django
router., exempt from CSRF: QBitFlow signs its deliveries instead.django_ view() - examples/webhook_handler.py (opens in a new tab)
The standard library
router., whose result gives the status to answer.handle(raw_ body, signature_ header)
Go
The block above: Go's net/http · examples/webhook_handler/main.go (opens in a new tab)
- README.md (opens in a new tab)
chi, gorilla/mux
The router is an
http.Handler: mount it as the block does. - README.md (opens in a new tab)
gin, echo, fiber
Hand the router's
Handlethe raw body and theQBitFlow-Signatureheader, then answer the status it returns.
PHP
The block above: plain PHP ($router->handleGlobals()) · examples/webhook-handler.php (opens in a new tab)
- examples/laravel (opens in a new tab)
Laravel
One route,
Route::qbitflowWebhooks('webhooks/(verified withqbitflow') QBITFLOW_, answered 200), and a listener per Laravel event (WEBHOOK_ SECRET PaymentCompleted,SubscriptionStatusChanged…, thenWebhookReceivedfor every delivery). - README.md (opens in a new tab)
Slim, Mezzio, Symfony
psr15(), a PSR-15 request handler (Slim, Mezzio), orhandleRequest()for a PSR-7 request (Symfony with the PSR-7 bridge; without it,handle()on the raw body and header).
Create the endpoint
Point an endpoint at your route, here or in Settings › Webhooks, and store its secret: the API returns it only in this answer.
webhook-endpoint-create.sh
# Left out, "events" means every type, including the ones added later. The whsec_… signing secret# is returned only this once: store it now (QBITFLOW_WEBHOOK_SECRET).curl https://api.qbitflow.app/v2/webhooks/endpoints \ -H "X-API-Key: $QBITFLOW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "url": "https://shop.example.com/webhooks/qbitflow", "events": ["payment.completed", "checkout.expired", "subscription.statusChanged"], "description": "Order fulfilment" }'# 201: { "uuid": "…", "url": "https://shop.example.com/webhooks/qbitflow", "secret": "whsec_…", … }const endpoint = await client. webhooks. endpoints. create( { url: 'https://shop.example.com/webhooks/qbitflow', // Left out: every type, including the ones added later. events: [ EventType.PaymentCompleted, EventType.CheckoutExpired, EventType. SubscriptionStatusChanged, ], description: 'Order fulfilment',});// The whsec_… signing secret is returned only this once: store it now.await storeSecret( 'QBITFLOW_WEBHOOK_SECRET', endpoint.secret);console.log(`endpoint ${endpoint.uuid} created`);webhook_endpoints.py
created = client. webhooks. endpoints. create( url="https://shop.example.com/webhooks/qbitflow", events=[ # None: every type, including the ones added later qbitflow. EventType. PAYMENT_COMPLETED, qbitflow. EventType. CHECKOUT_EXPIRED, qbitflow. EventType. SUBSCRIPTION_STATUS_CHANGED, ], description="Order fulfilment",)# The whsec_… secret is returned only this once: put it in your secret store now# (QBITFLOW_WEBHOOK_SECRET for your webhook handler).print(created.uuid, created.secret)created, err := client. Webhooks. Endpoints. Create( ctx, &qbitflow. CreateWebhookEndpointParams{ URL: "https://shop.example.com/webhooks/qbitflow", Events: []qbitflow.EventType{ // nil: every type, including the ones added later qbitflow. EventTypePaymentCompleted, qbitflow. EventTypeCheckoutExpired, qbitflow. EventTypeSubscriptionStatusChanged, },})if err != nil { log.Fatal(err)}// The whsec_… secret is returned only this once: put it in your secret store now, and give// it to your webhook handler as QBITFLOW_WEBHOOK_SECRET.fmt.Println("endpoint", created.UUID, "secret", created.Secret)$endpoint = $client->webhooks->endpoints->create( new \QBitFlow\Params\CreateWebhookEndpointParams( url: 'https://shop.example.com/webhooks/qbitflow', events: [ // null: every type, including the ones added later \QBitFlow\Enums\EventType::PAYMENT_COMPLETED, \QBitFlow\Enums\EventType::CHECKOUT_EXPIRED, \QBitFlow\Enums\EventType::SUBSCRIPTION_STATUS_CHANGED, ],));// The whsec_… secret is returned only this once: put it in your secret store now.echo "Endpoint {$endpoint->uuid}: QBITFLOW_WEBHOOK_SECRET={$endpoint->secret}\n";Delete the events line to receive every type, new ones included (empty or absent means every
type). To widen one you already have:
Widen an endpoint to every type
(PATCH /webhooks/
- Live endpoints are
https, on a public address. Test ones take any port but still need a public address: a tunnel reaches your laptop. - Test and live are separate. Create it again in live, with its own secret.
- An organization's endpoint also hears its members' events, unless
includeMembers: false.
Before you ship
Avoid
Do the work, then answer
Do
Store, answer 2xx, then work
id: delivery is at least once.- Acknowledge what you ignore. Unknown types still get a
2xx; the routers do it for you. - Test it. POST /webhooks/
endpoints/ :uuid/ test posts a signed webhook.test; GET /webhooks/endpoints/ :uuid/ deliveries shows each attempt and your answer.