Skip to content

Plugin API Reference

Plugin Interface

go
type Plugin interface {
    Name() string        // Unique name, e.g. "order-email"
    Version() string     // Semver, e.g. "1.0.0"
    Description() string // Short description
    Init(app *AppContext) error
    Shutdown() error
}

AppContext

go
type AppContext struct {
    DB           *pgxpool.Pool           // PostgreSQL connection pool
    Router       chi.Router              // HTTP router for custom endpoints
    AssetRouter  chi.Router              // Mounted under /plugins/{name}/assets/
    Hooks        *HookRegistry           // Event system
    Config       map[string]interface{}  // Plugin-specific config from config.yaml
    Logger       zerolog.Logger          // Structured logger
    Auth         *AuthHelper             // Authentication middleware and context helpers
    CheckoutFn   CheckoutFn              // Programmatic checkout (see below)
    SecureCookie bool                    // true when running behind HTTPS
}

AuthHelper

The Auth field provides authentication middleware and context helpers so plugins can protect their HTTP endpoints without importing internal Stoa packages:

go
type AuthHelper struct {
    OptionalAuth func(http.Handler) http.Handler           // Extracts auth if present, never blocks
    Required     func(http.Handler) http.Handler           // Requires valid token, returns 401 otherwise
    RequireRole  func(roles ...string) func(http.Handler) http.Handler // Requires one of the given roles
    UserID       func(ctx context.Context) uuid.UUID       // Authenticated user ID from context
    UserType     func(ctx context.Context) string          // "admin", "customer", or "api_key"
}

Valid role strings for RequireRole: "super_admin", "admin", "manager", "customer", "api_client".

Usage

go
func (p *Plugin) Init(app *sdk.AppContext) error {
    app.Router.Route("/api/v1/store/myplugin", func(r chi.Router) {
        r.Use(app.Auth.Required)  // All routes require authentication
        r.Post("/action", p.handleAction(app.Auth))
    })
    return nil
}

func (p *Plugin) handleAction(auth *sdk.AuthHelper) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        userID := auth.UserID(r.Context())
        // Use userID to verify ownership...
    }
}

Use auth for store-facing routes

The plugin router is the root Chi router — it does not inherit the OptionalAuth middleware from Stoa's /api/v1/store/* group. Always apply app.Auth.Required or app.Auth.OptionalAuth explicitly to your plugin's store-facing routes.

CheckoutFn

CheckoutFn lets plugins trigger a full server-side checkout programmatically without making an HTTP call back into the server. All server-side enforcement runs: price validation, tax calculation, stock deduction, and checkout hooks.

go
type CheckoutFn func(ctx context.Context, customerID *uuid.UUID, req json.RawMessage) (json.RawMessage, error)
ParameterTypeDescription
ctxcontext.ContextRequest context
customerID*uuid.UUIDAuthenticated customer ID; nil for guest checkouts
reqjson.RawMessageJSON-encoded checkout request (see fields below)

The req payload follows the same shape as POST /api/v1/store/checkout:

FieldTypeDescription
currencystringISO 4217 currency code, e.g. "EUR"
itemsarrayLine items with variant_id and quantity
shipping_addressobjectShipping address
billing_addressobjectBilling address
shipping_method_idstringUUID of the selected shipping method
payment_method_idstringUUID of the selected payment method
payment_referencestringProvider-side payment reference (e.g. Stripe PaymentIntent ID)

The return value is a JSON-encoded order response: {"data": {"id": "uuid", "guest_token": "...", ...}}.

Use case

Payment plugins that use an asynchronous payment flow (e.g. payment links, hosted pages) receive the payment confirmation via a webhook after the customer has already left the checkout page. At that point there is no active checkout session to finalize. CheckoutFn solves this: the plugin validates the provider webhook, reconstructs the checkout parameters, and creates the order in one call.

Example

go
func (p *Plugin) Init(app *sdk.AppContext) error {
    app.Router.Post("/api/v1/store/myplugin/complete", func(w http.ResponseWriter, r *http.Request) {
        // ... validate payment provider signature ...

        checkoutReq, _ := json.Marshal(map[string]any{
            "currency":           "EUR",
            "items":              items,
            "shipping_address":   addr,
            "billing_address":    addr,
            "payment_method_id":  pmID,
            "shipping_method_id": smID,
            "payment_reference":  paymentIntentID,
        })

        result, err := app.CheckoutFn(r.Context(), customerID, checkoutReq)
        if err != nil {
            http.Error(w, "checkout failed", http.StatusUnprocessableEntity)
            return
        }
        // result contains the created order as JSON
        w.Header().Set("Content-Type", "application/json")
        w.Write(result)
    })
    return nil
}

