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:
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";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 wantshttps://(port 443, 8443 or 80); test takes any port. Neverlocalhostor a private network: develop through a tunnel with a publichttps://URL.- The answer carries the signing secret (
whsec_…), only there: store it asQBITFLOW_WEBHOOK_SECRET. Dashboard/Settings/Webhooks can also add an endpoint, and show its secret again. - At most 10 per space and mode (
409 conflictpast 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/
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.
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();Avoid
Parse the JSON first
Do
Give the router the raw bytes
Deduplicate, and answer fast
- At least once. An event can come twice (a lost
2xx, a resend). Deduplicate on itsid(also theQBitFlow-Event-Idheader): 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
2xxcomes 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
2xxto the ones you don't know. - Order holds within a batch (
subscription.created, then its firstsubscription.billed). Across events, rely oncreatedAtand 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:
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 itwebhook_verify/main.go
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 tooIt 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"}data.amount: What the customer paid, in the token;amountUsdis the same in USD.data.reference: Your order id, as the checkout had it.data.uuid: The checkout'spay@…id, now the payment's.id: Deduplicate on it: the same on every retry and resend.
| Event | What to do |
|---|---|
payment.completed | Fulfil the order data.reference names. |
checkout.expired | Release 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.