Intermediate May 3, 2026 · 3 min read

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 Customer Thread with a subscription_active boolean field
  • A Stripe webhook receiver at /webhooks/stripe that verifies signatures and updates customers based on Stripe events
  • A small main.go that 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.id and 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 EventHandler callback. If you have many event types, dispatch in your code via a switch.

These are explicitly v0.2.0 territory.

Was this article helpful?