Webhooks and events
Every event, posted to your endpoints, signed and retried until you answer 2xx. Fulfil here.
Live mode · from the contract
- Live mode: QBitFlow signs each event and posts it to your endpoint, then waits for a 2xx answer within 30 seconds. Otherwise it tries again 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours later, then every 12 hours, for 3 days. An endpoint that fails every delivery for 3 days is turned off. If your team's failure emails are on, your team hears of it after a day of failures, and when it's turned off.
- Test mode: QBitFlow signs each event and posts it to your endpoint, then waits for a 2xx answer within 30 seconds. Otherwise it tries again 5 minutes, 30 minutes and 2 hours later, then gives up on that event. A test endpoint is never turned off for failing, and nobody is emailed about it: it's usually only reachable while you test.
Endpoints
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";- POST /webhooks/
endpoints takes a public url(https://in live) and theeventsit receives. Delete theeventsline to receive every type, new ones included (widen an existing endpoint). - The answer carries its secret (
whsec_…), in this response only: store it now. - Up to 10 per space and mode. A test endpoint only gets test events; live endpoints are created again in live, with their own secrets.
- An organization endpoint also receives every member's events, by default (
includeMembers).
Signatures
Deliveries come from workers without fixed IPs: verify QBitFlow-Signature, don't allow-list.
- Read
tand everyv1; refuse atmore than 5 minutes from your clock. - Compute the hex HMAC-SHA256 of
t + "." + rawBodywith your secret, on the raw bytes: a framework that re-serializes the body breaks every signature, so mount the handler before any body parser. - Compare it with each
v1, in constant time. For 24 hours after a rotation, the previous secret signs too.
The SDKs do it all, with no call to QBitFlow (the whole check):
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 tooDelivery and retries
- At least once. The same event can come twice: deduplicate on its
id. - Any
2xxwithin 30 seconds is delivered. Anything else (a redirect, a4xx, a5xx, a timeout) is retried. A late answer is a failed attempt, retried even if you processed it: store the event, answer, then work. - Order holds within one publication (
subscription.createdbefore its firstsubscription.billed). Across events, usecreatedAtand the resources' dates. - A failing live endpoint is turned off after 3 days without a
delivery. Its team is emailed after 24 hours of failures, and when it's
turned off. Turn it back on in Dashboard/Settings/Webhooks
or with PATCH /webhooks/
endpoints/ :uuid ( {"enabled": true}). A test endpoint is never turned off for failing: it's usually up only while you test.
The event log and resends
Events are kept at least 90 days. GET /webhooks/
events-list.sh
# Newest first, one page: pass nextCursor back as ?cursor= for the next.curl "https://api.qbitflow.app/v2/webhooks/events?type=payment.completed&limit=10" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: { "items": [ { "id": "evt_…", "type": "payment.completed", "createdAt": "…", … } ], "hasMore": true, "nextCursor": "evt_…" }// Newest first, each exactly as delivered; one page (iterate walks them all).const page = await client.webhooks.events.list({ type: EventType.PaymentCompleted, limit: 10 });for (const event of page.items) { console.log(`${event.id} ${event.type} at ${event.createdAt}`);}if (page.hasMore) { console.log(`more: pass ${page.nextCursor} as cursor`); // opaque, never an event id}webhook_endpoints.py
page = client. webhooks. events. list( type=qbitflow. EventType. PAYMENT_COMPLETED, limit=20)for event in page.items: # newest first, each exactly as delivered print(event.id, event.type, event.created_at)if page.has_more: print("more: pass", page.next_cursor, "as cursor=") # opaque, never an event idpage, err := client.Webhooks.Events.List(ctx, &qbitflow.EventListParams{ Type: qbitflow. EventTypePaymentCompleted, Limit: 20,})if err != nil { log.Fatal(err)}for _, e := range page.Items { // newest first, each exactly as delivered fmt.Println(e.ID, e.Type, e. CreatedAt. Format( time. RFC3339))}if page.HasMore { fmt.Println("more: pass", page.NextCursor, "as Cursor") // opaque, never an event id}$page = $client->webhooks->events->list( new \QBitFlow\Params\EventListParams( type: \QBitFlow\Enums\EventType::PAYMENT_COMPLETED, limit: 20,));foreach ($page->items as $event) { // newest first, each exactly as delivered echo "{$event->id} {$event->type} {$event->createdAt->format(DATE_ATOM)}\n";}if ($page->hasMore) { echo "More: pass {$page->nextCursor} as cursor\n"; // opaque, never an event id}POST /webhooks/webhook.test (30 an hour per space). Both are rate-limited
(limits).
What to do on each event
Each delivery is one event, as JSON:
payment.completed· the body your endpoint receives
{ "createdAt": "2026-10-01T12:00:00Z", "data": { "reference": "order-1042", "uuid": "pay@01a0f755-f200-7000-8000-000000000001", … 26 more fields }, "id": "evt_82c1e15d-c0d9-584f-a65a-fb4099925273", "test": false, "type": "payment.completed", "version": "v2"}data: The resource, as the API returns it to its owner.id:evt_…: the same across retries and resends. Deduplicate on it (also theQBitFlow-Event-Idheader).test: Test or live. A member's events also carryuserUuid, the space it happened in.type: What happened. New types will come: answer2xxto the ones you don't know.
Avoid
Fulfil on the redirect
Do
Fulfil on the webhook
payment.completed, subscription.created, or the session's status.| Event | Do |
|---|---|
payment.completed | fulfil the order |
checkout.expired | release what the order held |
subscription.created | grant access until accessUntil; store the sub@… id |
subscription.billed | record the payment; access runs until subscription.accessUntil |
subscription.billingFailed | tell your customer what to do (reason, managementPageLink) |
subscription.statusChanged | apply your access rule (billing) |
refund.requested | review it in the dashboard |
refund.approved | reverse the order, in full or in part |
member.joined, heldFunds.released | onboard the seller; reconcile what you paid out |