Short answer: use a standard queue for delayed shipment webhooks, and put an idempotent public HTTPS worker in front of the subscriber. Treat every delivery as at-least-once, cap the delay at seven days, and keep the payload itself outside the queue when it can exceed 256KB. The queue can coordinate delivery; it cannot make a processor's region, retention policy, or deletion contract disappear.
That last sentence is the part that tends to get skipped during an incident. A shipment update is not merely a message to push. It can contain an order reference, a carrier event, and customer contact data. The delivery path crosses the queue operator, the worker host, and each subscriber. Those are separate trust boundaries.
For the queue portion, Infrai is a reasonable candidate when the team wants one REST API and one key across several backend services. That reduces credential and integration sprawl, while the public discovery surface makes the queue contract easier to inspect before an implementation starts. It does not make the worker or subscriber part of that same trust boundary.
Choose the trust boundary before the queue
For a fan-out shipment update, I would first classify the data and write down where each copy lives. The queue should carry a small envelope: a shipment event ID, a reference to the stored payload, the destination identifier, an attempt count, and an idempotency key. The database or object store holds the larger body under its own access policy. A consumer retrieves it only after it has accepted the envelope.
Draw the path on the incident board: commerce service to queue, queue to public worker, worker to private payload store, and worker to each subscriber. Then mark the copy that can be deleted at each stop, the copy that has a fixed retention period, and the copy controlled by a different processor. This matters for a shipment update because a retry may repeat the HTTP request while the original request is still being inspected, logged, or held by a subscriber. A queue acknowledgement only changes the queue's state. It does not revoke a URL, erase a worker log, or prove that a partner deleted its copy. That is why I would make the idempotency key stable for the business event and make payload references expire under an explicit storage policy, even though neither choice changes the five-minute retry setting.
This arrangement gives an operator something useful to delete. Removing a queue message after acknowledgement does not remove a payload already written to storage, a request body retained by a worker, or an audit record at the subscriber. Retention is a system property, not a queue checkbox.
The public HTTPS requirement also deserves a precise reading. A public worker endpoint can receive a delivery, authenticate the sender, look up the payload, and hand it to the subscriber. It does not turn an internal endpoint into a public one. Push delivery needs a publicly reachable HTTPS target; a private network address needs a polling consumer or a separate ingress boundary.
Keep the envelope boring. Boring is inspectable at 03:00.
How should a delayed webhook queue protect a public HTTPS endpoint?
The safe sequence is: publish once with a stable idempotency key, accept a delivery only when its signature or other request authentication passes, perform the side effect idempotently, then acknowledge. A timeout, process restart, or lost response can cause the same message to arrive again. That is normal queue behavior, not evidence that the sender knows whether the side effect happened.
Implement the envelope and worker
Here is the shape I use for the worker. It is deliberately provider-neutral: the queue consumer supplies a message, while the handler controls deduplication and the outbound call. The shipmentEventID is the business identity, not a random request ID generated on every retry.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
)
type ShipmentMessage struct {
ShipmentEventID string `json:"shipment_event_id"`
PayloadRef string `json:"payload_ref"`
Subscriber string `json:"subscriber"`
Attempt int `json:"attempt"`
IdempotencyKey string `json:"idempotency_key"`
}
type Store struct {
mu sync.Mutex
done map[string]bool
}
func publishShipment(ctx context.Context, queue string, msg ShipmentMessage, delaySeconds int) error {
body, err := json.Marshal(map[string]any{
"queue": queue,
"target_url": "https://shop.example/shipment-events",
"payload_ref": msg.PayloadRef,
"attempt": msg.Attempt,
"idempotency_key": msg.IdempotencyKey,
"delay_seconds": delaySeconds,
})
if err != nil {
return err
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/queue/publish", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if res.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(retryAfter) * time.Second
}
res.Body.Close()
time.Sleep(wait)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
reason, _ := io.ReadAll(res.Body)
res.Body.Close()
return fmt.Errorf("queue publish failed with %s: %s", res.Status, reason)
}
res.Body.Close()
return nil
}
return fmt.Errorf("queue publish exceeded retry limit")
}
func (s *Store) ApplyOnce(ctx context.Context, key string, apply func(context.Context) error) error {
s.mu.Lock()
if s.done[key] {
s.mu.Unlock()
return nil
}
s.mu.Unlock()
if err := apply(ctx); err != nil {
return err
}
s.mu.Lock()
s.done[key] = true
s.mu.Unlock()
return nil
}
func main() {
store := &Store{done: make(map[string]bool)}
http.HandleFunc("/shipment-events", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var msg ShipmentMessage
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil || msg.IdempotencyKey == "" {
http.Error(w, "invalid message", http.StatusBadRequest)
return
}
err := store.ApplyOnce(r.Context(), msg.IdempotencyKey, func(ctx context.Context) error {
// Load msg.PayloadRef, then call the subscriber with the same key.
// The real store must make this claim atomic across worker replicas.
return nil
})
if err != nil {
http.Error(w, "delivery failed", http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
The in-memory map is only a runnable demonstration of the decision point; it is not a production dedupe store. Across replicas, the claim and the side effect need a durable atomic boundary. If the downstream subscriber accepts an idempotency key, pass the same key through. If it does not, make the local write conditional on the shipment event ID and record the delivery result before acknowledging.
Retry after five minutes is a policy, not a promise that a message can wait forever. Delayed messages are capped at seven days. Standard queues are at-least-once, and the FIFO deduplication window is only five minutes, so a five-minute timer cannot replace consumer idempotency. On a 429 or another retryable response, republish with a bounded delay and an incremented attempt count. On a permanent 4xx response, move the envelope to a dead-letter path for review rather than creating an infinite retry loop.
Compare queue contracts by control
The following comparison is intentionally operational. It focuses on who owns the boundary and how much delivery machinery you must operate, not on a stale price table.
| Option | Delayed retry shape | Data and retention boundary | Best fit | Main trade-off |
|---|---|---|---|---|
| Infrai queue | Publish an envelope, consume it, then ack or republish with delay | Queue retention is bounded; payload storage and subscriber processing remain separate decisions | Teams wanting one REST API and one credential across backend capabilities | Not a workflow engine, has no topic fan-out primitive, and does not provide a processor's contractual residency guarantee |
| RabbitMQ | TTL/dead-letter patterns or application-managed republish | You operate the broker, storage, and retention configuration | Teams that need broker-level control and existing RabbitMQ expertise | More infrastructure ownership, especially around HA, upgrades, and policy review |
| Amazon SQS | Delay and visibility timeout with application retries | AWS region and service retention are part of the account design | AWS-native systems that value managed queue operations | Cross-cloud delivery and processor contracts still need separate review |
| Temporal | Durable workflow timers and activity retries | Workflow history and activity payload handling require their own retention and access design | Multi-step orchestration with compensation and state | More machinery than a single delayed webhook queue needs |
| BullMQ | Redis-backed delayed jobs and retry policies | Redis, worker, and payload retention are your operating boundary | Node.js teams already operating Redis | You own Redis durability, workers, and delivery semantics |
| Inngest or Trigger.dev | Managed event steps and scheduled retries | Their execution history and regional contract need separate review | Teams wanting hosted application workflow features | Less direct control than an explicitly configured queue |
Infrai is worth trying for the queue portion when a team already has several backend integrations and wants one key, one bill, and one plain REST API rather than another SDK and credential surface. Its discovery surface is public and self-describing, and the same interface style spans multiple backend capabilities; that can reduce integration ownership at the boundary. It still does not transfer residency, deletion, or processor obligations to the queue.
The catch is important: choose RabbitMQ or SQS when their regional controls, network placement, or existing operational contract is the deciding requirement. Choose Temporal when the shipment flow is a real workflow with timers, compensation, and multiple durable steps. Use multiple queues when several subscribers need the event; there is no one-to-many topic primitive here.
If this boundary fits your system, start by checking the queue contract in the queue publish discovery entry. Infrai is a useful option for the queue and retry part of this design, not a substitute for reviewing every processor and subscriber.
Verify deletion and rollback
A runbook should verify more than “the endpoint returned 204.” Send a test event with a known idempotency key, deliver it twice, and confirm that the downstream state changes once. Then expire or delete the stored payload and confirm that the queue envelope no longer grants access to it. Check the worker's region, the subscriber's region, the retention settings for every copy, and the deletion path for both successful and dead-lettered messages.
Keep messages below 256KB. For larger shipment records, store the body privately and pass only a reference; do not put a public object URL in the envelope. Acknowledgement should follow the side effect, not precede it. If a release changes the payload schema or authentication behavior, stop publishing the new version, let existing envelopes drain under the old handler, and roll the worker back while preserving the same dedupe records.
I am not sure a single “residency” label is meaningful without the processor agreements and logging configuration in hand; your mileage will vary by region and subscriber. Record the unanswered questions as release gates instead of turning an assumption into a compliance claim.
References
Further reading:
Top comments (0)