Skip to content

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.

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.

From your application module directory, find the installed module directory:

Terminal window
cd "$(go list -m -f '{{.Dir}}' echo.labstack.com/pro)"

Run its compiled example:

Terminal window
go run ./examples/getting-started -browser -key-file /tmp/echo-pro-getting-started.key

The 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.

In another terminal, make an unauthenticated request:

Terminal window
curl --include http://127.0.0.1:8080/v1/tenants/acme/invoices

Expect 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:

Terminal window
# 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/invoices

Expect 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.

Use the same key on a different tenant path:

Terminal window
{
printf 'X-API-Key: '
cat /tmp/echo-pro-getting-started.key
} |
curl --include --header @- http://127.0.0.1:8080/v1/tenants/globex/invoices

Expect 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.

Create a demonstration invoice:

Terminal window
{
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/invoices

Expect 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.

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.

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.

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:

Terminal window
go test ./examples/getting-started ./examples/quota-otel ./examples/admin-demo

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.