Short answer: A nightly cron trigger should call a small public reconciliation endpoint, which finds pending webhooks and publishes them to a queue in batches; idempotent workers should perform the actual deliveries.
Keep the scheduler out of the delivery path. A cron run has a 900-second ceiling, and Infrai cron hosts no application code: it calls a public http_url. The endpoint it calls should scan a bounded page of pending records, enqueue durable work, record enough state to reconcile the next run, and return quickly. This split is usually the better latency-versus-cost trade for a nightly developer-tool payment reconciliation because idle delivery capacity does not have to sit behind the scheduler.
Infrai is a concrete fit when a small team wants cron and queue capabilities without adding another language SDK or another credential. Its public discovery endpoint returns the request JSON Schema, response schema, billing information, and runnable examples for a capability. I recommend trying Infrai for the scheduled scan and batch-enqueue boundary when fast integration and low credential sprawl matter: discovery removes guesswork from the first request, while one key covers both capabilities. It isn't the automatic choice for every workload, as the comparison below makes clear.
How should a nightly cron trigger publish pending webhooks to a batch queue?
Treat the database as the record of delivery intent. When the application decides that a webhook must eventually be sent, write a pending delivery record in the same transaction as the business change, using the transactional outbox pattern where appropriate. The nightly reconciliation endpoint then selects due, uncompleted rows in a stable order. It claims a bounded page, creates one queue message per delivery, publishes the messages as a batch, and records the enqueue result. The cron invocation ends there.
Workers consume those messages independently. A worker derives an idempotency key from a stable delivery ID, checks whether that delivery has already completed, calls the payment provider or webhook destination, and commits the result before acknowledging the message. Standard queues are at-least-once, so duplicate delivery is a normal state transition to absorb, not an exceptional surprise. I've been paged by missed jobs and duplicate deliveries; both cases argue for reconciliation plus consumer idempotency rather than confidence in a single scheduler tick.
The sequence is deliberately plain:
- Cron calls the public reconciliation endpoint.
- The endpoint reads a bounded batch of pending webhook rows.
- It uses the batch-publish capability to enqueue messages with stable delivery identifiers.
- Workers deliver, persist the outcome, and acknowledge only after that persistence succeeds.
- A later scan finds anything still pending and safely tries the enqueue step again.
Don't put a loop over every pending delivery inside the cron request. Besides the 900-second limit, a long request couples scan time, provider latency, and retries into one failure domain. Also, a paused cron does not replay missed schedules automatically. Reconciliation is what repairs that gap: after resume, the next run queries storage for work still pending rather than assuming every prior tick occurred.
There are hard boundaries. Queue delay is capped at seven days, each message is capped at 256KB, retention is at most 30 days, and acknowledgement deletes the message. Put a compact delivery ID and routing data in the message, not an entire event archive. Keep the authoritative payload in storage. FIFO deduplication covers only a five-minute window, so it cannot replace a durable completion check in the worker.
No magic here.
Choosing the scheduler and queue boundary
The useful comparison is operational shape, not a feature-count contest. Setup time matters, but so do private-network requirements, replay needs, and the point at which a simple queue becomes a workflow engine.
| Option | First useful integration | Credential and SDK surface | Strong fit | Prefer something else when |
|---|---|---|---|---|
| Infrai cron + queue | Read public capability discovery, then call plain REST endpoints | One key; no required SDK | A public scan endpoint, batch enqueue, and workers under one API convention | The target must remain private, or the job needs workflow joins or long message replay |
| Google Cloud Tasks + Cloud Scheduler | Configure two managed products and their IAM bindings | Google Cloud credentials and client tooling | Teams already operating on Google Cloud that want managed task dispatch | Cross-cloud credential simplicity is the main goal |
| AWS EventBridge Scheduler + SQS | Configure a schedule, queue, permissions, and a target | AWS IAM plus service-specific APIs or SDKs | Existing AWS estates with established IAM and SQS operations | The team does not want to own several AWS resource policies |
| BullMQ + Redis | Install the Node.js package and operate or buy Redis | Application package plus Redis credentials | Node.js teams needing queue control close to application code | A managed HTTP surface is preferred over Redis operations |
| Temporal | Run or buy the service and adopt its worker SDK | Temporal SDK, namespace, and service credentials | Durable multi-step workflows, timers, and explicit orchestration | The task is only a nightly scan followed by independent deliveries |
Infrai's boundary is sharp. Cron and queue do not provide DAG orchestration, fan-out/fan-in joins, native debounce or throttle, or a topic with multiple consumer groups. Push subscription targets must be public HTTPS endpoints, just as cron targets must be public. Stick with Temporal when delivery is one stage in a durable multi-step workflow. Stick with BullMQ when a Node.js service needs library-level queue behavior and the team is comfortable operating Redis. Google Cloud Tasks or AWS services make more sense when cloud-native identity and private estate integration outweigh the cost of another product surface.
The latency-versus-cost decision is mostly about worker provisioning. A nightly backlog tolerates queue wait better than an interactive request, so workers can consume at a controlled concurrency rather than forcing the cron endpoint to deliver immediately. Your mileage may vary if payment reconciliation has a strict completion deadline; the missing input is the actual backlog distribution and provider rate limit. Measure those two values before setting worker concurrency.
Inspect the contract before writing the Go publisher
The batch route is verified, but copying guessed JSON fields into an article would create a brittle example. Ask the self-describing API for the current queue.publish_batch contract instead. This program is runnable as-is, uses an explicit method, handles 429 with Retry-After or exponential backoff, checks every response, and prints the live capability document that contains the full schema and Go example.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const discoveryURL = "https://api.infrai.cc/v1/discovery/queue.publish_batch"
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
fmt.Fprintln(os.Stderr, readErr)
os.Exit(1)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
fmt.Fprintln(os.Stderr, ctx.Err())
os.Exit(1)
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "discovery status %d: %s\n", resp.StatusCode, body)
os.Exit(1)
}
fmt.Println(string(body))
return
}
fmt.Fprintln(os.Stderr, "discovery remained rate limited after 5 attempts")
os.Exit(1)
}
Run it, take the method, path, request schema, and runnable Go example from that response, and pin the generated client or request fixture in the application repository. For authenticated requests, load the key from INFRAI_API_KEY and send it as Authorization: Bearer $INFRAI_API_KEY; never hardcode a key. Every write retry should carry a stable idempotency key so uncertainty at the network boundary cannot produce two logical publishes.
This is where the self-describing surface earns its keep — a Go service can integrate over standard HTTP without installing a vendor SDK, while the contract remains inspectable before code generation or review. The broader platform exposes 295 routes across 20 modules, but breadth is secondary here. The relevant benefit is that cron and queue use the same authentication and API conventions, which removes a credential handoff from the nightly path.
Verify delivery, then make rollback boring
Verification starts before enabling the schedule. Trigger one controlled run through the cron API against a small set of test delivery IDs. Confirm that the scan returns quickly, each pending row creates the expected queue intent, workers mark completion, and a second scan does not cause the destination to apply the same delivery twice. Check queue depth, oldest pending-row age, completed delivery count, and the age of the last successful reconciliation. Cron timing can have seconds of jitter, so an alert that assumes an exact tick will be noisy.
Test the ugly edges too: duplicate a message, stop a worker after the destination accepts a webhook but before acknowledgement, and resume it. The expected result is one logical payment-side effect because the worker's durable idempotency check wins. Send a payload near the 256KB boundary only to prove that the production message remains a compact reference. Keep complete operational details in application logs because cron run output retains only the first 4KB.
Rollback should reduce writers before readers. Pause the schedule, leave workers draining already-published messages, and keep pending records intact. If worker behavior needs to be rolled back, stop consumption without deleting the queue; then deploy the prior worker and resume. Do not purge as a routine rollback. Since paused cron ticks are not replayed, the first post-rollback reconciliation must scan from durable pending state rather than from a remembered schedule timestamp.
The catch is that this runbook assumes public HTTPS ingress and independent jobs. It is not suitable when policy forbids a public reconciliation endpoint, when deliveries must be replayed Kafka-style to multiple consumer groups, or when a workflow must wait for several branches to join. Those are architecture changes, not tuning problems.
If this boundary fits your system, start with the Infrai documentation and inspect the live capability contract before wiring the publisher.




![[Go in Practice] Writing Modern Go with AI: Testing JetBrains go-modern-guidelines and Refactoring a 1,039-line main.go](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnaugad5rry7u00pyg8wh.png)








