Webhooks in production: HMAC signature, idempotency, retry — the complete guide

5 min readJune 11, 2026#webhook#hmac#sécurité#paiement#mobile-money#node#typescript#convex#idempotence#production

Webhooks in production: HMAC signature, idempotency, retry — the complete guide

A webhook is a POST request that your payment provider sends to your server to tell you a payment happened.

Anyone can send a POST request to your server.

If you don't verify that the request really came from your provider — and not from a third party who found your URL — you'll credit payments that never happened.


The three problems to solve

1. Authenticity. Does this webhook really come from Moneroo/FedaPay — or from someone who found my endpoint?

2. Idempotency. I may have already received and processed this webhook. Providers replay webhooks when they don't get a response. If I process it twice, I credit twice.

3. Reliability. My server might be temporarily unavailable when the webhook arrives. How do I avoid losing payment confirmations?


1. Verify the HMAC signature

Every payment provider signs its webhooks with a shared secret key. The signature travels in an HTTP header — x-moneroo-signature, x-fedapay-signature, depending on the provider.

The verification process:

"use node"; // required in Convex to access crypto
 
import * as crypto from "crypto";
 
export function verifySignature(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
 
  // timingSafeEqual prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Absolute rule: verify the signature before deserializing the body. Never parse the JSON first and verify second — because HMAC verification applies to the raw body, not the parsed JSON.

// Inside your Convex httpAction
handler: httpAction(async (ctx, req) => {
  const rawBody = await req.text(); // raw body first
  const sig = req.headers.get("x-moneroo-signature") ?? "";
 
  if (!verifySignature(rawBody, sig, process.env.MONEROO_WEBHOOK_SECRET!)) {
    return new Response("Unauthorized", { status: 401 });
  }
 
  const event = JSON.parse(rawBody); // parse only after verification
  await ctx.runMutation(internal.webhooks.handle, { event });
  return new Response("OK", { status: 200 });
}),

2. Idempotency — process the same event only once

Payment providers replay webhooks if your server doesn't respond within a short window (often 5–30 seconds). A webhook can therefore arrive twice, three times, for the same payment.

If you process every reception, you credit multiple times.

The solution: check the order's state before processing it.

// convex/webhooks/mutations.ts
export const handle = internalMutation({
  handler: async (ctx, { event }) => {
    if (event.type !== "payment.success") return;
 
    const order = await ctx.db
      .query("orders")
      .withIndex("by_payment_reference", (q) =>
        q.eq("paymentReference", event.data.id)
      )
      .first();
 
    // Idempotency: if the order isn't "pending", skip it
    if (!order || order.status !== "pending") return;
 
    // Otherwise schedule verification and confirmation
    await ctx.scheduler.runAfter(0, internal.payments.verifyAndConfirm, {
      orderId: order._id,
    });
  },
});

The pending status is the sentinel. A confirmed order (paid) or a cancelled one (cancelled) can no longer be altered by a replayed webhook.


3. Never trust the webhook alone

The webhook says the payment succeeded. But a webhook can be replayed, forged, or sent by mistake.

The rule: after validating the signature and checking idempotency, call the provider's API to confirm the payment actually exists.

// convex/payments/actions.ts
"use node";
 
export const verifyAndConfirm = internalAction({
  handler: async (ctx, { orderId }) => {
    const order = await ctx.runQuery(internal.orders.get, { orderId });
 
    // Verification API call
    const res = await fetch(
      `https://api.moneroo.io/v1/payments/${order.paymentReference}`,
      { headers: { Authorization: `Bearer ${process.env.MONEROO_SECRET_KEY}` } }
    );
    const { data } = await res.json();
 
    if (data.status === "success") {
      await ctx.runMutation(internal.orders.confirm, { orderId });
    } else {
      await ctx.runMutation(internal.orders.cancel, { orderId });
    }
  },
});

Two passes. The signature says the sender is legitimate. The API call says the payment is real.


4. Respond 200 fast — process in the background

Providers expect a 200 OK response within a short window. If your server takes too long to process the webhook (checks, database updates, sending an email), the provider considers delivery failed and replays the webhook.

The solution: respond 200 immediately, delegate processing to the background.

handler: httpAction(async (ctx, req) => {
  // Signature verification (fast)
  // ...
 
  const event = JSON.parse(rawBody);
 
  // Delegate to a mutation — don't wait
  await ctx.runMutation(internal.webhooks.handle, { event });
 
  // Respond right away
  return new Response("OK", { status: 200 });
}),

The mutation records the event and schedules the real processing. The real processing (API verification, order update, sending a confirmation email) runs in the background via the scheduler.


The mistakes I made

Missing "use node". The crypto module isn't available without this directive in Convex. Result: silent crash on the first HMAC verification attempt.

ctx.db inside the httpAction. Convex httpActions don't have access to ctx.db — only to ctx.runMutation and ctx.runQuery. Result: an error that doesn't show up while writing the code, only at runtime.

Parsing the JSON before verifying the signature. The signature is computed over the raw body. If you normalize the JSON by parsing then re-serializing it, characters can change and verification fails.

No status check. The webhook gets processed, the order gets confirmed — then the webhook arrives a second time and the order gets confirmed again. Stock decremented twice. Duplicate invoice. Confused customer.


The complete circuit in one picture

Webhook arrives
    ↓
HMAC signature verification → 401 if invalid
    ↓
ctx.runMutation (fast, records the event)
    ↓
200 OK (immediate)
    ↓
Mutation → status check (idempotency)
    ↓
Scheduler → action → verification API call
    ↓
Mutation → order confirmation / cancellation

Every step can be tested independently. Every failure is isolated.


How to integrate Mobile Money on your website — the complete flow Moneroo, FedaPay, CinetPay — how to choose your payment gateway The full Next.js + Convex + Moneroo tutorial with all the code