Scheduled FCM Pushes From a PHP Cron on Shared Hosting

Result notifications for 8 Flutter apps from a 5-minute PHP cron and FCM HTTP v1. Send when the data changed, once a day, and never trust a 200.

Eight of our Flutter apps show lottery and horse-racing results. The most useful notification they can send is "results are out". That message has two hard requirements: it must not go out before the results exist, and it must not go out twice.

Firebase's console can schedule a message for a fixed time, but it cannot check whether the draw results are actually published yet. So the scheduling lives on our own server: a PHP script on shared hosting, run by cron every five minutes, talking to FCM's HTTP v1 API directly. Here is how it is built and which details mattered.

Sending without the SDK: FCM HTTP v1 in plain PHP

The legacy FCM API with a static server key is gone. v1 needs an OAuth2 access token derived from a service account. Without any SDK, that is three steps:

// 1. a JWT signed with the service account's private key (RS256)
$head  = b64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claim = b64url(json_encode([
    'iss' => $sa['client_email'],
    'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
    'aud' => 'https://oauth2.googleapis.com/token',
    'iat' => $now, 'exp' => $now + 3600,
]));
openssl_sign("$head.$claim", $sig, $sa['private_key'], 'sha256WithRSAEncryption');

// 2. exchange it for an access token, cache until 5 minutes before expiry
$exp = $now + (int)($j['expires_in'] ?? 3600) - 300;
file_put_contents(FCM_TOKEN_CACHE, json_encode(['token' => $tok, 'exp' => $exp]));
@chmod(FCM_TOKEN_CACHE, 0600);

// 3. POST to projects/<id>/messages:send; on 401, fetch a fresh token and retry once

Operational details that came from running it:

  • Cache the token in a file. Each cron run is a new PHP process, so there is no memory between runs. The token lives in a file readable only by the owner (0600) and is refreshed five minutes before it expires.
  • Retry once on 401, not more. An expired or revoked token is the common case. Anything else is a real error and should be logged, not looped.
  • Short timeouts. 8 seconds to connect, 15 seconds total. A cron run that hangs blocks the next one.
  • validate_only for dry runs. FCM can validate a message without sending it, so the admin preview and cron.php --dry exercise the whole path safely.
  • The key stays on the server. The service account has a single role (FCM sending), and its key sits in a directory that the web server refuses to serve.

Topics, and why "200 OK" means nothing

FCM v1 has no "send to every install of this app" call. You target a token, a topic or a condition. Each app subscribes to a topic named <appId>_all at startup, and the server sends to that topic.

The trap: a topic send returns 200 even if the topic has zero subscribers. That is written as a comment above our send function, because it misled us first. When we first enabled schedules, some apps had not yet shipped a build that subscribed to the topic. Every send looked successful in the log, and those messages went nowhere.

Delivery has to be measured from the device. Each message carries a random 12-character push_id in its data payload. When the user opens the app from the notification, the app reports it:

FirebaseMessaging.onMessageOpenedApp.listen((m) {
  final id = m.data['push_id'];
  if (id is String && id.isNotEmpty) AdConfig.reportPushOpen(id);  // config.php?...&ping=1&push_id=
});

Notifications received while the app is in the foreground are not counted as opens. The ping reuses the app's existing config endpoint with ping=1, so it does not inflate launch statistics.

On iOS, subscribing to a topic can fail if it runs before the APNs token exists. The app retries the subscription once on the first onTokenRefresh.

The cron: every five minutes, at most once a day

The scheduler is registered in the hosting control panel as */5 * * * *, and runs php cron.php from the command line. Each run:

  1. Takes a non-blocking flock. If the previous run is still going, it prints skipped: already_running and exits.
  2. Loads active schedules and asks, for each one, whether it should send now.
  3. Sends, logs the attempt, and marks success.

The "should send now" check returns a reason for every skip:

Reason Meaning
wrong weekday the draw is not today
already_sent_today last_sent_date equals today in the schedule's timezone
before slot too early
window_closed the slot plus its window has passed; give up for today
trigger_unavailable the results source could not be read; when unsure, don't send

The trigger is what makes it useful. A schedule can wait for data, not just a clock: "send when the lottery API reports today's draw date", or "send when the latest round number changes from the stored one". Several sources can be combined with &&.

The most important line is the one that marks a send as done:

if ($res['ok']) {
    // Mark as sent only on success. On failure the next tick retries until the window closes.
    $today = $nowUtc->setTimezone(new DateTimeZone($row['tz'] ?: 'Asia/Tokyo'))->format('Y-m-d');
    $db->prepare('UPDATE tlabo_ad_push_schedule SET last_sent_date = ?, last_sent_at = NOW() WHERE id = ?')
       ->execute([$today, (int)$row['id']]);
}

A failed send is retried five minutes later and keeps retrying until the window closes. "Today" is computed in the schedule's own timezone, because a Japanese draw and a US draw do not share a calendar day.

One more operational detail: the cron prints nothing on quiet runs. Shared hosting emails cron output to the account owner, and a job that prints every five minutes produces 288 emails a day. Output appears only when something was sent or failed. Each run also writes a heartbeat file, so "the cron is alive" can be checked without reading mail.

Problems we hit

  • The same push twice. One app's push had been split across several schedule rows, one per data source. Each row decided independently that the results were out. The fix was one row with all sources joined by &&, so there is one decision and one send.
  • A PHP parsing surprise. A message template contained a multibyte character immediately after a variable ("$v≠"). PHP read the character as part of the variable name. Writing it as {$v} fixed it.
  • Timezones are per audience, topics are not. A topic reaches every subscriber at once. A morning push for US users at 9:00 Eastern lands at 6:00 Pacific. Per-region topics would fix this; for now it is a known limitation. Morning pushes for different language groups are spaced 30 minutes apart.

Numbers so far

The apps that subscribe to topics were approved in the stores on 2026-11-05, so server-side open rates are still accumulating. The only longer-running figure comes from console-sent pushes before this system: Google Analytics recorded 4,688 push_opened events, about 11% of sessions in the largest app. That is the baseline the scheduled pushes will be compared against.

Checklist

  • [ ] Service account with only the FCM role; key outside the web root
  • [ ] Access token cached in a 0600 file, refreshed before expiry, one retry on 401
  • [ ] Non-blocking lock; quiet output; heartbeat file
  • [ ] Mark a schedule as sent only after FCM accepted the message; compute "today" in the schedule's timezone
  • [ ] Send on data, not only on time; skip when the source is unreadable
  • [ ] Measure opens from the device; never treat 200 as delivered

FAQ

Can you send FCM v1 messages from plain PHP without the Firebase SDK?

Yes. Sign a JWT with the service account key using openssl_sign (RS256), exchange it at Google's OAuth2 token endpoint for an access token with the firebase.messaging scope, cache the token until shortly before it expires, and POST to the messages:send endpoint with curl.

Does a 200 response from FCM mean the notification was delivered?

No. A topic send returns 200 even when the topic has zero subscribers. It only means FCM accepted the message. Delivery has to be measured on the device, for example by reporting opens back to your server.

How do you avoid sending the same push twice when cron runs every 5 minutes?

Store the last sent date per schedule in that schedule's own timezone, and set it only after a successful send. Add a non-blocking file lock so overlapping runs exit immediately.

  • #fcm
  • #firebase
  • #push-notifications
  • #php
  • #cron
  • #flutter

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