Developers

Plugins, webhooks, two APIs

Extend it without forking it

Skelet is built to be built on, through contracts that are versioned and checked: a Go SDK, two GraphQL schemas, signed webhooks and providers, and registries in both frontends.

zsh~/skelet · main

$ ./skelet cli plugin --helpTier 1 plugin scaffolding and managementAvailable Commands:  add         Add a plugin as a Go module dependency and compile it in  disable     Leave a plugin in the tree but out of the build  enable      Compile a plugin into the build  install     Vendor an out-of-tree plugin into plugins/ and compile it in  list        Show every plugin compiled into this binary and what its manifest declares  migrate     Apply or roll back Tier 1 plugin SQL migrations  new         Generate boilerplate for a new Tier 1 plugin  uninstall   Roll back every migration + delete config rows for one plugin

Plugins

Go, compiled in

apps/backend/sdk

Plugins, compiled into the binary

A plugin is a Go package written against the public SDK. It registers itself from init() and gets a typed handle for everything else - and it never sees core's internals.

  • Payment providers, shipping carriers, promotion rules and price resolvers
  • Routes, events, scheduled jobs, config, permissions and its own GraphQL
  • The SDK surface pinned in sdk/api.json, and diffed against the last release
  • Installed by module path: ./skelet cli plugin add
  • Card gateways are plugins, kept in process for PCI scope
  • Uninstalling sweeps exactly what a plugin registered
plugin.goscreen.go

plugins/acme-fraud/plugin.go

package acmefraud

import (
	"net/http"

	"github.com/skeletg/skelet/apps/backend/sdk"
)

func init() {
	p := sdk.RegisterPlugin(sdk.Manifest{
		Slug:     "acme-fraud",
		Name:     "Acme Fraud",
		Version:  "1.2.0",
		SDKRange: "^0.1.0",
		Consumes: []string{sdk.EventCheckoutValidating},
	})

	// mounted at /api/v1/plugins/acme-fraud/ping
	p.RegisterRoute("GET ping", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("pong"))
	})

	// before checkout commits - an error refuses it
	p.SubscribeSync(sdk.EventCheckoutValidating, 100, screen)

	p.RegisterConfigField("fraud/acme/api_key", sdk.ConfigField{
		Type:        sdk.TypeSecret,
		Description: "Acme API key",
	})
}

Gosdk ^0.1.0gofmt ✓

  1. The manifest: slug, version, the SDK range it was built for and the events it listens to.

  2. Routes mount under /api/v1/plugins/<slug>/, behind the host's kill switch, rate limit, timeout and panic isolation.

  3. A sync subscription runs before the change commits and can refuse it. Async ones run after, with a replay-deduped variant.

  4. Config fields show up in Settings, secrets masked.

Two APIs

Admin and storefront

apps/backend/core/graph/schema/{admin,front}

Two GraphQL APIs, two schemas

Admin and storefront share a process and the services under it, but each endpoint has its own schema, its own generated executable and its own resolver tree.

  • Storefront introspection can never see an admin operation
  • Playgrounds at /playground/admin and /playground/front in development
  • Anonymous storefront queries listed, so they can be cached
  • Every admin resolver guarded - a test fails on one that isn't
order.graphqlscatalog_products.graphqls

graph/schema/admin/order.graphqls

extend type Query {
  # channelId scopes to one channel's orders, omit it for the global list
  orders(channelId: ID): [Order!]!
}

extend type Mutation {
  createDraftOrder(input: CreateDraftOrderInput!): Order!
  placeDraftOrder(id: ID!): Order!
  cancelOrder(id: ID!):    Order!
}

POST /api/admin/graphql

cart.graphqlscatalog.graphqls

graph/schema/front/cart.graphqls

extend type Query {
  myCart:        Cart!
  cart(id: ID!): Cart
  pickupLocations: [PickupLocation!]!
}

extend type Mutation {
  createGuestCart: Cart!
  # Names and prices the line from the catalog by SKU
  addToCart(cartId: ID!, sku: String!, quantity: Float!): Cart!
}

POST /api/front/graphql

  1. Admin mutations exist only in the admin schema - the storefront endpoint has no way to reach them.

  2. myCart answers for whoever is signed in; a guest's cart goes by its id.

  3. Lines are priced from the catalog by SKU, never by what the client sends.

Codegen

Schemas are the contract

bin/codegen

Schemas are the contract

