WordPress HTTP API Deep Dive: Transport Layers, SSRF Protection, and Production Patterns
WordPress powers a massive portion of the web, and a significant number of those installations depend on outgoing HTTP requests. Whether your site fetches oEmbed data, checks for plugin updates, validates licenses with external servers, or pushes data to third-party APIs, the WordPress HTTP API sits at the center of that network communication. Despite its importance, few developers look closely at what happens beneath functions like wp_remote_get(). This article tears apart the internals of the WordPress HTTP API, examines its transport layers, explains the SSRF protections built into core, and lays out production patterns for building integrations that survive real-world failure conditions.
The Architecture of WP_Http
All outgoing HTTP requests in WordPress eventually pass through the WP_Http class, defined in wp-includes/class-wp-http.php. This class acts as the primary dispatcher: it receives request parameters, selects the appropriate transport, fires the request, and returns a standardized response array. You rarely instantiate it directly. Instead, you call wrapper functions like wp_remote_get(), wp_remote_post(), or wp_remote_request(), all of which internally call WP_Http::request().
The request() method on WP_Http is responsible for merging your supplied arguments with defaults, filtering the URL and arguments through hooks, selecting a transport, and returning either a response array or a WP_Error object. Here is a simplified view of the execution path:
wp_remote_get( $url, $args )
→ WP_Http::request( $url, $args )
→ URL parsing and validation
→ Argument defaults merged
→ pre_http_request filter (short-circuit opportunity)
→ Transport selection (cURL or PHP streams)
→ Transport::request()
→ http_response filter
→ Return array or WP_Error
The response array returned by a successful request follows a consistent shape:
array(
'headers' => (Requests_Utility_CaseInsensitiveDictionary),
'body' => (string),
'response' => array(
'code' => (int),
'message' => (string),
),
'cookies' => array( WP_Http_Cookie objects ),
'filename' => (string|null),
'http_response' => (WP_HTTP_Requests_Response),
)
Functions like wp_remote_retrieve_body(), wp_remote_retrieve_response_code(), and wp_remote_retrieve_headers() exist to safely extract values from this array without risking undefined index errors. Always use them instead of accessing the array directly.
The Requests Library Under the Hood
Starting with WordPress 4.6 and later updated in subsequent releases, WordPress delegates the actual HTTP transport to the Requests library (now part of the WpOrg\Requests namespace as of WordPress 6.2). This library provides a unified interface for both cURL and PHP stream transports. When WP_Http::request() fires, it constructs a WpOrg\Requests\Requests call with the appropriate transport class already selected.
The Requests library handles connection reuse, redirect following, header parsing, cookie management, and decompression. WordPress layers additional logic on top: proxy support, SSL verification overrides, timeout adjustments, and the pre/post request filter hooks that plugin developers rely on.
Transport Layers: cURL Adapter vs. PHP Streams
WordPress supports two HTTP transports, and the selection logic determines which one gets used for each request. The two transports are the cURL adapter (WpOrg\Requests\Transport\Curl) and the PHP streams adapter (WpOrg\Requests\Transport\Fsockopen). On the vast majority of production servers, cURL is the preferred transport because it offers better performance, more granular control over SSL, and support for features like HTTP/2.
cURL Transport
The cURL transport wraps PHP’s curl_* functions. It is selected when the cURL extension is loaded and functional. The transport sets options through curl_setopt() calls that map to the request arguments you pass. For example, the timeout argument maps to CURLOPT_TIMEOUT, sslverify maps to CURLOPT_SSL_VERIFYPEER, and the request body maps to CURLOPT_POSTFIELDS.
The cURL transport supports connection pooling through persistent handles. If you make multiple requests to the same host within a single PHP process, the transport can reuse the TCP connection and TLS session, reducing latency significantly. This matters for plugin code that hits the same API endpoint repeatedly during a single page load or cron execution.
One important detail: the http_api_curl filter fires after the cURL handle is created but before curl_exec() runs. This filter passes the cURL handle reference, the parsed request arguments, and the URL. You can use it to set custom cURL options that WordPress does not expose through its standard arguments:
add_filter( 'http_api_curl', function( $handle, $parsed_args, $url ) {
// Force HTTP/2 for requests to a specific API.
if ( str_contains( $url, 'api.example.com' ) ) {
curl_setopt( $handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0 );
}
return $handle;
}, 10, 3 );
Be careful with this filter. Setting incorrect cURL options can break requests silently. Always test changes in a staging environment before deploying to production.
PHP Streams Transport
The streams transport uses PHP’s native fopen() wrappers with stream_context_create() to configure HTTP behavior. It serves as a fallback when cURL is unavailable. While it works for basic GET and POST requests, it lacks the performance characteristics and fine-grained control of cURL. You cannot enable HTTP/2, connection pooling is limited, and debugging SSL issues is harder because the error messages are less descriptive.
In practice, encountering a server without cURL is rare on modern hosting. If you do find yourself on such a server, the streams transport will handle most standard API integrations without issue. But for high-throughput or latency-sensitive operations, cURL is the transport you want.
Transport Selection and Testing
The Requests library tests each transport for availability before selecting one. You can determine which transport is being used by hooking into http_api_debug:
add_action( 'http_api_debug', function( $response, $context, $class, $parsed_args, $url ) {
error_log( sprintf(
'HTTP API: %s %s via %s - Status: %s',
$parsed_args['method'],
$url,
$class,
is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response )
) );
}, 10, 5 );
The $class parameter tells you which transport class handled the request. This hook fires after every HTTP request and is invaluable for debugging transport selection issues.
SSRF Protection: wp_safe_remote_get() vs. wp_remote_get()
Server-Side Request Forgery (SSRF) is a vulnerability class where an attacker tricks a server into making HTTP requests to unintended destinations. In WordPress, this risk surfaces whenever user-supplied data influences the URL passed to an HTTP function. An attacker could craft a URL that points to an internal service, a cloud metadata endpoint (like http://169.254.169.254/ on AWS), or a local network resource that should not be accessible from the web server.
WordPress provides two families of HTTP functions, and the distinction between them is critical for security:
wp_remote_get() / wp_remote_post() / wp_remote_request() perform no URL validation. They send the request to whatever URL you give them. Use these when the URL is hardcoded, stored in a trusted configuration, or otherwise not influenced by user input.
wp_safe_remote_get() / wp_safe_remote_post() / wp_safe_remote_request() run the URL through wp_http_validate_url() before dispatching. This validation function blocks requests to internal IP ranges, restricts allowed ports, and prevents DNS rebinding attacks. Use these whenever the URL originates from user input, a database field that users can modify, or any other untrusted source.
The “safe” variants work by adding 'reject_unsafe_urls' => true to the request arguments. You can also pass this argument manually to wp_remote_get() to get the same protection:
// These two calls are functionally equivalent:
wp_safe_remote_get( $url );
wp_remote_get( $url, array( 'reject_unsafe_urls' => true ) );
A common mistake is using wp_remote_get() for webhook verification URLs, oEmbed fetches, or any feature where the URL comes from user-submitted content. If you accept a URL through a settings page, a custom field, or an API parameter, always use the safe variant or pass reject_unsafe_urls.
Inside wp_http_validate_url()
The function wp_http_validate_url() is the core of WordPress SSRF protection. It performs multiple checks on the URL before allowing the request to proceed. Understanding each check helps you reason about what attacks it blocks and where its limitations lie.
Port Restrictions
By default, wp_http_validate_url() restricts requests to ports 80, 443, and 8080. This prevents an attacker from probing internal services that listen on non-standard ports, like databases on port 3306, Redis on 6379, or internal admin interfaces on arbitrary high ports. The allowed ports are filterable through the http_allowed_safe_ports filter:
add_filter( 'http_allowed_safe_ports', function( $ports, $host, $url ) {
// Allow port 9200 for Elasticsearch requests.
if ( $host === 'search.internal.example.com' ) {
$ports[] = 9200;
}
return $ports;
}, 10, 3 );
Be deliberate when expanding the allowed ports. Each additional port increases the attack surface if the URL source is compromised.
Host and IP Validation
After port validation, the function resolves the hostname to an IP address and checks it against a blocklist of private and reserved IP ranges. The blocked ranges include:
0.0.0.0/8(Current network)10.0.0.0/8(Private, Class A)127.0.0.0/8(Loopback)169.254.0.0/16(Link-local, including cloud metadata endpoints)172.16.0.0/12(Private, Class B)192.168.0.0/16(Private, Class C)
This check is performed after DNS resolution, which is important. An attacker might register a domain like evil.example.com that resolves to 127.0.0.1. Because wp_http_validate_url() resolves the hostname and inspects the resulting IP, this DNS rebinding trick is caught. The function uses gethostbyname() for resolution and then runs the IP through wp_is_ip_address() and a series of range checks.
One limitation to be aware of: the validation happens at the time of the initial request. If the target server issues a redirect to an internal IP, the redirect-following behavior could bypass the initial validation. WordPress mitigates this by re-validating the URL on each redirect when reject_unsafe_urls is enabled, but older versions had gaps in this behavior. Always test with the latest WordPress release.
Redirect Following and Validation
By default, wp_remote_get() follows up to 5 redirects. Each redirect creates an opportunity for SSRF if the redirect target is not validated. When reject_unsafe_urls is active, the Requests library is configured to validate each redirect URL through the same wp_http_validate_url() checks. This means a chain like https://public.example.com → http://127.0.0.1/admin will be blocked at the redirect step.
You can control the number of redirects with the redirection argument:
$response = wp_safe_remote_get( $url, array(
'redirection' => 3, // Follow at most 3 redirects.
) );
Setting redirection to 0 disables redirect following entirely. This is the safest option if you know the target URL should respond directly without redirects, such as a well-defined API endpoint.
Proxy Configuration in WordPress
Some hosting environments route outgoing HTTP traffic through a proxy server for security monitoring, caching, or compliance. WordPress supports proxy configuration through constants defined in wp-config.php.
Proxy Constants
The relevant constants are:
define( 'WP_PROXY_HOST', '10.0.0.50' );
define( 'WP_PROXY_PORT', '3128' );
define( 'WP_PROXY_USERNAME', 'proxyuser' );
define( 'WP_PROXY_PASSWORD', 'proxypass' );
define( 'WP_PROXY_BYPASS_HOSTS', 'localhost, *.local, 10.*' );
WP_PROXY_HOST and WP_PROXY_PORT define the proxy server’s address. WP_PROXY_USERNAME and WP_PROXY_PASSWORD are optional and used for proxy authentication. WP_PROXY_BYPASS_HOSTS is a comma-separated list of hosts that should be contacted directly, bypassing the proxy.
The WP_Http_Proxy class manages proxy logic. When a request fires, the class checks whether the destination host matches any entry in WP_PROXY_BYPASS_HOSTS. If it does, the request goes directly. Otherwise, the request is routed through the proxy. Wildcard matching uses simple string comparisons against the end of the hostname, so *.local matches mysite.local but not local.example.com.
Proxy Debugging
Proxy configuration issues are notoriously hard to debug because the symptoms (timeouts, connection refused, empty responses) overlap with many other failure modes. Start by verifying the proxy is reachable from the web server:
// Quick proxy connectivity test.
$proxy = new WP_Http_Proxy();
error_log( 'Proxy enabled: ' . ( $proxy->is_enabled() ? 'yes' : 'no' ) );
error_log( 'Proxy host: ' . $proxy->host() );
error_log( 'Proxy port: ' . $proxy->port() );
// Test if a specific URL bypasses the proxy.
error_log( 'Bypasses proxy for api.wordpress.org: ' .
( $proxy->send_through_proxy( 'https://api.wordpress.org' ) ? 'no' : 'yes' )
);
If you are running WordPress behind a corporate proxy and seeing failures for plugin/theme updates, check that api.wordpress.org and downloads.wordpress.org are not in the bypass list unless you have a direct route to them.
Timeout Strategies: Connection vs. Response Timeouts
Timeouts are the first line of defense against slow or unresponsive external services. WordPress provides two timeout parameters that control different phases of the request lifecycle.
The timeout Argument
The timeout argument sets the maximum total time for the entire request, including DNS resolution, connection establishment, TLS handshake, sending the request, and receiving the response. The default is 5 seconds. For cron-triggered requests (detected by wp_doing_cron()), the Requests library adjusts its internal defaults, but you should always set explicit timeouts for critical integrations:
$response = wp_remote_get( 'https://api.example.com/data', array(
'timeout' => 15, // 15 seconds maximum for the entire request.
) );
Connection Timeout via cURL
The WordPress HTTP API does not expose a separate connection timeout argument in its public interface. However, you can set one through the http_api_curl filter:
add_filter( 'http_api_curl', function( $handle, $parsed_args, $url ) {
if ( str_contains( $url, 'api.example.com' ) ) {
// Fail fast if the server does not accept the connection within 3 seconds.
curl_setopt( $handle, CURLOPT_CONNECTTIMEOUT, 3 );
}
return $handle;
}, 10, 3 );
Separating connection timeout from response timeout is useful when you interact with servers that are slow to respond but quick to accept connections. A short connection timeout lets you fail fast if the server is down entirely, while a longer response timeout accommodates slow processing.
Timeout Behavior During Cron
WordPress cron requests often need longer timeouts because they run background tasks without a user waiting. The http_request_timeout filter lets you adjust timeouts dynamically:
add_filter( 'http_request_timeout', function( $timeout, $url ) {
// Allow 30 seconds for cron-triggered API syncs.
if ( wp_doing_cron() && str_contains( $url, 'api.example.com/sync' ) ) {
return 30;
}
return $timeout;
}, 10, 2 );
Keep in mind that PHP’s own max_execution_time is a hard ceiling. If your PHP process has a 30-second limit and you set a 45-second HTTP timeout, the PHP process will die before the HTTP timeout fires. Coordinate your HTTP timeouts with your PHP execution limits.
Async Patterns
WordPress does not natively support asynchronous HTTP requests in the way that Node.js or Python’s asyncio does. Each wp_remote_get() call blocks the PHP process until the response arrives or the timeout fires. For operations where you do not need the response immediately, consider these patterns:
Fire-and-forget with a short timeout: Set the timeout to 0.01 seconds. The request will be sent, but PHP will not wait for the response. This works for triggering webhooks or logging events where you do not need confirmation. Note that this is unreliable because the connection may be terminated before the server processes the request.
Deferred processing via WP-Cron: Store the request parameters in a transient or custom option, then schedule a cron event to execute the request later. This decouples the user-facing page load from the external API call:
function schedule_deferred_api_call( $endpoint, $payload ) {
$job_id = wp_generate_uuid4();
set_transient( 'deferred_api_' . $job_id, array(
'endpoint' => $endpoint,
'payload' => $payload,
), HOUR_IN_SECONDS );
wp_schedule_single_event( time(), 'process_deferred_api_call', array( $job_id ) );
}
add_action( 'process_deferred_api_call', function( $job_id ) {
$job = get_transient( 'deferred_api_' . $job_id );
if ( ! $job ) {
return;
}
wp_remote_post( $job['endpoint'], array(
'body' => wp_json_encode( $job['payload'] ),
'headers' => array( 'Content-Type' => 'application/json' ),
'timeout' => 30,
) );
delete_transient( 'deferred_api_' . $job_id );
} );
Action Scheduler: For production plugins that need reliable background processing, the Action Scheduler library (used by WooCommerce) provides a more reliable queue system than WP-Cron. It handles retries, failure tracking, and concurrent execution limits out of the box.
SSL Verification: Certificates, Bundles, and Debugging
Every HTTPS request requires SSL certificate verification to prevent man-in-the-middle attacks. WordPress handles SSL verification through the sslverify argument, which defaults to true for most requests.
How SSL Verification Works
When sslverify is true, the cURL transport sets CURLOPT_SSL_VERIFYPEER to true and CURLOPT_SSL_VERIFYHOST to 2. This means cURL verifies both the certificate chain and the hostname match. WordPress ships its own CA certificate bundle at wp-includes/certificates/ca-bundle.crt, and it sets CURLOPT_CAINFO to point to this file. The bundled certificate list is derived from the Mozilla CA bundle and is updated with WordPress releases.
The sslcertificates argument lets you specify a custom CA bundle path:
$response = wp_remote_get( 'https://internal.example.com/api', array(
'sslverify' => true,
'sslcertificates' => '/etc/ssl/custom/internal-ca.pem',
) );
This is useful for internal APIs that use a private certificate authority.
Common SSL Failures
SSL verification failures are among the most common HTTP API issues. Here are the typical causes and their solutions:
Expired certificates: The remote server’s certificate has expired. This is not a WordPress problem. Contact the API provider or server administrator.
Outdated CA bundle: The WordPress-bundled CA certificates do not include the root CA for the remote server’s certificate. This happens with newer certificate authorities or after major CA changes (like the Let’s Encrypt root transition). Update WordPress to get the latest CA bundle, or use the https_ssl_verify filter to point to the system CA bundle:
add_filter( 'https_ssl_verify', '__return_true' );
add_action( 'http_api_curl', function( $handle ) {
// Use the system CA bundle instead of the WordPress-bundled one.
curl_setopt( $handle, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt' );
} );
Missing intermediate certificates: The remote server is not sending the full certificate chain. The server administrator needs to configure their web server to include intermediate certificates. You can diagnose this with the SSL Labs server test or by running openssl s_client -connect host:443 -showcerts.
Self-signed certificates in development: Local development environments often use self-signed certificates. You can disable SSL verification for local requests:
add_filter( 'https_ssl_verify', function( $verify, $url ) {
// Only disable SSL verification for local development URLs.
if ( wp_get_environment_type() === 'local' && str_contains( $url, '.local' ) ) {
return false;
}
return $verify;
}, 10, 2 );
Never disable SSL verification in production. Doing so negates the entire purpose of HTTPS and exposes your site to man-in-the-middle attacks. If you find a plugin that sets sslverify to false unconditionally, file a security report with the plugin author.
Debugging SSL Issues
When SSL verification fails, the WP_Error object typically contains a cURL error message. Enable verbose cURL output for detailed debugging:
add_filter( 'http_api_curl', function( $handle, $parsed_args, $url ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
$verbose_log = fopen( WP_CONTENT_DIR . '/curl-debug.log', 'a' );
curl_setopt( $handle, CURLOPT_VERBOSE, true );
curl_setopt( $handle, CURLOPT_STDERR, $verbose_log );
}
return $handle;
}, 10, 3 );
This writes detailed cURL session information to a log file, including the TLS handshake steps, certificate chain verification, and the specific point of failure. Remember to remove this logging before deploying to production, as it generates significant output and may contain sensitive information.
Building Reliable Integrations: Retry Logic, Backoff, and Circuit Breakers
External API calls fail. Networks have transient issues. Servers go down for maintenance. Rate limits kick in. Production-grade integrations need strategies for handling these failures gracefully instead of breaking the user experience.
Retry Logic with Exponential Backoff
The simplest improvement over a bare wp_remote_get() call is retry logic. When a request fails, wait briefly and try again. Exponential backoff increases the wait time between retries, reducing the load on a struggling server and increasing the chance of a successful retry.
function wpkite_http_get_with_retry( $url, $args = array(), $max_retries = 3 ) {
$base_delay = 1; // seconds
for ( $attempt = 0; $attempt <= $max_retries; $attempt++ ) {
$response = wp_remote_get( $url, $args );
// Success: return immediately.
if ( ! is_wp_error( $response ) ) {
$status = wp_remote_retrieve_response_code( $response );
// Don't retry client errors (4xx) except 429 (rate limited).
if ( $status < 500 && $status !== 429 ) {
return $response;
}
// For 429, respect the Retry-After header if present.
if ( $status === 429 ) {
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
if ( $retry_after && is_numeric( $retry_after ) ) {
$base_delay = max( (int) $retry_after, $base_delay );
}
}
}
// Don't sleep after the last attempt.
if ( $attempt < $max_retries ) {
$delay = $base_delay * pow( 2, $attempt );
// Add jitter to prevent thundering herd.
$delay += wp_rand( 0, 1000 ) / 1000;
sleep( (int) ceil( $delay ) );
}
}
return $response;
}
The jitter (random fractional addition) prevents the thundering herd problem where multiple WordPress processes all retry at the exact same moment. Without jitter, a rate-limited API could see a burst of retry requests arriving simultaneously.
One thing to watch: retry logic increases the total execution time of the PHP process. If you have three retries with exponential backoff, a failing request could block for 1 + 2 + 4 = 7 seconds plus the request timeouts themselves. Set your retry count and backoff multiplier with the PHP execution time limit in mind. For user-facing page loads, two retries with a 0.5-second base delay is usually the maximum that is tolerable.
Circuit Breaker Pattern
Retry logic handles transient failures, but what about persistent outages? If an API goes down for hours, retrying every request wastes time and resources. The circuit breaker pattern addresses this by tracking failure rates and "opening the circuit" when failures exceed a threshold. While the circuit is open, requests fail immediately without attempting the network call.
class API_Circuit_Breaker {
private $service_name;
private $failure_threshold;
private $recovery_timeout;
public function __construct( $service_name, $failure_threshold = 5, $recovery_timeout = 300 ) {
$this->service_name = sanitize_key( $service_name );
$this->failure_threshold = $failure_threshold;
$this->recovery_timeout = $recovery_timeout;
}
public function is_open() {
$state = get_transient( 'circuit_' . $this->service_name );
if ( ! $state ) {
return false;
}
// If recovery timeout has passed, allow a probe request.
if ( $state['opened_at'] + $this->recovery_timeout < time() ) {
return false; // Half-open state: allow one request through.
}
return $state['failures'] >= $this->failure_threshold;
}
public function record_success() {
delete_transient( 'circuit_' . $this->service_name );
}
public function record_failure() {
$state = get_transient( 'circuit_' . $this->service_name );
if ( ! $state ) {
$state = array( 'failures' => 0, 'opened_at' => time() );
}
$state['failures']++;
$state['opened_at'] = time();
set_transient( 'circuit_' . $this->service_name, $state, $this->recovery_timeout * 2 );
}
public function execute( $url, $args = array() ) {
if ( $this->is_open() ) {
return new WP_Error(
'circuit_open',
sprintf( 'Circuit breaker open for %s. Service appears unavailable.', $this->service_name )
);
}
$response = wp_remote_get( $url, $args );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 500 ) {
$this->record_failure();
} else {
$this->record_success();
}
return $response;
}
}
Usage looks like this:
$breaker = new API_Circuit_Breaker( 'payment_gateway', 5, 300 );
$response = $breaker->execute( 'https://api.payment.com/charge', array(
'method' => 'POST',
'body' => $payload,
'timeout' => 10,
) );
if ( is_wp_error( $response ) && $response->get_error_code() === 'circuit_open' ) {
// Show a friendly message or use cached data.
error_log( 'Payment gateway circuit open, using fallback.' );
}
This implementation stores the circuit state in a WordPress transient, which means it works across multiple PHP processes. The recovery timeout allows the circuit to close automatically after a cool-down period, sending a "probe" request to check if the service has recovered.
Combining Retries and Circuit Breakers
In production, you typically layer these patterns. Retries handle transient blips. The circuit breaker handles extended outages. When the circuit is open, retries are skipped entirely, saving time and resources:
function reliable_api_call( $url, $args = array() ) {
static $breaker;
if ( ! $breaker ) {
$breaker = new API_Circuit_Breaker( 'my_api' );
}
if ( $breaker->is_open() ) {
return new WP_Error( 'circuit_open', 'Service temporarily unavailable.' );
}
$response = wpkite_http_get_with_retry( $url, $args, 2 );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 500 ) {
$breaker->record_failure();
} else {
$breaker->record_success();
}
return $response;
}
Monitoring Outgoing HTTP Requests
You cannot optimize what you do not measure. WordPress fires several hooks that let you instrument outgoing HTTP traffic for logging, timing, and failure tracking.
The pre_http_request Filter
This filter fires before the request is sent. Returning a non-false value from this filter short-circuits the request entirely, which is useful for caching, mocking, or blocking specific requests:
add_filter( 'pre_http_request', function( $preempt, $parsed_args, $url ) {
// Cache GET requests to a specific API for 5 minutes.
if ( $parsed_args['method'] === 'GET' && str_contains( $url, 'api.example.com' ) ) {
$cache_key = 'http_cache_' . md5( $url );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
}
return $preempt;
}, 10, 3 );
add_filter( 'http_response', function( $response, $parsed_args, $url ) {
// Store successful GET responses in cache.
if ( $parsed_args['method'] === 'GET'
&& str_contains( $url, 'api.example.com' )
&& ! is_wp_error( $response )
&& wp_remote_retrieve_response_code( $response ) === 200
) {
$cache_key = 'http_cache_' . md5( $url );
set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS );
}
return $response;
}, 10, 3 );
This caching pattern reduces load on external APIs and improves page load times for responses that do not change frequently. Be careful about cache invalidation. Stale cached responses can cause subtle bugs that are hard to diagnose.
Request Timing and Logging
The http_api_debug action is the primary hook for observing completed requests. Combined with a timing mechanism, you can build a lightweight monitoring system:
// Track request start times.
add_filter( 'pre_http_request', function( $preempt, $parsed_args, $url ) {
$GLOBALS['wpkite_http_start'][ md5( $url . $parsed_args['method'] ) ] = microtime( true );
return $preempt;
}, 1, 3 );
// Log completed requests.
add_action( 'http_api_debug', function( $response, $context, $class, $parsed_args, $url ) {
$key = md5( $url . $parsed_args['method'] );
$start = $GLOBALS['wpkite_http_start'][ $key ] ?? microtime( true );
$duration = round( ( microtime( true ) - $start ) * 1000, 2 );
$is_error = is_wp_error( $response );
$status = $is_error ? 'ERROR' : wp_remote_retrieve_response_code( $response );
$message = $is_error ? $response->get_error_message() : '';
error_log( sprintf(
'[HTTP] %s %s | Status: %s | Time: %sms | Transport: %s%s',
$parsed_args['method'],
$url,
$status,
$duration,
$class,
$message ? ' | Error: ' . $message : ''
) );
// Track slow requests.
if ( $duration > 3000 ) {
error_log( sprintf( '[HTTP SLOW] %s took %sms', $url, $duration ) );
}
unset( $GLOBALS['wpkite_http_start'][ $key ] );
}, 10, 5 );
This produces log entries like:
[HTTP] GET https://api.wordpress.org/plugins/info/1.2/ | Status: 200 | Time: 342.5ms | Transport: Requests_Transport_cURL
[HTTP] POST https://api.stripe.com/v1/charges | Status: 402 | Time: 891.3ms | Transport: Requests_Transport_cURL
[HTTP SLOW] https://slow-api.example.com/data took 4521.8ms
Failure Tracking with Custom Metrics
For sites with heavy API usage, logging alone is not enough. You need aggregate metrics to spot trends. A simple approach stores failure counts in an option that is periodically reviewed:
function track_http_failure( $url, $error_message ) {
$host = wp_parse_url( $url, PHP_URL_HOST );
$metrics = get_option( 'http_failure_metrics', array() );
$hour = gmdate( 'Y-m-d-H' );
$key = $host . ':' . $hour;
if ( ! isset( $metrics[ $key ] ) ) {
$metrics[ $key ] = array(
'count' => 0,
'last_error' => '',
);
}
$metrics[ $key ]['count']++;
$metrics[ $key ]['last_error'] = substr( $error_message, 0, 200 );
// Keep only the last 48 hours of data.
$cutoff = gmdate( 'Y-m-d-H', time() - 48 * HOUR_IN_SECONDS );
foreach ( $metrics as $metric_key => $data ) {
$parts = explode( ':', $metric_key );
$metric_hour = end( $parts );
if ( $metric_hour < $cutoff ) {
unset( $metrics[ $metric_key ] );
}
}
update_option( 'http_failure_metrics', $metrics, false );
}
add_action( 'http_api_debug', function( $response, $context, $class, $parsed_args, $url ) {
if ( is_wp_error( $response ) ) {
track_http_failure( $url, $response->get_error_message() );
} elseif ( wp_remote_retrieve_response_code( $response ) >= 500 ) {
track_http_failure( $url, 'HTTP ' . wp_remote_retrieve_response_code( $response ) );
}
}, 10, 5 );
You can then build a simple admin page that displays these metrics, or write a cron job that sends alerts when failure counts exceed thresholds. For larger installations, consider forwarding these metrics to an external monitoring service like Datadog, New Relic, or a self-hosted Prometheus/Grafana stack.
wp_remote_request() Internals and Response Parsing
All of the convenience functions (wp_remote_get(), wp_remote_post(), wp_remote_head()) are thin wrappers around wp_remote_request(). Understanding the full set of arguments this function accepts gives you complete control over outgoing requests.
Full Argument Reference
The $args array passed to wp_remote_request() supports the following keys:
$defaults = array(
'method' => 'GET',
'timeout' => 5,
'redirection' => 5,
'httpversion' => '1.0',
'user-agent' => 'WordPress/' . $wp_version . '; ' . home_url(),
'reject_unsafe_urls' => false,
'blocking' => true,
'headers' => array(),
'cookies' => array(),
'body' => null,
'compress' => false,
'decompress' => true,
'sslverify' => true,
'sslcertificates' => ABSPATH . WPINC . '/certificates/ca-bundle.crt',
'stream' => false,
'filename' => null,
'limit_response_size' => null,
);
Several of these deserve deeper explanation:
blocking: When set to false, the function returns immediately after initiating the request without waiting for a response. The response array will contain placeholder values. This is similar to the fire-and-forget pattern discussed earlier but is built into the API. Note that non-blocking requests are only supported by the cURL transport.
httpversion: Defaults to 1.0 for compatibility. Setting this to 1.1 enables chunked transfer encoding and persistent connections. You can force HTTP/2 through the http_api_curl filter as shown earlier, but you cannot set it through this argument.
stream and filename: When stream is true, the response body is written directly to a file instead of being held in memory. The filename argument specifies the destination path. This is essential for downloading large files (plugins, themes, media) without exhausting PHP's memory limit:
$response = wp_remote_get( 'https://example.com/large-file.zip', array(
'timeout' => 60,
'stream' => true,
'filename' => wp_tempnam( 'download' ),
) );
if ( ! is_wp_error( $response ) ) {
$file_path = $response['filename'];
// Process the downloaded file.
}
limit_response_size: Sets a maximum byte count for the response body. If the response exceeds this limit, it is truncated. This prevents a malicious or misconfigured server from flooding your site with an enormous response that exhausts memory:
// Only read the first 1MB of the response.
$response = wp_remote_get( $url, array(
'limit_response_size' => 1048576,
) );
compress: When true, WordPress sends an Accept-Encoding: gzip, deflate header and compresses the request body if it exceeds a certain size. The decompress argument controls whether the response body is automatically decompressed. Both default to values that handle compression transparently.
Response Parsing Helpers
WordPress provides several helper functions for extracting data from the response array. Using these functions instead of accessing array keys directly protects your code from unexpected response formats:
$response = wp_remote_get( $url );
// Always check for WP_Error first.
if ( is_wp_error( $response ) ) {
$error_message = $response->get_error_message();
$error_code = $response->get_error_code();
// Handle network-level failure.
return;
}
$status_code = wp_remote_retrieve_response_code( $response ); // int
$status_msg = wp_remote_retrieve_response_message( $response ); // string
$body = wp_remote_retrieve_body( $response ); // string
$headers = wp_remote_retrieve_headers( $response ); // object
$cookies = wp_remote_retrieve_cookies( $response ); // array
// Retrieve a specific header (case-insensitive).
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
// For JSON APIs, decode the body.
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
// Handle invalid JSON.
}
A common bug is forgetting to check is_wp_error() before accessing the response. When a request fails at the network level (DNS resolution failure, connection timeout, SSL error), the function returns a WP_Error object, not an array. Passing a WP_Error to wp_remote_retrieve_body() returns an empty string, which can mask the real failure.
Working with the HTTP Response Object
The http_response key in the response array contains a WP_HTTP_Requests_Response object. This object wraps the underlying Requests library response and provides methods for accessing response details that are not available through the convenience functions:
$response = wp_remote_get( $url );
$http_response = $response['http_response'];
// Get the raw response object from the Requests library.
$raw = $http_response->get_response_object();
// Access protocol version.
$protocol = $raw->protocol_version;
// Get the full URL after redirects.
$final_url = $raw->url;
This is particularly useful when debugging redirect chains or when you need to know the actual URL that served the response.
Common Pitfalls and Debugging Techniques
Years of debugging WordPress HTTP issues have revealed several recurring problems. Here are the ones I encounter most frequently, along with practical solutions.
Pitfall 1: Ignoring WP_Error Returns
This is the single most common mistake. Developers write code like this:
// BAD: No error checking.
$response = wp_remote_get( $url );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
update_option( 'api_data', $data );
When the request fails, wp_remote_retrieve_body() returns an empty string, json_decode() returns null, and your option gets overwritten with null, wiping out the previous good data. The fix is straightforward:
// GOOD: Proper error handling.
$response = wp_remote_get( $url );
if ( is_wp_error( $response ) ) {
error_log( 'API request failed: ' . $response->get_error_message() );
return; // Keep existing data.
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status !== 200 ) {
error_log( 'API returned status ' . $status );
return;
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
error_log( 'API returned invalid JSON: ' . json_last_error_msg() );
return;
}
update_option( 'api_data', $data );
Pitfall 2: Hardcoded Timeouts That Are Too Short
The default 5-second timeout works for fast APIs, but many external services occasionally take longer to respond. If your integration fails intermittently, check whether the timeout is too aggressive. Log the actual request durations using the monitoring approach described earlier, then set your timeout to cover the 99th percentile response time with some headroom.
Conversely, do not set excessively long timeouts on user-facing requests. A 30-second timeout on a page load creates a terrible experience. If an API is slow, offload the request to a cron job or AJAX call so the page renders quickly and the data loads asynchronously.
Pitfall 3: Not Handling Rate Limits
Many APIs return HTTP 429 (Too Many Requests) with a Retry-After header. Ignoring this and continuing to hammer the API can result in your IP being blocked. Always check for 429 responses and respect the Retry-After value. The retry logic shown earlier includes this handling.
For APIs with strict rate limits, implement client-side throttling. Use a transient to track your request count per time window and delay or queue requests when you approach the limit:
function throttled_api_request( $url, $args = array() ) {
$rate_key = 'api_rate_' . md5( wp_parse_url( $url, PHP_URL_HOST ) );
$window = 60; // 1-minute window
$max_calls = 30; // 30 requests per minute
$current = get_transient( $rate_key );
if ( $current === false ) {
$current = 0;
}
if ( $current >= $max_calls ) {
return new WP_Error(
'rate_limited',
'Local rate limit reached. Try again shortly.'
);
}
set_transient( $rate_key, $current + 1, $window );
return wp_remote_get( $url, $args );
}
Pitfall 4: Large Response Bodies Consuming Memory
When fetching data from an API that returns large payloads, the entire response body is stored in memory by default. If the response is tens of megabytes, this can push PHP past its memory limit. Use the stream argument for large downloads, or set limit_response_size to cap the body size. For JSON APIs that return paginated data, fetch one page at a time instead of requesting everything at once.
Pitfall 5: DNS Resolution Failures
DNS resolution failures produce cryptic error messages like "Could not resolve host" or "name lookup timed out." These failures can be transient (the DNS server was briefly unreachable) or persistent (the domain does not exist, or the server's DNS configuration is broken). Before investigating complex causes, verify that DNS resolution works from the server:
// Quick DNS check from WordPress.
$ip = gethostbyname( 'api.example.com' );
if ( $ip === 'api.example.com' ) {
// Resolution failed; gethostbyname returns the hostname on failure.
error_log( 'DNS resolution failed for api.example.com' );
} else {
error_log( 'api.example.com resolves to ' . $ip );
}
If DNS works manually but HTTP requests fail, check whether the DNS resolver has a short timeout. Some hosting providers configure aggressive DNS caching that can lead to stale results after a server migration.
Pitfall 6: Conflicting Plugins Filtering Requests
Security plugins and performance plugins sometimes hook into pre_http_request or http_api_curl to modify or block outgoing requests. If your integration suddenly stops working after installing a new plugin, check for filters on these hooks:
// List all callbacks on the pre_http_request filter.
global $wp_filter;
if ( isset( $wp_filter['pre_http_request'] ) ) {
foreach ( $wp_filter['pre_http_request']->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $id => $callback ) {
error_log( sprintf(
'pre_http_request: priority %d, callback %s',
$priority,
is_array( $callback['function'] )
? get_class( $callback['function'][0] ) . '::' . $callback['function'][1]
: $callback['function']
) );
}
}
}
This diagnostic code dumps all registered callbacks on the filter, helping you identify which plugin or theme is interfering with your requests.
Pitfall 7: Not Using the User-Agent Header
WordPress sets a default User-Agent header that includes the WordPress version and site URL. Some API providers block requests with generic or missing User-Agent headers. If you are hitting a 403 or receiving unexpected responses, set a descriptive User-Agent:
$response = wp_remote_get( $url, array(
'user-agent' => 'MyPlugin/1.2.3 (https://myplugin.com; [email protected])',
) );
A well-formed User-Agent also helps API providers reach out to you if your integration is causing issues on their end.
Pitfall 8: Sending JSON Without the Content-Type Header
When POSTing JSON data, developers sometimes pass the JSON string as the body but forget to set the Content-Type header. Many API servers reject the request or parse it incorrectly without this header:
// BAD: Missing Content-Type.
$response = wp_remote_post( $url, array(
'body' => wp_json_encode( $data ),
) );
// GOOD: Content-Type explicitly set.
$response = wp_remote_post( $url, array(
'body' => wp_json_encode( $data ),
'headers' => array(
'Content-Type' => 'application/json',
),
) );
Without the Content-Type header, WordPress's default behavior sends the body as application/x-www-form-urlencoded, which causes JSON parsing failures on the receiving end.
Advanced Debugging with the http_api_debug Action
The http_api_debug action deserves its own section because it is the most powerful tool for understanding what the WordPress HTTP API is doing. This action fires after every HTTP request completes (or fails) and provides access to the full response, the request arguments, the URL, and the transport class used.
Here is a production-ready debug logger that writes structured data to a custom log file:
add_action( 'http_api_debug', function( $response, $context, $class, $parsed_args, $url ) {
// Only log when WP_DEBUG is enabled.
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
$log_entry = array(
'timestamp' => gmdate( 'Y-m-d H:i:s' ),
'method' => $parsed_args['method'],
'url' => $url,
'transport' => $class,
'timeout' => $parsed_args['timeout'],
'blocking' => $parsed_args['blocking'],
'sslverify' => $parsed_args['sslverify'],
);
if ( is_wp_error( $response ) ) {
$log_entry['status'] = 'error';
$log_entry['error'] = $response->get_error_message();
$log_entry['code'] = $response->get_error_code();
} else {
$log_entry['status'] = wp_remote_retrieve_response_code( $response );
$log_entry['body_length'] = strlen( wp_remote_retrieve_body( $response ) );
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
if ( $content_type ) {
$log_entry['content_type'] = $content_type;
}
}
$log_file = WP_CONTENT_DIR . '/http-api-debug.log';
file_put_contents(
$log_file,
wp_json_encode( $log_entry ) . "\n",
FILE_APPEND | LOCK_EX
);
}, 10, 5 );
This creates a JSON-lines log file that you can parse with standard tools like jq. Each line is a self-contained JSON object representing one HTTP request. You can filter for failures, sort by response time, or group by host to identify problematic external services.
For temporary debugging during development, the Query Monitor plugin displays all HTTP API requests made during a page load, along with their timing, status, and the calling code location. It is far more convenient than custom logging for interactive debugging sessions.
Security Hardening for Outgoing Requests
Beyond SSRF protection, there are several security practices that should be part of any production integration.
Never Log Sensitive Headers
Authorization tokens, API keys, and session cookies often travel in HTTP headers. If your logging captures request headers, make sure to redact sensitive values:
function redact_sensitive_headers( $headers ) {
$sensitive = array( 'authorization', 'x-api-key', 'cookie', 'x-api-secret' );
$redacted = array();
foreach ( $headers as $key => $value ) {
$lower_key = strtolower( $key );
if ( in_array( $lower_key, $sensitive, true ) ) {
$redacted[ $key ] = '[REDACTED]';
} else {
$redacted[ $key ] = $value;
}
}
return $redacted;
}
Validate Response Content Types
If you expect a JSON response, verify the Content-Type header before parsing. An attacker who controls the response (through a compromised API or a man-in-the-middle attack on an HTTP endpoint) could return HTML containing malicious scripts that get evaluated if your code passes the response to a function that processes HTML:
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
if ( strpos( $content_type, 'application/json' ) === false ) {
error_log( 'Unexpected content type: ' . $content_type );
return new WP_Error( 'invalid_content_type', 'Expected JSON response.' );
}
Use HTTPS for All External Requests
This should go without saying in 2022, but I still encounter plugins that make requests over plain HTTP. Every outgoing request that carries authentication credentials, user data, or receives data that influences site behavior must use HTTPS. Validate URLs before sending requests:
function ensure_https( $url ) {
$scheme = wp_parse_url( $url, PHP_URL_SCHEME );
if ( $scheme !== 'https' ) {
return new WP_Error( 'insecure_url', 'HTTPS is required for API requests.' );
}
return $url;
}
Putting It All Together: A Production HTTP Client
Let me close with a complete example that combines the patterns discussed throughout this article into a reusable HTTP client class suitable for production use:
class WPKite_API_Client {
private $base_url;
private $api_key;
private $circuit_breaker;
private $max_retries;
private $timeout;
public function __construct( $base_url, $api_key, $options = array() ) {
$this->base_url = trailingslashit( $base_url );
$this->api_key = $api_key;
$this->max_retries = $options['max_retries'] ?? 2;
$this->timeout = $options['timeout'] ?? 10;
$service_name = sanitize_key( wp_parse_url( $base_url, PHP_URL_HOST ) );
$this->circuit_breaker = new API_Circuit_Breaker(
$service_name,
$options['failure_threshold'] ?? 5,
$options['recovery_timeout'] ?? 300
);
}
public function get( $endpoint, $query_params = array() ) {
$url = $this->base_url . ltrim( $endpoint, '/' );
if ( ! empty( $query_params ) ) {
$url = add_query_arg( $query_params, $url );
}
return $this->request( 'GET', $url );
}
public function post( $endpoint, $body = array() ) {
$url = $this->base_url . ltrim( $endpoint, '/' );
return $this->request( 'POST', $url, $body );
}
private function request( $method, $url, $body = null ) {
// Circuit breaker check.
if ( $this->circuit_breaker->is_open() ) {
return new WP_Error(
'service_unavailable',
'Service temporarily unavailable due to repeated failures.'
);
}
$args = array(
'method' => $method,
'timeout' => $this->timeout,
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'User-Agent' => 'WPKite/1.0 (' . home_url() . ')',
),
);
if ( $body !== null ) {
$args['body'] = wp_json_encode( $body );
}
// Retry loop with exponential backoff.
$last_response = null;
for ( $attempt = 0; $attempt <= $this->max_retries; $attempt++ ) {
$response = wp_remote_request( $url, $args );
if ( ! is_wp_error( $response ) ) {
$status = wp_remote_retrieve_response_code( $response );
if ( $status < 500 && $status !== 429 ) {
$this->circuit_breaker->record_success();
return $this->parse_response( $response );
}
if ( $status === 429 ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
if ( $retry_after > 0 && $attempt < $this->max_retries ) {
sleep( min( $retry_after, 10 ) );
continue;
}
}
}
$last_response = $response;
if ( $attempt < $this->max_retries ) {
$delay = pow( 2, $attempt ) + ( wp_rand( 0, 1000 ) / 1000 );
sleep( (int) ceil( $delay ) );
}
}
// All retries exhausted.
$this->circuit_breaker->record_failure();
if ( is_wp_error( $last_response ) ) {
return $last_response;
}
return new WP_Error(
'api_error',
'API returned status ' . wp_remote_retrieve_response_code( $last_response ),
array( 'status' => wp_remote_retrieve_response_code( $last_response ) )
);
}
private function parse_response( $response ) {
$body = wp_remote_retrieve_body( $response );
$status = wp_remote_retrieve_response_code( $response );
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
if ( strpos( $content_type, 'application/json' ) !== false ) {
$decoded = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return new WP_Error( 'json_parse_error', json_last_error_msg() );
}
return array(
'status' => $status,
'data' => $decoded,
);
}
return array(
'status' => $status,
'data' => $body,
);
}
}
// Usage:
$client = new WPKite_API_Client( 'https://api.example.com/v1/', $api_key, array(
'timeout' => 15,
'max_retries' => 3,
'failure_threshold' => 5,
'recovery_timeout' => 300,
) );
$result = $client->get( 'users', array( 'page' => 1, 'per_page' => 50 ) );
if ( is_wp_error( $result ) ) {
// Handle failure.
error_log( 'API call failed: ' . $result->get_error_message() );
} else {
// Work with $result['data'].
$users = $result['data'];
}
This client class encapsulates retry logic, circuit breaking, proper error handling, JSON content-type validation, and structured response parsing. It is a template you can adapt to any external API integration. Extend it with caching (using the transient-based approach shown earlier), request logging, and rate limiting as your needs dictate.
Summary of Key Functions and Hooks
For quick reference, here are the most important functions and hooks in the WordPress HTTP API:
Request functions: wp_remote_get(), wp_remote_post(), wp_remote_head(), wp_remote_request() for general use. wp_safe_remote_get(), wp_safe_remote_post(), wp_safe_remote_request() when the URL comes from untrusted input.
Response helpers: wp_remote_retrieve_body(), wp_remote_retrieve_response_code(), wp_remote_retrieve_headers(), wp_remote_retrieve_header(), wp_remote_retrieve_cookies(), wp_remote_retrieve_response_message().
Validation: wp_http_validate_url() for SSRF protection. wp_is_ip_address() for IP format validation.
Filters: pre_http_request for short-circuiting or caching. http_response for modifying responses. http_api_curl for custom cURL options. http_request_timeout for dynamic timeout adjustment. http_allowed_safe_ports for expanding allowed ports. https_ssl_verify for SSL verification control.
Actions: http_api_debug for monitoring and logging all requests.
The WordPress HTTP API is a capable foundation for building external integrations. Its SSRF protections, transport abstraction, and hook system provide the building blocks. But the API on its own does not guarantee reliability. Production integrations require the additional layers described here: retry logic, circuit breakers, proper timeout management, SSL debugging, monitoring, and disciplined error handling. Build these patterns into your workflow, and your integrations will be ready for the conditions they will actually face in production.
David Okonkwo
Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.