Securing Payment Webhooks: Verification, Idempotency, and Replay Protection
Securing payment webhooks requires three defenses: verifying the webhook actually came from the payment provider (not an attacker), ensuring you process each webhook exactly once (idempotency), and rejecting replayed old webhooks. Without these, an attacker can send fake "payment successful" requests to your endpoint and get free products or credits.
Why webhook security matters
Your webhook endpoint is a public URL. Anyone who knows it can send POST requests to it. If your endpoint blindly trusts every request as a legitimate payment notification, an attacker can forge a callback and trick your system into thinking a payment was made.
The attack is simple:
- Attacker discovers your callback URL (it might be in your client-side code, in network logs, or guessable from your domain structure)
- Attacker sends a POST request with a JSON body that looks like a successful payment callback
- Your server marks an order as "paid" and delivers the product
- No actual payment was made
This is not theoretical. Payment webhook fraud happens in production. The defenses are straightforward, but every layer matters.
Defense 1: Signature verification
Most payment providers sign their webhook payloads with a secret key. You verify the signature to confirm the request actually came from the provider.
Paystack (HMAC SHA-512)
Paystack includes an x-paystack-signature header containing an HMAC hash of the request body using your secret key.
import crypto from 'crypto';
import express from 'express';
const app = express();
// IMPORTANT: use raw body for signature verification
app.use('/webhook/paystack',
express.raw({ type: 'application/json' })
);
app.post('/webhook/paystack', (req, res) => {
const signature = req.headers['x-paystack-signature'];
const secret = process.env.PAYSTACK_SECRET_KEY!;
const hash = crypto
.createHmac('sha512', secret)
.update(req.body) // raw body buffer
.digest('hex');
if (hash !== signature) {
console.error('Invalid Paystack signature');
return res.sendStatus(401);
}
// Signature valid, process the event
const event = JSON.parse(req.body.toString());
console.log('Verified Paystack event:', event.event);
res.sendStatus(200);
});Paystack (Python/Flask)
import hmac
import hashlib
import os
from flask import Flask, request
app = Flask(__name__)
@app.route("/webhook/paystack", methods=["POST"])
def paystack_webhook():
signature = request.headers.get("x-paystack-signature", "")
secret = os.environ["PAYSTACK_SECRET_KEY"].encode()
body = request.get_data()
expected = hmac.new(secret, body, hashlib.sha512).hexdigest()
if not hmac.compare_digest(expected, signature):
return "Invalid signature", 401
event = request.get_json()
print(f"Verified Paystack event: {event['event']}")
return "OK", 200Stripe (signature with timestamp)
Stripe takes it further by including a timestamp in the signature, which protects against replay attacks. Use the Stripe SDK for verification:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post('/webhook/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']!;
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
endpointSecret
);
console.log('Verified Stripe event:', event.type);
res.sendStatus(200);
} catch (err) {
console.error('Invalid Stripe signature:', err);
res.sendStatus(401);
}
}
);M-Pesa / Daraja
Daraja does not include a cryptographic signature on callbacks. This makes M-Pesa webhook verification harder. Your options are:
- IP whitelisting: restrict your callback endpoint to only accept requests from Safaricom's known IP ranges. Check the Daraja documentation for the current list.
- Amount and reference verification: compare the callback amount and CheckoutRequestID against your database records. An attacker would need to know both the exact amount and the request ID.
- Transaction Status confirmation: for high-value transactions, follow up every callback with a Transaction Status query to Daraja to independently confirm the payment.
Use all three together for M-Pesa. No single method is sufficient on its own.
Defense 2: Idempotency
Payment providers may send the same webhook multiple times. Network glitches, timeouts, or retry logic can all cause duplicates. If your handler processes each one, a single payment could be credited multiple times.
The fix is idempotency: process each unique event exactly once, regardless of how many times it arrives.
Database-level idempotency (recommended):
// Use a unique constraint on the event identifier
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id TEXT UNIQUE NOT NULL, -- provider's event/transaction ID
provider TEXT NOT NULL, -- 'mpesa', 'paystack', 'stripe'
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now()
);async function processWebhookIdempotently(
eventId: string,
provider: string,
eventType: string,
payload: any,
handler: () => Promise<void>
): Promise<boolean> {
try {
// Try to insert. If eventId already exists, this throws.
await db.webhookEvents.create({
data: {
eventId,
provider,
eventType,
payload,
},
});
// First time seeing this event. Process it.
await handler();
return true;
} catch (error: any) {
if (error.code === '23505') {
// Unique constraint violation: duplicate
console.log(`Duplicate webhook ${eventId}, skipping`);
return false;
}
throw error; // Re-throw unexpected errors
}
}
// Usage in callback handler
app.post('/api/mpesa/callback', async (req, res) => {
res.sendStatus(200);
const callback = req.body.Body.stkCallback;
const eventId = callback.CheckoutRequestID;
await processWebhookIdempotently(
eventId,
'mpesa',
'stk_callback',
req.body,
async () => {
// Process the payment
if (callback.ResultCode === 0) {
await markOrderPaid(eventId, callback);
}
}
);
});The database unique constraint is the strongest idempotency guarantee. Even if two webhook requests arrive simultaneously on different server instances, only one insert succeeds. The other gets a unique constraint violation and is skipped.
In-memory sets (like a JavaScript Set or Python set) work for prototypes but fail in production because they do not survive server restarts and do not work across multiple server instances.
Defense 3: Replay protection
A replay attack is when an attacker captures a legitimate webhook request and re-sends it later. If your system only checks the signature, a replayed request passes verification because the signature is valid.
Replay protection adds a time check: reject webhooks with timestamps that are too old.
Stripe does this automatically (the timestamp is part of the signature). The constructEvent method rejects events older than 5 minutes by default.
For providers without built-in replay protection (including M-Pesa), implement it yourself:
function isReplayAttack(
receivedAt: Date,
maxAgeSeconds: number = 300 // 5 minutes
): boolean {
// If we cannot determine when the event was created,
// rely on idempotency instead.
const age = (Date.now() - receivedAt.getTime()) / 1000;
return age > maxAgeSeconds;
}
// Combined defense in a webhook handler
app.post('/api/mpesa/callback', async (req, res) => {
// Defense 1: IP check (simplified)
const sourceIP = req.ip;
if (!SAFARICOM_IP_RANGES.includes(sourceIP)) {
console.error(`Rejected webhook from ${sourceIP}`);
return res.sendStatus(403);
}
// Defense 2: acknowledge immediately
res.sendStatus(200);
const callback = req.body.Body?.stkCallback;
if (!callback) return;
// Defense 3: idempotency (database unique constraint)
const processed = await processWebhookIdempotently(
callback.CheckoutRequestID,
'mpesa',
'stk_callback',
req.body,
async () => {
// Defense 4: amount verification
const order = await getOrder(
callback.CheckoutRequestID
);
if (!order) {
await alertOps('orphaned_callback', callback);
return;
}
if (callback.ResultCode === 0) {
const amount = callback.CallbackMetadata.Item
.find((i: any) => i.Name === 'Amount').Value;
if (amount !== order.amountKes) {
await alertOps('amount_mismatch', {
expected: order.amountKes,
received: amount,
});
return;
}
await markOrderPaid(order.id, callback);
}
}
);
});Complete Python implementation
Here is a complete Python/Flask webhook handler with all three defenses:
import hmac
import hashlib
import os
import json
from datetime import datetime
from flask import Flask, request
app = Flask(__name__)
# Simulated database (use a real DB in production)
processed_events: set[str] = set()
def verify_paystack_signature(body: bytes, signature: str) -> bool:
secret = os.environ["PAYSTACK_SECRET_KEY"].encode()
expected = hmac.new(secret, body, hashlib.sha512).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route("/webhook/paystack", methods=["POST"])
def paystack_webhook():
# Defense 1: signature verification
signature = request.headers.get("x-paystack-signature", "")
body = request.get_data()
if not verify_paystack_signature(body, signature):
return "Invalid signature", 401
event = json.loads(body)
event_id = event.get("data", {}).get("reference", "")
# Defense 2: idempotency
if event_id in processed_events:
return "Already processed", 200
processed_events.add(event_id)
# Process the event
if event["event"] == "charge.success":
data = event["data"]
amount = data["amount"] # in kobo (NGN) or cents
reference = data["reference"]
print(
f"Payment confirmed: {amount}, ref: {reference}"
)
# TODO: update order, send confirmation
return "OK", 200
# M-Pesa callback with IP check
SAFARICOM_IPS = [
# Add Safaricom callback IPs here
# Check Daraja documentation for current list
]
@app.route("/webhook/mpesa", methods=["POST"])
def mpesa_webhook():
# Defense 1: IP whitelisting
# (simplified; use proper IP checking in production)
client_ip = request.remote_addr
# if client_ip not in SAFARICOM_IPS:
# return "Forbidden", 403
data = request.get_json()
callback = data.get("Body", {}).get("stkCallback", {})
checkout_id = callback.get("CheckoutRequestID", "")
# Defense 2: idempotency
if checkout_id in processed_events:
return {"ResultCode": 0}, 200
processed_events.add(checkout_id)
result_code = callback.get("ResultCode")
if result_code == 0:
items = callback.get("CallbackMetadata", {}).get("Item", [])
amount = next(
(i["Value"] for i in items if i["Name"] == "Amount"),
None,
)
receipt = next(
(i["Value"] for i in items
if i["Name"] == "MpesaReceiptNumber"),
None,
)
print(
f"M-Pesa payment: KES {amount}, "
f"receipt: {receipt}"
)
# Defense 3: verify amount against order
# order = get_order(checkout_id)
# if order and order.amount_kes != amount:
# alert_ops("amount_mismatch", ...)
return {"ResultCode": 0, "ResultDesc": "Accepted"}, 200
if __name__ == "__main__":
app.run(port=3000)Security checklist
Use this checklist before going live with any payment webhook:
- Signature verification implemented (HMAC for Paystack/Stripe, IP whitelist + amount check for M-Pesa)
- Idempotency enforced via database unique constraint on event/transaction ID
- Raw request body used for signature computation (not parsed JSON)
- HTTPS enforced on the webhook endpoint
- Webhook endpoint responds within 5 seconds
- Heavy processing runs asynchronously after acknowledging
- Amount in callback verified against expected order amount
- Unknown/unexpected event types logged but do not crash the handler
- Full raw payloads stored for audit trail
- Alerts configured for verification failures, amount mismatches, and high error rates
- Webhook secret keys stored in environment variables, not in code
- Secrets rotated on a schedule (or after any potential exposure)
Frequently Asked Questions
- Why do I need to use the raw body for signature verification?
- The signature is computed over the exact bytes that were sent. If you parse the JSON body first and then serialize it back to a string, the byte order, whitespace, or key ordering might differ from the original. This produces a different hash, and verification fails even though the payload is legitimate. Always compute the signature from the raw request body.
- What if a payment provider does not support webhook signatures?
- If the provider does not sign webhooks (like M-Pesa Daraja), you need alternative verification: IP whitelisting, amount and reference matching, and independent status queries back to the provider API. Use all of them together. No single method is enough on its own.
- Should I verify every webhook or only payment success events?
- Verify every webhook. An attacker could send a fake "refund" event to trigger a credit to a customer account, or a fake "subscription cancelled" event to disrupt service. All webhook types that trigger any action in your system need verification.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon. Ship 8 production apps with M-Pesa, USSD, and WhatsApp integrations, and get career support until placement.
See Programs