A webhook endpoint that trusts every incoming request is a genuine risk on a store handling payments — an unverified endpoint means anyone who finds the URL could, in theory, send a fake “payment completed” callback and trigger order fulfillment without an actual payment happening.
Verifying the signature, not just the payload
Every major payment gateway signs its webhook payloads with a secret only you and the gateway know. The verification step is what actually matters — checking that the request came from the gateway, not just that it looks like a plausible payload:
// Example pattern - exact implementation depends on your specific gateway's SDK
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, MYPLUGIN_WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
status_header(401);
exit('Invalid signature');
}
hash_equals() specifically, not == or ===, matters here — it’s a constant-time comparison, which prevents a timing attack where an attacker could theoretically guess the signature one character at a time based on how long the comparison takes to fail.
Idempotency: handling the same webhook twice
Payment gateways commonly retry webhook delivery if they don’t get a fast enough response, which means your endpoint needs to handle receiving the same event twice without double-fulfilling an order:
$event_id = $payload_data['event_id'];
if (get_transient("webhook_processed_$event_id")) {
status_header(200); // acknowledge, but don't reprocess
exit;
}
set_transient("webhook_processed_$event_id", true, DAY_IN_SECONDS);
// ... process the event
Logging failures somewhere you’ll actually see them
A webhook that fails silently is worse than one that fails loudly — a customer whose payment succeeded but whose order never got marked as paid, with no error anywhere, becomes a support ticket days later instead of an alert you could have caught immediately:
if (!$order) {
error_log("Webhook received for unknown order: " . $payload_data['order_id']);
status_header(200); // still acknowledge receipt to stop retries
exit;
}
Returning a 200 even on an internal failure (after logging it) matters — returning an error status tells the gateway to keep retrying, which just repeats the same failure on a loop instead of surfacing it once for you to actually fix.