Monitor Billing Cron Jobs, Renewals, Emails, and Reports
Updated
Contents
To monitor a billing, renewal, email, or report job, check three things on every run: that it ran on schedule, that it did the amount of work a good run does, and that it finished before the business deadline that depends on it. An exit code of 0 proves only that a run happened and nothing in it failed. A renewal job that found nothing due because it read the wrong date, a digest that reached 40 of 12,000 recipients, and an export that landed after the partner's pickup all exit 0. Have each job report counts with its completion, alert when they're missing or out of range, and run a second job that checks the result against the source of truth.
Why "It Ran" Isn't Enough for These Jobs#
Most scheduled jobs fail cheaply: they don't run, and someone reruns them later. Jobs that move money or face customers fail in three more ways:
- The wrong amount of work. Zero invoices, half the digests, a deletion that matched nothing. Silent success covers where these runs come from.
- The work done twice. A retry or manual rerun charges a card again. Monitoring can't undo that; the job has to make repeats harmless, as idempotent scheduled jobs shows.
- The work done too late. The report was right but arrived after the customer's morning meeting.
A failed or missed run is recoverable if the alert arrives while a rerun can still meet the deadline, so the deadline, not the schedule, sets the grace period, the alert route, and the runbook.
The Jobs and How Each One Fails#
Billing and Invoicing#
A missed charge loses revenue quietly; a double charge brings refunds, support tickets, and possibly disputes. Monitoring catches the first, and idempotency prevents the second. Stripe accepts an Idempotency-Key on every v1 POST, replays the saved response for a repeated key "including 500 errors," and may prune keys once they're at least 24 hours old, after which a reused key runs as a new request (idempotent requests, checked October 25, 2026). A key built from subscription and period protects a same-day rerun, not a catch-up two days later, so keep your own table of attempted charges as well. Stripe also advises against using email addresses or other personal identifiers as keys.
Subscription Renewals and Dunning#
First settle who renews. With Stripe Billing subscriptions, "Stripe automatically generates invoices, attempts payment collection, and manages the subscription status," and "handles the payment retry logic, dunning, and status transitions" (how subscriptions work). Retries follow the policy you configure; Smart Retries' recommended default is 8 tries within 2 weeks (automate payment retries, checked October 25, 2026). A job of yours that also retries those invoices is a second retry policy against the same cards.
Your part is then a webhook endpoint, and its failure is quiet. If Stripe doesn't receive a successful response to invoice.created, it delays finalizing all automatically collected invoices for up to 72 hours, and an invoice that fails to finalize can't be paid while its subscription stays active (subscription webhooks). Renewals stop being charged and none of your scheduled jobs failed; the reconciliation job below notices. If your own job renews, it carries both billing risks above, and it's the job the code example monitors. On WordPress, WooCommerce and other plugins queue renewal work that depends on WP-Cron running; WordPress cron monitoring covers why it runs late and how to alert on it.
Transactional and Digest Emails#
Count how many expected recipients had a message accepted by the provider, and don't trust the HTTP status alone. Postmark's batch endpoint takes up to 500 messages per call and "will return a 200-level http status, even when validation for individual messages may fail" (Postmark email API, checked October 25, 2026), so a job that checks only the status counts rejected messages as sent. Acceptance isn't delivery either: bounces arrive later through a bounce webhook.
If your trial-expiry email is triggered by Stripe's customer.subscription.trial_will_end event, sent 3 days before a trial ends (subscription webhooks), the only scheduled check is a daily job counting those trials against reminders sent.
Customer Reports and Partner File Drops#
These have the clearest deadlines: a report before the customer's day starts, a partner's pickup at 05:00 UTC. In batch mode, sftp -b aborts when a put or rename fails (sftp(1)) and exits 1 (sftp.c, OpenSSH 10.2p1), so its exit code is trustworthy. Upload under a temporary name and rename when complete, if the partner's server allows it, so a pickup never reads half a file, and report whether the file arrived.
Data Retention and Deletion#
A deletion job that stops leaves no error and no unhappy customer, only data kept past its limit. Under the EU's GDPR, for example, the European Commission tells organisations to keep personal data for "the shortest time possible" and to "establish time limits to erase or review the data stored" (GDPR principles). Which periods apply is a question for your counsel; the monitoring question is whether each run proves nothing is past its limit. The opposite failure, a date bug that deletes far too much, needs a ceiling inside the job, before the DELETE, since outcome values arrive after the fact.
Certificate and Credential Rotation#
These fail weeks before anything breaks, since the current certificate or key works until it expires. Report days until expiry; certbot renewal monitoring builds this for Let's Encrypt with days_left >= 14. The same check fits signing keys and API credentials that have an expiry date.
What Each Job Should Report#
Counts and flags, never records. The numbers are illustrative; take yours from your own history:
| Job | Report on completion | Expectations | Deadline in business terms |
|---|---|---|---|
| Monthly invoicing | invoices_due, invoices_created, invoices_missing, charge_failures |
invoices_due >= 900, invoices_missing = 0 |
Issued by 09:00 UTC on the 1st, before close |
| Digest emails | expected_recipients, emails_sent, emails_failed, emails_unsent |
emails_unsent = 0, emails_failed <= 50 |
In inboxes before customers start work |
| Customer reports | reports_expected, reports_generated, reports_missing |
reports_missing = 0, reports_expected >= 40 |
Ready at the start of the customer's day |
| Partner file drop | file_delivered, rows_exported |
file_delivered is true, rows_exported >= 800 |
On the partner's server before 05:00 UTC |
| Retention purge | rows_deleted, expired_remaining |
expired_remaining = 0 |
Nothing past its limit after each nightly run |
| Certificate renew | days_left |
days_left >= 14 |
Renewed with weeks to spare |
The job computes the _missing and _unsent values, expected minus everything accounted for, so one = 0 expectation catches a loop that stopped early without raising.
CronRadar marks a run warning once its expected time passes, and critical, opening an incident, once it's past the grace period and at least two minutes late. Choose a grace longer than normal lateness and short enough to leave time for a rerun: an export scheduled at 03:00 that takes 20 minutes, with a 05:00 pickup, can afford about 30 minutes.
Report Outcomes From a Renewal Job#
There's no installable CronRadar SDK for Python today and no auto-discovery for it, so the job reports with its own HTTP check-ins: start before the work, complete with a JSON body of outcomes, and fail when it raises.
# renewals_monitored.py (Python 3.9+): run today's renewals, then report the counts.
# renew_due_subscriptions is your renewal code. It returns totals for the period,
# so a rerun that finds everything already charged reports the same numbers.
import base64
import datetime as dt
import json
import os
import sys
import urllib.parse
import urllib.request
from billing.renewals import renew_due_subscriptions
KEY = "subscription-renewals"
def checkin(event, run_id, body=None, **params):
"""Report to CronRadar. Prints and ignores every error, so renewals never depend on it."""
try:
query = urllib.parse.urlencode({"run_id": run_id, **params}, quote_via=urllib.parse.quote)
request = urllib.request.Request(
f"https://cron.life/api/v1/ping/{KEY}/{event}?{query}",
data=json.dumps(body).encode() if body is not None else None,
method="POST",
)
token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
request.add_header("Authorization", f"Basic {token}")
if body is not None:
request.add_header("Content-Type", "application/json")
urllib.request.urlopen(request, timeout=5).close()
except Exception as exc:
print(f"CronRadar {event} check-in failed: {exc}", file=sys.stderr)
def main():
day = dt.datetime.now(dt.timezone.utc).date()
run_id = f"{KEY}-{day.isoformat()}-{os.getpid()}"
checkin("start", run_id, schedule="0 6 * * *", gracePeriod=1800)
try:
result = renew_due_subscriptions(day)
except Exception as exc:
# Exception text can quote customer data, so send only its type.
checkin("fail", run_id, message=f"renewals for {day} raised {type(exc).__name__}")
raise
outcomes = {
"renewals_due": result.due,
"renewals_charged": result.charged,
"renewals_failed": result.failed,
"renewals_unattempted": result.due - result.charged - result.failed,
}
checkin("complete", run_id, body={"outcomes": outcomes})
if __name__ == "__main__":
main()Run it at 06:00 UTC, matching schedule; CronRadar evaluates schedules in UTC. The charge loop in idempotent scheduled jobs is one way to write renew_due_subscriptions. A declined card is a counted result, not an exception. An exception reports fail, which alerts at once without waiting for the grace period, and is re-raised, so the process still exits non-zero.
urlopen raises on a 4xx or 5xx response, and timeout=5 bounds the connection attempt and each read (urllib.request); the except absorbs both, so a monitoring outage can't stop renewals. CronRadar answers an outcome body it can't accept, such as a sixth value, an uppercase key, or a string, with a 400 and doesn't record the completion, so read the first run's stderr. The schedule and gracePeriod apply only if this check-in creates the monitor or recreates a deleted one; change an existing monitor's grace in its settings.
Then, in the monitor's settings under Outcome checks, configure the expectations:
| Outcome check | Catches |
|---|---|
renewals_due >= 150 |
Nothing found due: the wrong date, test-mode API keys, a renamed status |
renewals_unattempted = 0 |
A loop that stopped early or skipped subscriptions without raising |
renewals_failed <= 40 |
A wave of failures that points at your integration, not customers' cards |
renewals_charged >= 120 |
Collection far below normal even though every renewal was attempted |
A missing or violated expectation marks the run outcome-failed and opens an incident showing observed and expected values. A configured key the completion leaves out fails its check, so a refactor that stops sending a count is caught too.
Reconcile Against the Source of Truth#
The renewal job's counts are its own account. A second job with its own monitor checks what should have happened against what did, from records the first job doesn't write. Run it after the renewals usually finish, such as 0 9 * * *:
| Outcome | What it counts | Expectation |
|---|---|---|
renewals_missing |
Subscriptions your database says renew today with no charge attempt recorded | = 0 |
duplicate_payments |
Subscriptions with more than one successful payment for the period in the provider's records, grouped by the metadata you attach | = 0 |
stale_draft_invoices |
Stripe Billing renewal invoices still draft hours after creation |
= 0 |
failures_without_notice |
Failed renewals with no dunning email queued | = 0 |
Count duplicates from the provider's records: the dangerous double charge is the one your job didn't record. For stale_draft_invoices, Stripe's invoice list filters by status and created (list invoices), and renewal invoices have a billing_reason of subscription_cycle (invoice object). Stripe attempts payment an hour after a successful invoice.created response (subscription webhooks), so allow a few hours, and leave out past_due and unpaid subscriptions, whose new invoices "remain in draft status" (how subscriptions work).
The reconciliation also covers a gap in the renewal job's own monitor. CronRadar's stuck detection works per job, not per run: every check-in moves the expected time to the job's next scheduled occurrence, and a start with no complete or fail goes critical only when that occurrence plus the grace period passes in silence. A run that hangs at 06:05 is flagged the next morning at the earliest, and never if the next run starts on time. A timeout inside the job turns a hang into a fail; renewals_missing catches a host that died mid-run.
Route Alerts by What a Failure Costs#
Page someone when waiting until morning costs money or compliance: billing, renewals, a deletion job with a deadline, a partner drop collected before office hours. Send the rest to Slack or email for the owner to handle the same day; cron job alerting covers the tiers. Under the CronRadar Scheduled Job Methodology's priority rules, these are P1 jobs, which carry Critical severity and are the only jobs that route to PagerDuty.
In CronRadar, each monitor's settings hold Owner or team, Severity (Warning, Error, or Critical), Environment, Alert destinations (any of email, Slack, webhook, Discord, and PagerDuty), Logs URL, Runbook URL, and Repository URL. Blank fields inherit the application's default, then the workspace's, so set Slack and email on the application and override the money and compliance monitors to PagerDuty and Slack. CronRadar has no on-call schedules or escalation policies; PagerDuty decides who wakes up.
- Owner: a person or rotation who can change the job, such as the billing on-call, not "engineering." The owner appears in alerts on all five channels, and severity on every channel except email.
- Runbook URL: the rerun command for one period, the deadline, and who to tell when it will be missed: finance, support, the partner's contact. Runbook, logs, and repository URLs appear in webhook payloads and PagerDuty custom details; scheduled job runbooks has a template.
For an unresolved incident, CronRadar sends the first alert, at most one reminder every 24 hours, and a recovery message when a successful run resolves it. Acknowledging the incident in the console stops the reminders, not the monitoring. A manual rerun that checks in to the same monitor resolves it, so make the runbook's rerun command report.
Create the Monitor Before the First Run#
A monitor created by its first check-in can't flag a job that never runs, such as a crontab entry that never deployed. For a new money or compliance job, create the monitor in the CronRadar dashboard before the deploy, with the job's key, UTC schedule, and grace period. It waits in pending, and if the first expected run plus the grace period passes with no report, CronRadar sends a never-executed alert.
For Hangfire, CronRadar.Hangfire 0.1.0 (checked September 20, 2026) creates monitors when it discovers recurring jobs at startup, so the never-executed alert covers them without that step. Hangfire retries a failed job 10 times by default (dealing with exceptions), each from the first line, so a billing job must be idempotent. Version 0.1.0 can report a failure before Hangfire finishes retrying, including cancellations; reporting only after retries are exhausted requires the pending 0.2.1 release. Neither 0.1.0 nor the CronRadar 1.0.13 SDK sends outcome values, so a job that needs them can take [SkipMonitor] and report over HTTP to a monitor you create in the dashboard. If a deploy drops a discovered job, CronRadar pauses its monitor and marks it no longer discovered, without an alert; review paused monitors after deploys.
Month-End and Quarter-End Jobs#
0 2 1 * * runs at 02:00 UTC on the 1st, and 0 2 1 */3 * on the 1st of January, April, July, and October. A monthly job exercises its code twelve times a year, and every deploy in between can break it with no run to notice.
- Create the monitor ahead, as above, so the first 1st of the month is covered.
- Size grace from the deadline. For invoices due by 09:00, a grace of 3600 seconds turns a run that never started
criticalat about 03:00, leaving hours to rerun. - Don't wait for stuck detection. The per-job window means a hang on the 1st is flagged a month later at the earliest. Give the job a timeout, and run its reconciliation the same morning, such as
0 8 1 * *. - Test the path before the first real run. In staging, with its own CronRadar application and key, run the job for last month's period and confirm a completed run with its outcome values. Run it again: an idempotent job reports the same totals and charges nothing. Then raise one expectation above what staging produced, rerun, confirm the outcome-failure alert arrives, and restore the expectation.
For the last day of the month, cron last day of month shows the candidate-date schedule and why every invocation must check in.
Keep Your Own Audit Trail#
CronRadar keeps evidence that a run happened and how much it did: lifecycle timestamps, run IDs, the bounded fail message, and up to five configured outcome values per run, for 30 days of run history on Free and 90 on Grow. That answers "did invoicing run on the 1st, and create 1,240 invoices?" It doesn't record which customers were invoiced, and shouldn't: never send customer IDs, email addresses, invoice numbers, or card details in outcomes or failure messages, or build them into key names such as customer_4182_charged.
Keep the record yourself, in a runs table with the period, counts, times, and the provider's object IDs, retained as long as your finance and compliance requirements say. The monitor is the independent check that the record keeps growing on schedule.
Frequently Asked Questions#
How do I know if my billing cron job ran?#
Have it report start and complete to a monitor outside your infrastructure that knows its schedule, so a missed run alerts after the grace period. Send counts such as invoices_created with the completion and alert when they're missing or below a floor, then confirm the result against your database and the payment provider in a reconciliation job.
How do I prevent a renewal job from charging twice?#
Give every charge an idempotency key built from the subscription and the period, and record each attempt in your own table before calling the provider. Stripe may prune keys after 24 hours, so your table has to stop a later catch-up run. Count duplicate payments in a reconciliation job and alert on any.
How do I get alerted when scheduled emails don't go out?#
Have the job report expected recipients, messages sent, messages failed, and the unsent difference, and alert when that difference is above zero. With a batch API, count each message's result, since some return 200 when individual messages fail. A schedule-based monitor also catches the run that never started.
Which scheduled jobs should page someone?#
Jobs where waiting until morning costs money, customer trust, or compliance: billing and renewals, deletion jobs with deadlines, and deliveries a partner collects before office hours. Route those to PagerDuty with a named owner and a runbook link, and the rest to Slack or email.
How do I monitor a job that only runs once a month?#
Create its monitor before the first run with a UTC schedule such as 0 2 1 * *, so the never-executed alert covers the first month. Size the grace from the business deadline, give the job a timeout, reconcile the same morning, and test the path in staging first.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.