Verify a webhook signature
Trust a delivery only once its signature checks out. The SDKs do it locally, no call to QBitFlow.
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 tooAnswer 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
t=1790856000: when it was signed, in unix seconds: refuse it more than 5 minutes from your clockv1=4a158046…2f4d: the hex HMAC-SHA256 of t + "." + the raw body, keyed with your endpoint's secretv1=1cc78a62…c4f5: for 24 hours after a rotation only: the same, with the previous secret
- Read the raw body, byte for byte as received.
- Split the header on
,and each part on its first=: keeptand everyv1. Notor nov1: invalid. - Refuse a
tmore than 5 minutes from your clock, either way: a replay. - Compute the HMAC-SHA256 of
t + "." + rawBodywith your secret, in lowercase hex, once. - Compare it with each
v1in 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
v1checked. 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
- Webhook events › Verify the signature has the
contract's worked example: accept both secrets at
t + 60 s, refuse every secret att + 6 min. - POST /webhooks/
endpoints/ :uuid/ test sends a signed webhook.test(30 an hour per space). - POST /webhooks/
verify runs the check on QBitFlow's side with the current secret: for debugging, never needed in production.