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