=== Cronheart ===
Contributors: cronheart
Tags: cron, wp-cron, monitoring, healthcheck, deadman-switch
Requires at least: 6.0
Tested up to: 7.1
Requires PHP: 8.2
Stable tag: 0.5.6
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Dead-man-switch monitoring for WP-Cron: a site heartbeat plus start, success and fail pings per event. Get alerted when scheduled jobs stop running.

== Description ==

**WP-Cron is request-driven.** On a low-traffic site no requests arrive, no events fire, and a scheduled backup can be stalled for weeks before anyone notices. Uptime monitors do not catch this — the site responds to HTTPS just fine, it just is not running its jobs.

Cronheart turns WP-Cron into a **dead-man switch**: the plugin pings [cronheart.com](https://cronheart.com) every five minutes, and sends a `start`, `success` or `fail` ping on every run of each event you register. With an optional API token, its wp-admin screens let you assign monitors to events, pause or snooze them, test your alert channels and read recent pings without leaving WordPress. If the pings stop, cronheart alerts you via email, Telegram, Slack, Discord, Microsoft Teams, Google Chat, PagerDuty, or a signed webhook.

= What it does =

* **Site heartbeat.** A 5-minute custom WP-Cron event whose only job is to ping cronheart. Proves WP-Cron itself is alive on this site.
* **Per-event monitoring.** Register any scheduled hook for start / success / fail pings with one PHP one-liner: `cronheart_monitor( 'my_nightly_report', 'xxxxxxxx-…' );`
* **PHP fatal-error capture.** When a scheduled callback fatals or throws, the fail-ping body includes the `error_get_last()` summary — the cronheart dashboard shows the cause without you tailing `debug.log`. This holds for runs through a page request, `wp-cron.php`, `ALTERNATE_WP_CRON` and WP-CLI's `wp cron event run`.
* **Settings page.** A read-only "Monitored events" table at Settings → Cronheart shows every hook the plugin is watching and where its UUID came from (constant, option, filter).
* **Monitor picker.** Save a cronheart.com API token and the site heartbeat field becomes a dropdown of your account's monitors instead of a hand-typed UUID. Entirely optional — without a token you paste the UUID as before, and any API hiccup falls back to that field. The token is write-only and never leaves wp-admin.
* **Account overview and monitor management.** With a token configured, Settings → Cronheart shows your plan and monitor budget, and a "Your monitors" table listing each monitor with its status and any active snooze. From that table you can pause, resume, snooze (1 hour, 4 hours, 1 day, or 1 week), or unsnooze a monitor; the change applies on cronheart.com immediately. Every action is an authenticated administrator request — nothing happens without your click.
* **Per-event monitoring UI.** A Settings → Cronheart Events screen lists the recurring WP-Cron events on your site and lets you, per event, either assign one of your monitors from a dropdown or auto-create an interval monitor for it in one click — no code required. This is the point-and-click alternative to the `cronheart_monitor()` helper and `CRONHEART_EVENT_<HOOK>_UUID` constants, which still work. A constant overrides an assignment made on this screen; an assignment made on this screen overrides the helper.
* **Channel management.** A Settings → Cronheart Channels screen lists your cronheart.com notification channels and lets you, per channel, send a real test alert through it or — for webhook channels — rotate the signing secret (revealed once). Creating channels stays on cronheart.com; this screen tests and maintains the ones you already have.
* **Ping & alert history.** A Settings → Cronheart History screen shows the most recent pings (kind, time, runtime) and alerts (kind, time, channels notified) for a monitor you pick (the picker lists up to 200 monitors) — a read-only at-a-glance log without leaving WordPress.
* **Configuration through `wp-config.php` constants** for production (`CRONHEART_HEARTBEAT_UUID`, `CRONHEART_EVENT_<HOOK>_UUID`), with admin-UI fallback for sites where editing `wp-config.php` is not practical.

= Requirements =

* WordPress 6.0 or later.
* PHP 8.2 or later with the cURL extension (`curl`), which most hosts enable.
* A [cronheart.com](https://cronheart.com) account; the free plan is enough. An API token is optional.

= Never breaks WP-Cron =

The plugin's hard contract: a broken cronheart backend, an unreachable network, a misbehaving PSR-18 HTTP client — none of them may cause WP-Cron to fail. Every network / HTTP error is swallowed; with `WP_DEBUG` and `WP_DEBUG_LOG` on, a ping that fails leaves one line in the debug log. A third-party filter that throws while the plugin reads its settings is caught too. If cronheart goes down for a day, your `wp_schedule_event` callbacks still run normally; you just stop seeing pings on the dashboard. When the plugin cannot send pings safely (the PHP cURL extension is missing, or the endpoint setting is one it does not accept), it sends none and shows an error notice in wp-admin instead.

= External services =

This plugin sends HTTP requests to [cronheart.com](https://cronheart.com) in two distinct situations: the monitoring pings your scheduled jobs send, and the account-management calls the admin settings page makes. Both are opt-in: without configuration the plugin loads and does nothing — no telemetry, no usage statistics, no anonymous reports.

**1. Monitoring pings (front end / WP-Cron).** Sent on every scheduled WP-Cron run, but **only when you supply a monitor UUID**. The exact data sent per ping:

* The per-monitor UUID you configured (path segment).
* A short body — capped at 10 KB — that is empty for `start` / `success` / `heartbeat` pings. For a `fail` ping it holds the PHP error type, the file path and line, and the message of the fatal error or uncaught exception, or a one-line note that the event did not complete.
* The bundled SDK's name and version in a `User-Agent` header (for example `cron-monitor-php-sdk/1.9`).

**2. Account management (wp-admin only).** When — and only when — you save a cronheart.com API token, the **Cronheart admin screens** (Settings → Cronheart, Settings → Cronheart Events, Settings → Cronheart Channels, and Settings → Cronheart History) talk to the cronheart.com management API at `https://cronheart.com/api/v1/...`. Every such request carries the token as an `Authorization: Bearer` header and runs **only while a logged-in administrator is on one of those screens** — and, for the write actions below, only when that administrator clicks the control. Never on the front end, during WP-Cron, or in any other context. No token, no request. The calls are:

* **Read your monitors** — `GET /api/v1/monitors` — to populate the heartbeat picker, the "Your monitors" table, the per-event assignment dropdowns, and the Cronheart History monitor picker. Sends nothing beyond the token and paging parameters.
* **Read your account** — `GET /api/v1/account` — to show your plan, monitor budget, and API rate-limit standing. Sends nothing beyond the token.
* **Lifecycle actions** — `POST /api/v1/monitors/<uuid>/pause` (or `/resume`, `/snooze`, `/unsnooze`) — sent when you click a pause / resume / snooze / unsnooze button. Sends the monitor's UUID (in the path) and the action; snooze also sends the chosen duration (1 hour, 4 hours, 1 day, or 1 week).
* **Create a monitor** — `POST /api/v1/monitors` — sent when you click "Auto-create & assign" for a recurring event on the Cronheart Events screen. Sends the event's hook name (as the monitor name), its schedule as an interval in seconds, the site timezone, and a grace period — all derived from the WP-Cron schedule — plus an idempotency key that is a SHA-256 hash of the site URL and the hook name (the URL itself is not sent), so a repeated click does not create a second monitor.
* **Read your channels** — `GET /api/v1/channels` — to populate the Cronheart Channels screen. Sends nothing beyond the token.
* **Send a test alert** — `POST /api/v1/channels/<id>/test` — sent when you click "Send test" on a channel. This **delivers a real notification** to that channel's destination (your configured email, Telegram, Slack, Discord, Microsoft Teams, Google Chat, PagerDuty, or webhook), so it is the one management call with an effect outside cronheart.com. Sends only the channel id (in the path).
* **Rotate a webhook secret** — `POST /api/v1/channels/<id>/rotate-secret` — sent when you click "Rotate secret" on a webhook channel. cronheart.com mints a new signing secret (shown to you once) and invalidates the previous one. Sends only the channel id (in the path); the new secret is shown in the page and never stored by the plugin.
* **Read ping & alert history** — `GET /api/v1/monitors/<uuid>/pings` and `GET /api/v1/monitors/<uuid>/alerts` — to populate the Cronheart History screen for the monitor you select. Reads the most recent page only. Sends the monitor's UUID (in the path) and paging parameters.

The lifecycle, create, test, and rotate-secret calls are the only requests that change anything — and "Send test" additionally delivers a real notification to the channel's destination, while "Rotate secret" invalidates the old webhook secret — but each is one deliberate administrator click. The token is optional: without it the plugin makes none of these management calls — you assign monitors by hand (or via the constants / helper) and only the monitoring pings above are ever sent.

[Cronheart.com Terms of Service](https://cronheart.com/terms) · [Privacy policy](https://cronheart.com/privacy)

= Open source =

Source code and issue tracker: [github.com/alexander-po/cronheart-wp](https://github.com/alexander-po/cronheart-wp).

The plugin wraps the [`cron-monitor/php-sdk`](https://github.com/alexander-po/cron-monitor-php) PHP package (also open source, MIT-licensed). Both projects are maintained independently.

== Installation ==

The plugin needs PHP 8.2 or later with the cURL extension (`curl`), which most hosts enable. Without the extension the plugin sends no pings and shows an error notice in wp-admin.

1. Install the plugin: **WP Admin → Plugins → Add New →** search for "Cronheart" → **Install Now → Activate.** Or upload `cronheart.zip` from a GitHub release.
2. Sign up at [cronheart.com](https://cronheart.com) and create a monitor for your site's heartbeat. Copy the monitor UUID from the dashboard.
3. Configure the UUID. Either add it to `wp-config.php` with `define( 'CRONHEART_HEARTBEAT_UUID', 'xxxxxxxx-…' );` (recommended), or paste it under **Settings → Cronheart** in the WP admin.
4. Done. Within five minutes you should see the first `heartbeat` ping on the cronheart dashboard.
5. *(Optional)* To choose a monitor from a dropdown instead of pasting its UUID, create a Personal Access Token at cronheart.com (**Account → API tokens**) and save it under **Settings → Cronheart**. Every plan, the free one included, can use the API; the account email must be verified before a token can be created. The plugin works fully without a token. For production, prefer `define( 'CRONHEART_API_TOKEN', 'cmk_…' );` in `wp-config.php` to keep the account credential out of the database.

For per-event monitoring (a specific scheduled hook, not just the site heartbeat), register the hook from a plugin / theme / mu-plugin:

`add_action( 'plugins_loaded', function () {
    cronheart_monitor( 'my_nightly_report', 'xxxxxxxx-…' );
}, 1 );`

The hook then emits `start` / `success` (or `fail` on a fatal / thrown exception) pings on every scheduled run.

== Frequently Asked Questions ==

= Does this work when WP-Cron is disabled (system-cron mode)? =

Yes. If you set `define( 'DISABLE_WP_CRON', true );` and trigger `wp-cron.php` from a real system cron, the plugin's `cronheart_heartbeat_tick` event still fires on each run — the trigger mechanism is different, the action chain is the same. Running events with WP-CLI (`wp cron event run --due-now`) works as well, including the `fail` ping of an event that throws.

= What if my host blocks outgoing HTTPS? =

The plugin will retry up to twice (three attempts in all, each limited to 3 seconds) after a connection error or a server error, then give up; an answer that refuses the ping, such as a wrong monitor UUID, is not retried. Your scheduled callbacks still run normally — the plugin never raises an exception that could break the cron runner. The monitor on cronheart.com reports the missed run. With `WP_DEBUG` and `WP_DEBUG_LOG` on, each ping that fails also leaves one line in `wp-content/debug.log` (a ping the service refuses, such as one with a wrong monitor UUID, too), with the reason but without the monitor UUID or the full ping URL.

= Do I need a paid cronheart.com account? =

No. Cronheart's free tier covers 20 monitors per account — enough for a typical site's heartbeat plus several per-event monitors. Every tier, the free one included, gets all notification channels (email, Telegram, Slack, Discord, Microsoft Teams, Google Chat, PagerDuty and signed webhooks). The REST API, which the optional token features in wp-admin use, is included on every tier, the free one too. Paid tiers (Starter / Growth / Scale) raise the monitor cap and the API rate limit.

= Do I need an API token? =

No — it is entirely optional. Paste a monitor UUID under Settings → Cronheart (or define it in `wp-config.php`) and the plugin works on any plan, including the free tier. A token only adds convenience: the settings page can then list your monitors and let you pick one from a dropdown instead of copying a UUID by hand. The token is an account-level credential, so for production prefer defining `CRONHEART_API_TOKEN` in `wp-config.php` over storing it in the database. The token features work on every plan, the free one included, but cronheart.com only lets an account with a verified email address create a token. If the token is refused or cronheart.com cannot be reached, the page shows a notice and falls back to manual UUID entry.

= Where do I find my monitor UUID? =

Sign in at [cronheart.com](https://cronheart.com), open the monitor you created, and copy the UUID shown under its name (it has a copy button), or take it from the address bar or from the end of the monitor's ping URL.

= What happens to my scheduled jobs if cronheart.com is unreachable? =

Nothing. The plugin catches every network / HTTP error from the SDK (and, with `WP_DEBUG` and `WP_DEBUG_LOG` on, writes one line per failed ping to the debug log) — your `wp_schedule_event` callbacks continue to run. You will stop seeing pings on the cronheart dashboard, and after the configured grace period cronheart sends you the down-alert. When cronheart comes back the next successful ping resolves the incident automatically.

= Does the plugin track or report anything about my site? =

No. The plugin sends a ping to cronheart only when you have configured a monitor UUID. The ping payload is the UUID, an optional short body excerpt (capped at 10 KB), and the SDK's `User-Agent` header. There is no anonymous-statistics beacon, no plugin-usage telemetry, no calls to any third-party analytics service.

= Can I point the plugin at a non-production cronheart deployment (staging / private / self-hosted)? =

Yes. Define `CRONHEART_ENDPOINT` in `wp-config.php` with the URL of your alternate deployment. For plain `http://` endpoints (local development, private VPNs without TLS) also set `CRONHEART_ALLOW_INSECURE_ENDPOINT` to `true`. With both unset, the plugin pings the production cronheart.com over HTTPS. The API token is only ever sent over HTTPS, so with a plain `http://` endpoint the settings screens fall back to manual UUID entry while pings keep working. An endpoint the plugin does not accept (one with no `http://` or `https://` scheme, or plain `http://` without `CRONHEART_ALLOW_INSECURE_ENDPOINT`) stops all pings and shows an error notice in wp-admin; the plugin does not send them to cronheart.com instead. An empty `CRONHEART_ENDPOINT` means cronheart.com even when the `cronheart_endpoint` option is set, and a `CRONHEART_ENDPOINT` that is not a string is ignored.

= What does the "Cronheart is not sending pings" notice mean? =

The plugin shows this error notice on every wp-admin screen, to users who can manage options, when it cannot send pings safely; it then sends none. The notice names the cause. If the PHP cURL extension is not loaded on the server, ask your host to enable it. If the endpoint setting is not accepted, the notice says whether `CRONHEART_ENDPOINT` in `wp-config.php` or the `cronheart_endpoint` option holds it: use an `https://` URL (a plain `http://` URL also needs `CRONHEART_ALLOW_INSECURE_ENDPOINT`), or remove the setting to use cronheart.com. Until then your monitors alert that the jobs stopped checking in. The notice cannot be dismissed; it disappears on the next page load after the cause is fixed.

= Where can I report bugs or request features? =

Open an issue on [GitHub](https://github.com/alexander-po/cronheart-wp/issues).

== Screenshots ==

1. The Cronheart settings page in WP admin: the cronheart.com connection section with the account plan and monitor-budget card, and the site-heartbeat monitor picker.
2. The "Your monitors" table on the settings page — each monitor's status and any active snooze, with pause, resume, snooze, and unsnooze actions applied straight to cronheart.com.
3. The Cronheart Events screen — the site's recurring WP-Cron events, each with a dropdown to assign a monitor or an "Auto-create & assign" button.
4. The Cronheart Channels screen — your cronheart.com notification channels, each with a "Send test" action and, for webhook channels, a "Rotate secret" action.
5. The Cronheart History screen — the most recent pings and alerts for a selected monitor.

== Changelog ==

= 0.5.6 =
* A server without the PHP cURL extension no longer gets a fatal error on every request. The plugin then sends no pings and shows an error notice in wp-admin to users who can manage options.
* An endpoint setting the plugin rejects (no `http://` or `https://` scheme, or plain `http://` without `CRONHEART_ALLOW_INSECURE_ENDPOINT`) now stops all pings and shows an error notice in wp-admin naming the constant or option that holds it. Before, the pings and the monitor UUIDs in them went to cronheart.com instead of the configured host.
* A third-party filter that throws while the plugin reads its monitor settings no longer stops a monitored WP-Cron event's own callbacks, ends the rest of the run, or ends every request; the plugin skips the affected ping instead.
* The `cronheart_allow_insecure_endpoint` option now reads `false`, `no`, `off` and any unrecognised value as false, like the `CRONHEART_ALLOW_INSECURE_ENDPOINT` constant.
* With `WP_DEBUG` and `WP_DEBUG_LOG` on, each failed ping writes one line to the debug log, including a ping the service refuses (such as one with a wrong monitor UUID). The line never holds a monitor UUID, an API token or a full URL.
* The plugin description is brought up to date: all eight alert channels, what a `fail` ping's body and the `User-Agent` header contain, the idempotency key of the auto-create call, WP-CLI runs, the retry rules and where to find a monitor's UUID.

= 0.5.5 =
* A monitored WP-Cron event that throws or hits a fatal error now sends its `fail` ping when it is run with `wp cron event run` (including `--due-now`), as hosts that drive WP-Cron from a system cron through WP-CLI do. Before, WordPress's own fatal-error handling ended the WP-CLI process before the plugin could send it, so the monitor received a `start` and no `fail` and stayed up. Runs through a page request, `wp-cron.php` or `ALTERNATE_WP_CRON` already sent it. The `fail` is sent once, and the WP-CLI command's output and exit code are unchanged.

= 0.5.4 =
* A ping that meets a server error or a connection error is now tried up to three times instead of two. After a 503 that carries a whole-second `Retry-After` the bundled SDK waits for it; after any other failure it waits a short fixed step; all waits of one ping stay within the ping timeout, and a 4xx answer is not retried. The ping timeout is now 3 seconds per attempt, so one ping takes at most about 12 seconds when the service keeps answering 503 slowly and about 10 seconds when it is unreachable; a ping sent inline in a visitor's request (`ALTERNATE_WP_CRON`, or a monitored hook fired by a page request) is held for that time. Requests that use your account API token keep one retry. Bundles `cron-monitor/php-sdk` 1.9. The FAQ answer about a blocked host no longer promises a `debug.log` line.

= 0.5.3 =
* Maintenance release with no change in behaviour. Code comments describe the limits of the cronheart.com monitor API (interval, monitor name length, grace period, channel ids) in the API's own terms. The development smoke script, which is not part of the plugin package, now fails within seconds on a wrong API token or monitor UUID and waits briefly for the pings to show up.

= 0.5.2 =
* Corrected the notice shown when an API token entered on the settings page is rejected: it now points to Account → API tokens on cronheart.com, where tokens are created, instead of Settings → API Tokens. Wording only; no behaviour change.

= 0.5.1 =
* Upgraded the bundled `cron-monitor/php-sdk` to `^1.5.2`. The SDK keeps the API token and monitor UUIDs out of its own stack-frame arguments, which an error tracker records, when an API call or a ping fails, and it refuses a monitor UUID with a trailing newline before any request is made. The plugin validates the UUIDs its own admin screens save, but a `CRONHEART_HEARTBEAT_UUID` or `CRONHEART_EVENT_<HOOK>_UUID` constant, a `cronheart_monitor()` call or a `cronheart_monitor_map` filter value reaches the SDK as written; with a trailing newline the older SDK sent the ping to a mangled address, which cronheart.com rejected. The ping still does not arrive, so the monitor keeps reporting missed runs until the value is fixed, but no request is sent; the WP-Cron run is unaffected either way.
* Corrected the admin copy that tied the REST API to a paid plan. cronheart.com includes the API on every plan, the Free plan included; the plan sets the rate limit and the monitor cap. The Settings → Cronheart introduction no longer says API access needs a Starter plan and now says creating a token needs a verified email address, and the "does not include API access" notices on the settings, Events, Channels and History screens now say the plan does not allow the request. Wording only; no behaviour change.

= 0.5.0 =
* **Channel management.** A new Settings → Cronheart Channels screen lists your cronheart.com notification channels and lets you, per channel, send a real test alert through it or — for webhook channels only — rotate the signing secret (revealed once, then never shown again). Creating channels stays on cronheart.com for now; this screen tests and maintains the channels you already have.
* **Ping & alert history.** A new Settings → Cronheart History screen shows the most recent pings (kind / time / runtime) and alerts (kind / time / channels notified) for a monitor you pick (the picker lists up to 200 monitors). Read-only, and only the first page (latest 50 of each) — it never walks your whole history.
* Both screens reuse the same admin-AJAX contract as the earlier management UI (nonce, `manage_options`, boundary validation, no public endpoint) and the throwing management client; "Send test" delivers a real notification, and the rotated webhook secret is shown once and never stored by the plugin. The runtime ping path is unchanged.
* Upgraded the bundled `cron-monitor/php-sdk` to `^1.4`. It adds the typed channel-test delivery exception, so a failed test send reports "the destination rejected delivery" distinctly from other errors, and it keeps monitor UUIDs and API tokens out of its log lines and error text.
* A monitor status, ping kind or alert kind this version does not recognise yet is now shown as-is on the admin screens instead of failing the whole listing.
* The API token is now sent only over HTTPS. With a plain `http://` endpoint the management screens fall back to manual UUID entry; pings keep working.
* Tested up to WordPress 7.1.
* Corrected the FAQ: notification channels are available on every cronheart.com plan, including the free one.
* Corrected the description of the Cronheart Events screen: an assignment made there overrides the `cronheart_monitor()` helper, and only a `CRONHEART_EVENT_<HOOK>_UUID` constant overrides the assignment. Earlier notes said the helper took precedence.
* The "External services" disclosure now also covers the channel reads / test / rotate-secret calls and the history reads — including that "Send test" sends a real notification to the channel's destination.

= 0.4.0 =
* **Per-event monitoring UI.** A new Settings → Cronheart Events screen lists the site's recurring WP-Cron events and lets you, per event, assign one of your monitors from a dropdown or auto-create an interval monitor for it in one click — the point-and-click alternative to the `cronheart_monitor()` helper and `CRONHEART_EVENT_<HOOK>_UUID` constants (both still work and still take precedence).
* **Auto-create.** "Auto-create & assign" derives an interval monitor from the event's WP-Cron schedule — name from the hook, interval in seconds, site timezone, and a grace period — within cronheart.com's accepted ranges, and is offered only for unmapped, recurring events. It is idempotency-keyed so a double click is safe.
* The assign / auto-create actions go through the same admin-AJAX contract as 0.3.0 (nonce, `manage_options`, boundary validation, no public endpoint); the request hook is always validated against the discovered event set, and assigning a monitor is a local option write that needs no token. The live controls are token-gated like the heartbeat picker; without a token the screen is a read-only view.
* The "External services" disclosure now also covers the create call (`POST /api/v1/monitors`), sent only when you click "Auto-create & assign".

= 0.3.0 =
* **Account overview.** With a cronheart.com API token configured, Settings → Cronheart now shows your plan, monitor budget (used / limit / remaining), and API rate-limit standing, with an upgrade nudge when you are near your monitor limit.
* **Monitor management from wp-admin.** A new "Your monitors" table lists each monitor with its status and any active snooze, and lets you pause, resume, snooze (1 hour / 4 hours / 1 day / 1 week), or unsnooze it — applied on cronheart.com immediately. This is the plugin's first admin-AJAX surface: every action is nonce-checked, capability-gated (manage_options), validated at the boundary, and degrades to a readable message rather than a fatal if the API call fails. No public (nopriv) endpoint is registered.
* **Monitor status in the picker.** The heartbeat dropdown now shows each monitor's status alongside its name.
* Upgraded the bundled `cron-monitor/php-sdk` to `^1.1`, which adds the account and monitor-lifecycle endpoints these features use.
* The "External services" disclosure has been rewritten to cover the new read (`GET /api/v1/account`) and write (`POST /api/v1/monitors/<uuid>/{pause,resume,snooze,unsnooze}`) calls; all remain wp-admin-only, token-gated, and triggered by an administrator's explicit action.

= 0.2.1 =
* Documentation and screenshots for the monitor picker. No functional change from 0.2.0 — the plugin code is identical; this release refreshes the readme ("What it does", external-services disclosure, FAQ) and adds an updated settings-page screenshot showing the picker.

= 0.2.0 =
* **Monitor picker.** Save a cronheart.com API token under Settings → Cronheart and the heartbeat field becomes a dropdown of your monitors instead of a free-text UUID box. The selection still saves to the same `cronheart_heartbeat_uuid` option, so nothing changes about how pings are sent — only how you fill in the UUID.
* **Write-only API token field.** The token is never echoed back into the page; it can be set in the database or, preferred for production, via a new `CRONHEART_API_TOKEN` constant in `wp-config.php`.
* **wp-admin-only API call.** Listing your monitors happens only on the settings page, only when a token is configured, and never on the front end or during WP-Cron. The runtime ping path is unchanged and carries no account credential. See the updated "External services" disclosure.
* **Graceful fallback.** If the listing fails — no API access on your plan, an invalid token, a rate limit, or a network error — the page shows a notice and falls back to manual UUID entry. The admin page never fatals.
* Upgraded the bundled `cron-monitor/php-sdk` to `^1.0`, which adds the authenticated management-API client the picker uses.

= 0.1.9 =
* Directory review fix: `Contributors:` set to the slug owner. No code changes.

= 0.1.8 =
* "Tested up to" 7.0. No code changes.

= 0.1.7 =
* Restored the Terms of Service and Privacy policy links. No code changes.

= 0.1.6 =
* Directory review fixes: dead links removed, `Contributors:` corrected, CLI binaries dropped from the zip. No behaviour changes.

= 0.1.5 =
* "Tested up to" 6.9. No code changes.

= 0.1.4 =
* Release zip trimmed to runtime files, a copyright header in `LICENSE`, changelog cleanup. No behaviour changes.

= 0.1.3 =
* Plugin Check fixes: direct-access guards and output escaping. No behaviour changes.

= 0.1.2 =
* Full readme for the Plugin Directory submission. No code changes.

= 0.1.1 =
* Endpoint override (`CRONHEART_ENDPOINT`, `CRONHEART_ALLOW_INSECURE_ENDPOINT`) for staging, private or local deployments.

= 0.1.0 =
* First release: site heartbeat, per-event monitoring through `cronheart_monitor()` and constants, the Settings → Cronheart page, and the PHP fatal error in the fail ping.

== Upgrade Notice ==

= 0.5.6 =
An endpoint setting the plugin does not accept now stops pings, with an error notice in wp-admin, instead of sending them to cronheart.com. Also: no fatal error without the cURL extension, and failed pings in the debug log.

= 0.5.5 =
A failing monitored event run with `wp cron event run` now reports `fail` instead of leaving the monitor up. No settings change.

= 0.5.4 =
Pings are tried up to three times with a 3 second timeout per attempt, and the bundled SDK is updated to 1.9. No settings change.

= 0.5.3 =
Maintenance release: code comments and development tooling only. No change in behaviour.

= 0.5.2 =
Corrects the page name in the invalid API token notice: tokens are created under Account → API tokens on cronheart.com. Wording only.

= 0.5.1 =
Bundles cron-monitor/php-sdk 1.5.2: a monitor UUID with a trailing newline is refused before any request, and the API token and monitor UUIDs stay out of the SDK's stack-frame arguments. Also corrects copy that tied the REST API to a paid plan.

= 0.5.0 =
Adds Cronheart Channels (test a channel, rotate a webhook secret) and Cronheart History (recent pings & alerts) screens. "Send test" sends a real notification; a rotated secret is shown once. The API token now needs an https:// endpoint; the ping path is unchanged.

= 0.4.0 =
Adds a Cronheart Events screen to assign or auto-create monitors for your recurring WP-Cron events from wp-admin — no code needed. Same admin-AJAX safety as 0.3.0; the constants and helper still work and take precedence. No token required; the runtime ping path is unchanged. Safe to upgrade.

= 0.3.0 =
Adds an account/plan card and a "Your monitors" table to pause, resume, snooze, or unsnooze monitors from wp-admin — the plugin's first admin-AJAX endpoint, nonce- and capability-gated. No token required; the runtime ping path is unchanged. Safe to upgrade.

= 0.2.1 =
Documentation + screenshot refresh for the 0.2.0 monitor picker. No code change. Safe to upgrade.

= 0.2.0 =
Adds an optional monitor picker: save a cronheart.com API token and choose your heartbeat monitor from a dropdown instead of pasting a UUID. No token required — manual UUID entry works exactly as before. The runtime ping path is unchanged. Safe to upgrade.

= 0.1.9 =
Plugin Directory review round 2 metadata fix (Contributors set to the slug owner). No code changes. Safe to upgrade.

= 0.1.8 =
"Tested up to" bump to 7.0. No code changes. Safe to upgrade.

= 0.1.7 =
Restored Terms / Privacy links with the correct URLs (cronheart.com/terms and /privacy). No code changes.

= 0.1.6 =
Plugin Directory review round 1 metadata fixes. No code changes. Safe to upgrade.

= 0.1.5 =
"Tested up to" bump to 6.9. No code changes. Safe to upgrade.

= 0.1.4 =
Pre-submission cleanup only — no behaviour changes. Safe to upgrade.

= 0.1.3 =
WordPress.org Plugin Check fixes only — no behaviour changes. Safe to upgrade.

= 0.1.2 =
WordPress.org metadata polish only. Safe to upgrade.

= 0.1.1 =
Adds opt-in endpoint override. Existing installs are unaffected — the default endpoint remains https://cronheart.com.
