Webhooks

Webhook retry strategy for payment systems

How Qvard retries a payment webhook, what counts as a failed delivery, and how to build a handler that survives retries, repeats and downtime without double-fulfilling an order.

A payment webhook is a message that must not be lost and must not be acted on twice. Networks fail and servers restart, so the sender retries; that is the reason your handler has to be written for repeats. This guide describes how Qvard retries, what it counts as a failure and how to build a handler and a recovery routine around that behaviour.

When a webhook is sent

Qvard sends the webhook when a payment is completed. It is a POST to the callback URL saved in your project, with a small JSON body containing your id (the identifier you gave when you created the payment) and the status, signed in the X-Qvard-Signature header.

A payment that is short, rejected or still waiting does not produce a completion webhook. If you need to know about such payments, look at them in the cabinet; do not wait for a message that is not sent.

What counts as delivered

Qvard treats a delivery as successful only when your endpoint answers exactly HTTP 200. Everything else is a failure:

  • any other status code, including 201, 202, 204 and every 4xx and 5xx;
  • a timeout, because your server was slow or unreachable;
  • a redirect: Qvard does not follow 301 or 302, so a rule that redirects http to https or adds a trailing slash makes the delivery fail.

This precision is deliberate. A 204 No Content looks like a perfectly good answer and is still a failed delivery. Return 200, with an empty body if you like.

How the retries work

After a failed delivery Qvard tries again. In total there are five attempts over roughly six minutes. Two consequences follow:

  • A short outage is covered. If your server restarts or a deploy takes a minute or two, a later attempt succeeds and you lose nothing.
  • A longer outage is not. After the last attempt Qvard stops. If your server was down for more than those six minutes, the notification does not arrive by itself; resend it for that payment from the cabinet.

Because of retries your handler will sometimes see the same event more than once, for example when your server did the work but the answer was lost on the way back. The example below is built for that: the update only changes an order that is not paid yet, the fulfilment is queued only when a row changed, and the answer is 200 either way.

Pythonwebhook.py
import os
from fastapi import FastAPI, Request, Response
from qvard import Qvard

app = FastAPI()
SECRET = os.environ["QVARD_SECRET_KEY"]
qvard = Qvard(SECRET)

@app.post("/webhooks/qvard")
async def qvard_webhook(request: Request):
    try:
        body = qvard.webhooks.construct_event(await request.body(), request.headers.get("X-Qvard-Signature", ""), SECRET)
    except ValueError:
        return Response(status_code=400)

    order_id, status = body.get("id"), body.get("status")

    # Idempotent: the UPDATE changes a row only for an order that is not paid yet.
    if status == "completed":
        changed = await db.execute(
            "UPDATE orders SET state = 'paid' WHERE id = :id AND state <> 'paid'", {"id": order_id}
        )
        if changed:
            await queue.enqueue("fulfil", order_id)   # slow work after the answer

    return Response(status_code=200)   # exactly 200, also for repeats and unknown orders

What your handler must do

Four rules cover almost every problem:

  1. Verify the signature. Check the X-Qvard-Signature header (HMAC-SHA256 with your secret key over the raw body) in constant time and answer 400 otherwise. Do this before you act on the payment. See how to secure crypto payment webhooks.
  2. Be idempotent. Make the state change conditional, "mark paid if not paid yet", ideally in a single database statement, so two parallel deliveries cannot both win.
  3. Answer fast. Check, update, enqueue, answer exactly 200. Do emails, file generation and calls to other systems from a queue, so a slow downstream service does not turn into a timeout on the webhook.
  4. Answer 200 for what you will never handle. An unknown order or a repeat should still return 200, otherwise Qvard keeps retrying a message you have already dealt with or never will.

Failures that look like Qvard bugs

Most "the webhook did not arrive" reports have a cause on the receiving side:

  • A firewall or WAF blocking requests from unknown addresses, or a "coming soon" or maintenance page.
  • A redirect from http to https, from www to the bare domain or to a trailing slash.
  • A framework error after the response was already decided, for example an exception in a middleware that turns your 200 into a 500.
  • A slow handler that does the work before answering and runs into a timeout.
  • A cache or CDN in front of the route.
  • The wrong URL saved in the project, or a typo in the route.

Test the endpoint from outside your network with a request that carries a valid X-Qvard-Signature header, and check the access log of your server for the answer code, not just whether the request arrived.

A recovery routine for missed notifications

Retries cover short outages; for the rest, add a safety net.

  • Log every delivery with the id, the status, your decision (applied, repeat, unknown order, rejected key) and the time. A missing log line is how you notice a gap.
  • Reconcile on a schedule. Once a day, compare the orders you still consider unpaid with the payments and balance in your Qvard cabinet. Anything the cabinet shows as completed and you show as unpaid is a missed notification.
  • Resend from the cabinet for those payments and let the idempotent handler apply them.
  • Alert when a run of requests is answered with an error, so you learn about a broken endpoint in minutes, not at the end of the day.

The rest of the integration, including creating the payment, is in how to accept crypto payments on a website and crypto payment API for developers.

FAQ

How many times does Qvard retry a webhook?

There are five attempts in total over roughly six minutes. After that Qvard stops, and you can resend the notification for the payment from the cabinet.

What status code should my endpoint return?

Exactly 200. Any other status, including 204, a timeout or a redirect, counts as a failed delivery and is retried.

Can the same webhook arrive twice?

Yes, for example when your server processed the first delivery but the answer was lost. Make the handler idempotent and still answer 200 to repeats.

Do I get a webhook for a short or rejected payment?

No. The webhook is sent when a payment is completed. Check other payments in the cabinet.

Related articles

Related articles