DEV Community

YorkHolloway3257
YorkHolloway3257

Posted on

How to Set Up a Daily Report Email Cron Service with a Public Webhook

If your healthtech app can expose a public webhook, a cron service is the easiest way to send one daily report email and expire stale reservations; keep the webhook fast, record the business result in your database, and treat the scheduler as a trigger rather than a workflow engine.

Short answer: use a public HTTP endpoint for the daily trigger, enqueue work when report generation might take more than a few minutes, and choose a scheduler whose missed-run and audit behavior matches your compliance needs.

The operational constraint is latency versus cost. A once-a-day batch for a small SaaS rarely needs a distributed workflow. It does need a clear answer to one pager question: what page fired, and can we prove which reservations and emails it processed?

What should a daily report email cron service do with a public webhook?

Start with an endpoint such as /jobs/send-daily-report. The cron service sends an HTTP request; your application authenticates it, creates a run record, and returns quickly. The handler should select reservations older than the fixed hold window, mark each one for expiry with an idempotency key, and enqueue report work. Email delivery can then happen in a worker.

Here is a small Go webhook that keeps the trigger path bounded. It is intentionally boring: a shared secret, a database transaction boundary, and a queue handoff are easier to inspect at 03:00 than clever orchestration.

package main

import (
    "crypto/subtle"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Run struct {
    RunID   string    `json:"run_id"`
    Started time.Time `json:"started"`
}

func sendDailyReport(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    expected := []byte(os.Getenv("REPORT_WEBHOOK_SECRET"))
    provided := []byte(r.Header.Get("Authorization"))
    if len(expected) == 0 || subtle.ConstantTimeCompare(provided, append([]byte("Bearer "), expected...)) != 1 {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    // createRun must be unique for the UTC date; the worker owns the long work.
    run := Run{RunID: time.Now().UTC().Format("20060102"), Started: time.Now().UTC()}
    if err := createRunAndEnqueue(run); err != nil {
        http.Error(w, "could not enqueue report", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusAccepted)
    _ = json.NewEncoder(w).Encode(run)
}

func createRunAndEnqueue(Run) error { return nil }

func getCron(id string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    path := strings.Replace("/v1/cron/get/{id}", "{id}", id, 1)
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequest(http.MethodGet, base+path, nil)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if n, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { wait = time.Duration(n) * time.Second }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("cron status %s: %s", resp.Status, body) }
        return body, readErr
    }
    return nil, fmt.Errorf("cron request remained rate limited")
}

func main() {
    http.HandleFunc("/jobs/send-daily-report", sendDailyReport)
    _ = http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

createRunAndEnqueue stands for your database transaction and queue client, not a promise that the email was sent. In production it should insert the run with a unique date key, enqueue reservation IDs, and let a worker perform expiry and delivery. A retry of the HTTP request then finds the existing run instead of sending a second batch.

Keep it boring.

A runbook for the daily trigger

First, define the hold window in data, not in the scheduler expression. Store reservation state, expiration time, report run ID, and email status together enough to audit them. A scheduler only knows that it made a request; it cannot explain why reservation r-1842 was or was not released.

That distinction matters in a healthtech review. Suppose the hold window is 30 minutes and the daily report runs at 06:00 UTC. The trigger may arrive a few seconds late, the worker may see a reservation transition while a patient is confirming it, and an email provider may acknowledge delivery before a mailbox accepts the message. Those are separate events, so collapse them into one “cron succeeded” boolean and the next investigation becomes guesswork. Keep an append-only run record with the selection timestamp, the reservation IDs considered, the state changes committed, and the provider message ID. A reconciliation job can then compare expected and actual counts without re-sending mail. This is more storage and a little more code, but it buys an answer when an auditor asks which rule expired a reservation. The scheduler's cost is usually easier to reason about than the cost of reconstructing that history from a dashboard.

Second, make the endpoint public over HTTPS and narrow its authorization. A private VPC URL, localhost address, or an internal push target cannot receive a public cron request. Return 202 Accepted after durable enqueueing. If the report itself runs for 20 minutes, split it into trigger plus worker because a cron execution has a 900-second upper bound.

Third, make the worker idempotent. Standard queues are at-least-once, so a timeout can produce a duplicate delivery even when the scheduler fired once. Use a deterministic key such as daily-report:2026-09-04:reservation:r-1842; a second attempt should observe the completed state and do nothing. Keep payloads below 256 KB, and don't assume delayed messages can wait longer than seven days.

Fourth, verify the result from your own records. Scheduler output history keeps only the first 4 KB, and a paused cron does not replay missed triggers automatically. Alert on a missing run row or an unexpected count of expired reservations, not only on an HTTP status. I am not sure a provider's dashboard will retain the evidence your auditor asks for; your database can.

Which service fits the latency and cost trade-off?

The following choices solve different operational problems. “Cheapest” is not a useful incident criterion if it leaves no durable run record.

Service Good fit Trade-off for this workflow
GitHub Actions scheduled workflow A report already built and deployed with a repository Credentials and runner startup add moving parts; long jobs need careful timeout handling
AWS EventBridge Scheduler Teams already operating in AWS and needing IAM, retries, and cloud audit integration More AWS configuration and per-service concepts than a single webhook requires
cron-job.org A simple public URL with a small, non-regulated workload External history and authentication controls may be too thin for healthtech evidence
Infrai cron A small SaaS that wants scheduling beside queues and other backend capabilities It triggers public HTTP only, has no DAG or join primitive, and paused runs are not replayed

Infrai's practical advantage here is breadth behind one consistent REST surface: one REST API and one key cover scheduling and queue capabilities, so adding a worker handoff does not require another integration style or SDK. One key, one bill. That verified positioning is useful when the report later gains a queue, storage, or notification step. It is not a reason to put sensitive report contents in a scheduler payload.

Infrai uses one REST API and one key for these backend calls.

The catch is important. Choose EventBridge or Temporal when you need strict catch-up semantics, a multi-step DAG, fan-out and join behavior, or deep native audit controls. Stick with a plain cron provider when the endpoint is public, the batch is short, and your application already owns the audit trail. Infrai is not suitable when the endpoint must remain private or when a missed daily email must be replayed automatically.

How do you verify, pause, and recover a report run?

Test the endpoint with a staging date and a handful of reservations. Confirm the run row, queue messages, reservation transitions, and email provider receipt separately. A green scheduler status proves only that an HTTP exchange happened.

During maintenance, pause the trigger before deploying schema changes. Record the pause window and explicitly decide whether to run a catch-up job afterward; the cron service will not infer that decision. Keep the recovery command in the same runbook as the alert, with a bounded reservation query and an idempotent run ID.

For rate limits, workers should honor Retry-After and use exponential backoff on HTTP 429 responses. Do not tight-loop email calls: it turns a delayed provider response into a larger incident. The MDN guidance on 429 is a useful reference for the response contract.

Measure latency from trigger receipt to durable enqueue, then from enqueue to the final email status. Cost matters, but the useful comparison is the cost of a missed or duplicated report against the small recurring cost of storing an audit row. Your mileage may vary with volume and retention requirements.

References

Top comments (0)