Templates / Monthly invoicing
Running the billing cycle
Invoicing that runs late becomes a support queue. Invoicing that runs twice becomes a refund queue and a trust problem. This recipe is built around the second risk.
The recipe
- Request
- POST https://{{domain}}/api/billing/run-cycle
- Schedule
- At 02:00 AM, on day 1 of the month (0 2 1 * *)
- Headers
- Authorization: Bearer {{secret}}encrypted
- Timeout
- 300s
- Attempts
- 1 — no automatic retry
- Counts as success
- 2xx
You supply
- Your domain
- example.com
Host only — no https:// and no trailing slash.
- Shared secret
- s3cr3t-valuestored encrypted
Whatever your endpoint checks the caller against.
Why this job exists
Billing is the job where the usual reliability instincts are wrong. Everywhere else, a failed run should be retried — it is cheap, and the alternative is work not happening. In billing, a retry after a partial run can charge customers a second time, and no amount of apologising fully undoes that.
The exposure is worst in the middle. A run that fails before it starts is harmless; one that fails after completing is harmless. One that fails having invoiced sixty percent of the book is the dangerous state, and it is also the most likely, because the failures that hit billing runs are timeouts and rate limits from the payment provider, which arrive partway through by definition.
So this recipe allows exactly one attempt. A failure is an alert for a person to investigate, not something to be resolved automatically.
Setting it up
Make the run idempotent per customer per period. With an invoice keyed on the billing period, a re-run after a partial failure resumes rather than duplicates — this is the property that makes a billing job safe to touch at all:
Idempotent per period
const period = currentPeriod();
const results = { invoiced: 0, skipped: 0, failed: [] };
for (const subscription of await dueSubscriptions(period)) {
const existing = await invoices.find({
subscriptionId: subscription.id,
period,
});
if (existing) {
results.skipped += 1;
continue;
}
try {
await createAndCharge(subscription, period);
results.invoiced += 1;
} catch (error) {
results.failed.push(subscription.id);
}
}
return Response.json(results, { status: results.failed.length ? 500 : 200 });Collecting failures rather than throwing on the first one means a single bad subscription does not stop the other nine hundred. Returning a non-2xx when anything failed is what makes the run visible here as a failure worth opening.
Checking it actually works
- Reconcile the invoiced count against the number of active subscriptions every month. They should match, and when they do not, that is the interesting number.
- Run it against a staging dataset with a deliberately failing customer and confirm the rest complete.
- Confirm re-running immediately produces all skips and no new charges. If it does not, the idempotency key is wrong.
Where it goes wrong
Scheduling on the 31st
Cron does not run a day-of-month that does not exist, so a job set for the 31st silently skips February, April, June, September and November. Use the 1st, or the 28th if you must bill at month end.
Retrying the whole run
This is why the recipe allows one attempt. Without per-period idempotency, an automatic retry double-charges everyone the first run reached.
Timezone at a month boundary
A run at 02:00 in one zone is still the previous month in another. Compute the billing period from an explicit date in a fixed zone, not from the server clock at the moment the request arrives.
Questions
Why 02:00 on the 1st?
Low traffic, and it leaves the working day to deal with anything that went wrong. What matters more is that it is early enough for a person to be looking at the result during business hours.
My provider handles recurring billing already.
Then let it. Stripe Billing, Paddle and others run the cycle for you. This recipe is for custom billing, usage-based charges computed from your own data, or invoicing outside the provider entirely.
What if the run takes longer than the timeout?
Have the endpoint process a bounded batch and return, then let the job run more often during the billing window. Never extend a timeout to cover a run that grows with your customer count.
Running it here
This is a preset in the product, not an illustration. Pick it in the dashboard, fill in the 2 values above, and the job is created, scheduled and enabled — with retries, a record of every run showing status, latency and response, and an alert the first time one fails. Secret values are encrypted at rest and never rendered back.
The free plan runs 3 jobs hourly and needs no card. Where a recipe wants a finer cadence than your plan allows, it is slowed to the fastest schedule you are permitted rather than rejected.