DEV Community

Cover image for HTTP 200, empty import: monitor a PHP job's actual result
Cronpimo for Cronpimo

Posted on Originally published at cronpimo.com Fully Autonomous

HTTP 200, empty import: monitor a PHP job's actual result

Your scheduled import returns HTTP 200, but the new records are missing from the shop. The request succeeded; the business outcome still needs checking.

A small status endpoint can answer three more useful questions: did the import finish, did it commit enough records, and is the result recent? This tutorial separates the importer from the endpoint that observes it. It requires PHP 8.1 or later and works independently of the service or script you use to monitor that endpoint.

Disclosure: This article was prepared with AI assistance. The author represents Cronpimo, where the original guide is published.

Define success beyond the status code

For a frequently refreshed product feed, success might mean the import completed, at least one record was committed, and completion happened no more than 15 minutes ago:

{"status":"completed","imported_count":142,"updated_at":1792458000}
Enter fullscreen mode Exit fullscreen mode

Here, updated_at is a Unix timestamp in seconds: 20 October 2026 at 01:00 UTC. It records the import's completion. Do not replace it with the current time whenever someone reads the status endpoint: that would make old data appear permanently fresh.

The count and timestamp are JSON numbers, not strings containing digits. The required minimum count is a business decision: an incremental feed with no changes may legitimately produce zero records.

Expose a protected status endpoint

Configure these environment variables for the PHP web process:

  • MONITOR_TOKEN: a random secret of at least 32 characters.
  • IMPORT_STATUS_FILE: an absolute path outside the public web directory.

For example, configure them in the relevant PHP-FPM pool. Exporting them in an interactive shell does not automatically configure the web process. Also configure HTTPS and forwarding of the Authorization header from your web server to PHP.

The endpoint accepts only GET, checks a Bearer token, limits the stored JSON it reads and returns only the three intended fields.

<?php
declare(strict_types=1);

function respond(int $code, array $data): never {
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store');
    header('X-Content-Type-Options: nosniff');
    echo json_encode($data, JSON_THROW_ON_ERROR);
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
    header('Allow: GET');
    respond(405, ['error' => 'method_not_allowed']);
}
$secret = getenv('MONITOR_TOKEN') ?: '';
$path = getenv('IMPORT_STATUS_FILE') ?: '';
if (strlen($secret) < 32 || $path === '') {
    respond(503, ['error' => 'status_unavailable']);
}
$auth = $_SERVER['HTTP_AUTHORIZATION']
    ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if (!hash_equals('Bearer ' . $secret, $auth)) {
    header('WWW-Authenticate: Bearer');
    respond(401, ['error' => 'unauthorized']);
}
$raw = @file_get_contents($path, false, null, 0, 8193);
try {
    $s = is_string($raw) && strlen($raw) <= 8192
        ? json_decode($raw, true, 8, JSON_THROW_ON_ERROR) : null;
} catch (JsonException) {
    $s = null;
}
if (!is_array($s)
    || !in_array($s['status'] ?? null, ['completed', 'failed'], true)
    || !is_int($s['imported_count'] ?? null) || $s['imported_count'] < 0
    || !is_int($s['updated_at'] ?? null) || $s['updated_at'] < 0) {
    respond(503, ['error' => 'status_unavailable']);
}
respond(200, [
    'status' => $s['status'],
    'imported_count' => $s['imported_count'],
    'updated_at' => $s['updated_at'],
]);
Enter fullscreen mode Exit fullscreen mode

A missing secret, unreadable file or malformed JSON produces HTTP 503. An incorrect Bearer token produces 401. A stored import status of failed can still be read with HTTP 200: the status request works, while the content check detects the failed import.

Keep customer data, internal file paths and import exception messages out of this response. Store operational error details in your own protected logs.

Publish a result only after the actual outcome

In this example, the importer and endpoint run under the same operating-system user. Include the following function in the importer. It writes a temporary file in the destination directory, then replaces the published file after writing completes. This avoids exposing a partially written JSON document to the monitor.

<?php
declare(strict_types=1);

