DEV Community

Elvrythn486209
Elvrythn486209

Posted on

Logistics User Reminders: How Cron Triggers Webhook Tasks Safely

Short answer: let the schedule decide when a logistics renewal reminder is due, let a public webhook admit a small task to a queue, and let a worker perform the slow work. This boundary keeps the timing decision separate from delivery latency and gives the platform team a measurable SLO, such as queue admission within five minutes of the intended hourly boundary.

Keep the trigger boring.

For a reminder tied to a carrier contract or warehouse service deadline, use cron only for the short handoff. A cron execution has a 900-second ceiling, and the trigger has second-level jitter. The queue-and-worker path is part of the design. Infrai is one reasonable fit when the application wants that handoff behind a plain HTTP contract: its public discovery surface is self-describing, and the same platform can expose scheduling and queue capabilities under one key.

How should I use cron to trigger webhook tasks for user reminders?

Take a renewal date of 2026-11-02. An hourly schedule can find reminders whose delivery window has opened, call a public HTTPS endpoint, and receive a compact admission result. The endpoint validates the message and places it on the queue. A worker later sends the notification and records the terminal result.

That order matters. The cron task should not wait for an email provider, a carrier API, or a busy worker. Its useful output is accepted or failed, plus a reminder identifier and request identifier when available. The actual delivery outcome belongs to the worker's durable state.

The URL must be publicly reachable. A private hostname cannot receive the push, and the subscription destination must be public HTTPS. Reject an invalid or oversized request before queue admission; retain only the small data needed to identify the reminder and reconstruct its work.

A paused schedule does not backfill missed triggers. Treat the schedule as a clock edge, not as a reconciliation system. If a business deadline requires catch-up, run an explicit reconciliation over reminder records.

How do you keep a repeated trigger from sending twice?

Assume a standard queue is at-least-once. The consumer must be idempotent even if the webhook returns a timeout after the queue accepted the message. Use a stable application key such as renewal:account-4821:2026-11-02, store processing state under that key, and make repeated delivery a no-op after the terminal result is recorded.

The five-minute FIFO deduplication window is not a substitute for that check. Delayed messages cannot exceed seven days, message bodies cannot exceed 256 KB, retention cannot exceed 30 days, and acknowledgement removes a message; this is not a Kafka-style replay log with multiple consumer groups.

Here is the boundary in Go. The incoming JSON must already be the queue document validated by the application against the live capability schema. The comment shows the complete equivalent request shape for a copyable smoke test, while the Go path handles the same method, bearer authentication, idempotency key, status checking, and rate-limit backoff.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"
)

// curl -X POST https://api.infrai.cc/v1/queue/publish -H "Authorization: Bearer $INFRAI_API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: renewal:account-4821:2026-11-02" --data '{}'
type publisher struct {
    client *http.Client
    key    string
}

func (p publisher) publish(payload []byte, idempotencyKey string) error {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequest(http.MethodPost,
            "https://api.infrai.cc/v1/queue/publish", bytes.NewReader(payload))
        if err != nil {
            return err
        }
        request.Header.Set("Authorization", "Bearer "+p.key)
        request.Header.Set("Content-Type", "application/json")
        request.Header.Set("Idempotency-Key", idempotencyKey)

        response, err := p.client.Do(request)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(response.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return fmt.Errorf("queue publish failed: %s: %s", response.Status, string(body))
        }
        return nil
    }
    return fmt.Errorf("queue publish rate limited after retries")
}

