The HTTP API
wp_remote_get and wp_remote_post make outbound requests through WordPress's transport layer instead of raw cURL.
wp_remote_get( string $url, array $args = array() ): array|WP_ErrorThe HTTP API is WordPress's wrapper for outbound requests. wp_remote_get( $url, $args ), wp_remote_post(), wp_remote_head() and the general wp_remote_request() all return either a response array or a WP_Error. Check with is_wp_error() before anything else — the array keys do not exist on failure.
Reading the response
The array has headers, body, response, cookies and http_response. Read them through the helpers rather than by index: wp_remote_retrieve_body() returns the body or an empty string, wp_remote_retrieve_response_code() returns the status as an int, and wp_remote_retrieve_header() pulls one header. They never throw on a WP_Error, which keeps error handling in one place.
Arguments
Defaults worth knowing: timeout is 5 seconds, redirection is 5, httpversion is '1.0', sslverify is true, and blocking is true. A 5-second timeout is short for a slow third-party API and is the cause of most mysterious http_request_failed errors. Setting blocking to false fires and forgets — you get no body back.
Why not cURL
The API picks a working transport, so it still runs where the cURL extension is missing. It also honours site configuration your raw call would ignore: proxy constants, the WP_HTTP_BLOCK_EXTERNAL allowlist, and the http_request_args and pre_http_request filters that let tests and plugins intercept requests.
Example
<?php
/**
* Fetch the current stock level for a SKU, cached for five minutes.
*
* @return array|WP_Error Decoded payload, or WP_Error on any failure.
*/
function scx_fetch_stock( string $sku ) {
$key = 'scx_stock_' . md5( $sku );
$cached = get_transient( $key );
if ( false !== $cached ) {
return $cached;
}
$response = wp_remote_get(
add_query_arg( 'sku', rawurlencode( $sku ), 'https://api.example.com/v1/stock' ),
array(
// The default is 5 seconds, which most third-party APIs will exceed.
'timeout' => 15,
'headers' => array(
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . SCX_API_TOKEN,
),
)
);
// Transport-level failure only: DNS, TLS, timeout.
if ( is_wp_error( $response ) ) {
return $response;
}
// A 404 arrives here as a perfectly successful request, so check the code.
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new WP_Error( 'scx_bad_status', 'Stock API returned ' . $code );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new WP_Error( 'scx_bad_json', 'Unparseable response body.' );
}
set_transient( $key, $data, 5 * MINUTE_IN_SECONDS );
return $data;
}When to use it
- Call a third-party JSON API from a shortcode and cache the result in a transient.
- Send a webhook when an order is created without blocking the checkout response.
- Stop a staging site making outbound calls to a live payment provider.
- Stub an external API in integration tests without touching the network.
More examples
POSTing form data and POSTing JSON
The array-versus-string body distinction accounts for a large share of "the API says my request is empty" bug reports. wp_remote_post() is shorthand for wp_remote_request() with method set to POST; wp_remote_get() and wp_remote_head() are the same wrapper for GET and HEAD, so anything you can do with one you can do with wp_remote_request().
// Array body: WordPress form-encodes it and sets the content type for you.
$response = wp_remote_post(
'https://api.example.com/v1/orders',
array(
'timeout' => 20,
'body' => array(
'sku' => $sku,
'quantity' => 2,
),
)
);
// String body: you must set Content-Type yourself, or the endpoint
// will read your JSON as form data and reject it.
$response = wp_remote_post(
'https://api.example.com/v1/orders',
array(
'timeout' => 20,
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'body' => wp_json_encode(
array(
'sku' => $sku,
'quantity' => 2,
)
),
)
);
// Any other verb goes through wp_remote_request().
$response = wp_remote_request(
'https://api.example.com/v1/orders/42',
array( 'method' => 'DELETE', 'timeout' => 20 )
);Applying a longer timeout to one host only
These two filters are the practical reason to use the HTTP API rather than cURL: they give you a single place to adjust or intercept every request in the site, including ones made by plugins you did not write. pre_http_request returning false means "carry on", and returning an array means "use this instead" — which makes integration tests possible without a network.
/**
* http_request_args runs on every outbound request, so filter narrowly.
*
* @param array $args Request arguments.
* @param string $url The URL being requested.
*/
function scx_slow_partner_timeout( array $args, string $url ): array {
if ( str_contains( wp_parse_url( $url, PHP_URL_HOST ) ?? '', 'slow-partner.example' ) ) {
$args['timeout'] = 30;
}
return $args;
}
add_filter( 'http_request_args', 'scx_slow_partner_timeout', 10, 2 );
/**
* pre_http_request short-circuits entirely: return a response array and
* no network call happens. This is how you stub an API in tests.
*/
add_filter(
'pre_http_request',
static function ( $preempt, array $args, string $url ) {
if ( ! defined( 'SCX_STUB_HTTP' ) || ! str_contains( $url, 'api.example.com' ) ) {
return $preempt; // false: let the real request proceed
}
return array(
'headers' => array(),
'body' => '{"stock":7}',
'response' => array( 'code' => 200, 'message' => 'OK' ),
'cookies' => array(),
);
},
10,
3
);Locking down outbound requests
WP_HTTP_BLOCK_EXTERNAL stops a staging clone from emailing real customers or hitting a production API; only hosts in WP_ACCESSIBLE_HOSTS get through. The blocking => false form is for webhooks where you do not care about the reply — but be clear that you cannot check the status code afterwards, so never use it for anything you need to confirm.
// wp-config.php on a staging site: block all outbound HTTP except an allowlist.
define( 'WP_HTTP_BLOCK_EXTERNAL', true );
define( 'WP_ACCESSIBLE_HOSTS', 'api.example.com,*.wordpress.org' );
// Reading response headers. Header names are matched case-insensitively.
$response = wp_remote_head( 'https://example.com/big.zip', array( 'timeout' => 10 ) );
if ( ! is_wp_error( $response ) ) {
$length = wp_remote_retrieve_header( $response, 'content-length' );
$type = wp_remote_retrieve_header( $response, 'content-type' );
}
// Fire-and-forget: returns immediately, body is empty by design.
wp_remote_post(
'https://hooks.example.com/notify',
array(
'blocking' => false,
'timeout' => 1,
'body' => array( 'event' => 'order.created' ),
)
);
Discussion