Model shipment status changes as events, update a tracking store and fan out to webhooks and partners, with Encore Pub/Sub or SNS/SQS and Pub/Sub by hand.
A shipment tracking backend is an event pipeline with a queryable store on the side. Every scan, exception and delivery confirmation is a status event; the current location a customer sees is a projection of those events, and partner webhooks are another projection that must survive retries. Treating each of those as a synchronous call chain breaks the first time a carrier webhook times out and a driver app hangs.
This guide builds a tracking service that publishes status events, updates a Postgres store, fans out to a webhook worker and reconciles stuck shipments on a schedule, first as a plain AWS or GCP layout and then declared in application code. Pallet runs an event-driven logistics platform on GCP with Encore, and Quiqup built a logistics platform spanning fulfillment, logistics and order channels.
shipment-status and two SQS queues (project-tracking, notify-webhooks) subscribed to it, each with a redrive policy to a dead-letter queue after several receives.shipments, shipment_events and webhook_deliveries.ON CONFLICT (event_id) DO NOTHING. The webhook worker posts to the shop URL and records webhook_deliveries so retries are idempotent.in_transit rows older than 48 hours.A publish from the scan API with the AWS SDK looks like:
// Hand-rolled SNS publish after validating the scan payload.
out, err := snsClient.Publish(ctx, &sns.PublishInput{
TopicArn: aws.String(statusTopicArn),
Message: aws.String(string(payloadJSON)),
})
IAM needs sns:Publish on the topic for the API role, sqs:ReceiveMessage / DeleteMessage on each queue for the workers, and rds-db:connect (or security-group access) for Postgres. Terraform usually owns the topic, queues, DLQs, RDS, scheduler and roles, and you repeat it per environment.
The same pattern is a Pub/Sub topic with two subscriptions, Cloud SQL for Postgres, Cloud Run or GKE workers, and Cloud Scheduler for reconciliation. Push subscriptions can replace long-polling; pull subscriptions keep the worker in control of concurrency.
Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. A tracking pipeline is a Pub/Sub topic for status events, a Postgres database for the projection, subscriptions for each consumer and a cron job for reconciliation, all declared in the service that owns the shipments.

