WordPress Webhook Receiver Example: Secure Incoming Requests With HMAC
Build a secure WordPress REST webhook receiver with HMAC verification, timestamp checks, idempotency, safe JSON handling, and clean error responses.
Published
April 22, 2026
Reading Time
3 min read
Updated
April 22, 2026

Hardening Notes
Baselines, access reduction, and default settings that stand up in production.
Best For
Teams preparing, launching, or maintaining WordPress as a backend service in a production stack.
Primary Topics
Editorial Focus
Control Ledger: Baselines, access reduction, and default settings that stand up in production. Updated on April 22, 2026.
Full Report
Last reviewed: April 22, 2026
Webhook receivers are common in WordPress integrations, but many are shipped as public endpoints with only a hidden URL as protection. A hidden URL is not authentication. If an external service can trigger changes inside WordPress, the request needs a real verification model.
This guide shows a webhook receiver built on the WordPress REST API with HMAC signature verification, JSON parsing, idempotency, and safe responses.
Integration scenario
Use this pattern when an external service sends events to WordPress: payment completed, scan finished, deployment complete, content approved, or ticket updated. The receiver should verify the sender before processing the event.
Register the webhook route
<?php
add_action( 'rest_api_init', 'vulnwp_register_webhook_route' );
function vulnwp_register_webhook_route() {
register_rest_route(
'vulnwp/v1',
'/webhook',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'vulnwp_handle_webhook',
'permission_callback' => 'vulnwp_verify_webhook_signature',
)
);
}
The route uses permission_callback for signature verification so invalid requests never reach the main handler.
Verify an HMAC signature
function vulnwp_verify_webhook_signature( WP_REST_Request $request ) {
$secret = get_option( 'vulnwp_webhook_secret' );
if ( ! is_string( $secret ) || '' === $secret ) {
return new WP_Error( 'webhook_not_configured', 'Webhook secret is not configured.', array( 'status' => 500 ) );
}
$signature = $request->get_header( 'x-vulnwp-signature' );
$body = $request->get_body();
$expected = hash_hmac( 'sha256', $body, $secret );
if ( ! is_string( $signature ) || ! hash_equals( $expected, $signature ) ) {
return new WP_Error( 'webhook_forbidden', 'Invalid webhook signature.', array( 'status' => 401 ) );
}
return true;
}
Use hash_equals() for comparison. Do not use loose comparison for signatures.
Timestamp replay protection
Some providers include a timestamp header in the signed payload. If your provider supports that, reject old timestamps to reduce replay risk. The exact implementation depends on how the provider builds signatures, but the principle is the same: verify both integrity and freshness.
$timestamp = absint( $request->get_header( 'x-vulnwp-timestamp' ) );
if ( ! $timestamp || abs( time() - $timestamp ) > 5 * MINUTE_IN_SECONDS ) {
return new WP_Error( 'webhook_expired', 'Webhook timestamp is outside the allowed window.', array( 'status' => 401 ) );
}
Do not invent a timestamp rule if the provider does not sign it. Unsigned timestamps can be changed by an attacker.
Handle the event
function vulnwp_handle_webhook( WP_REST_Request $request ) {
$payload = json_decode( $request->get_body(), true );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $payload ) ) {
return new WP_Error( 'invalid_json', 'Invalid JSON payload.', array( 'status' => 400 ) );
}
$event_id = isset( $payload['id'] ) ? sanitize_text_field( $payload['id'] ) : '';
$type = isset( $payload['type'] ) ? sanitize_key( $payload['type'] ) : '';
if ( '' === $event_id || '' === $type ) {
return new WP_Error( 'missing_fields', 'Webhook event is missing required fields.', array( 'status' => 400 ) );
}
if ( get_option( 'vulnwp_webhook_event_' . $event_id ) ) {
return rest_ensure_response( array( 'status' => 'duplicate_ignored' ) );
}
update_option( 'vulnwp_webhook_event_' . $event_id, time(), false );
return rest_ensure_response(
array(
'status' => 'accepted',
'type' => $type,
)
);
}
Production checklist
- Use HTTPS for webhook delivery.
- Verify the raw request body before trusting JSON fields.
- Use
hash_hmac()andhash_equals(). - Make processing idempotent with an event ID.
- Return safe error messages without leaking secrets.
- Rotate webhook secrets when exposure is suspected.
Common mistakes
- Using a hidden URL as authentication. URLs leak in logs and browser histories.
- Verifying decoded JSON instead of raw body. Signature algorithms usually sign the raw body.
- No idempotency. Many providers retry webhooks.
- Processing before verification. Invalid requests should stop at the permission callback.
- Logging secrets. Never log the shared secret or full signed payload if it contains private data.
Related reading
This extends our register_rest_route example and pairs with the HTTP API example for outbound integrations.