Guest checkouts

Pass nil as customerID for unauthenticated (guest) checkouts. The returned order response will include a guest_token that the client can use to look up the order later.

Webhook goroutines

If you call CheckoutFn from a goroutine spawned inside a webhook handler, use context.Background() with a timeout rather than the request context — the request context is cancelled when the HTTP response is sent.

SecureCookie

SecureCookie reflects the server's HTTPS configuration (security.csrf.secure in config.yaml). Plugins should propagate this value to any cookie they set so that the Secure flag matches the deployment environment.

go
http.SetCookie(w, &http.Cookie{
    Name:     "my_token",
    Value:    token,
    Secure:   app.SecureCookie, // Follows server HTTPS configuration
    HttpOnly: true,
    SameSite: http.SameSiteLaxMode,
})

Hard-coding Secure: true breaks local development (HTTP). Hard-coding Secure: false is a security regression in production. Always use app.SecureCookie.

HookRegistry

Registering a handler

go
app.Hooks.On(sdk.HookAfterOrderCreate, func(ctx context.Context, event *sdk.HookEvent) error {
    // ...
    return nil
})

Dispatching a hook (from webhook handlers, etc.)

go
app.Hooks.Dispatch(ctx, &sdk.HookEvent{
    Name:   sdk.HookAfterPaymentComplete,
    Entity: transaction,
})

HookEvent

go
type HookEvent struct {
    Name     string                 // Hook name constant
    Entity   interface{}            // The affected entity (type depends on hook)
    Changes  map[string]interface{} // Changed fields (before-update hooks)
    Metadata map[string]interface{} // Arbitrary extra data
}

Cast Entity to the concrete type for the hook you are handling:

go
o := event.Entity.(*order.Order)
p := event.Entity.(*product.Product)
c := event.Entity.(*customer.Customer)

Hook Constants

All constants are in pkg/sdk/hooks.go.

Products

ConstantValueEntity typeCan cancel
HookBeforeProductCreateproduct.before_create*product.ProductYes
HookAfterProductCreateproduct.after_create*product.ProductNo
HookBeforeProductUpdateproduct.before_update*product.ProductYes
HookAfterProductUpdateproduct.after_update*product.ProductNo
HookBeforeProductDeleteproduct.before_delete*product.ProductYes
HookAfterProductDeleteproduct.after_delete*product.ProductNo

Categories

ConstantValueCan cancel
HookBeforeCategoryCreatecategory.before_createYes
HookAfterCategoryCreatecategory.after_createNo
HookBeforeCategoryUpdatecategory.before_updateYes
HookAfterCategoryUpdatecategory.after_updateNo
HookBeforeCategoryDeletecategory.before_deleteYes
HookAfterCategoryDeletecategory.after_deleteNo

Orders

ConstantValueEntity typeCan cancel
HookBeforeOrderCreateorder.before_create*order.OrderYes
HookAfterOrderCreateorder.after_create*order.OrderNo
HookBeforeOrderUpdateorder.before_update*order.OrderYes
HookAfterOrderUpdateorder.after_update*order.OrderNo

Cart

ConstantValueCan cancel
HookBeforeCartAddcart.before_add_itemYes
HookAfterCartAddcart.after_add_itemNo
HookBeforeCartUpdatecart.before_update_itemYes
HookAfterCartUpdatecart.after_update_itemNo
HookBeforeCartRemovecart.before_remove_itemYes
HookAfterCartRemovecart.after_remove_itemNo

Customers

ConstantValueCan cancel
HookBeforeCustomerCreatecustomer.before_createYes
HookAfterCustomerCreatecustomer.after_createNo
HookBeforeCustomerUpdatecustomer.before_updateYes
HookAfterCustomerUpdatecustomer.after_updateNo

Checkout & Payment

ConstantValueCan cancel
HookBeforeCheckoutcheckout.beforeYes
HookAfterCheckoutcheckout.afterNo
HookAfterPaymentCompletepayment.after_completeNo
HookAfterPaymentFailedpayment.after_failedNo

BaseEntity

Shared fields available on all entities via sdk.BaseEntity:

go
type BaseEntity struct {
    ID           uuid.UUID
    CreatedAt    time.Time
    UpdatedAt    time.Time
    CustomFields JSONB     // map[string]interface{}
    Metadata     JSONB
}

MCPStorePlugin

Plugins can register additional tools on the Store MCP server by implementing the optional MCPStorePlugin interface:

go
type MCPStorePlugin interface {
    Plugin
    RegisterStoreMCPTools(server any, client StoreAPIClient)
}

