Fulfil orders from webhooks

payment.completed is when you ship. Receive it, verify it, and fulfil each order exactly once.

When a payment confirms on-chain, QBitFlow posts payment.completed to your webhook endpoint, even if your customer closed the tab. That's when you fulfil, never on the redirect (webhooks).

Create an endpoint

The URL QBitFlow posts events to. One per mode:

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";
  • events: this example lists three types, and its endpoint receives only those. Delete the line to receive every type, future ones included: the rest of the guides assume it.
  • url: public. Live wants https:// (port 443, 8443 or 80); test takes any port. Never localhost or a private network: develop through a tunnel with a public https:// URL.
  • The answer carries the signing secret (whsec_…), only there: store it as QBITFLOW_WEBHOOK_SECRET. Dashboard/Settings/Webhooks can also add an endpoint, and show its secret again.
  • At most 10 per space and mode (409 conflict past it). One is enough.

Widen an endpoint to every type

Created one with a short list? Send an empty events list; what you don't send (URL, secret) stays.

Shell

curl -X PATCH "https://api.qbitflow.app/v2/webhooks/endpoints/$ENDPOINT_UUID" \  -H "X-API-Key: $QBITFLOW_API_KEY" \  -H "Content-Type: application/json" \  -d '{    "events": []  }'

$ENDPOINT_UUID comes from its creation or GET /webhooks/endpoints. The dashboard's endpoint page does the same.

Handle the events

