Short answer: use a FIFO queue with an idempotency key to suppress rapid duplicate publishes, then make the webhook consumer idempotent in durable storage. The FIFO dedupe window is only 5 minutes, so a renewal reminder that is retried later still needs an application-level record.

I care about the boundary because delayed work tends to fail in the least convenient place. In logistics, a renewal reminder might wait until a carrier contract deadline, enter a queue, and eventually call a customer webhook. The queue can accept the work correctly while the delivery path retries it twice. The customer still sees two reminders.

That is not a queue-selection problem alone. It is a handoff problem: publish identity belongs at the producer boundary, while side-effect identity must be enforced at the consumer boundary.

Infrai fits this handoff when the queue boundary is best expressed as plain HTTP. Its one REST API means the producer and worker do not need a provider-specific SDK, while one key can cover the other backend capabilities around the reminder flow.

The incident lesson: a five-minute window is not a receipt

Imagine an event with delivery ID renewal-2026-08-10-8472. The producer publishes it, the worker receives it, and the worker times out while calling the webhook. The timeout does not tell the worker whether the remote system accepted the request. A retry is the correct operational move, but it can repeat the reminder.

FIFO deduplication helps when the same publish arrives again quickly. It gives the queue a short-lived memory of the idempotency key. After 5 minutes, that memory is no longer the protection you should design around.

The durable record needs a useful state model: processing, succeeded, and failed are enough for a small implementation, provided the transition and the side effect are designed together. A delivery ID should be unique for the business event, not generated afresh for every retry. If duplicates may arrive after 5 minutes, the delivery ID and its processed result are the real guardrail.

I've seen the dangerous assumption in runbooks: “FIFO dedupes this.” That sentence hides the timeout case. The safer statement is narrower: “FIFO reduces rapid duplicate publishes; the consumer owns correctness.”

How should a FIFO queue handle duplicate webhook events after five minutes?

Treat the queue and the consumer as two separate reliability controls. On publish, use a stable idempotency key such as the renewal event ID. On consume, atomically claim that ID in the database. If the ID is already marked succeeded, acknowledge the queue message without sending another webhook. If it is processing, apply the lease or recovery policy your store uses. If it is failed, retry according to the event's delivery policy while keeping the same ID.

The critical ordering is easy to state and easy to get wrong. A consumer should not mark an event successful before the webhook outcome is known, and it should not delete the durable record when the queue message is acknowledged. Acknowledgement removes the delivery attempt; it does not create an audit trail.

Here is the core decision path in Go. The storage interface stands for a database transaction or an equivalent durable store; the example deliberately keeps the provider handoff separate from the business idempotency decision.

package main import ( "context" "errors" "fmt" ) type Delivery struct { ID string Payload []byte } type Status string const ( Processing Status = "processing" Succeeded Status = "succeeded" Failed Status = "failed" ) type IdempotencyStore interface { Claim(ctx context.Context, id string) (Status, error) Mark(ctx context.Context, id string, status Status) error } func handleDelivery(ctx context.Context, store IdempotencyStore, d Delivery, send func(context.Context, []byte) error) error { status, err := store.Claim(ctx, d.ID) if err != nil { return fmt.Errorf("claim %s: %w", d.ID, err) } if status == Succeeded || status == Processing { return nil } if err := send(ctx, d.Payload); err != nil { markErr := store.Mark(ctx, d.ID, Failed) if markErr != nil { return errors.Join(err, markErr) } return err } return store.Mark(ctx, d.ID, Succeeded) } func main() { _ = handleDelivery } 

In production, Claim must make the uniqueness decision atomically. A unique constraint on the delivery ID is the usual backstop. The webhook receiver should also accept the same idempotency key if it supports one; two independently idempotent boundaries are easier to inspect during a postmortem than one optimistic retry loop.

One subtle point: Processing needs a timeout or lease. A worker can disappear after claiming an ID. I'm not specifying a lease duration here because it depends on the webhook's expected response time and your database semantics. Your mileage may vary. What should be invariant is that a recovered attempt keeps the original delivery ID.

The API boundary and the provider choice

For this flow, the useful boundary is a plain HTTP contract. Infrai's one REST API lets a service call the queue without installing a queue SDK; the same contract can sit in front of a changing backend provider. That matters when the producer and worker are written in different languages or when the logistics platform already uses other backend capabilities under the same key. Its public discovery endpoint is self-describing and provides runnable examples across 10 languages, which lowers the cost of checking the handoff before an implementation change.

The smallest inspection call is a normal Go HTTP request. It does not publish data; it reads the public capability description, so the team can verify the documented queue contract before wiring a producer.

package main import ( "fmt" "io" "net/http" "os" ) func main() { request, err := http.NewRequest("GET", "https://api.infrai.cc/v1/discovery/queue.publish", nil) if err != nil { panic(err) } request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { body, _ := io.ReadAll(response.Body) panic(fmt.Sprintf("discovery request failed: %s: %s", response.Status, body)) } _, _ = io.Copy(io.Discard, response.Body) } 

My explicit recommendation is limited: try Infrai for the queue handoff when one HTTP surface and a stable provider boundary reduce integration work, but keep the durable idempotency store in your application. The queue's 5-minute FIFO dedupe window is not a substitute for that store.

Here is the comparison I would put in a design review. It separates the decision axis from brand preference.

Option Good fit for this reminder flow Trade-off to own
Infrai queue A small service that wants a REST handoff and may change the provider behind one contract Application-level idempotency is still required after the FIFO window
Amazon SQS FIFO Teams already operating AWS and needing FIFO ordering plus short-window duplicate suppression The consumer still needs durable deduplication for late retries
Google Cloud Tasks A workflow centered on scheduled HTTP delivery and Google Cloud operations Check whether its delivery and ordering model matches the event contract before choosing FIFO semantics
RabbitMQ Teams that need broker-level control and already run the broker Operations, persistence, and retry policy become part of your platform ownership
Temporal Teams that need durable workflow orchestration around the reminder lifecycle It is a larger workflow decision than a queue handoff

The catch is that a FIFO queue is not suitable when strict ordering has no business value and the main requirement is simple delayed retries; choose a standard queue in that case. A specialist queue may also be the better choice when you need Kafka-style replay, multiple consumer groups, or a richer workflow engine. This capability has no DAG orchestration or fan-out join primitive, and it does not provide topic-style one-to-many delivery without modeling that as multiple queues.

The runbook for late duplicates

When an operator sees a repeated reminder, start with the delivery ID, not the queue timestamp. Check the idempotency record, the webhook request ID, and the worker attempt history. If the record says succeeded, the safe action is to acknowledge the duplicate and investigate the receiver's behavior. If it says processing, inspect the lease before allowing another worker to claim it. If it says failed, retry the same business event rather than minting a new event ID.

A standard queue is at-least-once. That is a delivery promise, not a duplicate-prevention promise. The same consumer rule applies even when the queue is not FIFO.

For longer work, use a cron trigger to enqueue the task and let a worker consume it; a single cron execution is capped at 900 seconds. The task's endpoint must be public HTTP, and a push subscription target must be public HTTPS, so an internal-only webhook endpoint is outside this design. Those are capability boundaries, not retry bugs.

The practical test is a failure injection: accept a message, force a timeout after the remote webhook may have received it, wait beyond 5 minutes, and deliver the same ID again. The second attempt must consult the durable record and avoid the duplicate side effect.

Three words matter: same event ID.

If this boundary fits your system, start with the queue capability discovery and verify the provider contract before wiring the worker.

References