RegisterStoreMCPTools is called once at Store MCP server startup, after the built-in core tools are registered. The server parameter satisfies the AddTool(mcp.Tool, server.ToolHandlerFunc) method — use an interface assertion (see example below). The client parameter implements StoreAPIClient.

Tool name convention

Tool names must use the prefix store_{pluginName}_. The MCP server enforces this at registration time — tools with incorrect prefixes are rejected and the plugin is skipped.

Plugin nameValid tool name
stripestore_stripe_create_payment_intent
paypalstore_paypal_checkout

StoreAPIClient

A store-scoped HTTP client interface for making calls to the Stoa store API:

go
type StoreAPIClient interface {
    Get(path string) ([]byte, error)
    Post(path string, body interface{}) ([]byte, error)
}

The client is restricted to /api/v1/store/* paths. Path validation rejects any attempt to reach admin or other endpoints. The validation pipeline applied to every path argument is:

  1. URL decoding — the raw path is decoded with url.PathUnescape so that percent-encoded traversal sequences such as %2e%2e (..) or %2f (/) are expanded before any check is made.
  2. Path normalizationpath.Clean resolves ., .., and double slashes on the decoded path.
  3. Prefix enforcement — the cleaned path must start with /api/v1/store/; anything else returns access denied.
  4. Defense-in-depth — a final .. substring check on the cleaned path guards against any remaining traversal attempt.

This prevents double-encoding bypass attacks where a raw path such as /api/v1/store/%2e%2e/admin/users would pass a naive prefix check but resolve to /api/v1/admin/users after the HTTP server decodes it.

Plugin isolation

The MCP server applies two isolation layers to prevent plugins from interfering with built-in tools or other plugins:

  1. Tool name prefix enforcement: plugins can only register tools named store_{pluginName}_*.
  2. Store-scoped API client: the StoreAPIClient only allows requests to /api/v1/store/* paths.
  3. Panic recovery: if a plugin panics during registration, the error is logged and the plugin is skipped — the MCP server continues to start.

Example

go
package myplugin

import (
    "context"

    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
    "github.com/stoa-hq/stoa/pkg/sdk"
)

// toolAdder is satisfied by both *server.MCPServer and *mcp.ScopedMCPServer.
type toolAdder interface {
    AddTool(mcp.Tool, server.ToolHandlerFunc)
}

// RegisterStoreMCPTools implements sdk.MCPStorePlugin.
func (p *Plugin) RegisterStoreMCPTools(srv any, client sdk.StoreAPIClient) {
    s := srv.(toolAdder)

    tool := mcp.NewTool("store_myplugin_action",
        mcp.WithDescription("Does something useful for agents"),
        mcp.WithString("order_id", mcp.Required()),
    )
    s.AddTool(tool, func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        data, err := client.Post("/api/v1/store/myplugin/action", map[string]interface{}{
            "order_id": req.GetString("order_id", ""),
        })
        if err != nil {
            // Return a sanitized error — do not leak internal details to agents.
            return mcp.NewToolResultError("action failed"), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    })
}

Use interface assertion, not concrete type

Use srv.(toolAdder) instead of srv.(*server.MCPServer). The MCP server passes a scoped wrapper that enforces tool name prefixes. A concrete type assertion would panic.

Plugin installer keeps both binaries in sync

stoa plugin install writes plugins_generated.go into both cmd/stoa/ and cmd/stoa-store-mcp/, so your plugin's init() runs in the Store MCP server process as well. Both files are gitignored.

Custom Endpoints

Plugins can register routes on the Chi router:

go
func (p *Plugin) Init(app *sdk.AppContext) error {
    app.Router.Route("/api/v1/my-plugin", func(r chi.Router) {
        r.Get("/", p.handleList)
        r.Post("/", p.handleCreate)
        r.Delete("/{id}", p.handleDelete)
    })
    return nil
}

The router is the same instance used by Stoa core, so global middleware (logging, rate limiting, CSRF) applies to all plugin routes.

CSRF

Plugin endpoints follow the same CSRF rules as the rest of Stoa:

Path patternCSRF required
/plugins/{name}/webhooks/*No — exempt (authenticates via provider signature)
/plugins/{name}/admin/*Yes, unless Authorization header is present
/plugins/{name}/store/*Yes, unless Authorization header is present
/api/v1/… (custom API paths)Yes, unless Authorization header is present

State-changing requests (POST, PUT, PATCH, DELETE) from cookie-authenticated clients must include the X-CSRF-Token header. See CSRF Protection for details.

Released under the APACHE 2.0 License.