The SDK's webhook router reads the raw body, checks the QBitFlow-Signature header with your secret, and runs your handler for the event's type. It answers 200 (also for types you don't handle), 400 for a bad signature, and 500 when your handler throws, so the event comes again.

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

Avoid

Parse the JSON first

A body parser hands on a re-serialized copy, and every signature fails.

Do

Give the router the raw bytes

Mount it before any body parser: the signature covers the body exactly as sent.

Deduplicate, and answer fast

  • At least once. An event can come twice (a lost 2xx, a resend). Deduplicate on its id (also the QBitFlow-Event-Id header): it never changes across retries.
  • Within 30 seconds. Later counts as a failure. Store the event, answer 2xx, then do the slow work in the background.
  • Retries. Anything but a 2xx comes again. Live: 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours after the failed attempt, then every 12 hours, for 3 days. Test: 3 retries, 5 minutes, 30 minutes and 2 hours after the failed attempt. A live endpoint that keeps failing is turned off after 3 days (delivery): turn it back on in Dashboard/Settings/Webhooks.
  • New types come over time: answer 2xx to the ones you don't know.
  • Order holds within a batch (subscription.created, then its first subscription.billed). Across events, rely on createdAt and the resources' own dates.

Verify by hand, when you need to

Only when your framework isn't one the router wraps, or a queue hands you the raw body later:

Verify and parse a webhook (lower level)
const secret = process.env.QBITFLOW_WEBHOOK_SECRET!;try {	// rawBody: the body exactly as received; signatureHeader: its QBitFlow-Signature header.	const event = webhooks.constructEvent(rawBody, signatureHeader, secret);	if (event.type === 'payment.completed') {		console.log(`fulfil order ${event.data.reference}`); // event.data is a PaymentCompleted	}	return 200; // acknowledge every event, the types you ignore included} catch (err) {	if (err instanceof WebhookSignatureError) {		console.log(`rejected: ${err.reason}`); // e.g. noMatchingSignature		return 400;	}	if (err instanceof ValidationError) {		console.log(`not a v2 event: ${err.message}`);		return 400;	}	throw err;}
try:    event = qbitflow.webhooks.construct_event(        raw_body,  # the raw bytes as received, never re-serialized        signature_header,  # the QBitFlow-Signature header        os.environ["QBITFLOW_WEBHOOK_SECRET"],    )except qbitflow.WebhookSignatureError as exc:    print("rejected:", exc.reason)  # e.g. noMatchingSignature, timestampOutsideTolerance    return 400except qbitflow.ValidationError:    return 400  # not a v2 eventif isinstance(event, qbitflow.PaymentCompletedEvent):  # narrow the union by class    print("fulfil order", event.data.reference)return 200  # acknowledge every other type too, or QBitFlow retries it
func handleWebhook(w http.ResponseWriter, r *http.Request) {	// The raw body, exactly as received: the signature covers its bytes.	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))	if err != nil {		http.Error(w, "cannot read the body", http.StatusBadRequest)		return	}	event, err := qbitflow.ConstructEvent(rawBody, r.Header.Get(qbitflow.SignatureHeader), os.Getenv("QBITFLOW_WEBHOOK_SECRET"))	var sigErr *qbitflow.WebhookSignatureError	if errors.As(err, &sigErr) {		log.Printf("rejected: %s", sigErr.Reason) // e.g. noMatchingSignature, timestampOutsideTolerance		http.Error(w, "invalid signature", http.StatusBadRequest)		return	}	if err != nil {		http.Error(w, "invalid event", http.StatusBadRequest) // not a v2 event		return	} 	if event.Type == qbitflow.EventTypePaymentCompleted {		payment, err := event.PaymentCompleted()		if err != nil {			http.Error(w, "invalid event", http.StatusBadRequest)			return		}		// Deliveries are at least once: fulfil the order once per event.ID.		log.Printf("event %s: order %q paid", event.ID, payment.Reference)	}	w.WriteHeader(http.StatusOK) // to the types you ignore too}
$rawBody = (string) file_get_contents('php://input'); // the bytes as received: never re-serializetry {	$event = \QBitFlow\Webhooks\Webhook::constructEvent(		$rawBody,		$_SERVER['HTTP_QBITFLOW_SIGNATURE'] ?? '',		(string) getenv('QBITFLOW_WEBHOOK_SECRET'),	);} catch (\QBitFlow\Exceptions\WebhookSignatureException $e) {	http_response_code(400); // $e->reason: noMatchingSignature, timestampOutsideTolerance, …	exit;} catch (\QBitFlow\Exceptions\ValidationException) {	http_response_code(400); // not a v2 event (unsupported payload version), or not JSON	exit;} if ($event instanceof \QBitFlow\Events\PaymentCompletedEvent) {	// Deliveries are at least once: skip an $event->id you already processed.	error_log("fulfil order {$event->data->reference} ({$event->data->uuid})");}http_response_code(200); // to every type, the ignored ones too

It checks that the header's t is within 5 minutes of your clock, and that the HMAC-SHA256 of t + "." + rawBody with your secret matches one of its v1 values (the rule, step by step).

Fulfil the order

What your endpoint receives when an order is paid:

payment.completed· the body your endpoint receives

{  "createdAt": "2026-10-01T12:00:00Z",  "data": {    "amount": "10",    "amountUsd": "10",    "currency": {…},    "reference": "order-1042",    "uuid": "pay@01a0f755-f200-7000-8000-000000000001",    … 23 more fields  },  "id": "evt_82c1e15d-c0d9-584f-a65a-fb4099925273",  "test": false,  "type": "payment.completed",  "version": "v2"}
The contract's example (webhooks.md § Examples)
  1. data.amount: What the customer paid, in the token; amountUsd is the same in USD.
  2. data.reference: Your order id, as the checkout had it.
  3. data.uuid: The checkout's pay@… id, now the payment's.
  4. id: Deduplicate on it: the same on every retry and resend.
EventWhat to do
payment.completedFulfil the order data.reference names.
checkout.expiredRelease what the order held (stock, a reservation): it can no longer be paid.

Fulfil idempotently: an order already fulfilled does nothing and answers 200. With the deduplication above, a retried or resent event is harmless.

Check it

In Dashboard/Settings/Webhooks, open your endpoint and choose Send a test event: your server gets webhook.test and the attempt shows 200 (a 400: check the secret, and that your handler gets the raw body). Then pay a test checkout: payment.completed shows in Dashboard/Settings/Events, delivered with 200, and the order is fulfilled. Resend it: nothing is fulfilled twice.