Publish events from Gin handlers and process them in a subscriber with retries, a dead-letter queue and idempotent handling, using Encore or SNS and SQS wired by hand.

Gin handles the HTTP side of a Go API well, and the moment a handler needs to send a receipt, call a webhook or kick off a report, the work has to move off the request path. A goroutine looks like the answer until a deploy restarts the container mid-send, and then the choice is between a queue you run yourself and a managed service such as SNS and SQS with its own IAM, dead-letter queue and local stand-in.
This guide adds Pub/Sub to a small Gin orders API, first with Encore, which declares the topic and subscriber in Go and runs them locally and in your own AWS or GCP account, and then with SNS and SQS wired up by hand.
Encore works alongside Gin and provisions the infrastructure the code declares in your own AWS or GCP account. The router you already have is mounted in one raw endpoint with a fallback route, the Gin handlers publish to a topic declared a few lines above them, and a subscriber in another service picks the events up.
Run in your terminal to get started locally.
The orders service declares the topic, builds the Gin router in initService and publishes from the POST /orders handler:
// orders/orders.go
package orders
import (
"net/http"
"encore.dev/pubsub"
"github.com/gin-gonic/gin"
)
// OrderPlaced is published after an order is accepted.
type OrderPlaced struct {
OrderID string `json:"order_id"`
Email string `json:"email"`
Total int64 `json:"total_cents"`
}
// SNS and SQS on AWS, Google Cloud Pub/Sub on GCP.
var OrdersPlaced = pubsub.NewTopic[*OrderPlaced]("orders-placed", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
//encore:service
type Service struct {
router *gin.Engine
}
func initService() (*Service, error) {
r := gin.New()
r.Use(gin.Recovery())
r.POST("/orders", placeOrder)
return &Service{router: r}, nil
}
// Fallback sends every request that no Encore endpoint matches to Gin.
//
//encore:api public raw path=/!fallback
func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) {
s.router.ServeHTTP(w, req)
}
type placeOrderRequest struct {
OrderID string `json:"order_id" binding:"required"`
Email string `json:"email" binding:"required,email"`
Total int64 `json:"total_cents" binding:"required,gt=0"`
}
func placeOrder(c *gin.Context) {
var req placeOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msgID, err := OrdersPlaced.Publish(c.Request.Context(), &OrderPlaced{
OrderID: req.OrderID,
Email: req.Email,
Total: req.Total,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not publish event"})
return
}
c.JSON(http.StatusAccepted, gin.H{"order_id": req.OrderID, "message_id": msgID})
}
The notify service subscribes. Delivery is at least once, so the handler claims the order id in a receipts table before it sends anything, and a second delivery of the same event finds the row and returns:
// notify/notify.go
package notify
import (
"context"
"time"
"encore.app/orders"
"encore.dev/pubsub"
"encore.dev/rlog"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("notify", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
var _ = pubsub.NewSubscription(
orders.OrdersPlaced, "send-receipt",
pubsub.SubscriptionConfig[*orders.OrderPlaced]{
Handler: sendReceipt,
MaxConcurrency: 20,
AckDeadline: 30 * time.Second,
RetryPolicy: &pubsub.RetryPolicy{
MinBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
MaxRetries: 10,
},
},
)
func sendReceipt(ctx context.Context, e *orders.OrderPlaced) error {
// Delivery is at least once, so claim the order id before sending.
res, err := db.Exec(ctx, `
INSERT INTO receipts (order_id, email) VALUES ($1, $2)
ON CONFLICT (order_id) DO NOTHING
`, e.OrderID, e.Email)
if err != nil {
return err // retried with backoff
}
if res.RowsAffected() == 0 {
return nil // receipt already sent for this order
}
rlog.Info("sending receipt", "order_id", e.OrderID, "total_cents", e.Total)
return nil
}
-- notify/migrations/1_create_receipts.up.sql
CREATE TABLE receipts (
order_id TEXT PRIMARY KEY,
email TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
encore run starts the app with the topic, the subscription and a local Postgres for the receipts table, so a POST to localhost:4000/orders publishes an event that the notify handler picks up a moment later, and the local dashboard shows the publish and the subscriber call in the same trace. A handler that returns an error is retried with the backoff in RetryPolicy, and after MaxRetries the message moves to a dead-letter queue for that subscription instead of blocking the ones behind it, as described in the Pub/Sub docs.
On deploy, Encore creates an SNS topic with an SQS queue and dead-letter queue per subscription on AWS, or a Google Cloud Pub/Sub topic and subscription on GCP, and grants the orders service permission to publish and the notify service permission to consume, as listed in the AWS infrastructure docs. A coding agent asked to "also post new orders to Slack" adds a second subscription to the same topic in its own service and can trigger it against the local app before the change is reviewed.
Tests can publish to the topic and assert on the receipts table, since encore test gives each test isolated Postgres and Pub/Sub instances, as the test infrastructure docs describe.
Best fit: Gin services that should keep their router and move receipts, webhooks and other side effects onto a topic that behaves the same on a laptop and in your own AWS or GCP account.
On AWS the standard fan-out is an SNS topic with one SQS queue per consumer. The Gin handler publishes with the AWS SDK for Go v2, and a separate worker process long-polls its queue:
func (s *server) placeOrder(c *gin.Context) {
var req placeOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
body, _ := json.Marshal(req)
_, err := s.sns.Publish(c.Request.Context(), &sns.PublishInput{
TopicArn: aws.String(os.Getenv("ORDERS_TOPIC_ARN")),
Message: aws.String(string(body)),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not publish event"})
return
}
c.JSON(http.StatusAccepted, gin.H{"order_id": req.OrderID})
}
Around that code sits the infrastructure: the topic, a queue and a dead-letter queue with a redrive policy, an SNS subscription with raw message delivery, a queue policy that lets the topic send to the queue, a task role for the API with sns:Publish on the topic ARN, and a role for the worker with sqs:ReceiveMessage, sqs:DeleteMessage and sqs:ChangeMessageVisibility on the queue. The worker needs its own deployment, a visibility timeout longer than its slowest message, and a loop that deletes each message only after the handler succeeds. AWS documents the pattern in its SNS to SQS fan-out guide.
Locally, the options are LocalStack or pointing the laptop at a shared dev account, and the topic ARN and queue URL differ per environment, so each one lands in config. The result is full control over every queue attribute, at the cost of a second copy of the setup for staging and a local loop that only approximates the deployed one.
Best fit: Teams whose SNS and SQS resources are already managed in Terraform by a platform team and who want the Gin app to only call the SDK.
The shortest path is go sendReceipt(order) before returning the response, and for best-effort work such as warming a cache it is enough. For anything that must happen it has three gaps: the work is lost when the process exits during a deploy, a failure is logged and then forgotten with no retry, and there is no backlog to look at when the email provider is down for an hour. Gin's own docs also warn that a goroutine must use c.Copy() rather than the original context, since the context is reused after the handler returns.
Best fit: Fire-and-forget work where losing an occasional run costs nothing.
Pick Encore when the Gin router should stay and the side effects should run from a topic declared in Go, with retries, a dead-letter queue and publish and consume permissions for each service provisioned in your own AWS or GCP account, and the same flow running under encore run. Pick SNS and SQS by hand when the messaging layer belongs to a platform team and already lives in Terraform. Keep goroutines for work you can afford to drop.
Create the topic once at package level, then call Publish with c.Request.Context() inside the handler and return 202 once the publish succeeds. The subscriber runs separately, so the HTTP response does not wait for the email, webhook or report the event triggers.
Declare the topic with pubsub.NewTopic[*OrderPlaced]("orders-placed", pubsub.TopicConfig{DeliveryGuarantee: pubsub.AtLeastOnce}), call OrdersPlaced.Publish from the Gin handler, and register a handler with pubsub.NewSubscription in another service. encore run delivers the events locally, and on deploy Encore provisions SNS and SQS on AWS or Google Cloud Pub/Sub on GCP in your own account.
A goroutine started from a handler is lost when the process restarts or the deploy replaces the container, and nothing retries it after a failure. Use a queue or Pub/Sub topic for anything that must happen, such as receipts, webhooks or billing, and keep goroutines for best-effort work like warming a cache.
SQS, SNS and Google Cloud Pub/Sub deliver at least once, so a subscriber can see the same message twice after a timeout or a retry. Record the event id or business key in a table with a unique constraint before doing the side effect, and skip the work when the insert finds an existing row.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.