Run in your terminal to get started locally.
-- shipments/migrations/1_create_shipments.up.sql
CREATE TABLE shipments (
id BIGSERIAL PRIMARY KEY,
tracking_code TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE shipment_events (
id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
shipment_id BIGINT NOT NULL REFERENCES shipments(id),
status TEXT NOT NULL,
location TEXT NOT NULL DEFAULT '',
recorded_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE webhook_deliveries (
event_id TEXT PRIMARY KEY,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
// shipments/shipments.go
package shipments
import (
"context"
"errors"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/cron"
"encore.dev/pubsub"
"encore.dev/rlog"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("shipments", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
var secrets struct {
CarrierToken string // shared secret for handheld/carrier scanners
}
//encore:authhandler
func AuthHandler(ctx context.Context, token string) (auth.UID, error) {
if token == "" || token != secrets.CarrierToken {
return "", &errs.Error{Code: errs.Unauthenticated, Message: "invalid token"}
}
return auth.UID("carrier"), nil
}
// StatusEvent is one scan or status change. event_id makes handlers idempotent.
type StatusEvent struct {
EventID string `json:"event_id"`
TrackingCode string `json:"tracking_code"`
Status string `json:"status"`
Location string `json:"location"`
RecordedAt time.Time `json:"recorded_at"`
}
var StatusEvents = pubsub.NewTopic[*StatusEvent]("shipment-status", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
// Projector: update the tracking store. Safe to run twice for the same event_id.
var _ = pubsub.NewSubscription(StatusEvents, "project-tracking",
pubsub.SubscriptionConfig[*StatusEvent]{
Handler: applyStatus,
RetryPolicy: &pubsub.RetryPolicy{
MaxRetries: 8,
MinBackoff: 2 * time.Second,
MaxBackoff: 5 * time.Minute,
},
},
)
func applyStatus(ctx context.Context, ev *StatusEvent) error {
var shipmentID int64
err := db.QueryRow(ctx, `
INSERT INTO shipments (tracking_code, status, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (tracking_code) DO UPDATE
SET status = CASE
WHEN EXCLUDED.updated_at >= shipments.updated_at THEN EXCLUDED.status
ELSE shipments.status
END,
updated_at = GREATEST(shipments.updated_at, EXCLUDED.updated_at)
RETURNING id
`, ev.TrackingCode, ev.Status, ev.RecordedAt).Scan(&shipmentID)
if err != nil {
return err
}
_, err = db.Exec(ctx, `
INSERT INTO shipment_events (event_id, shipment_id, status, location, recorded_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_id) DO NOTHING
`, ev.EventID, shipmentID, ev.Status, ev.Location, ev.RecordedAt)
return err
}
// Webhook fan-out: separate subscription so a slow partner does not block the store.
var _ = pubsub.NewSubscription(StatusEvents, "notify-webhooks",
pubsub.SubscriptionConfig[*StatusEvent]{
Handler: notifyWebhook,
RetryPolicy: &pubsub.RetryPolicy{
MaxRetries: 10,
MinBackoff: 5 * time.Second,
MaxBackoff: 15 * time.Minute,
},
},
)
func notifyWebhook(ctx context.Context, ev *StatusEvent) error {
var already string
err := db.QueryRow(ctx, `
SELECT event_id FROM webhook_deliveries WHERE event_id = $1
`, ev.EventID).Scan(&already)
if err == nil {
// Already delivered for this event_id.
return nil
}
if !errors.Is(err, sqldb.ErrNoRows) {
return err
}
// POST to the shop webhook URL here. Return an error to retry before marking delivered.
rlog.Info("webhook sent", "tracking", ev.TrackingCode, "status", ev.Status)
_, err = db.Exec(ctx, `
INSERT INTO webhook_deliveries (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
`, ev.EventID)
return err
}
type RecordScanParams struct {
EventID string `json:"event_id"`
TrackingCode string `json:"tracking_code"`
Status string `json:"status"`
Location string `json:"location"`
}
type RecordScanResponse struct {
MessageID string `json:"message_id"`
}
// RecordScan accepts a scan from a handheld or carrier feed and enqueues it.
// Auth-gated so only verified carrier or warehouse credentials can rewrite status.
//
//encore:api auth method=POST path=/scans
func RecordScan(ctx context.Context, p *RecordScanParams) (*RecordScanResponse, error) {
id, err := StatusEvents.Publish(ctx, &StatusEvent{
EventID: p.EventID,
TrackingCode: p.TrackingCode,
Status: p.Status,
Location: p.Location,
RecordedAt: time.Now().UTC(),
})
if err != nil {
return nil, err
}
return &RecordScanResponse{MessageID: id}, nil
}
type TrackingResponse struct {
TrackingCode string `json:"tracking_code"`
Status string `json:"status"`
UpdatedAt time.Time `json:"updated_at"`
}
//encore:api public method=GET path=/track/:code
func Track(ctx context.Context, code string) (*TrackingResponse, error) {
var out TrackingResponse
err := db.QueryRow(ctx, `
SELECT tracking_code, status, updated_at FROM shipments WHERE tracking_code = $1
`, code).Scan(&out.TrackingCode, &out.Status, &out.UpdatedAt)
if err != nil {
return nil, err
}
return &out, nil
}
// Hourly sweep for shipments stuck in "in_transit" longer than 48 hours.
// Cron jobs run in deployed environments, not under `encore run`.
var _ = cron.NewJob("reconcile-stuck", cron.JobConfig{
Title: "Reconcile stuck shipments",
Every: 1 * cron.Hour,
Endpoint: ReconcileStuck,
})
//encore:api private
func ReconcileStuck(ctx context.Context) error {
rows, err := db.Query(ctx, `
SELECT tracking_code FROM shipments
WHERE status = 'in_transit' AND updated_at < now() - interval '48 hours'
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return err
}
rlog.Warn("stuck shipment", "tracking", code)
// Publish an exception event or open a support ticket.
}
return rows.Err()
}
With encore run, POST /scans returns a message ID immediately, the projector updates the store a moment later and GET /track/:code returns the new status. Publishing the same event_id twice does not duplicate history or webhooks. On deploy, Encore provisions SNS and SQS with dead-letter queues on AWS, or Pub/Sub topics and subscriptions on GCP, plus RDS or Cloud SQL. Encore provisions an IAM role for the service on AWS or a service account on GCP. The cron job is Encore platform Managed: the platform calls ReconcileStuck on schedule, with no EventBridge Scheduler or Cloud Scheduler rule in your account.
Best fit: logistics and supply-chain backends that want status events, a tracking store and webhook fan-out declared in application code and running in their own AWS or GCP account.
Logistics backends that want the same event pipeline locally and in the cloud can start with Encore, declare the topic, store and cron job in application code, and deploy into SNS/SQS or GCP Pub/Sub plus Postgres in your own account.
Publish each status change as an event (picked up, in transit, out for delivery, delivered), update a tracking store from a subscriber, and fan out to webhooks or partner topics from separate subscriptions. Use at-least-once delivery with idempotent handlers keyed by shipment ID and event ID, and run a scheduled job to reconcile stuck shipments.
Declare a topic with pubsub.NewTopic for status events, a Postgres database with sqldb.NewDatabase for the tracking store, subscriptions that update the store and call webhooks, and cron.NewJob for reconciliation. Encore runs the queue locally under encore run and provisions SNS and SQS on AWS or Pub/Sub on GCP in your own account.
The write that records a scan can acknowledge quickly, then publish an event so webhooks, analytics and partner feeds run out of band. That keeps the scan API fast when a partner endpoint is slow, and retries land on the subscription instead of blocking the driver app.
A durable topic and subscriptions with retries and a dead-letter queue, a Postgres database for current status and history, optional object storage for labels or proof-of-delivery images, and a scheduler for reconciliation. On AWS that is typically SNS, SQS, RDS and EventBridge Scheduler; on GCP it is Pub/Sub, Cloud SQL and Cloud Scheduler.
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.