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 normalization — path.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.