Nov 9, 20267 min read

Shipment Tracking Event Pipeline on AWS and GCP

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.

Tracking pipeline pieces at a glance

PieceJobEncore primitiveTypical cloud resource
Status topicCarry every shipment eventPub/Sub topicSNS + SQS or GCP Pub/Sub
Tracking storeCurrent status and historyPostgres databaseRDS or Cloud SQL
ProjectorApply events to the storeSubscriptionSame queue infrastructure
Webhook workerNotify shop or carrier systemsSecond subscriptionSame
ReconciliationFind stuck shipmentsScheduled jobEventBridge Scheduler or Cloud Scheduler

How do you build the pipeline by hand?

On AWS

  1. Create an SNS topic 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.
  2. Create an encrypted RDS Postgres instance in private subnets for shipments, shipment_events and webhook_deliveries.
  3. Run two workers (Lambda or ECS tasks) that long-poll their queues. The projector upserts the shipment row and inserts the event with ON CONFLICT (event_id) DO NOTHING. The webhook worker posts to the shop URL and records webhook_deliveries so retries are idempotent.
  4. Put the scan API on ECS or Lambda behind API Gateway. It writes nothing durable itself; it publishes to SNS and returns the message id.
  5. Add an EventBridge Scheduler rule that invokes a reconcile Lambda hourly to find 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.

On GCP

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.

How do you build it with Encore?

Encore

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.

Application code passes through Encore to infrastructure in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create tracking --example=hello-world$ cd tracking && mkdir -p shipments/migrations
-- 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.

Frequently asked questions

How do you build a shipment tracking event pipeline?

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.

How do I build a shipment tracking pipeline with Encore?

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.

Should shipment status updates be synchronous?

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.

What cloud resources does a tracking pipeline need?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders