Countdown Timer API with WordPress
For a single countdown on a page, you do not need this — a plugin or the free builder is less work. This guide is for the case where WordPress itself creates timers: a WooCommerce checkout hold, a membership expiry, a per-post deadline. It covers a shortcode with transient caching, a WooCommerce hook, and a REST webhook endpoint that verifies signatures.
The API wrapper
wp_remote_get rather than cURL directly, so the request honours whatever HTTP transport and proxy configuration the site already has.
countdown-timer-api.php
<?php
/**
* Plugin Name: Countdown Timer API
* Description: [countdown id="..."] backed by the CountdownShare API.
*/
if (! defined('ABSPATH')) exit;
// Keys belong in wp-config.php, never in the database or a
// theme file that could end up in a public repository.
// define('COUNTDOWNSHARE_API_KEY', 'cs_live_...');
function cds_api(string $path): array|WP_Error {
$response = wp_remote_get(
'https://countdownshare.com/api/v1' . $path,
[
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . COUNTDOWNSHARE_API_KEY,
'Accept' => 'application/json',
],
]
);
if (is_wp_error($response)) return $response;
$body = json_decode(wp_remote_retrieve_body($response), true);
if (wp_remote_retrieve_response_code($response) >= 400) {
return new WP_Error(
$body['error']['code'] ?? 'api_error',
$body['error']['message'] ?? 'Request failed'
);
}
return $body['data'];
}wp-config.php as a constant. Storing it in the options table means it appears in every database dump and in anything that exports site settings — and a leaked production key can create timers on your account until you revoke it.A shortcode that renders the timer
The [countdown] shortcode
<?php
add_shortcode('countdown', function ($atts) {
$atts = shortcode_atts(['id' => '', 'format' => 'embed'], $atts, 'countdown');
if (! $atts['id']) return '';
// Cache the outputs, not the countdown. The URLs are stable
// for a published timer; the content behind them updates on
// every request, so an hour of caching costs nothing.
$key = 'cds_outputs_' . md5($atts['id']);
$outputs = get_transient($key);
if ($outputs === false) {
$outputs = cds_api('/timers/' . rawurlencode($atts['id']) . '/outputs');
if (is_wp_error($outputs)) {
// Never surface an API failure as a broken page.
error_log('CountdownShare: ' . $outputs->get_error_message());
return '';
}
set_transient($key, $outputs, HOUR_IN_SECONDS);
}
$html = $atts['format'] === 'email'
? ($outputs['email_embed_html'] ?? '')
: ($outputs['website_embed_html'] ?? '');
// Null means the timer is a draft, or the key is a Sandbox key.
if (! $html) return '';
return wp_kses($html, [
'iframe' => [
'src' => true, 'width' => true, 'height' => true,
'style' => true, 'title' => true, 'loading' => true,
'scrolling' => true, 'frameborder' => true,
],
'div' => ['style' => true],
'a' => ['href' => true],
'img' => ['src' => true, 'width' => true, 'alt' => true, 'style' => true],
]);
});Three decisions worth explaining
The transient caches the outputs response, not the countdown. Those URLs are stable for a published timer while the content behind them re-renders on every request, so an hour of caching saves an API call per page view and costs nothing in accuracy.
An API failure returns an empty string rather than an error. A countdown that fails to load should leave a gap, not break the page — the failure goes to the error log where it belongs.
wp_kses with an explicit allowlist keeps the output safe even though it comes from a trusted source. It is cheap insurance and it satisfies review requirements if the plugin is ever distributed.
website_embed_html means the timer is still a draft, or the key is a Sandbox key — publishing is Production-only. See embeds and hosted pages.Creating timers from WooCommerce
This is where the API is doing something a plugin cannot: one timer per order, created automatically at checkout.
WooCommerce checkout hook
<?php
// Create a hold timer when a WooCommerce order enters checkout.
add_action('woocommerce_checkout_order_created', function ($order) {
$response = wp_remote_post(
'https://countdownshare.com/api/v1/timers',
[
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . COUNTDOWNSHARE_API_KEY,
'Content-Type' => 'application/json',
// Derived from the order: a retried checkout returns
// the original timer, not a second fifteen minutes.
'Idempotency-Key' => 'wc_hold_' . $order->get_id(),
],
'body' => wp_json_encode([
'name' => 'Order ' . $order->get_order_number(),
'type' => 'duration',
'duration_seconds' => 900,
'metadata' => ['order_id' => (string) $order->get_id()],
'publish' => true,
]),
]
);
if (is_wp_error($response)) return;
$data = json_decode(wp_remote_retrieve_body($response), true)['data'] ?? null;
if ($data) {
$order->update_meta_data('_countdown_timer_id', $data['id']);
$order->save();
}
});The idempotency key is derived from the order ID. If the hook fires twice — a retried request, a duplicated action — the original timer comes back rather than a second one granting another fifteen minutes.
Receiving the callback
When the hold expires, WordPress needs to hear about it. A REST route handles this.
REST webhook endpoint
<?php
add_action('rest_api_init', function () {
register_rest_route('countdownshare/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'cds_handle_webhook',
// Public by design: the HMAC signature authenticates it,
// and WordPress must not require a nonce or a logged-in user.
'permission_callback' => '__return_true',
]);
});
function cds_handle_webhook(WP_REST_Request $request) {
// get_body() is the RAW body. get_json_params() is parsed and
// cannot be turned back into the bytes that were signed.
$raw = $request->get_body();
$timestamp = $request->get_header('x_countdownshare_timestamp');
$signature = $request->get_header('x_countdownshare_signature');
if (! $timestamp || ! $signature) {
return new WP_REST_Response('Unsigned', 401);
}
if (abs(time() - (int) $timestamp) > 300) {
return new WP_REST_Response('Stale', 401);
}
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $raw,
COUNTDOWNSHARE_WEBHOOK_SECRET
);
if (! hash_equals($expected, preg_replace('/^v1=/', '', $signature))) {
return new WP_REST_Response('Invalid signature', 401);
}
$event = json_decode($raw, true);
if (($event['data']['status'] ?? null) === 'ended') {
$order_id = $event['timer']['metadata']['order_id'] ?? null;
if ($order_id && ($order = wc_get_order($order_id))) {
$order->update_status('cancelled', 'Checkout hold expired');
}
}
return new WP_REST_Response(null, 200);
}The two WordPress-specific traps
permission_callback must return true. WordPress rejects the request before your callback runs otherwise, and the resulting 401 looks like a signature problem when it is not. The HMAC is what authenticates the caller.
And $request->get_body() is the raw body. get_json_params() returns parsed data that cannot be re-encoded into the bytes that were signed — whitespace and key order differ, so the HMAC will never match.
X-CountdownShare-Timestamp becomes x_countdownshare_timestamp in get_header(). Using the original casing returns null and produces a 401 that is genuinely confusing to debug.Keeping it off the critical path
Two rules. Never call the API during page render without a cache in front of it — a ten-second timeout on a slow response becomes a ten-second page load. And put timer creation in an action hook that runs after the response where you can, or in WP-Cron, so a slow API call cannot delay a checkout.
Common questions
Do I need the API for a simple WordPress countdown?
No. For one countdown to one date, a plugin or the free builder is far less work — there is no key to manage and no code. The API earns its place when countdowns are created by something other than a person: one per order, per customer, or per post.
Where do I put the API key?
wp-config.php, as a constant. Not in the options table, not in a theme file, and not in a plugin settings field stored in the database — a database dump or a theme pushed to a repository leaks it. wp-config.php sits outside what most backup and export tools include by default.
Why does my REST webhook return 401 from WordPress itself?
The permission_callback. WordPress rejects the request before your handler runs unless it returns true. Use __return_true here — the HMAC signature is what authenticates the caller, and it is stronger than a nonce for an external service.
How does this relate to the CountdownShare WordPress plugin?
The plugin covers the no-code path: create a timer, paste a shortcode. This guide is for when WordPress itself needs to create timers programmatically — a WooCommerce hook, a membership expiry, a scheduled post. The two work together; the plugin displays, the API creates.
Next steps
Only worth it when WordPress is doing the creating
If you need one countdown on one page, use the free builder. If WooCommerce should create a hold per order, Sandbox is free with any account.