Stripe usage export
Consume 12 token units and deliver the quota event to a Stripe exporter. Check the accepted usage still in the Open state, before the exporter seals it for sending. The default run makes no Stripe API calls. It shows event delivery without a sandbox account.
Prerequisites
Section titled “Prerequisites”Install Echo Pro with Go 1.26 or later. The offline run needs no Stripe credential, Redis, database or browser. Both quota state and exporter history use disposable memory stores. Do not use this history for paid usage that must survive process exit.
Step 1: run offline
Section titled “Step 1: run offline”Find the installed source directory:
cd "$(go list -m -f '{{.Dir}}' echo.labstack.com/pro)"Run the default command:
go run ./examples/stripeThe program prints accepted open units: 12 and offline demo: no Stripe API calls, then exits. It uses a strict 100-token daily plan and consumes 12 units.
It waits for its outbox delivery callback to accept the event. It closes the limiter before it reports Drained.
quotastripe.Config.Meters maps tokens to a Stripe meter, event name and payload keys.
Customer maps an event to a Stripe customer. The first accepted mapping is stored. Keep it stable while export history exists.
The example reports Drained only after requests stop and outbox processing returns.
There are no pending reservations or future settlements in this demo.
In your application, wait for those too before claiming all usage through a cutoff has been exported.
Matching a minute’s total does not prove each invoice is correct.
Read the complete program:
// Command stripe demonstrates quota/outbox wiring. It is offline by default.// -online makes owner-authorized Stripe requests using an injected environment.package main
import ( "context" "errors" "flag" "fmt" "net/http" "os" "os/signal" "sync" "sync/atomic" "time"
"echo.labstack.com/pro/quota" "echo.labstack.com/pro/quota/quotastripe")
func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }}
func run() error { online := flag.Bool( "online", false, "make Stripe calls with owner-injected sandbox configuration", ) flag.Parse() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() if err := demo(ctx, *online); err != nil { return err }
return nil}
func demo(ctx context.Context, online bool) (resultErr error) { // Demonstration only: replace with a persistent RedisStore for paid usage. store, err := quotastripe.NewMemoryStore(quotastripe.StoreConfig{}) if err != nil { return err } var caughtUp atomic.Bool cfg := quotastripe.Config{ Namespace: "demo-single-process-history-1", Store: store, Client: &http.Client{}, APIKey: func(context.Context) (string, error) { key := os.Getenv("STRIPE_SANDBOX_KEY") if key == "" { return "", errors.New("inject STRIPE_SANDBOX_KEY") } return key, nil }, Customer: func(context.Context, quota.ConsumptionEvent) (string, error) { if !online { return "cus_demo", nil } customer := os.Getenv("STRIPE_SANDBOX_CUSTOMER_ID") if customer == "" { return "", errors.New("inject STRIPE_SANDBOX_CUSTOMER_ID") } return customer, nil }, Drained: func(context.Context) (bool, error) { return caughtUp.Load(), nil }, Meters: map[string]quotastripe.Meter{"tokens": { ID: "mtr_demo", EventName: "tokens", CustomerKey: "stripe_customer_id", ValueKey: "value", }}, } if online { m := cfg.Meters["tokens"] m.ID = os.Getenv("STRIPE_SANDBOX_METER_ID") m.EventName = os.Getenv("STRIPE_SANDBOX_EVENT_NAME") cfg.Meters["tokens"] = m } exporter, err := quotastripe.New(cfg) if err != nil { return err } accepted := make(chan struct{}) var once sync.Once lim, err := quota.New(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "demo", Limits: []quota.Limit{ quota.PerDay("tokens", 100).Meter("tokens").Strict(), }}}, Outbox: "a.Outbox{PollInterval: time.Millisecond, Deliver: func( ctx context.Context, ev quota.Event, ) error { err := exporter.Deliver(ctx, ev) if err == nil { once.Do(func() { close(accepted) }) } return err }}}) if err != nil { return err } closed := false defer func() { if closed { return } call, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() resultErr = errors.Join(resultErr, lim.Close(call)) }() periodStart := time.Now().UTC().Truncate(time.Minute) if _, err = lim.Allow(ctx, quota.Request{ Identity: quota.Identity{Tenant: "demo"}, Cost: quota.Cost{"tokens": 12}, }); err != nil { return err } select { case <-accepted: case <-ctx.Done(): return ctx.Err() } // Stop all source admissions. This example has no reservations or future // settlements; Close joins outbox processing before affirming catch-up. if err := lim.Close(ctx); err != nil { return err } closed = true caughtUp.Store(true) status, err := exporter.Status(ctx) if err != nil { return err } fmt.Println("accepted open units:", status.States[quotastripe.Open].Amount) if !online { fmt.Println("offline demo: no Stripe API calls") return nil } runCtx, cancel := context.WithCancel(ctx) defer cancel() finished := make(chan error, 1) go func() { finished <- exporter.Run(runCtx) }() // A production service owns dispatcher startup/readiness. Flush reports // ErrNotRunning until preflight and the namespace lease have succeeded. periodEnd := periodStart.Add(time.Minute) timer := time.NewTimer(time.Until(periodEnd)) defer timer.Stop() select { case <-timer.C: case <-ctx.Done(): cancel() runErr := <-finished if errors.Is(runErr, context.Canceled) { runErr = nil } return errors.Join(ctx.Err(), runErr) case err = <-finished: return err } result, flushErr := exporter.Flush(ctx, periodEnd) fmt.Println( "targeted/sent/unresolved:", result.Targeted, result.Sent, result.Unresolved, ) var report quotastripe.Report if flushErr == nil { report, err = exporter.Reconcile(ctx, os.Getenv( "STRIPE_SANDBOX_CUSTOMER_ID", ), cfg.Meters["tokens"].ID, periodStart, periodEnd) if err == nil { fmt.Println("aggregate verdict:", report.Verdict) } } cancel() runErr := <-finished if errors.Is(runErr, context.Canceled) { runErr = nil } return errors.Join(flushErr, err, runErr)}Step 2: configure and run sandbox dispatch
Section titled “Step 2: configure and run sandbox dispatch”Use your own authorized Stripe sandbox resources. Inject STRIPE_SANDBOX_KEY, STRIPE_SANDBOX_CUSTOMER_ID, STRIPE_SANDBOX_METER_ID and STRIPE_SANDBOX_EVENT_NAME through your runtime secret manager. Create the Stripe meter with sum aggregation over raw events. Keep the credential out of arguments and logs.
Only after those resources are configured, choose the networked run:
go run ./examples/stripe -online-online starts the dispatcher. Run checks the Stripe configuration and takes a lease so only one dispatcher sends for the namespace. Flush returns ErrNotRunning until that readiness succeeds. The example waits until the current UTC minute ends before flushing its range. It reports targeted, sent and unresolved amounts. After a successful flush, reconciliation reports whether Stripe’s totals match.
The online path performs real sandbox API operations. Its output depends on the account, meter and retained history. Do not replace an unresolved request with a new event ID just to make a retry succeed. Preserve the frozen request, receipt and source event ID.
Step 3: stop and inspect failures
Section titled “Step 3: stop and inspect failures”A strict quota check can return 503 before billed work starts. An unresolved Stripe send happens later, while exporting recorded usage.
The online command cancels and joins the dispatcher before returning. Ctrl-C cancels its parent context. A provider timeout can be ambiguous. Inspect retained state before deciding another send is safe. See the callback limits. Log OnError and OnBacklog categories without request bodies or API keys.
The offline example verifies event acceptance only. It does not certify a live Stripe account, invoice correctness or reconciliation coverage. Test online dispatch with your sandbox and the package’s pinned Stripe API version before enabling billed traffic.
If you add the quota telemetry example, replace metrics.Hooks() with metrics.OutboxHooks() and call metrics.Count(ctx, ev) after exporter.Deliver(ctx, ev) succeeds. Count counts redeliveries again; use the exporter’s event-ID deduplication for billing.
Change for deployment
Section titled “Change for deployment”Replace both memory stores with persistent stores. Use a fixed namespace for each export history within a Stripe account. Configure store capacity, retention and exporter settings consistently. Keep final usage strict so an unavailable store cannot admit unrecorded billed work. Deduplicate quota events by Event.ID because the outbox can deliver them more than once and out of order.
Coordinate dispatcher leases, source progress records and shutdown across instances. Size the quota outbox and exporter backlog for the longest Stripe outage you expect. Keep an operator route for unresolved records. The optional Stripe panel exposes status and reviewed reconciliation/flush actions through the console.
See quota events and failure policy, accounting tasks and Stripe configuration fields.