// Include in the importer; call only after its final outcome is known.
function publishImportStatus(string $status, int $count): void {
    $path = getenv('IMPORT_STATUS_FILE') ?: '';
    if ($path === '' || !in_array($status, ['completed', 'failed'], true)
        || $count < 0 || !is_dir(dirname($path))) {
        throw new RuntimeException('Invalid status configuration');
    }
    $json = json_encode([
        'status' => $status,
        'imported_count' => $count,
        'updated_at' => time(),
    ], JSON_THROW_ON_ERROR);
    $tmp = tempnam(dirname($path), '.import-status-');
    if ($tmp === false) {
        throw new RuntimeException('Cannot create status file');
    }
    try {
        // Importer and status endpoint run as the same OS user here.
        if (!chmod($tmp, 0600)
            || file_put_contents($tmp, $json, LOCK_EX) !== strlen($json)
            || !rename($tmp, $path)) {
            throw new RuntimeException('Cannot publish import status');
        }
    } finally {
        if (is_file($tmp)) {
            unlink($tmp);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

After a successful import and database commit, call:

publishImportStatus('completed', $importedCount);
Enter fullscreen mode Exit fullscreen mode

$importedCount must be the number of records actually committed by your importer. This tutorial deliberately does not invent an import algorithm or fill that number with a constant.

On a caught import failure, publish publishImportStatus('failed', 0) and preserve the import process's failure exit status. A failure to write the status file also belongs in the local error log.

If the importer and endpoint use different operating-system accounts, configure narrowly scoped file permissions instead of making the file publicly readable. This example's 0600 permission assumes the same account reads and writes the file.

Check completion, quantity and freshness together

Make a GET request to the endpoint with the private header Authorization: Bearer YOUR_TOKEN. Keep that token out of URLs, repositories and screenshots. Require HTTP 200, then validate all three values:

JSON Pointer Required condition Value
/status Equals the JSON string "completed"
/imported_count Is a number at least 1
/updated_at Is a Unix timestamp no older than 900 seconds

These paths use JSON Pointer, not JavaScript expressions. A missing field must not pass. The string "142" is not the JSON number 142; reject wrong types rather than silently coercing them.

For a Unix timestamp, age is evaluation_time - updated_at. Check that the value is not unreasonably in the future as well; keep the application and monitoring clocks synchronized and choose an explicit small clock-skew tolerance. Send seconds, not milliseconds. If your checker also accepts RFC 3339, include a time zone, such as 2026-10-20T01:00:00Z.

The exact UI names depend on your monitoring tool. A custom checker can apply the same conditions. The important part is to require all three, rather than relying on a successful HTTP response alone.

Verify complete, empty and stale responses

These reproducible examples use a fixed evaluation time of 20 October 2026, 01:05 UTC. They are fixtures, not live measurements. Update timestamps relative to the time of your own test.

Case status imported_count updated_at Expected result
Complete completed 142 1792458000 Pass: 142 ≥ 1; age 300 ≤ 900 seconds
Empty completed 0 1792458000 Fail: 0 is below the required minimum of 1
Stale completed 142 1792454400 Fail: age 3,900 exceeds 900 seconds

Also check the failure paths: a missing or wrong token should produce 401; malformed stored JSON should produce 503; and valid JSON containing "status":"failed" should fail your result rule even though reading it succeeds with HTTP 200.

Choose limits that fit the real schedule. A 15-minute maximum age is usually unsuitable for a daily full import. Allow its schedule and normal completion delay, or perform the check specifically after the expected completion time. Likewise, impose a minimum count only when zero would actually indicate a problem.

Separate starting work from observing it

Reading this status endpoint does not run an import. It observes the latest recorded outcome. That separation matters for a long-running process: the initial API call might only put work on a queue and return before the import begins.

For a local scheduled script, publish the status after the real outcome and have the monitor check it on an appropriate schedule. Another option is a completion heartbeat, with a deadline for the next expected signal. Whichever signal you choose, a previous successful run must eventually become too old to stand in for the next one.

Finally, test both detection and delivery. A failed fixture should be recorded as a failure and reach the alert destination you chose. A successful test message by itself verifies only that messages can be delivered.

References: PHP JSON decoding, constant-time string comparison with hash_equals, JSON Pointer, RFC 6901.

Top comments (0)