How to verify a Qvard webhook
A short guide to the X-Qvard-Signature header: what it is and how to check it in a few lines of code.
What is a signature
When a payment changes status, Qvard sends a notification (a webhook) to the address in your shop settings. Anyone can send a request to that address, so you need a way to tell a real notification from a fake one.
The signature is that proof: a short code that Qvard calculates from the message and your secret key. Only Qvard and you know the key, so only Qvard can produce the right code.
What you receive
Every webhook carries one extra header and a small JSON body:
X-Qvard-Signature: t=1760950000,v1=3f2a…c91e
{"id":"order_1024","status":"paid"}t is the time the message was sent (Unix seconds). v1 is the code. For 24 hours after you rotate your secret key the header carries two v1 values: one for the new key and one for the old key.
The payload keeps id and status as before. Four money fields are added to the same signed body:
- amount and currency: the sum and its currency, as you sent them in the request.
- amountUsd: the sum in USD that is used in accounting.
- rate: units of currency for 1 USD at the moment the payment was created.
- Older integrations keep working: they read id and status and ignore the new fields. The signature is checked on the same body as before.
How to check it
- 1Take the request body exactly as it arrived: the raw text, before any JSON parsing.
- 2Read t and every v1 from the X-Qvard-Signature header.
- 3Compute HMAC-SHA256 of the text t + "." + body with your secret key (qvard_sk_…) and write it as lowercase hex.
- 4If your result equals any v1 (compare in constant time), the message is genuine.
- 5Check that t is not older than 5 minutes, so an old message cannot be replayed.
- 6Only then use the data, and confirm the paid amount for that order in your own database.
Ready code
const crypto = require('crypto'); function verifyQvardSignature(rawBody, header, secret, toleranceSec = 300) { const parts = header.split(',').map((p) => p.trim()); const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2)); const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3)); if (!t || sigs.length === 0) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest(); return sigs.some((s) => { const got = Buffer.from(s, 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected); }); } // Use the RAW body: express.raw, not express.json app.post('/qvard-webhook', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); if (!verifyQvardSignature(raw, req.get('X-Qvard-Signature') || '', process.env.QVARD_SECRET_KEY)) { return res.sendStatus(400); } const { id, status } = JSON.parse(raw); // Confirm the paid amount for order `id` in your own database before fulfilling it. res.sendStatus(200); });
<?php function verifyQvardSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $t = 0; $sigs = []; foreach (explode(',', $header) as $part) { $part = trim($part); if (strpos($part, 't=') === 0) { $t = (int) substr($part, 2); } elseif (strpos($part, 'v1=') === 0) { $sigs[] = substr($part, 3); } } if (!$t || !$sigs || abs(time() - $t) > $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $s) { if (hash_equals($expected, $s)) { return true; } } return false; } $raw = file_get_contents('php://input'); if (!verifyQvardSignature($raw, $_SERVER['HTTP_X_QVARD_SIGNATURE'] ?? '', getenv('QVARD_SECRET_KEY'))) { http_response_code(400); exit; } $event = json_decode($raw, true); // ['id' => ..., 'status' => ...] http_response_code(200); ?>
The header carries two v1 values for 24 hours after you rotate your secret key; accept the webhook if any one matches.
Common mistakes
- Parsing the JSON and serializing it again changes the text. Always use the raw body.
- Using the publishable key (qvard_pk_…) instead of the secret key.
- A server clock that is out of sync makes the 5-minute check fail. Keep it synced.
- Comparing with == instead of a constant-time comparison.
Where to find your secret key
In the cabinet: your shop, Settings, API keys. Keep it on your server only. If it leaks, rotate it there: the old key keeps working for 24 hours for payments (not for payouts).
Older keys
If your shop still has an old-format key (without the qvard_sk_ prefix), webhooks also carry an x-api-key header with that key, as before. After you rotate it, that header disappears. Switch to checking the signature.