How-to

A webhook endpoint in your framework

Mount the SDK's webhook router on one route. It verifies, parses, dispatches and answers for you.

Handle webhooks
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"])
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.nodeHandler()) · examples/webhook-handler.ts (opens in a new tab)

  • Express

    router.nodeHandler() on the route, behind express.raw({ type: 'application/json' }) and registered before 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.fetchHandler(), a (request: Request) => Promise<Response> (Workers with the nodejs_compat flag). The Edge runtime has no node:crypto: the package can't load there.

    README.md (opens in a new tab)

Python

The block above: FastAPI (router.fastapi_endpoint()) · examples/webhook_fastapi.py (opens in a new tab)

Go

The block above: Go's net/http · examples/webhook_handler/main.go (opens in a new tab)

PHP

The block above: plain PHP ($router->handleGlobals()) · examples/webhook-handler.php (opens in a new tab)

  • Laravel

    One route, Route::qbitflowWebhooks('webhooks/qbitflow') (verified with QBITFLOW_WEBHOOK_SECRET, answered 200), and a listener per Laravel event (PaymentCompleted, SubscriptionStatusChanged…, then WebhookReceived for every delivery).

    examples/laravel (opens in a new tab)
  • Slim, Mezzio, Symfony

    psr15(), a PSR-15 request handler (Slim, Mezzio), or handleRequest() for a PSR-7 request (Symfony with the PSR-7 bridge; without it, handle() on the raw body and header).

    README.md (opens in a new tab)

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.

Create a webhook endpoint

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`);
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/endpoints/:uuid).

  • 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

A reply after 30 seconds is a failed attempt, retried even if you processed it.

Do

Store, answer 2xx, then work

Process in the background, and deduplicate on the event's id: delivery is at least once.