An API connection can look finished as soon as data appears on the page. The harder part is making it dependable when the service is slow, a field changes, or someone runs the import twice.
My work on the Stagecoach UK and Canadian websites has involved connecting WordPress to custom systems for courses, venues and enquiries. That experience has made me careful about separating the request, the data mapping and the website that uses the result.
This tutorial uses a small, fictional catalogue API to explain that approach. The PHP is an adapted teaching example, not a copy of Stagecoach’s private endpoints or a claim that every safeguard below is already deployed there.
Implementation notes reviewed: 30 September 2026. The examples below are adapted for this tutorial.
In this tutorial
What we are building
We will fetch a public catalogue on the server, validate it, save a successful snapshot and display that local data. A visitor loading the page will not trigger the external request.
There are two useful patterns in the projects behind this article. Imported records become local WordPress content. Location searches can take a separate live API route. I would not cache both in the same way: a catalogue and a customer’s location search have different freshness and privacy requirements.
Before you start
The example needs WordPress, PHP 8.1 or newer, and a regular plugin file such as wp-content/plugins/example-catalogue/example-catalogue.php. Add a plugin header and keep the following functions together in that file.
Agree the feed contract first: required fields, stable IDs, pagination, authentication, update frequency and what an empty result means. Here, a successful response contains a complete items list. Each item has a string id and title.
The URL below uses the reserved .example domain. Replace it with a fixed, trusted HTTPS endpoint. Configure CN_PROVIDER_TOKEN in your server configuration or wp-config.php, outside source control. Never put it in JavaScript, rendered HTML or a public repository.
1. Check the transport before using the data
WordPress’s HTTP API provides the request and response helpers. I prefer those to adding a separate HTTP client for a small integration.
A connection error, an HTTP error and invalid JSON are different failures. This function gives each one a distinct error code. It also limits the response size, sets a timeout and refuses redirects so a credential is not forwarded to an unexpected destination.
function cn_fetch_catalogue(): array|WP_Error {
if (!defined('CN_PROVIDER_TOKEN') || !is_string(CN_PROVIDER_TOKEN) || CN_PROVIDER_TOKEN === '') {
return new WP_Error('provider_unconfigured', 'Provider token is missing.');
}
$response = wp_remote_get('https://api.provider.example/v1/catalogue', [
'timeout' => 10,
'redirection' => 0,
'limit_response_size' => 2 * 1024 * 1024,
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . CN_PROVIDER_TOKEN,
],
]);
if (is_wp_error($response)) {
return new WP_Error('provider_network', 'Provider request failed.');
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('provider_http', 'Unexpected provider status.', ['status' => $status]);
}
try {
$data = json_decode(wp_remote_retrieve_body($response), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
return new WP_Error('provider_json', 'Provider returned invalid JSON.');
}
return cn_normalise_catalogue($data);
}
The example accepts HTTP 200 because that is our agreed response. Do not treat every response below 400 as valid data. A login page, redirect or maintenance response can otherwise slip through an importer.
2. Normalise at the boundary
A template should not need to understand the provider’s entire payload. Give it a small, predictable structure. This function rejects the whole update if required fields or IDs are invalid, rather than quietly publishing part of an incomplete feed.
function cn_normalise_catalogue(mixed $data): array|WP_Error {
if (!is_array($data) || !isset($data['items']) || !is_array($data['items']) || !array_is_list($data['items'])) {
return new WP_Error('provider_schema', 'Expected an items list.');
}
$items = [];
$seen = [];
foreach ($data['items'] as $row) {
if (!is_array($row) || !isset($row['id'], $row['title']) || !is_string($row['id']) || !is_string($row['title'])) {
return new WP_Error('provider_schema', 'Required item fields are missing.');
}
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $row['id']) || isset($seen[$row['id']])) {
return new WP_Error('provider_identity', 'Invalid or duplicate provider ID.');
}
$title = sanitize_text_field($row['title']);
if ($title === '') {
return new WP_Error('provider_schema', 'Item title is empty.');
}
$seen[$row['id']] = true;
$items[] = ['id' => $row['id'], 'title' => $title];
}
return $items;
}
For a real course importer I would extend this with explicit date formats, timezones, venue relationships and allowed statuses. A provider ID should stay separate from the WordPress post ID. Where multiple providers can own records, identity needs both the source and its ID.
Repeated imports should update the same record. Another important boundary is ownership: provider-owned fields can change during a sync, but manually maintained contact details should not be overwritten just because a feed omits them.
3. Refresh a snapshot outside the page request
The UK code includes a scheduled importer that maps external data into WordPress records. The Canadian code I revisited for this article reads a prepared local feed and directs longer imports through CLI scripts rather than an admin request that might time out.
For this small catalogue, one saved option is enough. Only a valid response replaces the previous snapshot. Failure leaves the last successful result intact.
function cn_refresh_catalogue(): void {
$items = cn_fetch_catalogue();
if (is_wp_error($items)) {
// Log a failure category, never the token or complete response body.
error_log('Catalogue refresh failed: ' . $items->get_error_code());
return;
}
update_option('cn_catalogue_snapshot', [
'items' => $items,
'updated_at' => time(),
], false);
}
add_action('cn_catalogue_refresh', 'cn_refresh_catalogue');
register_activation_hook(__FILE__, function (): void {
if (!wp_next_scheduled('cn_catalogue_refresh')) {
wp_schedule_event(time() + 60, 'hourly', 'cn_catalogue_refresh');
}
});
register_deactivation_hook(__FILE__, function (): void {
wp_clear_scheduled_hook('cn_catalogue_refresh');
});
WP-Cron is triggered by site visits, so an hourly event is not a promise that it runs on the hour. Use a system scheduler to run due WordPress events when reliable timing matters. Large imports also need batching and a lock to prevent overlapping runs. This small example is not a bulk importer.
A snapshot has a different job from a transient. Transients can disappear before their expiry and must be safe to regenerate. Keep your last successful import and refresh timestamp in durable storage if they are needed during an outage. Cache keys for separate feeds must include their source, region and any relevant parameters.
4. Render locally and set a freshness limit
Here the shortcode reads the saved option and escapes the title at output. It never calls the provider. The four-hour limit is an example policy, not a universal recommendation. Agree a suitable limit for the data you display.
function cn_render_catalogue(): string {
$snapshot = get_option('cn_catalogue_snapshot');
if (!is_array($snapshot) || !isset($snapshot['items'], $snapshot['updated_at']) || time() - (int) $snapshot['updated_at'] > 4 * HOUR_IN_SECONDS) {
return '<p>The catalogue is temporarily unavailable.</p>';
}
if ($snapshot['items'] === []) {
return '<p>No items are currently available.</p>';
}
$html = '<ul>';
foreach ($snapshot['items'] as $item) {
$html .= '<li>' . esc_html($item['title']) . '</li>';
}
return $html . '</ul>';
}
add_shortcode('cn_catalogue', 'cn_render_catalogue');
Add [cn_catalogue] through a Shortcode block, or call the renderer from a server-rendered block. A stale catalogue gets a clear unavailable message. A valid empty catalogue gets a different message. Neither case should pretend a request succeeded.
Availability, payment and enrolment decisions need a current authoritative check before confirmation. A public display cache should not become the source of truth for a transaction.
Check the behaviour
- Return HTTP 401, 429 and 503. Confirm the previous snapshot survives.
- Return HTML, broken JSON and a missing
itemsfield. None should replace valid data. - Send duplicate IDs, missing titles and a valid empty list. Confirm each follows the agreed contract.
- Refresh twice. A real post importer must not create duplicates or overwrite manual fields.
- Disable the provider, then load the page. It should render locally until the freshness limit is reached.
- Check scheduled events and the last successful refresh. Log status and error categories, not tokens, personal data or complete responses.
Things to watch
For me, the value of an integration is that people can rely on it without needing to understand the system behind it. Separating transport, mapping, storage and display makes problems easier to find and keeps an external outage from turning every page load into a slow request.
The same thinking applies to the integration projects in my portfolio: establish who owns the data, decide how fresh it must be, and make failure a deliberate part of the implementation.