Security

Webhook Security: HMAC Signature Verification, Raw Bodies and Idempotency

KriptoGo Team··4 min read

A payment webhook is an HTTP request that says 'you have been paid'. If you trust it without proof, anyone can send it. This guide covers the proof, the pitfalls and the code.

What a webhook is

Instead of your server asking "has this invoice been paid?" every few seconds, the gateway calls your server when something happens. KriptoGo sends a POST request with a JSON body to your callback URL when an invoice is confirmed on-chain. The body contains the invoice id, amount, paid amount, transaction hash and status.

The threat model

Your webhook URL is not secret; it appears in logs, browser tools and configuration. An attacker who finds it can send a request claiming that their order is paid. Without verification you would deliver the product for free. A signature proves that the request was produced by someone who holds your secret key, which only you and the gateway have.

How HMAC signatures work

The gateway computes HMAC-SHA256(secretKey, rawBody) and sends the hex result in the X-Kriptogo-Signature header (and with a sha256= prefix in X-Kriptogo-Signature-256). You compute the same value from the body you received and your stored secret. If they match, the body is authentic and unchanged. An attacker without the secret cannot produce a valid signature for any body.

Why the raw body matters

The signature covers the exact bytes the gateway sent. If your framework parses the JSON and you re-serialise it, key order, whitespace or number formatting can change and the signature will not match. Always capture the raw request body before parsing: express.json({ verify }) in Node, php://input in PHP, request.get_data() in Flask, request.body in Django.

Constant-time comparison

Comparing strings with === or == stops at the first different character, which leaks timing information an attacker can measure. Use crypto.timingSafeEqual (Node), hash_equals (PHP) or hmac.compare_digest (Python). Check lengths first in Node, because timingSafeEqual throws on different lengths.

Node.js example

Node.js · webhook
app.use('/kriptogo-webhook', express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }));

app.post('/kriptogo-webhook', async (req, res) => {
  const expected = crypto.createHmac('sha256', process.env.KRIPTOGO_SECRET_KEY).update(req.rawBody).digest('hex');
  const got = req.get('X-Kriptogo-Signature') || '';
  if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.status(401).send('bad signature');
  }
  res.sendStatus(200);                                  // respond first
  const { invoice_id, status, paid_amount } = req.body;
  if (status === 'PAID') await orders.markPaidOnce(invoice_id, paid_amount); // idempotent
});

PHP example

kriptogo-webhook.php
<?php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, getenv('KRIPTOGO_SECRET_KEY'));
$received = $_SERVER['HTTP_X_KRIPTOGO_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) { http_response_code(401); exit; }
http_response_code(200);
$data = json_decode($raw, true);
if (($data['status'] ?? '') === 'PAID') { markPaidOnce($data['invoice_id'], $data['paid_amount']); }

Python example

Flask
@app.post("/kriptogo-webhook")
def webhook():
    raw = request.get_data()
    expected = hmac.new(os.environ["KRIPTOGO_SECRET_KEY"].encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Kriptogo-Signature", "")):
        return "bad signature", 401
    data = request.get_json()
    if data.get("status") == "PAID":
        mark_paid_once(data["invoice_id"], data["paid_amount"])
    return "ok", 200

Retries and idempotency

If your endpoint times out or returns an error, KriptoGo retries up to three times. That means the same invoice can arrive more than once, and a network hiccup can deliver a duplicate even after you processed the first one. Make the handler idempotent: store the invoice id with the order, and if the order is already paid, return 200 and do nothing. A database unique constraint on the invoice id is the simplest guarantee.

Respond fast, work later

The gateway waits 10 seconds for a response. Verify the signature, write the minimal state change, respond 200, and do slow work (emails, inventory, third-party calls) afterwards or in a queue. A handler that sends an email before responding will time out on a slow mail server and trigger unnecessary retries.

HTTPS and URL hygiene

Use HTTPS so the body cannot be read or modified in transit. KriptoGo only accepts public https callback URLs. Do not put secrets in the URL; the signature already authenticates the request. Log the invoice id and the outcome for every delivery so support questions can be answered from logs.

Checklist

  • Raw body captured before parsing
  • HMAC-SHA256 with the secret from an environment variable
  • Constant-time comparison, length checked
  • 401 on mismatch, nothing else happens
  • 200 returned within 10 seconds
  • Invoice id processed at most once
  • paid_amount compared with the expected amount
  • HTTPS endpoint, no secrets in the URL

Summary

Webhook security is three habits: verify the signature over the raw body, compare in constant time, and process each event once. The code above is ready to paste; the full API reference is in the docs.

Regulatory noteThe use of crypto assets for payments is regulated differently from country to country, and some jurisdictions restrict it. This article is a technical guide, not legal or tax advice. Confirm with a professional that your activity complies with the laws of the country you operate in.

Frequently asked questions

Can I skip the signature if my URL is hard to guess?

No. URLs leak through logs, referrers and configuration. The signature is the only proof the request came from the gateway.

What if my framework already parsed the body?

Most frameworks offer a hook to keep the raw bytes: express.json verify, php://input, Flask get_data, Django request.body. Use it; do not re-serialise the parsed object.

Why did I receive the same webhook twice?

Delivery is retried when your endpoint is slow or returns an error. This is expected; idempotent handling makes it harmless.

Accept crypto payments in 30 minutes with KriptoGo

No setup fee, no monthly fee. Just 1% on successful payments.

Create a free account