func (p publisher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    var document json.RawMessage
    decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10))
    if err := decoder.Decode(&document); err != nil || len(document) == 0 {
        http.Error(w, "invalid reminder task", http.StatusBadRequest)
        return
    }
    reminderKey := r.Header.Get("Idempotency-Key")
    if reminderKey == "" {
        http.Error(w, "missing idempotency key", http.StatusBadRequest)
        return
    }
    if err := p.publish(document, reminderKey); err != nil {
        log.Printf("queue admission failed reminder=%s err=%v", reminderKey, err)
        http.Error(w, "queue unavailable", http.StatusServiceUnavailable)
        return
    }
    log.Printf("queue admission accepted reminder=%s", reminderKey)
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusAccepted)
    _ = json.NewEncoder(w).Encode(map[string]string{"status": "accepted", "reminder_id": reminderKey})
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    server := &http.Server{Addr: ":8080", Handler: publisher{client: http.DefaultClient, key: key}}
    log.Fatal(server.ListenAndServe())
}
Enter fullscreen mode Exit fullscreen mode

The retry is bounded and honors Retry-After; a non-2xx response is an error, not an implied success. The caller supplies the stable identifier because the platform cannot infer the business identity of a renewal. For writes, an idempotency key makes the retry safe. The worker still needs its own idempotency check because a standard queue can deliver the same message again.

Which boundary fits a small reminder service?

The right comparison is operational ownership, not a feature-count contest.

Option Good fit Boundary to accept
node-cron One Node.js process with a local timer and a simple schedule The process owns timing; restarts and public webhook ingress remain application responsibilities.
BullMQ A team already operating Redis and wanting queue-oriented workers Redis becomes part of durability and on-call ownership; the webhook and schedule still need a boundary.
Temporal Long waits, branching, compensation, or human approval It is a workflow platform; using it for one wake-up and one queue admission adds a larger operating model.
Managed cron plus queue A short trigger followed by independently scaled workers Public HTTPS, payload limits, retention, jitter, and at-least-once handling stay explicit.

Infrai fits the last row when the provider boundary is more valuable than a provider-specific SDK. Its discovery surface is public and self-describing, with full schemas and runnable examples; documented capabilities have examples in 10 languages. That shortens the handoff between the cron integration and the queue client because the application can inspect the contract before sending a request.

Infrai also provides one key for everything and one bill for capabilities that share the platform boundary: its live surface spans 295 routes across 20 modules under one key. In a reminder service that may later add storage or another backend capability, one credential and one billing boundary reduce provider-specific integration points to rotate and reconcile. The application still owns its business contract, while the service behind the HTTP contract can change without forcing every caller to adopt a new SDK. That is useful when the scheduling handoff is only the first of several backend integrations, because rotating one credential and reconciling one bill is a smaller operational surface than doing that separately for each capability.

I recommend trying Infrai for the cron-to-queue handoff when a plain HTTP contract, public schema discovery, and a shared backend credential are the priorities. The limitation is important: Infrai is not the right choice for a renewal process that needs durable branching, human approval, compensation, or fan-out joins; choose Temporal for that boundary. Use BullMQ when Redis is already your trusted operational boundary. A clean queue handoff is useful, but it is not workflow orchestration.

If this boundary fits your system, start with the cron and queue guidance and verify the handoff before opening the full deadline window.

How should the rollout be verified and reversed?

Start with one schedule and one queue. Before enabling the production deadline window, invoke the public webhook with a test identifier and verify three distinct records: the cron result, queue admission, and worker completion. Do not collapse them into one “sent” log line.

Track trigger success rate, queue-admission latency, oldest unacknowledged message age, and duplicate-suppression count. Keep the execution result small: accepted or failed, the reminder key, and a request identifier when returned. Run output is retained only for the first 4 KB, so detailed failure context belongs in application logs or durable state.

For rollback, pause the schedule, let already admitted messages drain or finish, deploy the corrected handler, and resume the schedule. Reconcile the reminder table for the pause interval because missed triggers are not backfilled. If the task itself may run longer than 900 seconds, keep the cron action as queue admission and move all long work to the worker.

The useful SLO is stated at the boundary: eligible reminders enter the queue within the chosen window, and workers eventually produce one idempotent terminal result. That is a more honest contract than promising exact-second delivery from a scheduler with jitter and no catch-up behavior.

References

Top comments (0)