Adding a Stripe Webhook with Shuttle
Wire pkg/shuttle/stripe into a Loom application to handle customer.subscription.updated events from Stripe. Estimated time: 30 minutes.
What you'll build
- A
CustomerThread with asubscription_activeboolean field - A Stripe webhook receiver at
/webhooks/stripethat verifies signatures and updates customers based on Stripe events - A small
main.gothat wires the runtime + the Stripe shuttle together
Step 1 — Customer Thread
# threads/customer.yaml
model: Customer
label: Customer
icon: building
fields:
- name: company_name
type: text
required: true
- name: stripe_customer_id
type: text
unique: true
- name: subscription_active
type: boolean
default: false
permissions:
- role: Admin
can: all
Step 2 — Custom main.go (eject mode)
The Stripe shuttle needs to dispatch events back into your runtime. The cleanest approach is a tiny custom main.go:
// main.go
package main
import (
"context"
"log"
"log/slog"
"os"
"github.com/orbweaver-dev/loom/internal/parser"
"github.com/orbweaver-dev/loom/internal/runtime"
"github.com/orbweaver-dev/loom/pkg/shuttle"
"github.com/orbweaver-dev/loom/pkg/shuttle/stripe"
)
func main() {
threads, err := parser.LoadDir("threads")
if err != nil {
log.Fatal(err)
}
db, err := runtime.Open(runtime.DBOptions{})
if err != nil {
log.Fatal(err)
}
auth, err := runtime.NewAuth(0)
if err != nil {
log.Fatal(err)
}
stripePlugin := stripe.New(func(ctx context.Context, ev stripe.Event) error {
switch ev.Type {
case "customer.subscription.updated":
return handleSubscriptionUpdate(ctx, db, ev)
}
return nil
})
srv, err := runtime.NewServer(runtime.ServerOptions{
DB: db,
Threads: threads,
Auth: auth,
Plugins: []shuttle.Shuttle{stripePlugin},
})
if err != nil {
log.Fatal(err)
}
if err := srv.Start(); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}
Step 3 — Implement the event handler
import "github.com/jmoiron/sqlx"
func handleSubscriptionUpdate(ctx context.Context, db *sqlx.DB, ev stripe.Event) error {
obj := ev.Data.Object
customerID, _ := obj["customer"].(string) // Stripe customer ID
status, _ := obj["status"].(string) // active|trialing|canceled|...
active := status == "active" || status == "trialing"
res, err := db.ExecContext(ctx,
"UPDATE customers SET subscription_active = ? WHERE stripe_customer_id = ?",
active, customerID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
slog.Warn("stripe webhook: no customer matched", "stripe_customer_id", customerID)
}
return nil
}
If the handler returns an error, the webhook responds 500 — Stripe retries automatically.
Step 4 — Configure secrets
export DATABASE_URL='mysql://...'
export LOOM_SECRET=$(openssl rand -hex 32)
export STRIPE_WEBHOOK_SECRET='whsec_...' # from your Stripe dashboard
The shuttle reads STRIPE_WEBHOOK_SECRET automatically; if it's not set, Init returns an error and the server refuses to start.
Step 5 — Build and run
go run .
You should see mounted plugin route plugin=stripe method=POST path=/webhooks/stripe in the startup logs.
Step 6 — Test locally with Stripe CLI
stripe listen --forward-to localhost:8080/webhooks/stripe
Then trigger an event:
stripe trigger customer.subscription.updated
You should see signature verification pass, your handler run, and the database update.
How signature verification works
The Stripe shuttle implements the documented HMAC-SHA256 scheme: it reads the Stripe-Signature header (t=<timestamp>,v1=<hex>), recomputes HMAC-SHA256(secret, <timestamp>.<body>), and compares using hmac.Equal (constant-time). Timestamps more than 5 minutes off the current clock are rejected. Tampered bodies fail at the HMAC compare step.
For deeper detail, read pkg/shuttle/stripe/stripe.go::VerifySignature.
What v0.1.0 leaves out
- Idempotency — Stripe may deliver the same event multiple times. For production, persist the
event.idand skip if seen. - Retry queue — failed handlers cause Stripe to retry, but they don't queue locally. For long handlers, enqueue work to a background system.
- Event-type dispatch as configuration — the shuttle gives you one
EventHandlercallback. If you have many event types, dispatch in your code via a switch.
These are explicitly v0.2.0 territory.