How-to

Verify a webhook signature

Trust a delivery only once its signature checks out. The SDKs do it locally, no call to QBitFlow.

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

Answer 400 when the check fails, and 2xx to every verified event, the types you ignore included: anything but a 2xx is retried.

What the check does

Without an SDK, this is the whole check, as the contract states it:

From the contract · webhooks.md § Signature

  1. t=1790856000: when it was signed, in unix seconds: refuse it more than 5 minutes from your clock
  2. v1=4a158046…2f4d: the hex HMAC-SHA256 of t + "." + the raw body, keyed with your endpoint's secret
  3. v1=1cc78a62…c4f5: for 24 hours after a rotation only: the same, with the previous secret
  1. Read the raw body, byte for byte as received.
  2. Split the header on , and each part on its first =: keep t and every v1. No t or no v1: invalid.
  3. Refuse a t more than 5 minutes from your clock, either way: a replay.
  4. Compute the HMAC-SHA256 of t + "." + rawBody with your secret, in lowercase hex, once.
  5. Compare it with each v1 in constant time. Any match is valid.

Why a valid delivery fails

Avoid

Verify the parsed JSON

Most frameworks parse the body before your handler: serialized again, it never matches.

Do

Verify the raw bytes

Give the route the body as received. Your framework shows where each SDK mounts.
  • The wrong secret. Each endpoint has its own whsec_…, and test and live endpoints are separate. The API returns it once, at creation; Settings › Webhooks can show it again.
  • A clock off by more than 5 minutes. Keep it synced (NTP).
  • Only the first v1 checked. For 24 hours after a rotation the header carries two signatures. Check them all.

Rotate a secret without losing a delivery

Rotate it in Settings › Webhooks: for the next 24 hours every delivery is signed with both secrets, so you deploy the new one when ready. A second rotation within that window ends the first overlap at once.

Test your verifier