Grant and revoke access
One rule, no state machine. Grant access while now is before accessUntil, whatever the status.
accessUntil is the paid-through date (currentPeriodEnd), an hour later for an active
subscription: its renewal is billed within minutes of its due date, so a subscriber whose bill is on
its way keeps access. Granting while status is active instead would cut off stopped and
paused customers who paid, and serve one whose bill failed. Checking currentPeriodEnd would cut
every subscriber off for a few minutes at each renewal: show it as "paid through", don't gate on it.
accessUntil vs now
- trial: accessUntil is the trial's end, still ahead: access
- active: accessUntil is an hour after the period paid for, still ahead: access
- paused: accessUntil is the end of the period paid for, still ahead: access
- stopped: accessUntil is the end of the period paid for, still ahead: access
- pastDue: accessUntil is the start of the unpaid period, already past: no access
- trialExpired: accessUntil is the trial's end, already past: no access
- cancelled: accessUntil is when access ended, already past: no access
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).
Act on the subscription events
Subscriptions need every type: create the endpoint without events, or
widen the one you have, then handle each on one router.
| Event | What to do |
|---|---|
subscription.created | Store it (below). Grant access until accessUntil (a trial's end, for trial). |
subscription.billed | Store data.subscription.accessUntil: access runs until then. If data.subscription.status is cancelled (a late charge), record it, grant nothing. |
subscription.statusChanged | Store the new accessUntil. cancelled is the end (cancellationReason says why). |
subscription.actionRequiredChanged | Ask the customer to act (topUpAllowance, raiseMaximum, confirmTrial) on their page. Don't cut access for it. |
subscription.billingFailed | Tell the customer what to do (next step). |
Later steps add subscription.upcomingBill, checkout.expired and the refund.* events. Without a
trial, subscription.created always comes before the first subscription.billed; a trial sends
subscription.created alone.
subscription.created· the body your endpoint receives
{ "createdAt": "2026-10-01T12:00:00Z", "data": { "accessUntil": "2026-10-31T13:00:00Z", "currentPeriodEnd": "2026-10-31T12:00:00Z", "managementPageLink": "https://qbitflow.app/user/019cadfd-8900-7a00-8000-0000000000a1/manage?uuid=sub@01a0f755-c320-7000-8000-000000000002", "nextBillingAt": "2026-10-31T12:00:00Z", "status": "active", "uuid": "sub@01a0f755-c320-7000-8000-000000000002", … 17 more fields }, "id": "evt_4fad5fdd-5eed-5d81-a971-ba2ade1e6c20", "test": false, "type": "subscription.created", "version": "v2"}data.accessUntil: Grant access until then: an hour pastcurrentPeriodEndwhileactive.data.currentPeriodEnd: Paid through then: show it, don't gate on it.data.managementPageLink: The customer's own page.data.uuid: The checkout'ssub@…, for life.
When the trial ends
To be billed after a trial, the customer confirms: signs the subscription, active under the same
sub@… (subscription.statusChanged, then its first subscription.billed).
- A few days before:
subscription.upcomingBillwithtrialEnding: true. Remind them. - Unconfirmed at the end:
trialExpired,actionRequired: "confirmTrial"(subscription.statusChanged, thensubscription.actionRequiredChanged). Live mode emails the customer the link. - Then 7 days of grace to confirm, then
cancelled(cancellationReason: "trialNotConverted"). Access meanwhile is your call: the rule says no.
Check access when you need to
has-access.sh
# Grant access while now < accessUntil, whatever the status: a stopped or paused# subscription has paid for its period, an active one keeps an hour while its renewal confirms.curl "https://api.qbitflow.app/v2/subscriptions/$SUBSCRIPTION_UUID" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: { "uuid": "sub@…", "status": "active", "currentPeriodEnd": "2026-10-31T12:00:00Z", "accessUntil": "2026-10-31T13:00:00Z", … }// Grant access while now < accessUntil, whatever the status: a stopped or paused// subscription has paid for its period, and an active one keeps it while its renewal is billed.const subscription = await client. subscriptions. get( subscriptionUuid);if (hasAccess(subscription)) { console.log('serve the premium content');} else { console.log('access ended: show the renewal page');}subscription = client. subscriptions. get( subscription_uuid)# now is before access_until, whatever the status: the end of the period paid for, plus an# hour for an active subscription while its renewal is billed.if subscription.has_access(): print("grant access")else: print("no access")subscriptions/main.go
// Access lasts while now < AccessUntil, whatever the status: the end of the period paid// for, plus an hour for an active subscription while its renewal is billed.sub, err := client.Subscriptions.Get(ctx, subscriptionUUID, nil)if err != nil { log.Fatal(err)}if sub.HasAccess(time.Now()) { // now before AccessUntil fmt.Println("access granted until", sub. AccessUntil. Format( time. RFC3339))} else { fmt.Println("no access")}$subscription = $client->subscriptions->get( $subscriptionUuid);// now < accessUntil, whatever the status: the end of the period paid for, plus an hour for an// active subscription while its renewal is billed.if ($subscription->hasAccess()) { echo "Access granted\n";} else { echo "No access: the paid period has ended\n";}The SDKs apply the rule. The whole subscription (status, actionRequired, dunning):
subscriptions-get.sh
# A subscription by its uuid (sub@…), whatever its status. Before its checkout completes: 404.curl "https://api.qbitflow.app/v2/subscriptions/$SUBSCRIPTION_UUID" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a Subscription// Any status, cancelled included. Before its checkout completes, it is a 404.const subscription = await client. subscriptions. get( subscriptionUuid);console. log( `${subscription. uuid}: ${subscription.status}`);console.log(`paid until ${subscription.currentPeriodEnd ?? '—'}`);subscription = client. subscriptions. get( subscription_uuid) # sub@…, cancelled ones tooprint(subscription.status, "paid until", subscription.current_period_end)subscriptions/main.go
sub, err := client.Subscriptions.Get(ctx, subscriptionUUID, nil) // sub@…, cancelled ones tooif err != nil { log.Fatal(err)}fmt.Println(sub.UUID, sub.Status) // trial, active, pastDue, stopped, cancelled…if sub.CurrentPeriodEnd != nil { fmt.Println("paid until", sub. CurrentPeriodEnd. Format( time. RFC3339))}$subscription = $client->subscriptions->get( $subscriptionUuid); // sub@…printf("%s: %s, paid until %s\n", $subscription->uuid, $subscription->status, $subscription->currentPeriodEnd?->format( 'Y-m-d') ?? '-');Check it
In Dashboard/Settings/Webhooks, Send a test event:
webhook.test gets 200 (a 400: check the secret and the raw body). Subscribe without a trial:
Dashboard/Settings/Events shows subscription.created
(active) then subscription.billed, both 200. A trial: created alone.