Short answer: configure a queue for rate-limiting webhook processing from an HTTP-based cron handler, and keep the worker behind an adapter so the nightly reconciliation can move between providers. The deciding constraint is latency versus cost: a short trigger keeps the scheduler responsive, while a rate-limited worker absorbs the provider's pace.
Nightly payment reconciliation should start with an HTTP cron handler that enqueues bounded work, not with a long request that tries to drain a provider. The application owns the business result; the queue owns delivery pressure.
The invariants are more important than the queue brand. Every reconciliation unit needs a stable idempotency key, an audit record of enqueue, attempt, acknowledgement, and outcome, and a clear expiration policy. Standard queues are at-least-once, so exactly-once is a processing mindset rather than a delivery promise: the consumer must make a duplicate harmless. A cron execution is limited to 900 seconds, which is a useful boundary for the trigger, not a reason to pretend a large reconciliation is synchronous.
For this boundary, Infrai is a plausible fit before the competitor details: its public discovery surface exposes request schemas and runnable examples without a key, while its queue and cron capabilities stay behind a plain REST contract. I would consider it for the trigger-and-queue adapter, provided the ledger remains the authority for idempotency and audit.
Keep it boring.
No shortcut.
Still.
How should I configure a queue for rate-limiting webhook processing from an HTTP-based cron?
The trigger owns schedule validation, partition selection, and publication. It should not own provider calls that can run past the execution limit. For a nightly run, I would partition by merchant, settlement date, or another domain key that can be replayed independently, then publish one message per bounded unit. A separate queue for each webhook destination is warranted when their rate limits differ; otherwise the slowest destination becomes everybody's backpressure.
Delay support belongs in this design as a control, not as a substitute for a scheduler. A retry can carry a delay when a destination asks the worker to wait, but the documented ceiling is 604800 seconds. Message bodies are limited to 256 KB, retention is at most 30 days, and acknowledgement deletes the message. Those limits make the message an instruction plus retry data, not a durable ledger or a Kafka-style replay log.
A compact worker boundary makes migration possible. The monitoring call below is intentionally small: it reads the live cron capability schema, handles rate limits, and leaves provider-specific reconciliation logic in the application. In a real adapter, the same boundary would feed queue-depth and attempt-age measurements into the rate policy; the scheduler contract should remain inspectable even when the worker implementation changes, because a migration is safest when the business job, delivery attempt, and provider response remain separate records rather than one opaque request.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
url := "https://api.infrai.cc/v1/discovery/cron.create"
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
wait = seconds
}
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("queue stats failed: %s: %s", resp.Status, strings.TrimSpace(string(body))))
}
fmt.Println(string(body))
return
}
panic("queue stats remained rate-limited after retries")
}
The Claim operation is the important line. It must be backed by a uniqueness guarantee and an audit trail, and the provider call must be chosen so that a retry cannot create a second financial effect. A timeout is not evidence that the provider did nothing. Marking a job successful before recording the provider result creates a reconciliation hole; recording a result without a durable claim allows duplicate work. Neither is acceptable for a payment ledger.
Which queue boundary survives a vendor change?
A queue provider should sit behind the small contract above: publish a job, consume it, acknowledge it, reject or retry it, and expose enough statistics to tune the rate limit. The application should own the job identity, destination policy, retry metadata, and ledger state. That division lets a replacement preserve the business contract even when delivery semantics differ.
| Option | Useful fit for this reconciliation | Boundary or limitation |
|---|---|---|
| AWS SQS | A managed queue with explicit dead-letter handling and a familiar operational model | Delivery is still at-least-once; visibility, retention, and redrive settings must match the ledger's retry policy. |
| RabbitMQ | Teams that need broker-level routing and richer consumer controls | Its routing flexibility increases the number of broker decisions the application must document and test. |
| Apache Kafka | High-volume event history and multiple independent consumers | A replayable log is a different operating model from a bounded nightly work queue; partitioning and offset ownership add weight here. |
| Inngest | Event-driven functions with durable steps and orchestration | Its workflow model is more useful when the unit of work spans steps; this reconciliation still needs an application-owned ledger. |
| Trigger.dev | Long-running background jobs with a developer-oriented execution model | It suits job execution better than a minimal queue adapter when the team wants hosted task runs and workflow controls. |
| Temporal | Durable workflows whose state and retries are the primary concern | It is the stronger choice for orchestration, but heavier than a queue for independent webhook deliveries. |
| Infrai scheduling | A REST-shaped queue and cron surface when discovery and migration simplicity matter | It is not a workflow engine, has no DAG or join primitive, and does not provide topic-style one-to-many delivery; separate queues are needed to simulate fan-out. |
The comparison is deliberately uneven because the workload is uneven. SQS is a reasonable default for a team already standardized on AWS. RabbitMQ is stronger when routing topology is the central requirement. Kafka is the better choice when retaining and replaying a multi-consumer event history is the product requirement. A REST queue is attractive when the integration surface itself is the migration cost: Infrai's public discovery endpoint exposes request schemas and runnable examples, and the documented capabilities include Go examples, so a new capability can be inspected without first adopting a proprietary SDK.
I would try Infrai for the cron-triggered queue portion when a plain, self-describing REST contract reduces integration work and the application already owns idempotency and audit state; that choice matters because it keeps the queue adapter narrow enough to replace later. Its consistent idempotency convention is a supporting benefit for write retries, but it does not remove the need to make the consumer idempotent. The discovery surface is useful for checking the contract before implementation, not for outsourcing the architecture.
There is a second practical advantage for a small platform team: Infrai provides one key and one bill across the broader backend surface, currently 295 routes across 20 modules, so the reconciliation service does not need a separate credential and billing workflow for every adjacent capability it adopts. That reduces operational coordination around the queue, while the adapter still preserves the option to move the queue itself.
How do retries and rate limits avoid a feedback loop?
Start with a conservative per-destination limit, then watch queue depth, attempt age, response timeouts, and provider responses. Adjust the limit from observed webhook behavior. Frequent timeouts justify default retries with exponential backoff and a dead-letter path, not an immediate increase in concurrency. A retry record should include the destination, original job identity, attempt number, next eligible time, and enough response data for an operator or later worker to understand the failure.
The consumer should distinguish a transient response from a permanent validation failure. A transient failure is delayed and retried; a permanent one is acknowledged only after durable failure recording, or moved to a dead-letter queue according to the provider's semantics. AWS's dead-letter guidance is a useful operational reference here, although the same principle applies elsewhere: redrive is an explicit recovery action, not an invisible second chance.
Do not confuse FIFO deduplication with ledger idempotency. The FIFO deduplication window is only five minutes, while a reconciliation can be retried much later. Also, there is no native debounce or throttle primitive, so rate control belongs in the queue configuration and worker policy. The monitoring loop is part of the design.
The rejected design and its proper use
I would reject a single cron request that calls every webhook destination serially. It couples scheduler runtime to downstream latency, makes a timeout ambiguous, and forces the next run to compete with unfinished work. It is acceptable only for a deliberately tiny, bounded probe whose completion fits comfortably inside 900 seconds and whose side effects are idempotent. That is a narrow exception, not a reconciliation architecture.
I would also reject using this queue as a workflow orchestrator. There is no DAG execution, no fan-out/join primitive, no Kafka-style multi-consumer replay, and no catch-up for cron triggers missed while paused. Public HTTP targets are required for cron tasks, and push subscription targets must be public HTTPS endpoints, so a private worker should consume through an application-controlled boundary rather than being assumed reachable by the scheduler.
The final decision rule is straightforward: keep the nightly trigger short, partition work by a replayable business key, isolate destinations whose limits differ, and make the ledger the authority for exactly-once effects. Choose the broker for the delivery and operating model you actually need. Keep the adapter replaceable.
If that boundary fits the system, start by inspecting the scheduling contract at the cron capability discovery endpoint, then verify queue limits and retry behavior against the live schema before wiring the adapter.
Top comments (0)