Getting started
Run one local Echo application and observe its authentication, tenant selection and quota checks. Queue a signed invoice.created event and inspect tenant usage in the embedded console. This tutorial uses memory stores. All key, usage and webhook state disappears when the process exits.
Prerequisites
Section titled “Prerequisites”Complete installation in an application module using Go 1.26 or later. Port 8080 must be free on loopback. Have curl and a browser available. You do not need Redis, PostgreSQL or a frontend build. Pick a file path that does not exist yet. The program writes a disposable API key there.
Step 1: start the application
Section titled “Step 1: start the application”From your application module directory, find the installed module directory:
cd "$(go list -m -f '{{.Dir}}' echo.labstack.com/pro)"Run its compiled example:
go run ./examples/getting-started -browser -key-file /tmp/echo-pro-getting-started.keyThe program writes the key to that path with mode 600. It exits with an error if the file already exists. It prints the API URL ending in /v1/tenants/acme/invoices and the admin URL. Keep this terminal running. The key is restricted to acme and has invoice read/write and admin-read scopes. The pepper exists only in memory, so the key stops working when the process exits.
With -browser, browser requests whose Host is exactly 127.0.0.1:8080 get read-only console access. Any local user or process that can reach that address gets the same access. In a deployed application, replace it with your own login.
Step 2: check authentication
Section titled “Step 2: check authentication”In another terminal, make an unauthenticated request:
curl --include http://127.0.0.1:8080/v1/tenants/acme/invoicesExpect HTTP 401 and Content-Type: application/problem+json. The authentication guard rejects it before tenant selection, quota or the invoice handler runs.
Send the key as a header. curl reads the header from standard input (--header @-), so the key never appears in command arguments:
# The key stays in curl's header file input; shell tracing never expands it.{ printf 'X-API-Key: ' cat /tmp/echo-pro-getting-started.key} | curl --include --header @- http://127.0.0.1:8080/v1/tenants/acme/invoicesExpect HTTP 200. The JSON contains tenant acme and invoice inv-1. This request uses 1 of the 3 requests the free plan allows per UTC day.
Step 3: verify tenant isolation
Section titled “Step 3: verify tenant isolation”Use the same key on a different tenant path:
{ printf 'X-API-Key: ' cat /tmp/echo-pro-getting-started.key} | curl --include --header @- http://127.0.0.1:8080/v1/tenants/globex/invoicesExpect HTTP 403. The URL names a different tenant from the one the key is restricted to. This refusal does not count against the quota. Editing the URL does not let a key reach another tenant.
Step 4: queue an event
Section titled “Step 4: queue an event”Create a demonstration invoice:
{ printf 'X-API-Key: ' cat /tmp/echo-pro-getting-started.key} | curl --include --header @- --request POST http://127.0.0.1:8080/v1/tenants/acme/invoicesExpect HTTP 202 with invoice and webhook_ref fields. The generated invoice ID changes each run. This request uses the second of the three. The handler queues an immutable event. Its webhook ID is derived from the invoice ID. The local worker signs it and sends it to the loopback receiver.
The server terminal prints Webhook delivered: verified invoice.created after verification. The receiver verifies the signature over the raw request body. It does not store invoices or skip duplicate events. The 202 returns as soon as the event is queued. It does not wait for delivery.
Step 5: exhaust the daily quota
Section titled “Step 5: exhaust the daily quota”Repeat the authenticated GET from Step 2 once. It returns 200 and uses the third unit. Repeat it again. The fourth authenticated request returns HTTP 429 with code quota_exceeded. Its type is https://iana.org/assignments/http-problem-types#quota-exceeded. The violated-policies field names the limit that was exceeded. Use reset_at for the next UTC day. Daily limits do not send Retry-After. The 401 from step 2 and the 403 from step 3 did not count.
The counter follows a UTC day. Restarting this memory-only demo clears its usage. In a deployment, use the Redis or Valkey store. It shares the budget across instances, and a restart does not reset it.
Step 6: inspect and stop
Section titled “Step 6: inspect and stop”Open http://127.0.0.1:8080/admin. Select Quota, then Usage, with tenant acme and plan free. The panel reads usage from the limiter the API uses. It does not consume a request. This console has no write actions.
Press Ctrl-C in the server terminal. HTTP serving stops first. The program then waits for the webhook worker to stop and for pending quota settlement to finish. Finally, it removes only the key file it created. If the process crashes, the file stays. Delete it before you restart with the same path.
Source and verification
Section titled “Source and verification”This is the complete program used above. Its tests exercise the live loopback routes, refusals, signed delivery and shutdown:
// Command getting-started runs a memory-only, loopback-only Echo Pro app.package main
import ( "context" "crypto/rand" "encoding/base64" "errors" "flag" "fmt" "io" "net" "net/http" "os" "os/signal" "time"
"github.com/labstack/echo/v5"
"echo.labstack.com/pro/admin" "echo.labstack.com/pro/auth" "echo.labstack.com/pro/auth/authbind" "echo.labstack.com/pro/problem/echoadapter" "echo.labstack.com/pro/quota" "echo.labstack.com/pro/quota/quotaadmin" "echo.labstack.com/pro/tenant" "echo.labstack.com/pro/webhook" "echo.labstack.com/pro/webhook/webhookmem")
func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }}
func run() error { browser := flag.Bool( "browser", false, "grant local browser requests read-only admin access (not for production)", ) keyFile := flag.String( "key-file", "", "new private file for the disposable API key (required)", ) flag.Parse() ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() if err := runWithContext(ctx, runConfig{ keyFile: *keyFile, browser: *browser, address: "127.0.0.1:8080", }); err != nil { return err }
return nil}
type runConfig struct { keyFile string browser bool address string ready func(net.Addr) verified func()}
func runWithContext(ctx context.Context, cfg runConfig) (result error) { keyFile := cfg.keyFile if keyFile == "" { return errors.New( "provide -key-file pointing to a new private file for the disposable API key", ) } pepper := make([]byte, 32) if _, err := rand.Read(pepper); err != nil { return err } ring := auth.PepperRing{{Version: 1, Key: pepper}} key, record, err := auth.IssueKey("demo", ring, "invoice-worker", auth.KeyOptions{ Kind: auth.Service, TenantRestriction: "acme", Scopes: []string{ "invoice:read", "invoice:write", "admin:read", }, }) if err != nil { return err } keys := &auth.MemoryKeyStore{Capacity: 1} if err := keys.Put(ctx, record); err != nil { return err } // The demo delivers its disposable credential through a private local file, // never stdout. Refuse existing paths; remove only the file we created. f, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err } defer func() { result = errors.Join(result, os.Remove(keyFile)) }() _, writeErr := io.WriteString(f, key+"\n") if err := errors.Join(writeErr, f.Close()); err != nil { return err } // Bind first so the sender and console use the actual test port. listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.address) if err != nil { return err } defer func() { if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { result = errors.Join(result, err) } }() authority := listener.Addr().String() authCfg := auth.Config{ Schemes: map[string]auth.Scheme{"key": auth.APIKey{ Namespace: "app", Prefix: "demo", Peppers: ring, Store: keys, Header: "X-API-Key", }}, Fallback: auth.Require(map[string][]string{"key": {"invoice:read"}}), } tenantPath, err := tenant.FromPathParam("tenant") if err != nil { return err } guard, err := authbind.Middleware(authCfg, tenant.Config{ Selectors: []tenant.Selector{tenant.FromCredential(), tenantPath}, Required: true, }) if err != nil { return err } // A daily counter makes the walkthrough repeatable without refill timing. lim, err := quota.New(quota.Config{ Store: quota.NewMemoryStore(quota.MemoryConfig{MaxKeys: 100}), Plans: []quota.Plan{{ID: "free", Limits: []quota.Limit{ quota.PerDay("requests", 3), }}}, }) if err != nil { return err } defer func() { closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() result = errors.Join(result, lim.Close(closeCtx)) }() identity, err := authbind.Quota() if err != nil { return err } quotaMW, err := (quota.MiddlewareConfig{ Limiter: lim, Identity: identity, }).ToMiddleware() if err != nil { return err } e := echo.New() e.HTTPErrorHandler, err = (echoadapter.ErrorHandlerConfig{}).ToErrorHandler( e.HTTPErrorHandler, ) if err != nil { return err } outbox, err := localWebhooks(e, authority, cfg.verified) if err != nil { return err } api := e.Group("/v1/tenants/:tenant", guard, quotaMW) api.GET("/invoices", func(c *echo.Context) error { id, _ := tenant.From(c.Request().Context()) return c.JSON(http.StatusOK, map[string]any{ "tenant": id, "invoices": []string{"inv-1"}, }) }) api.POST("/invoices", func(c *echo.Context) error { id, _ := tenant.From(c.Request().Context()) invoice := "inv_" + rand.Text() prepared, err := webhook.PrepareOutgoing(webhook.Outgoing{ Tenant: string( id, ), Destination: "invoices", Version: "v1", Key: invoice + "/created", Type: "invoice.created", Format: "json", Payload: []byte(fmt.Sprintf( `{"invoice":%q}`, invoice, )), }) if err != nil { return err } receipt, err := outbox.Enqueue(c.Request().Context(), prepared) if err != nil { return err } return c.JSON(http.StatusAccepted, map[string]string{ "invoice": invoice, "webhook_ref": receipt.Ref, }) }) if err := mountAdmin(e, lim, guard, key, cfg.browser, authority); err != nil { return err } worker, err := webhookmem.NewWorker(outbox, webhookmem.WorkerConfig{ Concurrency: 1, ShutdownTimeout: 5 * time.Second, }) if err != nil { return err } workerCtx, cancelWorker := context.WithCancel(ctx) done := make(chan error, 1) go func() { done <- worker.Run(workerCtx) }() defer func() { cancelWorker() if err := <-done; !errors.Is(err, context.Canceled) { result = errors.Join(result, err) } }() fmt.Printf("API: http://%s/v1/tenants/acme/invoices\n", authority) fmt.Printf( "Admin: http://%s/admin (use -browser for local browser access)\n", authority, ) sc := echo.StartConfig{ Listener: listener, ListenerAddrFunc: cfg.ready, GracefulTimeout: 5 * time.Second, BeforeServeFunc: func(s *http.Server) error { s.ReadHeaderTimeout = 5 * time.Second s.ReadTimeout = 10 * time.Second s.WriteTimeout = 15 * time.Second s.MaxHeaderBytes = 16 << 10 return nil }, } if err := sc.Start(ctx, e); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil}
func localWebhooks(e *echo.Echo, authority string, verified func()) ( *webhookmem.Store, error,) { secretBytes := make([]byte, 32) if _, err := rand.Read(secretBytes); err != nil { return nil, err } secret := "whsec_" + base64.StdEncoding.EncodeToString(secretBytes) verify, err := (webhook.StandardVerifierConfig{ Secrets: []string{ secret, }, Source: "app", Tenant: "acme", Type: "invoice.created", Format: "json", }).ToVerifyFunc() if err != nil { return nil, err } // This receiver verifies bytes only; it performs no business effect. A real // receiver also needs inbox deduplication before applying an effect. e.POST("/hooks/invoices", func(c *echo.Context) error { r := c.Request() body, err := io.ReadAll(io.LimitReader(r.Body, webhook.MaxPayloadBytes+1)) if err != nil { return echo.ErrBadRequest } if _, err := verify(r.Context(), r, body); err != nil { return echo.ErrBadRequest } fmt.Println("Webhook delivered: verified invoice.created") if verified != nil { verified() } return c.NoContent(http.StatusNoContent) }) send, err := (webhook.HTTPSenderConfig{Endpoints: map[string]webhook.Endpoint{ "invoices": { URL: "http://" + authority + "/hooks/invoices", Secrets: []string{secret}, }, }}).ToSendFunc() if err != nil { return nil, err } return webhookmem.New(webhookmem.StoreConfig{MaxMessages: 100, Routing: webhookmem.RoutingConfig{ Destinations: map[string]webhookmem.DestinationConfig{ "invoices": {Version: "v1", Send: send, ReplayWindow: 24 * time.Hour}, }, }, })}
func mountAdmin( e *echo.Echo, lim *quota.Limiter, guard echo.MiddlewareFunc, key string, browser bool, authority string,) error { panel, err := quotaadmin.New(quotaadmin.Config{Limiter: lim}) if err != nil { return err } chain := []echo.MiddlewareFunc{guard} if browser { // Not for production: anyone reaching this loopback listener at its exact // authority gets the demo's read-only admin credential on console routes. chain = []echo.MiddlewareFunc{func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { if c.Request().Host != authority { return echo.ErrForbidden } if c.Request().Header.Get("X-API-Key") == "" { c.Request().Header.Set("X-API-Key", key) } return next(c) } }, guard} } cfg := admin.Config{ Panels: []admin.Panel{panel}, Instance: "local", Origins: []string{ "http://" + authority, }, Middleware: chain, Authenticate: authbind.Admin(authbind.AdminGrants{ Viewer: "admin:read", }), } if err := cfg.Mount(e, "/admin"); err != nil { return err } return admin.VerifyRoutes(e, "/admin")}Run the example regression tests from the installed module directory:
go test ./examples/getting-started ./examples/quota-otel ./examples/admin-demoChange for deployment
Section titled “Change for deployment”Replace MemoryKeyStore with a store implementing Lookup(context.Context, string) (auth.StoredKey, error).
See the compiled lookup example.
Your store must return current revocation data. Load peppers from your secret manager. Keep authentication and tenant checks ahead of quota. Choose the plan from your subscription records, not from request data. Use redisstore for shared budgets.
Use webhookpg to keep inbox and outbox state in PostgreSQL. Give each business effect a permanent key, such as the invoice ID, stored in your own table. Keep receiver deduplication records for as long as the sender can resend an event. Replace the demo’s injected console credential with your login sessions. List exact allowed origins. The package explanations and full console tutorial explain storage and operator requirements.