Sooner or later every serious WordPress site has to talk to something that is not WordPress: a mobile app, an ERP, a React front end, a partner’s ordering system, a CRM that needs every new lead within seconds. The built-in endpoints under /wp-json/wp/v2/ get you part of the way, then you hit the wall. You need computed data, write access with proper permissions, webhooks that arrive when you are asleep, and responses fast enough that the other system does not time out. That is custom REST API integration for WordPress, and it is most of what we build on our custom REST API integration service.
This post is for a CTO, product owner or lead developer who has a concrete integration to ship and wants to know what “done right” looks like before scoping it. We cover the three integration directions, how to register endpoints properly, which authentication method fits which client, validation and errors, caching that does not serve stale data, and how we test it all. Code samples are from patterns we ship, simplified for reading.
what custom REST API integration for WordPress actually covers
Integrations fall into three directions, and most real projects need at least two of them:
| Direction | Example | Main risk |
|---|---|---|
| Expose WordPress data | A React app or mobile app reads products, posts and ACF fields | Slow responses, over-exposure of private data |
| Accept data into WordPress | A CRM or payment provider posts a webhook that creates or updates records | Unauthenticated writes, duplicate processing |
| WordPress calls an external API | New orders sync to an ERP; leads push to HubSpot | Blocking page loads, silent failures, no retry |
The first direction is what people usually mean by “the REST API”, and it is the backbone of every headless build, as we show in our step-by-step headless WordPress with React guide. The second and third are where most production incidents happen, because they involve writes, secrets and network calls that can fail.
registering endpoints the right way
Custom endpoints belong in a plugin, not the theme, under your own namespace with a version number. Every route needs a permission_callback (WordPress has warned about missing ones since 5.5) and every argument needs validation and sanitisation. Here is the shape we use for a read endpoint that returns a computed price list:
<?php
add_action( 'rest_api_init', function () {
register_rest_route( 'swift/v1', '/pricing/(?P<sku>[a-z0-9-]+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'swift_get_pricing',
'permission_callback' => '__return_true', // public read
'args' => [
'sku' => [
'required' => true,
'validate_callback' => fn( $v ) => (bool) preg_match( '/^[a-z0-9-]{3,40}$/', $v ),
'sanitize_callback' => 'sanitize_key',
],
'currency' => [
'default' => 'USD',
'enum' => [ 'USD', 'EUR', 'GBP' ],
],
],
] );
} );
function swift_get_pricing( WP_REST_Request $request ) {
$product = wc_get_product_id_by_sku( $request['sku'] );
if ( ! $product ) {
return new WP_Error( 'swift_not_found', 'Unknown SKU', [ 'status' => 404 ] );
}
$data = swift_build_price_table( $product, $request['currency'] );
return rest_ensure_response( $data );
}
Two details matter here. Returning a WP_Error with a status gives the client a proper HTTP code and a JSON body it can parse. And rest_ensure_response() lets you set headers later, which we will use for caching. Once an API grows past four or five routes, move to a class that extends WP_REST_Controller so you get schema, prepare_item_for_response() and consistent collection parameters for free.
authentication: pick the method that fits the client
The right auth method depends on who is calling. Using the wrong one is the most common security finding in the API audits we run.
- Cookie plus nonce. For JavaScript running on the WordPress site itself. Send
X-WP-Noncefromwp_create_nonce( 'wp_rest' ). Useless for anything external. - Application Passwords. Built into core since 5.6. Basic auth over HTTPS with a per-application password tied to a real user. Our default for server-to-server calls and headless previews. Create a dedicated user with a minimal custom role, never an administrator.
- JWT. Use the JWT Authentication for WP REST API plugin when a mobile app logs users in and holds a token. Rotate the secret key and set short expiry.
- OAuth 2. Only when third parties need delegated access on behalf of your users. Heavier to run; the WP OAuth Server plugin handles the flow.
- HMAC-signed webhooks. For inbound calls from Stripe, HubSpot, Shopify or your own systems. No user account involved; the caller signs the body with a shared secret.
For inbound webhooks, the permission callback verifies the signature and the callback rejects duplicates using the provider’s event ID:
<?php
function swift_verify_webhook( WP_REST_Request $request ) {
$secret = getenv( 'CRM_WEBHOOK_SECRET' ) ?: '';
$signature = $request->get_header( 'x-signature' );
$expected = hash_hmac( 'sha256', $request->get_body(), $secret );
if ( ! $signature || ! hash_equals( $expected, $signature ) ) {
return new WP_Error( 'swift_bad_signature', 'Invalid signature', [ 'status' => 401 ] );
}
return true;
}
function swift_handle_lead( WP_REST_Request $request ) {
$event_id = sanitize_text_field( $request['event_id'] );
if ( get_transient( 'swift_evt_' . $event_id ) ) {
return rest_ensure_response( [ 'status' => 'duplicate' ] ); // idempotent
}
set_transient( 'swift_evt_' . $event_id, 1, DAY_IN_SECONDS );
as_enqueue_async_action( 'swift_process_lead', [ $request->get_json_params() ] );
return new WP_REST_Response( [ 'status' => 'queued' ], 202 );
}
Note the 202 Accepted and the hand-off to Action Scheduler. The webhook returns in milliseconds; the actual work (creating a post, calling another API, sending mail) runs in the background where a failure can be retried and logged instead of making the sender give up. WooCommerce ships Action Scheduler; on non-commerce sites install it as a library.
validation, errors and versioning
Define a schema for every route. WordPress uses it to validate request bodies, generate the OPTIONS response that API clients read, and filter output through _fields. Keep error codes stable and namespaced (swift_not_found, swift_rate_limited) because client code will branch on them. When you need to change a response shape, add swift/v2 and keep v1 serving for a documented period rather than editing v1 in place. Also filter rest_authentication_errors to block anonymous access to /wp/v2/users, which otherwise lists every author login on the site.
caching that does not serve stale data
An endpoint that runs six WP_Query calls and joins wp_postmeta three times will take 400 ms or more on a busy site. The fix is layered:
- Object cache. Install Redis Object Cache and enable it with
wp redis enable. Everyget_transient()andwp_cache_get()now hits memory instead ofwp_options. - Endpoint-level cache with invalidation. Cache the built response under a key and clear it when the source data changes.
- HTTP cache headers. Set
Cache-Controlon public GET responses so CloudFront, Cloudflare or the host’s edge cache handles repeat traffic without touching PHP.
<?php
function swift_get_pricing( WP_REST_Request $request ) {
$key = 'swift_price_' . md5( $request['sku'] . $request['currency'] );
$data = wp_cache_get( $key, 'swift_api' );
if ( false === $data ) {
$data = swift_build_price_table( wc_get_product_id_by_sku( $request['sku'] ), $request['currency'] );
wp_cache_set( $key, $data, 'swift_api', 15 * MINUTE_IN_SECONDS );
}
$response = rest_ensure_response( $data );
$response->header( 'Cache-Control', 'public, max-age=300, s-maxage=900' );
return $response;
}
// Bust on product save so editors never wait for expiry.
add_action( 'woocommerce_update_product', function ( $product_id ) {
$product = wc_get_product( $product_id );
foreach ( [ 'USD', 'EUR', 'GBP' ] as $cur ) {
wp_cache_delete( 'swift_price_' . md5( $product->get_sku() . $cur ), 'swift_api' );
}
} );
Avoid transients for high-volume keys when there is no object cache: each one becomes a row in wp_options, and if a plugin sets them with autoload on, they get loaded on every page view. That single mistake is behind a surprising share of the slow sites we see in our maintenance and support work.
outbound calls without blocking the page
Never call an external API inside save_post or during checkout with no timeout. Use wp_remote_post() with an explicit timeout of 5 to 10 seconds, queue it through Action Scheduler, and log the response code. Replace WP-Cron with a real cron entry (define( 'DISABLE_WP_CRON', true ) plus */5 * * * * wp cron event run --due-now) so queued jobs run on a schedule instead of only when a visitor arrives. For a worked example on the commerce side, see our guide to integrating a third party CRM with WooCommerce.
testing and monitoring the integration
Before anything goes live we exercise every route from the command line with a real Application Password:
# Public read
curl -s "https://example.com/wp-json/swift/v1/pricing/sku-1234?currency=EUR" | jq .
# Authenticated write with an Application Password
curl -s -u "api-bot:xxxx xxxx xxxx xxxx xxxx xxxx"
-H "Content-Type: application/json"
-X POST "https://example.com/wp-json/swift/v1/leads"
-d '{"email":"[email protected]","source":"landing-a"}'
# Discover the schema WordPress generated from your args
curl -s -X OPTIONS "https://example.com/wp-json/swift/v1/pricing/sku-1234" | jq .endpoints[0].args
For regression coverage, PHPUnit with the WordPress test suite lets you build a WP_REST_Request, dispatch it through rest_get_server() and assert on the response without HTTP. In production, hook rest_request_after_callbacks to log route, status and duration to your log stack (CloudWatch Logs if you are on AWS), and alert when the 5xx rate or p95 latency moves. Rate limit public write routes at the edge or with a small transient counter keyed on IP and route.
the mistakes we fix most often in audits
- Routes with
permission_callbackset to__return_trueon write methods “because the mobile app needs it”. - Endpoints that return
get_post_meta( $id )with no field list, leaking internal keys and sometimes personal data. - External API calls made synchronously inside page requests, with the default timeout, so a slow partner makes checkout hang.
- Secrets stored in
wp_optionsin plain text. Put them in environment variables orwp-config.phpconstants outside the web root. - Business logic bolted onto
admin-ajax.php, which loads all of the admin bootstrap and cannot be cached. - No idempotency on webhooks, so a provider retry creates duplicate orders or leads.
Cleaning these up is usually a one to two week engagement and often improves site speed as a side effect. When the integration touches WooCommerce order data, our WooCommerce CRM integration service covers the commerce-specific hooks and order status mapping.
when to do this yourself vs hire someone
A single read-only endpoint with a permission callback and a transient is a good afternoon’s work for any WordPress developer, and you should do it in-house. Hire when the custom REST API integration for WordPress involves writes from external systems, money, personal data, or an SLA. Those projects need signed webhooks, background queues, retries, logging and a rollback plan, and the cost of getting one of those wrong (duplicate charges, leaked customer data, a partner cutting you off for hammering their API) is far higher than the cost of a senior engineer building it properly the first time.
get your integration scoped
Tell us what needs to talk to what, roughly how much traffic it carries and what happens when it fails, and we will come back within 24 hours with a fixed scope and fixed price in writing for your custom REST API integration for WordPress. You work directly with the engineers who write and test the code. Start the conversation through our contact page.




