Webhooks are how a payment, mail or deployment service tells you something happened. They are also the one place where none of the previous part’s rules apply. The caller is not a browser but somebody else’s server, and it holds neither your cookie nor your nonce.
A nonce check fails one hundred per cent of the time
wp_verify_nonce() validates a value tied to the current user and the current time. An external server is not logged in and has no way to mint one, so putting a nonce check on a webhook route makes every request fail, without exception. And it fails quietly: the provider retries a few times, gives up, and all you see on your side is “payments are not being confirmed”.
So a webhook route uses '__return_true' for its permission_callback. That is not an abandonment of verification — it means verification lives somewhere else, in the first lines of the handler, as a signature check.
Sign the raw body, not a re-encoded one
This is the most frequently broken part. People json_decode() the payload and then json_encode() it again to compute the hash. That changes key order, whitespace and unicode escaping, so the signature can never match. The values are identical; the bytes are not.
Use $request->get_body() to get exactly the string that arrived, and parse only after verification succeeds.
add_action( 'rest_api_init', function () {
register_rest_route(
'wper/v1',
'/webhook/payment',
[
'methods' => 'POST',
'callback' => 'wper_receive_payment_hook',
// No cookie exists on the caller. The handler authenticates instead.
'permission_callback' => '__return_true',
]
);
} );
function wper_receive_payment_hook( WP_REST_Request $request ) {
$raw = $request->get_body(); // raw. never re-serialise.
$sent = (string) $request->get_header( 'x-signature' );
$expect = hash_hmac( 'sha256', $raw, wper_webhook_secret() );
// Never ==. It leaks timing, and PHP has loose comparison surprises.
if ( ! hash_equals( $expect, $sent ) ) {
return new WP_Error( 'wper_bad_signature', 'signature mismatch', [ 'status' => 401 ] );
}
$event = json_decode( $raw, true );
$id = isset( $event['id'] ) ? sanitize_text_field( $event['id'] ) : '';
if ( '' === $id || get_transient( 'wper_hook_' . $id ) ) {
return rest_ensure_response( [ 'status' => 'success' ] ); // duplicate: succeed quietly
}
set_transient( 'wper_hook_' . $id, 1, DAY_IN_SECONDS );
wper_apply_payment_event( $event );
return rest_ensure_response( [ 'status' => 'success' ] );
}
Compare with hash_equals() and nothing else. String comparison with == returns as soon as it finds a difference, which leaks information through timing, and PHP’s loose comparison has its own surprises with certain string shapes. Never compare a secret with == is a rule that is easier to keep absolutely than conditionally.
Retries, idempotency and cache bypass
Webhooks deliver the same event more than once. A dropped connection or a slow 2xx from you triggers a retry. Store the event identifier and skip anything already handled — that is what the transient above is doing. And answer a duplicate with success, not an error: an error keeps the retries coming forever.
There is one more failure that really happens in production. If a page cache caches the webhook route, state stops updating. An nginx FastCGI cache or a CDN stores the first response and stops sending requests to PHP; the provider keeps receiving 200 while your database never changes. Nothing looks wrong in the logs, and only the payments fail to register. This is why webhook and payment routes must be in the cache bypass conditions.
Finally, do not trust amounts or statuses from the payload. Accept only the fact that an event occurred, then re-read the amount from the order record you created and compare.
The security thinking behind checks like these lives in the Security archive, and getting cache layers and payment routes to stop contradicting each other is part of the work in our optimization program.
Next part
The next part points the other way: what the API hands out without being asked — how usernames leak by default through the users endpoint and author archives, and how to close that.