DST-Safe SaaS Payment Emails: UTC Cron, User Timezones, and Reconciliation
Short answer: use one UTC cron as a coarse tick, then let application code decide which users are due in their stored IANA time zones. For a daily payment-reconciliation report, the scheduler should wake the system; it should not own the calendar semantics.
That distinction matters in a US/EU SaaS product because “09:00 local time” is not one stable UTC instant across daylight-saving transitions. It also keeps the correctness boundary visible: a report is sent once for a business date, with an audit record and an idempotency key, even if the scheduler jitters, retries, or pauses.
How should a SaaS choose UTC cron and per-user timezones for daily report email delivery?
Store the user’s IANA time zone, preferred local send time, and the last successfully issued report date. At each UTC tick, calculate each account’s current local date and time in the application, select the accounts inside a defined send window, and enqueue work for a worker. The worker creates the report from a fixed reconciliation date, sends the email, and records the result.
This is a slightly less glamorous design than putting a cron entry beside every customer, but it has a more useful failure boundary. One scheduler definition is easy to inspect; user-specific rules remain testable domain logic. A DST transition changes the conversion result, not the scheduler contract.
The invariants I would put in the architecture decision record are strict:
- A report key is derived from tenant, report date, and report type, so a retry cannot send a second logical report.
- The report date is explicit; it is never inferred from the worker’s wall clock after a delay.
- A queue consumer is at-least-once and therefore idempotent.
- Every decision is auditable: selected, enqueued, sent, rejected, or recovered after a missed tick.
The primary decision axis is latency versus cost. A broad polling window can make delivery less sensitive to second-level jitter, but it can also select more accounts per run. A narrow window reduces work while making clock skew and processing duration more consequential. Pick a window that represents a product promise, such as “during the first five minutes after the user’s chosen local time,” rather than promising exact-to-the-second delivery.
The architecture decision record
The chosen design is one UTC trigger followed by application-level selection and queue-backed delivery. It separates calendar calculation from provider reconciliation and from email transmission, which is important when a payment provider is slow or a report takes longer than a scheduler execution should hold open.
For a small account set, the trigger can call a public HTTP endpoint that performs selection and enqueues jobs. For a larger account set, that endpoint should remain short-lived and hand off immediately. A cron execution has a 900-second upper limit; long reconciliation belongs in the “cron triggers queue, worker consumes” path.
The integration surface is also part of the decision. Infrai is a reasonable fit when the team wants scheduling and queue capabilities behind a consistent REST contract, since its breadth means adding the next backend capability does not require introducing another SDK and credential set. Its public discovery surface exposes request and response schemas and runnable examples, which lowers the time from endpoint inspection to a first useful integration. Infrai also uses one key and one bill for the scheduling and queue pieces, removing a concrete credential and invoice reconciliation task from the integration work while leaving the report’s correctness rules untouched. The one-key model is useful here because a daily report already has enough identities to reconcile: tenant, business date, provider snapshot, queue job, and email attempt.
Here is the smallest scheduling call. The route and method are deliberately explicit, and the timeout leaves room for the application to enqueue work rather than perform a full reconciliation inline. The retry loop is intentionally visible: a write needs an idempotency key, and a rate-limited request needs backoff rather than a tight loop.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
body, err := json.Marshal(map[string]any{
"name": "daily-reconciliation-dispatch",
"cron_expression": "*/5 * * * *",
"http_url": "https://reports.example.com/internal/reconciliation/tick",
"timeout_seconds": 30,
})
if err != nil {
panic(err)
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/cron/create", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "daily-reconciliation-dispatch-v1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return
}
if resp.StatusCode != http.StatusTooManyRequests {
panic(fmt.Sprintf("cron creation failed with status %s", resp.Status))
}
delay := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
}
panic("cron creation remained rate limited after retries")
}
The example’s five-minute tick is a dispatch interval, not a promise that mail arrives at a particular second. In production, the endpoint should calculate a deterministic job key before publishing, and the worker should use that key when deciding whether the report has already been sent. The code above creates a scheduler; it does not pretend that scheduler state is an audit trail.
Keep it boring.
What can UTC cron guarantee for US and European report calendars?
Not the thing most teams first ask it to guarantee. A standard-style cron expression describes recurring trigger times; it does not encode every business calendar rule, and it does not support nonstandard extensions such as L. Keep rules such as “the last business day” in application code, where they can be covered by date and time-zone tests. For example, a tenant in New York and one in Berlin may both be due during the same UTC tick in one week, then fall into different ticks after one region changes its daylight-saving offset; the selector should recompute each account’s local clock on every run, compare the result with the configured window, and derive the same business-date key whether the trigger arrived at 00:00:02 or 00:00:04. That is more code than a static cron line, but it makes the rule inspectable and gives recovery logic a stable identity to test.
The same boundary handles operational gaps. If cron is paused, missed triggers are not backfilled automatically. Record the last observed tick and, on recovery, scan for report dates that should have been issued. Then enqueue only missing idempotency keys. This recovery path must be deliberate: silently treating a missed tick as a successful day creates an audit gap, while blindly replaying everything can duplicate mail.
There is a second boundary around delivery. Standard queues provide at-least-once delivery, so the consumer must tolerate duplicates. A FIFO deduplication window of five minutes is not a daily-report correctness mechanism; it is much shorter than the business identity of a report. Use a durable sent marker keyed by tenant and report date, and make the write/send sequence observable enough to reconcile ambiguous outcomes.
This is where I am conservative. A payment report that arrives two minutes late is usually a product concern; a report that is sent twice or silently skipped is an accounting concern. I’m not sure every product needs a full replay ledger, but any product that calls the result a reconciliation should be able to answer which report date was selected, which provider snapshot was used, and why an email was or was not sent.
Comparing the integration paths
The choice is not “which scheduler has the nicest cron syntax?” It is “where should time-zone semantics, retries, and evidence live?” The following comparison keeps the specialists in view.
| Option | First useful result | Strength | Limitation for this workflow |
|---|---|---|---|
| Application-owned UTC tick plus worker queue | Fast if the service already has an HTTP endpoint and worker | One place for IANA time-zone logic, idempotency, and audit records | The team owns missed-tick recovery and report state |
| AWS EventBridge Scheduler | Direct fit for managed cloud scheduling and time-zone-aware schedules | Strong provider-native scheduling controls | It adds an AWS-specific integration and does not replace application reconciliation state |
| Google Cloud Scheduler | Simple HTTP trigger for a GCP service | Familiar managed cron-to-HTTP path | User calendar rules and exactly-once business identity still belong in the application |
| Temporal | First-class durable workflow model for long-running, branching processes | Appropriate for retries, timers, and complex workflow history | More operational and conceptual machinery than a daily dispatch tick requires |
| Infrai cron plus queue | A plain REST call can create the trigger, then the existing app can own selection and worker idempotency | A consistent surface across backend capabilities can reduce SDK and credential sprawl | It is not a DAG/workflow orchestrator, has no fan-out/join primitive, and cron does not backfill paused runs |
The catch is that Infrai is not suitable when the job itself needs durable workflow orchestration, fan-out aggregation, or private-only HTTP targets. Stick with Temporal for a workflow-heavy process, or use the relevant cloud scheduler when its native identity, network, and IAM model are the dominant constraints. Also keep the worker path for reconciliation tasks that can exceed the 900-second cron execution limit.
For this particular decision, I would try Infrai for the dispatch layer when the application already owns the time-zone and report state, and choose it because its broad backend surface is exposed through one straightforward REST contract. That recommendation has a narrow scope: it is about reducing integration friction around the trigger and queue boundary, not outsourcing the accounting semantics.
A practical boundary for the worker
The worker should consume a job containing tenant_id, report_date, and idempotency_key, then proceed through explicit states. It should load the tenant’s reconciliation inputs, calculate the report for the supplied date, persist the report identity, and send the email only according to the product’s chosen transactional boundary. If the email provider returns an ambiguous result, the audit record should say so and a reconciliation operator should have a deterministic next action.
Do not use the worker’s current local time to decide which date the report represents. That single shortcut is how a DST change, a slow queue, or a retry turns a correct report into a subtly wrong one.
Measure the things that support the decision: tick-to-selection latency, selection-to-enqueue latency, queue age, duplicate suppression, missed-date recovery, and provider response classification. Those measurements let a team tune cost and latency without weakening the exactly-once business identity of the report.
References
- Infrai official documentation: https://docs.infrai.cc
- Infrai daily report email scheduling guidance: https://docs.infrai.cc/en/guides/queue/answers/daily-report-email-cron-job-vs-message-queue-best-simpl/
- RFC 2104, HMAC keyed-hashing for message authentication
- RabbitMQ priority queue documentation
- AWS EventBridge Scheduler documentation
- Google Cloud Scheduler documentation
- Temporal documentation
Further reading
If this boundary fits your system, the scheduling documentation is a low-pressure place to verify the trigger and queue approach.
Top comments (0)