A client came to me with a specific problem: their WooCommerce store had to stay in sync with an external inventory system that exposed a REST API. Stock levels changed constantly throughout the day, and the store was regularly showing items as available when they had zero units left. The API vendor had documentation — technically — but it was a PDF last updated in 2019 that described an endpoint structure that no longer matched what the server actually returned. That particular experience shaped a lot of how I approach WordPress API integrations today.
This guide covers the practical patterns I use when connecting WordPress to external APIs: the HTTP API, authentication, caching, background sync, and receiving inbound data via webhooks. The running example throughout is syncing product inventory from an external system — concrete enough to be useful, common enough that most of the patterns map directly to whatever you’re actually building.
WordPress HTTP API: The Right Starting Point
WordPress ships with its own HTTP API that wraps PHP’s various HTTP transport mechanisms. You should always use it instead of file_get_contents() or raw cURL. It handles SSL, proxies, redirects, and WordPress-specific configuration, and it plays nicely with testing because requests can be filtered or short-circuited.
The two functions you’ll use most are wp_remote_get() and wp_remote_post(). There’s also wp_safe_remote_get() — and the distinction matters. wp_safe_remote_get() blocks requests to local and private IP ranges, which is important for any user-supplied URL (think plugin settings where admins paste an endpoint). If you’re hitting a hardcoded API endpoint under your control, wp_remote_get() is fine. If the URL comes from user input, use the safe variant to prevent server-side request forgery.
Basic GET Request
$response = wp_remote_get( 'https://inventory.example.com/api/v2/products', [
'timeout' => 15,
'headers' => [
'Accept' => 'application/json',
'X-API-Key' => get_option( 'my_inventory_api_key' ),
],
] );
if ( is_wp_error( $response ) ) {
// Network-level failure: DNS, timeout, SSL, etc.
error_log( 'Inventory API request failed: ' . $response->get_error_message() );
return false;
}
$status_code = wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( 200 !== $status_code ) {
error_log( 'Inventory API returned HTTP ' . $status_code . ': ' . $body );
return false;
}
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
error_log( 'Inventory API response was not valid JSON.' );
return false;
}
A few things to note here. The timeout argument defaults to 5 seconds — far too short for many external APIs, especially if you’re fetching a large dataset. Set it explicitly. Also, always check is_wp_error() first. This catches transport-level failures (no connection, SSL certificate error, timeout) before you try to read anything from the response. Then check the HTTP status code. A 401, 429, or 503 each mean something different, and collapsing them all into “it didn’t work” makes debugging painful later.
POST Request with JSON Body and Auth Header
Many APIs expect you to send JSON rather than form-encoded data. The body argument in WordPress’s HTTP API is sent as a string if you pass a string — so encode it yourself and set the Content-Type header.
$payload = wp_json_encode( [
'product_sku' => 'WIDGET-001',
'quantity' => 50,
'warehouse' => 'shimla-01',
] );
$response = wp_remote_post( 'https://inventory.example.com/api/v2/stock/update', [
'timeout' => 20,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . get_option( 'my_inventory_bearer_token' ),
'Accept' => 'application/json',
],
'body' => $payload,
] );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'api_request_failed', $response->get_error_message() );
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status = 300 ) {
return new WP_Error(
'api_error_response',
sprintf( 'API returned status %d: %s', $status, wp_remote_retrieve_body( $response ) )
);
}
return json_decode( wp_remote_retrieve_body( $response ), true );
Authentication Patterns
API Keys
The simplest pattern — pass a key in a header. Where you store that key matters. Never hardcode it in a plugin file, and never store it in a JavaScript-accessible location. Use get_option() with a settings page that uses wp_kses() or sanitization on save, or better yet, store it in wp-config.php as a constant and read it with defined() checks. Environment variables via a .env file (loaded through something like phpdotenv in a Bedrock setup) are cleaner still.
// wp-config.php approach
define( 'MY_INVENTORY_API_KEY', 'sk_live_xxxxxxxxxxxxxxxx' );
// In your integration class
$api_key = defined( 'MY_INVENTORY_API_KEY' ) ? MY_INVENTORY_API_KEY : get_option( 'my_inventory_api_key' );
OAuth2 with Token Refresh
OAuth2 is where things get tedious. Access tokens expire, and you need to handle refresh without requiring manual intervention. The pattern I use stores both the access token and refresh token as options (or in a custom table for multi-tenant setups), checks expiry before each request, and refreshes automatically when needed.
function my_get_valid_access_token() {
$token_data = get_option( 'my_inventory_oauth_token' );
// Token exists and is not expiring within the next 60 seconds
if (
! empty( $token_data['access_token'] ) &&
isset( $token_data['expires_at'] ) &&
$token_data['expires_at'] > ( time() + 60 )
) {
return $token_data['access_token'];
}
// Attempt token refresh
if ( empty( $token_data['refresh_token'] ) ) {
return new WP_Error( 'no_refresh_token', 'No refresh token available. Re-authorization required.' );
}
$response = wp_remote_post( 'https://inventory.example.com/oauth/token', [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => [
'grant_type' => 'refresh_token',
'refresh_token' => $token_data['refresh_token'],
'client_id' => defined( 'MY_OAUTH_CLIENT_ID' ) ? MY_OAUTH_CLIENT_ID : '',
'client_secret' => defined( 'MY_OAUTH_CLIENT_SECRET' ) ? MY_OAUTH_CLIENT_SECRET : '',
],
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $body['access_token'] ) ) {
return new WP_Error( 'token_refresh_failed', 'Token refresh did not return an access token.' );
}
$new_token_data = [
'access_token' => $body['access_token'],
'refresh_token' => $body['refresh_token'] ?? $token_data['refresh_token'],
'expires_at' => time() + ( $body['expires_in'] ?? 3600 ),
];
update_option( 'my_inventory_oauth_token', $new_token_data );
return $new_token_data['access_token'];
}
One API I worked with — a logistics platform used by a client — would return a 200 status on a failed token refresh with an error message buried in the JSON body. No HTTP error code, just {"status": "error", "message": "invalid_grant"}. This kind of thing is why you check the actual response structure, not just the status code.
Caching API Responses with Transients
Making a live API call on every page load is one of the most common mistakes in WordPress integrations. It blocks the page render, hammers the external API (often leading to rate limiting), and makes your site fragile — if the API is down, so is your page.
WordPress transients are the right tool for short-to-medium-lived cache. For longer-lived or high-volume data, look at object caching with Redis or Memcached via the WP Object Cache API, but transients are a solid starting point.
function my_get_inventory_data( $sku ) {
$cache_key = 'inventory_sku_' . sanitize_key( $sku );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$token = my_get_valid_access_token();
if ( is_wp_error( $token ) ) {
return $token;
}
$response = wp_remote_get(
'https://inventory.example.com/api/v2/products/' . rawurlencode( $sku ),
[
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
// Don't cache errors — let the next request try again
return false;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
// Cache for 5 minutes
set_transient( $cache_key, $data, 5 * MINUTE_IN_SECONDS );
return $data;
}
A few caching specifics worth knowing: transient keys are limited to 172 characters in the database. Build cache keys deterministically from the request parameters, but keep them short. Also, when you update data on the remote API (a stock adjustment, for example), delete the relevant transient immediately so the cache doesn’t serve stale data.
// After a successful stock update, bust the cache
delete_transient( 'inventory_sku_' . sanitize_key( $sku ) );
Background Processing with WP-Cron
For bulk syncs — pulling all 3,000 products from an inventory system on a schedule — you cannot do this synchronously during a page request. WP-Cron is the built-in scheduler, though for production sites with real traffic, disabling the default behavior and running it from a real system cron is better for reliability.
// Register a custom cron interval
add_filter( 'cron_schedules', function( $schedules ) {
$schedules['every_fifteen_minutes'] = [
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => 'Every 15 Minutes',
];
return $schedules;
} );
// Schedule the event on plugin activation
register_activation_hook( __FILE__, function() {
if ( ! wp_next_scheduled( 'my_inventory_sync_event' ) ) {
wp_schedule_event( time(), 'every_fifteen_minutes', 'my_inventory_sync_event' );
}
} );
// Clear the schedule on deactivation
register_deactivation_hook( __FILE__, function() {
wp_clear_scheduled_hook( 'my_inventory_sync_event' );
} );
// Hook the actual sync function
add_action( 'my_inventory_sync_event', 'my_run_inventory_sync' );
function my_run_inventory_sync() {
// Prevent overlapping runs
if ( get_transient( 'my_inventory_sync_running' ) ) {
return;
}
set_transient( 'my_inventory_sync_running', true, 10 * MINUTE_IN_SECONDS );
$page = 1;
$synced = 0;
do {
$token = my_get_valid_access_token();
if ( is_wp_error( $token ) ) {
break;
}
$response = wp_remote_get(
add_query_arg( [ 'page' => $page, 'per_page' => 100 ], 'https://inventory.example.com/api/v2/products' ),
[
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
break;
}
$result = json_decode( wp_remote_retrieve_body( $response ), true );
$products = $result['data'] ?? [];
foreach ( $products as $product ) {
my_update_woocommerce_stock( $product );
$synced++;
// Clear per-SKU transient after update
delete_transient( 'inventory_sku_' . sanitize_key( $product['sku'] ) );
}
$has_more = ! empty( $result['pagination']['next_page'] );
$page++;
} while ( $has_more );
update_option( 'my_inventory_last_sync', [
'time' => current_time( 'mysql' ),
'synced' => $synced,
] );
delete_transient( 'my_inventory_sync_running' );
}
The lock transient (my_inventory_sync_running) prevents a second cron instance from starting while the first is still running — a real problem on high-traffic sites where WP-Cron fires on every page load. For more complex background job queuing, the WP Background Processing library by Delicious Brains handles batching and retry logic cleanly.
Handling Rate Limits
Most APIs rate-limit requests. Many communicate this through response headers: X-RateLimit-Remaining, X-RateLimit-Reset, or a Retry-After header on 429 responses. Read those headers. The inventory API I worked with would return a 429 with a Retry-After: 60 header. The right behavior is to stop, wait, and reschedule — not retry in a tight loop.
$status = wp_remote_retrieve_response_code( $response );
if ( 429 === $status ) {
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
$wait = is_numeric( $retry_after ) ? (int) $retry_after : 60;
// Reschedule this sync to run after the rate limit window
wp_schedule_single_event( time() + $wait, 'my_inventory_sync_event' );
delete_transient( 'my_inventory_sync_running' );
return;
}
Receiving External Data: Webhook Endpoints
The sync above is pull-based — WordPress asks the inventory system for data on a schedule. Push-based (webhooks) is often better: the external system calls your WordPress site the moment something changes. You register a URL with the API provider, and they POST data to it when events occur.
WordPress’s REST API is the right place to register a webhook receiver endpoint. It handles routing, JSON parsing, and gives you a clean URL structure.
add_action( 'rest_api_init', function() {
register_rest_route( 'my-inventory/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'my_handle_inventory_webhook',
'permission_callback' => 'my_verify_webhook_signature',
] );
} );
function my_verify_webhook_signature( WP_REST_Request $request ) {
$secret = defined( 'MY_WEBHOOK_SECRET' ) ? MY_WEBHOOK_SECRET : get_option( 'my_webhook_secret' );
$signature = $request->get_header( 'X-Inventory-Signature' );
$body = $request->get_body();
if ( empty( $signature ) || empty( $secret ) ) {
return false;
}
$expected = 'sha256=' . hash_hmac( 'sha256', $body, $secret );
// Use hash_equals to prevent timing attacks
return hash_equals( $expected, $signature );
}
function my_handle_inventory_webhook( WP_REST_Request $request ) {
$payload = $request->get_json_params();
$event = $payload['event'] ?? '';
if ( 'stock.updated' === $event ) {
$sku = sanitize_text_field( $payload['sku'] ?? '' );
$quantity = absint( $payload['quantity'] ?? 0 );
if ( empty( $sku ) ) {
return new WP_REST_Response( [ 'error' => 'Missing SKU' ], 400 );
}
my_update_woocommerce_stock( [ 'sku' => $sku, 'quantity' => $quantity ] );
delete_transient( 'inventory_sku_' . sanitize_key( $sku ) );
}
// Always return 200 quickly — do heavy processing asynchronously if needed
return new WP_REST_Response( [ 'received' => true ], 200 );
}
A few things here. The signature verification using hash_equals() is not optional — it prevents timing attacks that could let someone forge webhook calls. Your endpoint URL will be https://yoursite.com/wp-json/my-inventory/v1/webhook. Return a 200 fast; if the remote system doesn’t get a quick response it will retry, and if your processing is slow you’ll end up with duplicate events. Offload heavy work to a scheduled event or background process and return immediately.
Common Pitfalls
- Skipping response code checks.
wp_remote_get()returning without aWP_Errordoes not mean the request succeeded. A 401 or 500 is still a valid HTTP response. Always read the status code. - Blocking page load with synchronous API calls. Any live API call that runs during a normal page request creates a dependency on a third-party server. Use transients, object cache, or background sync to decouple your site’s availability from the API’s availability.
- Not handling rate limits. Ignoring 429 responses and hammering the API leads to IP bans. Read the
Retry-Afterheader and back off. - Storing API keys insecurely. Don’t put secrets in version-controlled files. Use constants in
wp-config.php, environment variables, or a secrets manager. If you use options, at minimum restrict the settings page to administrators and sanitize on save. - Forgetting to set a timeout. The default 5-second timeout will cause silent failures with slow APIs. Set it explicitly based on what the endpoint actually needs.
- Using
wp_remote_get()with user-supplied URLs. Switch towp_safe_remote_get()any time the URL is not fully under your control. - Not logging errors. API integrations fail in production in ways that are invisible to end users. Log failures with enough context — endpoint, status code, response body excerpt — so you can debug without reproducing.
Putting It Together
The patterns here — HTTP API with proper error handling, token management, transient caching, scheduled background sync, and a verified webhook receiver — cover the majority of real-world integration scenarios. The specifics change depending on the API, but the structure stays consistent.
The inventory sync example maps directly to other use cases: pulling contacts from a CRM, syncing orders to a fulfillment platform, pushing leads from a form to a marketing automation tool. The WordPress primitives are the same. What changes is understanding the quirks of whoever designed the API on the other end — and building enough logging and defensive handling to deal with them when they behave unexpectedly.
If you’re building a WordPress plugin or theme integration and running into authentication complexity, rate limiting issues, or need help structuring a reliable background sync, get in touch. I’ve worked through most of these scenarios across payment gateways, shipping carriers, project management tools, and CRMs — and can help you avoid the time sink of debugging an underdocumented API from scratch.