The GraphQL executables, the data layer, the Signals wire contract and both frontends' types are all generated from their schemas - there are no hand-written database calls, and drift fails the build.

  • gqlgen from .graphqls, sqlc from .sql, buf from proto/
  • Both frontends' TypeScript types from the live schemas
  • The permission, webhook and settings tables in the docs, from the code that registers them
  • The remote-provider contract, from the structs core sends

$ ./skelet gen11 steps

  1. 01bufproto/skelet/signals/v1the core-to-Signals wire contract
  2. 02plugin setplugins/plugins.jsonplugins/firstparty and the SDL list gqlgen reads
  3. 03sqlcinternal/db/query/*.sqlthe data layer, in both Go modules
  4. 04gqlgen · admingraph/schema/adminthe admin executable and resolver stubs
  5. 05gqlgen · frontgraph/schema/frontthe storefront executable and resolver stubs
  6. 06plugin graphgqlgen's plugin stubsdelegation into each plugin's own resolvers
  7. 07provider contractcore's structsremoteprovider/contract/*.schema.json
  8. 08docs tablesthe registriespermissions, webhook events, settings
  9. 09security headerslib/httpx/securityheaders.gothe nginx and Caddy copies
  10. 10dashboard typesthe admin schemaapps/dashboard
  11. 11storefront typesthe front schemaapps/channels/*

Webhooks

Sync and async

Developer › Webhooks

Webhooks that can say no

Async webhooks hear about a change after it commits. Sync ones are asked before it does - and their answer can refuse it, or change it.

  • Async deliveries retried at 10s, 60s and 5 minutes, then a dead-letter list to replay from
  • A delivery record for every attempt, its request and its response
  • Wildcards: *, or a namespace like order.*
  • Global, or bound to one channel
  • A circuit breaker for endpoints that keep failing
  • Target URLs checked against an SSRF guard

checkout.validating → risk.acme.example

Request

POST /hooks/skelet HTTP/1.1

Content-Type:
application/json
X-Webhook-Event:
checkout.validating
X-Webhook-Timestamp:
1792920660
X-Webhook-Api-Version:
v1
X-Webhook-Signature-256:
sha256=9f2c6a1e…b7e41a
{
  "apiVersion": "v1",
  "event": "checkout.validating",
  "timestamp": "2026-10-25T09:31:00Z",
  "data": {
    "OrderID": "0192f4c1-7a3e-7b20-9d41-5c8e2a6f1b03",
    "Email": "ava.thompson@example.com",
    "Subtotal": 34500,
    "PaymentProvider": "acme-pay",
    "RiskScore": 0,
    "Tags": null
  }
}

Response

HTTP/1.1 200 OK

Content-Type:
application/json
{
  "reject": false,
  "riskScore": 91,
  "tags": ["manual-review"]
}
  1. 113 events: 92 fire after the change commits, 21 before it - and a sync webhook subscribes only to sync ones.

  2. HMAC-SHA256 over the timestamp, a dot and the raw body. Check the timestamp too: five minutes is the recommended window.

  3. Answer true to refuse: it aborts under either failure policy, and a reason beside it reaches the shopper or the admin.

  4. Short of refusing, some events take changes: discounts add up across webhooks, the risk score takes the highest, tags append.

Providers

Any language, over HTTP

Developer › Remote providers

Answer core's questions in any language

A remote provider is an endpoint you own that core calls mid-request - for tax, shipping rates, a fraud verdict - and waits on. One POST, a signature to check, JSON back. Nothing to install, nothing to rebuild.

  • 16 kinds, from shipping and tax to fraud, search and FX
  • fail_open by default, so core falls back to its own rules; fail_closed when a wrong answer is worse
  • A circuit breaker: five failures open it for 30 seconds
  • An optional answer cache, up to an hour, keyed on the payload alone
  • Timeouts set per provider, five seconds by default
  • ./skelet provider tests your endpoint against the contract, forged signatures included
verify.tsverify.pyverify.php

your-service/verify.ts

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the unparsed body
export function verify(secret: string, headers: Record<string, string>, rawBody: Buffer): boolean {
  const ts = headers["x-provider-timestamp"];
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = createHmac("sha256", secret).update(ts).update(".").update(rawBody).digest("hex");
  const got = (headers["x-provider-signature-256"] ?? "").replace(/^sha256=/, "");
  const a = Buffer.from(got), b = Buffer.from(want);
  return a.length === b.length && timingSafeEqual(a, b);
}

TypeScriptX-Provider-Signature-256

internal/remoteprovider/contract/17 files

  • tax_provider.schema.jsonTax owed on an order
  • shipping_carrier.schema.jsonRate a basket to a destination
  • delivery_points.schema.jsonPO boxes and lockers for a delivery
  • shipping_tracking.schema.jsonCarrier tracking status
  • address_validator.schema.jsonValid or not, and a normalized form
  • promotion_condition.schema.jsonWhether a rule matches the cart
  • promotion_action.schema.jsonThe discount a matched rule applies
  • pricing.schema.jsonOverride a line's unit price
  • currency_rate.schema.jsonFX rates for a base currency
  • fraud_risk.schema.jsonApprove or reject an order
  • returns_policy.schema.jsonApprove or refuse a return
  • inventory_availability.schema.jsonSalable quantity per product
  • recommendation.schema.jsonRelated products for a product page
  • reviews.schema.jsonReview aggregates from elsewhere
  • search.schema.jsonFree-text search, typeahead and /search
  • search.browse.schema.jsonFaceted browse - the second half of search
  • customer_segments.schema.jsonSegments a customer belongs to
  1. Refuse anything older than five minutes - core doesn't cap a timestamp's age, so without this a captured request replays forever.

  2. Sign over the header's timestamp, not the body's, and hash the raw bytes before parsing anything.

  3. Compare in constant time - timingSafeEqual, hmac.compare_digest, hash_equals.

Tokens

Machine access

Developer › Integrations

Tokens for machines, not people

An integration token calls the admin API with the permissions it was given and nothing else - and everything it does is recorded against the integration, not a person.

  • One token per integration, revoked on its own
  • Everything it does named in the audit log
  • Inventory syncs labelled integration:<name>
  • No locale scoping - permissions and channel only

an integration, reading orders

Request

POST /api/admin/graphql HTTP/1.1

X-Api-Token:
sk_••••••••••••
Content-Type:
application/json
query {
  orders(channelId: "9ead3bed-c091-4757-bd3c-ab6bad95f353") {
    number
  }
}

Response

HTTP/1.1 200 OK

{
  "data": {
    "orders": [{ "number": "1048" }, { "number": "1047" }]
  }
}

the same integration, reaching for another token

Request

POST /api/admin/graphql HTTP/1.1

X-Api-Token:
sk_••••••••••••
Content-Type:
application/json
mutation {
  regenerateIntegrationToken(id: "0192f4c1-8b54-7e11-a2c7-1d9f3e40b6a8") {
    __typename
  }
}

Response

HTTP/1.1 200 OK

{
  "errors": [
    {
      "message": "this needs a signed-in admin - integration tokens can't use it",
      "path": ["regenerateIntegrationToken"],
      "extensions": { "code": "ADMIN_ONLY", "class": "FORBIDDEN" }
    }
  ],
  "data": null
}
  1. A token holds a flat set of permission slugs, the ones roles grant, and can do whatever they allow.

  2. Pin it to one channel and its grants satisfy only that channel's checks - global gates refuse it.

  3. What only a person may do - accounts, sessions, other tokens - refuses it with ADMIN_ONLY.

Frontends

Admin and storefront

apps/dashboard/src/plugins · app/plugins

The admin and the storefront, extended in place

A plugin's interface registers into core's pages instead of editing them: nav entries and detail sections in the admin, slots and account links in the storefront.

  • Registered at import time - no core page edited
  • Dashboard plugins picked up by a glob, storefront ones by one line in loaded.ts
  • Admin translations through RegisterAdminTranslations
  • Copied into place today, npm packages once the SDKs exist
  • A storefront is a template you fork: ./skelet new channel
plugin.tsxpages.tsx

apps/dashboard/src/plugins/acme-fraud/plugin.tsx

import { extendGroup } from '@/nav/registry'
import { registerDetailSection } from '@/plugins/detail-section-registry'
import { DetailPageSection } from '@/components/layout/detail-page'
import { FlaggedOrders, RiskPanel } from './pages'

// a page under Orders, in the sidebar
extendGroup('orders', [
  { title: 'Flagged orders', path: '/orders/flagged', element: <FlaggedOrders />, permission: 'orders.view' },
])

// a section at the foot of every order's page
registerDetailSection<{ id: string }>({
  id: 'acme-fraud:risk',
  target: 'order',
  render: ({ entity }) => (
    <DetailPageSection title="Risk">
      <RiskPanel orderId={entity.id} />
    </DetailPageSection>
  ),
})

DashboardReact

index.tsxTrustNote.tsx

app/plugins/acme-fraud/index.tsx

import { ShieldCheckIcon } from 'lucide-react'
import { registerAccountNavItem, registerSlot } from '@/app/plugins/registry'
import { TrustNote } from './TrustNote'

// under the cart's totals, handed what the host already has
registerSlot<{ subtotal: number; currency: string }>({
  id: 'acme-fraud:trust',
  zone: 'cart:summary',
  render: ({ subtotal, currency }) => <TrustNote subtotal={subtotal} currency={currency} />,
})

// a link in the account sidebar
registerAccountNavItem({
  id: 'acme-fraud:verify',
  href: '/account/verify',
  label: 'Verify your identity',
  Icon: ShieldCheckIcon,
})

StorefrontNext.js

  1. Nav entries land inside a core group by its id - or in a group of their own with registerNavNode.

  2. Sections render at the end of the matching detail page, each owning its own state and save.

  3. Storefront slots: cart:summary under the totals, handed the subtotal and currency, and cart:actions beside Proceed to checkout.

  4. Account links share the built-in ones' look, active state and sizing.

Testing

Like core is

apps/backend/sdk/sdktest · internal/e2ekit

Tested the way core is

A fake host for the quick checks, and the real app assembled for the ones that matter - a plugin in the money path writes the same scenarios core does.

  • sdk/sdktest: a fake host for wiring and handler logic, no database
  • End-to-end on disposable databases, dropped and recreated every run
  • Plugins' scenarios run with core's, nothing copied across
  • Storefront browser tests with Playwright, in a container
credit_test.gocredit.go

plugins/acme-credit/credit_test.go

package acmecredit_test

import (
	"testing"

	"github.com/skeletg/skelet/apps/backend/core/internal/e2ekit"
)

// Embedded, so the plugin's own helpers can hang off it
type harness struct{ *e2ekit.Harness }

func newHarness(t *testing.T) *harness { return &harness{e2ekit.New(t)} }

func TestCreditIsReservedInThePlacementTx(t *testing.T) {
	h := newHarness(t)
	f := h.NewStore(t, 20, 2000)
	o, err := h.TryPlace(t, f.Channel, h.NewGuestCart(t, f, 1))
	if err != nil {
		t.Fatal(err)
	}
	h.Settle(t)
	h.Cancel(t, o.ID)
}

Gogo test

zsh~/skelet

$ ./skelet test e2e -run TestCreditIsReservedInThePlacementTx# core's scenarios and ./plugins/... together, against# skelet_e2e and skelet_e2e_signals - dropped and recreated$ ./skelet test storefront vetrina-demo -g addresses# Playwright in Microsoft's image, inside the storefront's# network - the host needs no Node or browser
  1. e2ekit assembles the real app - its services, its outbox - against a database made for the run.

  2. Fixtures for a store, carts and customers, so a test reads like the scenario it checks.

  3. Placing, settling, cancelling and outbox replays, driven the way core drives its own.

The CLI

One front door

./skelet

One front door for all of it

Every dev command goes through ./skelet - the toolchains run in containers, so Docker is all a machine needs, and the gates take about a second.

  • Run bare for the grouped list, skelet help <command> for one
  • ./skelet check: every gate, the same as the pre-commit hook and CI
  • ./skelet doctor for when something feels off
  • Files written as your user, never root

zsh~/skelet

$ ./skeletskelet - the dev commands for this repoCommon  setup      configure this clone (run this first)  up         start the dev stack          [--lean skips OpenSearch, Signals, dev tools]  check      every gate that can fail a commit  doctor     diagnose your environmentContainers  go         go inside the app            [skelet go test ./internal/...]  cli        the ops CLI                  [skelet cli admin create-superuser]Codegen  gen        (all) | sqlc | gqlgen | bufQuality  test       go test + frontend typechecks + unit testsScaffold  new        channel <name> [--from <storefront>] [--tracked]

zsh~/skelet

$ ./skelet cli plugin new --helpCreates plugins/<slug>/ with a starter set of files matchingthe plugin contract.Layout produced (depending on flags):  plugins/<slug>/    plugin.go                          init() - registry calls    provider.go                        (--payment-provider)    migrations.go                      (--with-migrations)    migrations/      0001_initial.up.sql              (--with-migrations)      0001_initial.down.sql            (--with-migrations)The plugin is enabled in plugins/plugins.json and plugins/firstparty isregenerated, so there is nothing to paste by hand - the command finisheswith a checklist of what is left.

Your code

Plugin exception

What you build stays yours

Skelet is GPL-3.0, but the plugin exception covers everything on this page - plugins compiled in, remote providers, storefronts, dashboard extensions, language packs and themes. License them however you like, proprietary included, with no obligation to publish the source.

The line is modifying Skelet's own source. Change Skelet and convey it, and the GPL governs that; write something that extends it, and the exception applies.