Countdown Timer API with PHP and Laravel
A service class built on Laravel's HTTP client, a queued job for the webhook payload, and a controller that verifies the signature against the raw body. Two Laravel-specific traps get their own sections: the CSRF middleware that returns 419 before your controller runs, and $request->getContent() versus the parsed input helpers.
Configuration
Keys in config rather than scattered env() calls, so config caching works.
config/services.php
// config/services.php
'countdownshare' => [
'key' => env('COUNTDOWNSHARE_API_KEY'),
'webhook_secret' => env('COUNTDOWNSHARE_WEBHOOK_SECRET'),
'base' => 'https://countdownshare.com/api/v1',
],env() outside a config file breaks under php artisan config:cache — it returns null in production and the failure looks like an authentication problem rather than a configuration one.The service class
app/Services/Countdown.php
<?php
// app/Services/Countdown.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response;
use RuntimeException;
class CountdownException extends RuntimeException
{
public function __construct(
public readonly string $code,
string $message,
public readonly int $status,
public readonly string $requestId,
) {
parent::__construct("{$code}: {$message} ({$requestId})");
}
}
class Countdown
{
private function client()
{
return Http::withToken(config('services.countdownshare.key'))
->acceptJson()
->asJson()
->timeout(10)
// Retries transport failures. Application-level 429s are
// handled below, because only some of them should retry.
->retry(3, 200, throw: false)
->baseUrl(config('services.countdownshare.base'));
}
private function unwrap(Response $response): array
{
$payload = $response->json();
if ($response->failed()) {
throw new CountdownException(
$payload['error']['code'],
$payload['error']['message'],
$response->status(),
$payload['request_id'] ?? 'unknown',
);
}
return $payload['data'];
}
public function createTimer(array $body, string $idempotencyKey): array
{
return $this->unwrap(
$this->client()
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->post('/timers', $body)
);
}
public function status(string $timerId): array
{
return $this->unwrap($this->client()->get("/timers/{$timerId}/status"));
}
public function outputs(string $timerId): array
{
return $this->unwrap($this->client()->get("/timers/{$timerId}/outputs"));
}
}The exception carries code and requestId. Log both on failure — the request ID identifies the exact call in our logs and is the first thing support will ask for.
Creating a timer
app/Actions/StartCheckoutHold.php
<?php
// Creating a hold when an order enters checkout.
use App\Services\Countdown;
class StartCheckoutHold
{
public function __construct(private Countdown $countdown) {}
public function handle(Order $order): void
{
$timer = $this->countdown->createTimer([
'name' => "Cart hold {$order->id}",
'type' => 'duration',
'duration_seconds' => 900,
'metadata' => ['order_id' => (string) $order->id],
'publish' => true,
// Derived from the order: a retried job returns the original
// timer rather than granting a second fifteen minutes.
], "hold_{$order->id}");
$order->update(['countdown_timer_id' => $timer['id']]);
}
}hold_{$order->id}, not a random string. If the job is retried after a timeout the original timer comes back instead of a second one — see idempotency.The webhook route
Two things to get right: skipping CSRF, and reading the raw body.
routes/web.php
<?php
// routes/web.php — or api.php, which skips CSRF by default.
Route::post('/webhooks/countdownshare', CountdownWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);Putting the route in routes/api.php instead achieves the same thing — the API middleware group does not include VerifyCsrfToken. Either is fine; leaving it in web.php without the exclusion is what produces a 419 that never reaches your code.
The controller
CountdownWebhookController.php
<?php
// app/Http/Controllers/CountdownWebhookController.php
namespace App\Http\Controllers;
use App\Jobs\HandleCountdownEvent;
use Illuminate\Http\Request;
class CountdownWebhookController extends Controller
{
public function __invoke(Request $request)
{
// getContent() is the RAW body. Do not use $request->all()
// or ->json() before verifying — the bytes are then gone.
$raw = $request->getContent();
if (! $this->verify($raw, $request)) {
return response('Invalid signature', 401);
}
$event = json_decode($raw, true);
// Queue the work and return immediately. The delivery is
// treated as a timeout after 10 seconds.
HandleCountdownEvent::dispatch($event);
return response()->noContent();
}
private function verify(string $raw, Request $request): bool
{
$timestamp = $request->header('X-CountdownShare-Timestamp');
$signature = $request->header('X-CountdownShare-Signature');
if (! $timestamp || ! $signature) {
return false;
}
// Reject anything older than five minutes.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $raw,
config('services.countdownshare.webhook_secret'),
);
// hash_equals is constant time; === is not.
return hash_equals($expected, preg_replace('/^v1=/', '', $signature));
}
}$request->getContent() is the raw string. $request->all(), ->json(), and ->input() all give decoded data, and json_encode-ing that back produces different whitespace and key ordering — so the HMAC will never match. Verify first, decode second.The queued job
The controller returns in milliseconds; the job does the work. uniqueId() returning the event ID handles the at-least-once delivery guarantee.
app/Jobs/HandleCountdownEvent.php
<?php
// app/Jobs/HandleCountdownEvent.php
class HandleCountdownEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(private array $event) {}
/**
* Delivery is at-least-once, so the same event can arrive twice
* after a retry or a replay. A unique job lock on the event id
* makes the second one a no-op.
*/
public function uniqueId(): string
{
return $this->event['id'];
}
public function handle(): void
{
if ($this->event['data']['status'] !== 'ended') {
return;
}
$orderId = $this->event['timer']['metadata']['order_id'] ?? null;
if ($orderId) {
Order::find($orderId)?->releaseHold();
}
}
}ShouldBeUnique alongside ShouldQueue makes the lock effective. Without it a retried delivery enqueues a second job and the work runs twice — fine for “set released_at”, not fine for “issue a refund”.
Showing the countdown
A published timer returns ready-made HTML for a page and for an email, both reading the same server clock your webhook fires against.
Blade and Mailable
{{-- Dropping the countdown into a Blade view --}}
@php($outputs = app(App\Services\Countdown::class)->outputs($order->countdown_timer_id))
@if ($outputs['website_embed_html'])
{!! $outputs['website_embed_html'] !!}
@endif
{{-- And in a Mailable, the email GIF: --}}
{!! $outputs['email_embed_html'] !!}Cache the outputs call rather than making it per render — the URLs are stable for a published timer, and it is the countdown inside them that updates. Embeds and hosted pages covers when each field is populated.
Common questions
Why does my Laravel webhook return 419?
CSRF token mismatch. Routes in web.php run the VerifyCsrfToken middleware, and an external caller has no token. Either put the route in api.php, which skips CSRF, or exclude the middleware explicitly as shown above. The HMAC signature is what authenticates the request.
Why does signature verification fail even though the secret is right?
Almost certainly because the body was read as parsed input. Use $request->getContent() for the raw string. $request->all(), ->json(), and ->input() give you decoded data, and re-encoding it produces different whitespace and key ordering than what was signed.
Should I use Laravel’s HTTP client retry() for 429s?
Partly. retry() handles transport failures and generic retries well, but it cannot tell rate_limited from quota_exhausted — both are 429, and only the first clears by waiting. Let retry() cover connection problems and branch on the error code yourself for the rest.
Is there a Composer package?
No. There are no SDKs in any language. The service class here is short and has no dependencies beyond Laravel’s built-in HTTP client. The OpenAPI 3.1 spec is public if you would rather generate a client.
Next steps
Drop the service class in and run it
Sandbox is free with any account. Set COUNTDOWNSHARE_API_KEY in .env and everything on this page works as written.