Concepts

Webhooks and events

Every event, posted to your endpoints, signed and retried until you answer 2xx. Fulfil here.

Live mode · from the contract

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.
  • 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

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";
  • POST /webhooks/endpoints takes a public url (https:// in live) and the events it receives. Delete the events line 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.

  1. Read t and every v1; refuse a t more than 5 minutes from your clock.
  2. Compute the hex HMAC-SHA256 of t + "." + rawBody with your secret, on the raw bytes: a framework that re-serializes the body breaks every signature, so mount the handler before any body parser.
  3. 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):

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

Delivery and retries

  • At least once. The same event can come twice: deduplicate on its id.
  • Any 2xx within 30 seconds is delivered. Anything else (a redirect, a 4xx, a 5xx, 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.created before its first subscription.billed). Across events, use createdAt and 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 lists your space's, newest first; GET /webhooks/events/:id shows each endpoint's attempts.

List events

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}
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 id
page, 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/events/:id/resend posts an event to one endpoint again, now; a delivered resend ends its retries. POST /webhooks/endpoints/:uuid/test sends 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"}
The contract's example (webhooks.md § Examples)
  1. data: The resource, as the API returns it to its owner.
  2. id: evt_…: the same across retries and resends. Deduplicate on it (also the QBitFlow-Event-Id header).
  3. test: Test or live. A member's events also carry userUuid, the space it happened in.
  4. type: What happened. New types will come: answer 2xx to the ones you don't know.

Avoid

Fulfil on the redirect

Anyone can open your success URL. Reaching it proves nothing.

Do

Fulfil on the webhook

payment.completed, subscription.created, or the session's status.
EventDo
payment.completedfulfil the order
checkout.expiredrelease what the order held
subscription.createdgrant access until accessUntil; store the sub@… id
subscription.billedrecord the payment; access runs until subscription.accessUntil
subscription.billingFailedtell your customer what to do (reason, managementPageLink)
subscription.statusChangedapply your access rule (billing)
refund.requestedreview it in the dashboard
refund.approvedreverse the order, in full or in part
member.joined, heldFunds.releasedonboard the seller; reconcile what you paid out