WordPress Development
How to Receive Webhooks in WordPress (Without Building a Plugin)
Stripe, GitHub, Zapier, and your own services all want a URL to POST to. Three ways to give them one in WordPress — a REST route, admin-post, or a snippet endpoint — and the security rules every webhook needs.
6 min read Mark Ashton
- webhooks
- functions
- security
- wordpress
Sooner or later a WordPress site needs to be told about something that happened elsewhere: a Stripe payment, a GitHub push, a form submitted in another tool, a Zapier or Make automation. The other system sends an HTTP POST — a webhook — and your site needs a URL that receives it, checks it is genuine, and acts on it.
WordPress has no "webhooks" screen. This guide covers three ways to add a receiving endpoint without building and maintaining a full plugin, and the security and reliability rules that apply whichever you choose.
The rules every webhook endpoint needs
Get these right and the implementation details matter much less.
- Authenticate every request. A webhook URL is a public URL. Anyone who finds it can POST to it. Verify a signature or a shared secret on every call.
- Verify against the raw body. Signatures are computed over the exact bytes sent. Decoding and re-encoding JSON first changes them.
- Compare in constant time. Use
hash_equals(), never===, for secrets and signatures. - Respond fast. Most providers time out after a few seconds and retry. Acknowledge with a 2xx quickly and do slow work in the background.
- Expect duplicates. Retries mean the same event can arrive more than once. Record event IDs and skip ones you have already processed.
- Log what happened. When a vendor says "we sent it," you need to be able to say what you received and what you returned.
Option 1: A REST API route
The WordPress REST API is the standard way to add an endpoint. It gives you routing, method handling, and JSON responses. The code can live in a mu-plugin or a PHP snippet.
This example receives GitHub webhooks, which are signed with HMAC-SHA256 in the X-Hub-Signature-256 header:
<?php
defined( 'ABSPATH' ) || exit;
add_action( 'rest_api_init', function () {
register_rest_route( 'acme/v1', '/github', array(
'methods' => 'POST',
'callback' => 'acme_handle_github_webhook',
'permission_callback' => 'acme_verify_github_signature',
) );
} );
function acme_verify_github_signature( WP_REST_Request $request ): bool {
if ( ! defined( 'ACME_GITHUB_WEBHOOK_SECRET' ) ) {
return false;
}
$signature = (string) $request->get_header( 'x_hub_signature_256' );
$expected = 'sha256=' . hash_hmac( 'sha256', $request->get_body(), ACME_GITHUB_WEBHOOK_SECRET );
return hash_equals( $expected, $signature );
}
function acme_handle_github_webhook( WP_REST_Request $request ): WP_REST_Response {
$delivery = sanitize_text_field( (string) $request->get_header( 'x_github_delivery' ) );
if ( $delivery && get_transient( 'acme_gh_' . $delivery ) ) {
return new WP_REST_Response( array( 'duplicate' => true ), 200 );
}
set_transient( 'acme_gh_' . $delivery, 1, DAY_IN_SECONDS );
wp_schedule_single_event( time(), 'acme_process_github_event', array(
sanitize_key( (string) $request->get_header( 'x_github_event' ) ),
$request->get_json_params(),
) );
return new WP_REST_Response( array( 'received' => true ), 202 );
}The endpoint is https://yoursite.com/wp-json/acme/v1/github. Put the secret in wp-config.php:
define( 'ACME_GITHUB_WEBHOOK_SECRET', 'paste-the-secret-from-github' );A few details worth noticing:
permission_callbackdoes the verification. Never set it to__return_trueon a webhook.$request->get_body()is the raw body, which is what the signature covers.- Header names are normalized by WordPress:
X-Hub-Signature-256is read asx_hub_signature_256. - The work is deferred with
wp_schedule_single_event, so the response goes back immediately. On busy sites, Action Scheduler (bundled with WooCommerce) is more reliable than WP-Cron.
Each provider signs differently. Stripe, for example, sends a Stripe-Signature header containing a timestamp and a signature over timestamp.body; its official PHP library verifies it for you. Always follow the provider's documented scheme rather than guessing.
Good for: anything long-lived, anything other developers will integrate with, and endpoints that need a documented schema.
Watch out for: security plugins or hosts that block the REST API for logged-out users, and caching layers — make sure /wp-json/ POSTs are never cached.
Option 2: admin-post.php or admin-ajax.php
Older tutorials hook admin_post_nopriv_{action} or wp_ajax_nopriv_{action} and point the vendor at /wp-admin/admin-post.php?action=acme_webhook.
It works, but there is little reason to choose it today. It loads more of the admin, has no routing or method handling, and URLs under /wp-admin/ are more likely to be blocked or rate-limited by security tools. Use the REST API instead.
Option 3: A snippet endpoint
If your webhook handlers already live in snippets, a separate REST-route plugin for each one is overhead. SnipVault's Snippet Functions turn a published PHP snippet into an endpoint at https://yoursite.com/sv/{slug}, with a REST fallback at /wp-json/snipvault/v1/functions/{slug}/invoke.
You enable Enable as function on the snippet, pick a slug, allowed methods, and an auth mode, and SnipVault generates a secret. The handler reads a request context and returns a response:
<?php
if ( ! isset( $snipvault_function ) || ! is_array( $snipvault_function ) ) {
return;
}
$event = $snipvault_function['json'] ?? array();
if ( empty( $event['type'] ) ) {
return array( 'status' => 400, 'json' => array( 'error' => 'Missing event type' ) );
}
if ( 'lead.created' === $event['type'] ) {
wp_schedule_single_event( time(), 'acme_sync_lead', array( $event['data'] ?? array() ) );
}
return array( 'status' => 202, 'json' => array( 'received' => true ) );The guard at the top matters: it makes the snippet a no-op on normal page loads, so it only does anything when invoked as a function.
What you get without writing it yourself:
- Authentication modes: a secret header (
X-SnipVault-SecretorAuthorization: Bearer, compared withhash_equals), logged-in users or a required capability (POST only, to prevent CSRF), or public — which is only allowed when the security audit rates the snippet low risk and you explicitly confirm. - Limits: 60 requests per minute per slug per IP by default, 1 MB maximum payload, and a 1–30 second timeout.
- Integrity: the file must match its HMAC signature, and a snippet above your audit risk ceiling will not run.
- Logs and replay: the last 50 invocations with status and timing, and replay of successful or test calls from the admin.
Choosing an auth mode. A secret header is the simplest option when you control the caller — Zapier, Make, n8n, your own services, or any vendor that lets you add a custom header. Some providers, including Stripe and GitHub, do not let you add headers; they sign each request with their own scheme instead. For those, verify the provider's signature inside the handler as shown in Option 1, and check the Snippet Functions documentation for the auth mode that fits.
Good for: small, internal endpoints and automation glue that belong next to the rest of your snippet library.
Not a replacement for: a public REST API with a schema that other developers build against. Register a real route for that.
Testing a webhook endpoint
Test with curl before pointing a vendor at production. For a secret-header endpoint:
curl -i -X POST https://staging.example.com/sv/lead-intake \
-H "Content-Type: application/json" \
-H "X-SnipVault-Secret: $SNIPVAULT_SECRET" \
-d '{"type":"lead.created","data":{"email":"[email protected]"}}'For a signed endpoint like the GitHub example, compute the signature the same way the vendor does:
body='{"zen":"test"}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST https://staging.example.com/wp-json/acme/v1/github \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: ping" \
-H "X-GitHub-Delivery: test-1" \
-H "X-Hub-Signature-256: sha256=$sig" \
-d "$body"Then check three things: a request with a bad signature is rejected, a duplicate delivery ID is skipped, and the background job actually ran. Most providers also have a "send test event" button and a delivery log — use both.
Troubleshooting
- 401 or 403 on every request: the signature is being computed over a modified body, the secret has whitespace, or a security plugin is stripping headers.
- Works with curl, fails from the vendor: a firewall, CDN, or bot-protection rule is challenging the vendor's servers. Allow-list the path.
- Vendor reports timeouts: you are doing the work before responding. Defer it.
- Events processed twice: there is no idempotency check, or the stored IDs expire before the vendor's retry window ends.
- Scheduled job never runs: WP-Cron only runs on traffic. Use a real cron hitting
wp-cron.php, or Action Scheduler.
Which option to pick
- Building an integration others will use, or need a schema? Register a REST route.
- Adding a small endpoint or automation glue, and your custom code already lives in snippets? A snippet endpoint keeps it next to the rest of your code, with logs and limits included.
- Following a tutorial that uses
admin-ajax.php? Port it to the REST API.
Whichever you choose, the rules at the top of this article are what keep a public URL that runs PHP from becoming an incident.