How to secure crypto payment webhooks
What protects a payment webhook endpoint: verifying the X-Qvard-Signature header, idempotent handling, exact status codes, retries and logging. Based on how Qvard webhooks really work.

The webhook is the moment your shop decides that an order is paid. Anyone who can call that endpoint with the right body can try to get goods for free, so it deserves more care than most routes. This guide describes what protects a payment webhook, based on how Qvard webhooks actually work today, including the X-Qvard-Signature header that lets you check that a notification came from Qvard.
What a Qvard webhook is, and what it is not
When a payment completes, Qvard sends a POST request to the callback URL saved in your project. The body is small: the id you gave when you created the payment and the payment status. The webhook is sent when the payment is completed, which is the signal to mark the order as paid.
The request carries an X-Qvard-Signature header in the form t=<unix>,v1=<hex>: an HMAC-SHA256 of the timestamp and the raw body, computed with your secret key. Two v1 values are sent for 24 hours after a key rotation, and either one is valid. Merchants with a legacy key (one without the qvard_sk_ prefix) also receive an x-api-key header on the webhook; verify the signature anyway.
Two properties of the design shape everything else: your endpoint is a public URL, and the only secret protecting it is your secret key, used to verify the signature.
Threats that actually matter
A short list is more useful than a long one:
- A forged request. Someone who finds your endpoint posts
{ "id": "1234", "status": "completed" }to get an order marked paid. The key check is what stops this. - A leaked key. The same secret key signs the webhook and authenticates your calls to the API. If it appears in a repository, a log or front-end code, both are exposed.
- A repeated delivery. Not an attack but the most common cause of real damage: the same event arrives twice and the order is fulfilled twice.
- A slow or failing endpoint. Missed deliveries leave paid orders unfulfilled.
- Sloppy logging. Logging the full request puts the key in your log files.
Each of the next sections closes one of these.
Check the key, and compare it safely
Read the X-Qvard-Signature header on every request and verify it over the raw body before doing anything else. Two details matter:
- Constant-time comparison. A plain
===can leak information about how many leading characters matched.constructEventin the SDK compares in constant time. If you verify by hand, use your language's constant-time function (crypto.timingSafeEqualin Node.js,hash_equalsin PHP,hmac.compare_digestin Python). Check lengths first where the function requires equal sizes. - Reject quietly. Answer
400with no explanation. Do not say whether the signature was missing or wrong.
The example below shows the whole handler: key check, a small body limit, validation of the fields, an update that only changes an order that is not paid yet, and an answer of exactly 200. Keep the key in an environment variable or a secrets manager, never in the repository or in front-end code.
import express from 'express';
import { Qvard } from '@qvard/node';
const secret = process.env.QVARD_SECRET_KEY;
const qvard = new Qvard(secret);
const app = express();
app.post('/webhooks/qvard', express.raw({ type: 'application/json', limit: '10kb' }), async (req, res) => {
let event;
try {
event = qvard.webhooks.constructEvent(req.body, req.get('X-Qvard-Signature'), secret);
} catch {
return res.sendStatus(400);
}
const { id, status } = event;
if (typeof id !== 'string' || status !== 'completed') return res.sendStatus(200);
// Idempotent: the update only matches an order that is not paid yet.
const changed = await db.orders.markPaidIfNot(id);
if (changed) queue.push({ type: 'fulfil', orderId: id }); // slow work happens after the answer
return res.sendStatus(200); // exactly 200
});Make the handler idempotent
Qvard treats a delivery as successful only when your endpoint answers exactly HTTP 200. Any other status, a timeout or a redirect counts as a failure and is retried: five attempts in total over roughly six minutes. The consequence is that your handler will sometimes see the same event more than once, for example when your server did the work but the answer was lost.
Design for that:
- Make the state change conditional: "mark this order paid if it is not paid yet", ideally as a single database statement, so two parallel deliveries cannot both win.
- Start fulfilment (emails, keys, access) only when the update actually changed a row.
- Ignore events for orders you do not know, but still answer 200, otherwise Qvard keeps retrying something you will never handle.
- Act on
completedonly. Any other status should not mark an order as paid.
This is also the defence against a replayed request: a repeated valid event changes nothing the second time.
Answer fast, work later
Your endpoint has to respond before Qvard gives up, so keep it short. Check, update, enqueue, answer. Do the slow parts, such as sending emails, calling other systems and generating files, from a queue after the answer.
Also think about what your endpoint does not know. The body carries only the id and the status, so the amount you expected comes from your own order, not from the webhook. Never take an amount, a currency or a product from the request body.
Serve the endpoint over HTTPS only, and make sure no proxy or redirect sits in front of it: redirects are not followed, so a 301 from http to https is a failed delivery.
Logs, monitoring and recovery
Treat webhooks like money movements and keep a trail.
- Log what you decided, not the secret. Store the
id, thestatus, the time and your decision (applied, duplicate, unknown order, rejected key). Never log your secret key. - Alert on rejections. A sudden run of
400answers means either someone is probing the endpoint or you rotated your key somewhere and not everywhere. - Reconcile. Once a day, compare the orders you consider paid with your Qvard balance and payments in the cabinet.
- Recover. If your server was down longer than the retry window, resend the notification for that payment from the cabinet and let your idempotent handler deal with it.
If you suspect the key has leaked, replace it, and review which of your systems used it.
A checklist before you go live
- The endpoint is HTTPS, public and reachable from outside your network.
- It verifies the
X-Qvard-Signatureheader over the raw body and answers400otherwise. - It limits the body size and validates
idandstatus. - It marks an order paid only on
completed, only if it is not paid yet. - It answers exactly 200 quickly, and does slow work afterwards.
- It never trusts amounts or products from the body.
- It never logs the key.
- You tested a failing answer, a duplicate delivery and an unknown order.
For the full flow around the webhook, see how to accept crypto payments on a website. The request and response formats are in the developer documentation.
FAQ
Does Qvard sign webhooks with HMAC?
Yes. Each webhook carries an X-Qvard-Signature header (t=<unix>,v1=<hex>): an HMAC-SHA256 of the timestamp and the raw body, computed with your secret key. Verify it over the raw body before parsing JSON; the SDK does this with constructEvent.
What status code must my endpoint return?
Exactly 200. Any other status, a timeout or a redirect counts as a failed delivery and is retried, five attempts in total over about six minutes.
What if my server was down when the payment completed?
Retries cover about six minutes. After that, resend the notification for the payment from the cabinet. An idempotent handler makes this safe.
Can I trust the amount in the webhook?
The webhook carries only the payment id and its status. Use the amount from your own order and mark it paid only on the completed status.
