Skip to content

Building Extensions

Extensions add capabilities — payment gateways, tax providers, shipping carriers — without modifying the core engine. They are installed as Python packages and discovered automatically at startup via entry points. Extensions live outside the core repository: the first-party set ships from the vectiscommerce/enterprise-extensions repo, third-party extensions from their own repos, and in development the core discovers a checkout via the $VECTIS_DEV_EXTENSIONS dev-link (see Discovery). The in-tree backend/vectis/extensions/ directory contains only the namespace __init__.py — the core is extension-agnostic in-tree.

Looking for per-extension usage docs?

This page is the extension authoring guide. Setup steps, configuration references, and operational notes for every first-party extension live in the Extensions section — one page per extension, served at https://docs.vectisb2b.com/extensions/<id>/, which is where each manifest's docs_url points.

True Extension Platform (shipped 2026-07-02)

The extension contract was hardened in the True Extension Platform wave. The load-bearing rules, each covered in its own section below:

  1. Canonical ids — the extension id is the directory name minus ext_; entry-point keys use the bare id.
  2. Module-level manifestmanifest = ExtensionManifest(...) must be a module-level constant in extension.py.
  3. Register in on_activate — all registrations are ledgered to the activating extension and auto-unregistered on deactivate/uninstall.
  4. Import vectis_sdk.* only — new extensions may not import vectis.core / vectis.modules (frozen shrink-only allowlist).
  5. Own your migrations — extension-owned tables use the ext_<name>_ prefix and ship a per-extension Alembic chain as package data.
  6. extensions.lock — adding/removing/version-bumping an extension requires make lock-extensions.
  7. Sync v2 — extension admin and storefront UI is mirrored by make sync-extensions; production builds go through make prod-build.

Contributing admin UI?

Adding sidebar entries, dashboard widgets, entity tabs, list columns, or settings panels is the admin contribution bus's job — see Admin Contributions for the contribution bus, the 14 descriptor kinds, the extensionDataset / extensionAction wire, and the permission model.

Extensions catalog in admin

Filter by strategy category to see what's wired up for a domain — tax, for example:

Extensions filtered by tax strategy

Extension Structure

ext_authorize_net/
├── __init__.py
├── pyproject.toml  # the extension's own installable identity + entry point
├── extension.py    # module-level `manifest` + extension class (lifecycle hooks)
├── strategy.py     # Strategy implementations
└── resolvers.py    # GraphQL resolvers (saved cards, admin transactions)

Extensions that own database tables also ship a migrations/versions/ package-data directory (see Per-Extension Migrations), and extensions with admin/storefront UI ship admin/, admin_pages/, storefront/, and/or storefront_pages/ directories (see Extension UI Sync v2).

Building an extension as its own wheel?

An extension can carry its own pyproject.toml and be built + installed as a standalone wheel that activates via its entry point — with no core edit. The include globs must match the surfaces above (an admin-carrying ext needs .svelte/.ts globs, not just *.py). See Extension Packaging & Extraction.

ext_elastic_email follows the same layout (__init__.py, extension.py, strategy.py): it declares an ExtensionManifest with config_schema for the Elastic Email API and provides ElasticEmailStrategy, an EmailDeliveryStrategy implementation (the ABC is re-exported from vectis_sdk.strategies). Wire it where notification delivery is resolved (e.g. admin email settings), using channel extension config for api_key, default_from_email, default_from_name, and api_url.

Canonical Extension Ids

The canonical extension id is the package directory name with the ext_ prefix stripped: ext_shopifyshopify, ext_authorize_netauthorize_net. The id must match ^[a-z][a-z0-9_]{1,40}$ — lowercase, starts with a letter, then letters/digits/underscores.

Three things must agree on this id:

  1. The directory name (ext_<id>/).
  2. The entry-point key in the extension's own pyproject.toml — the bare id, not the ext_-prefixed directory name:

    [project.entry-points."vectis.extensions"]
    shopify = "vectis.extensions.ext_shopify.extension:extension"
    authorize_net = "vectis.extensions.ext_authorize_net.extension:extension"
    
  3. The extension class's name attribute and the manifest's name field.

Legacy variants (ext-excise-engine-style hyphenated names, ext_-prefixed declared names) are coerced to the canonical id with a warning at registration; a declared name that collides with an already-registered canonical id is refused — the duplicate is skipped and logged. Don't rely on the coercion for new extensions: use the canonical id everywhere.

How Extensions Contribute

Vectis enforces a strict separation between core and extensions, guarded in both directions by make check:

  • Core modules must never import ext_* — AST guard at backend/scripts/check_no_core_extension_imports.py.
  • Extensions must import vectis_sdk.* onlybackend/scripts/check_extension_imports.py freezes legacy vectis.core / vectis.modules imports to a shrink-only allowlist (see The vectis_sdk Import Surface).

Extensions contribute through three distinct mechanisms — pick the right one for the contribution:

1. Module-level ExtensionManifest — metadata + admin UI

Every extension must expose a module-level constant in extension.py:

manifest = ExtensionManifest(...)

The registry reads it via scan_manifests() before activation, so manifest metadata (including depends_on) is available even for extensions that are installed but not yet active. Calling registry.register_manifest(...) inside on_activate is still supported as an idempotent re-registration, but it is no longer where the manifest lives. There is no separate catalog module — the manifest is the only metadata source; catalog entries for known-but-not-installed extensions live in backend/vectis/core/extension_index.json.

The manifest fields below are declarative — the core engine reads them later (over GraphQL, in the admin layout, in the schedule runner) without ever importing your extension by name.

Manifest field What it contributes
category One of payment, shipping, tax, compliance, communication, authentication, marketing (others used in practice — fulfillment, ai, support — pass through but aren't in the validated tuple yet)
strategies Names of strategies this extension registers via strategy_resolver (used by admin filters)
depends_on / conflicts_with Other extension names that must (or must not) be active. Missing deps → activation skipped with a warning
config_schema JSON schema for the generic Configure modal in the admin
permissions Permission codenames introduced by the extension — upserted into the permissions table at every api boot (see Boot-Time Permission Sync)
admin_contributions Unified admin contribution bus — {slot, kind, payload, order?, permission?} entries served over the adminContributions root query; supersedes the individual admin_nav_items / admin_quick_actions / admin_page_tabs fields
storefront_contributions Customer-facing contribution bus — {slot, kind, payload, order?, when?} entries served over the storefrontContributions root query
display_name, icon_light, icon_dark Admin presentation
install_hint Short text rendered on the not-installed catalog card telling the operator how to install
docs_url Absolute URL of the extension's documentation page. First-party extensions point at this site: https://docs.vectisb2b.com/extensions/<id-with-hyphens>/ (a relative /docs/... value would resolve against the admin's own domain)
admin_nav_items Sidebar entries — {href, label, section, icon_name, permission?}. section matches a sidebar group; icon_name is mapped explicitly in +layout.svelte
admin_quick_actions Cmd-K palette entries — {href, label, icon_name, permission?}
admin_page_tabs Tabs grafted onto an existing admin page — {page, label, href, icon_name?, permission?}
admin_settings_pages Deep-link target for the Configure button on /settings/extensions — the first entry's path is the extension's settings link
admin_tabs Tabs on entity-detail pages
admin_pages, admin_widgets, admin_list_columns, admin_action_bar_items, admin_bulk_actions, admin_form_fields, admin_filters Other admin slot contributions
custom_field_definitions Custom fields per entity type. On registration these flow through custom_field_registry automatically

EXTENSION_CATEGORIES lives in vectis/core/extension.py and is the validated tuple — passing a category not in the tuple is currently silently accepted (no validator), but new categories should be added there before use.

2. Methods on the extension class — functional contributions

Some contributions need actual Python objects (workflow classes, activity callables, GraphQL resolver classes, SQLAlchemy models). For these, declare a method on your extension class that returns the contribution. The registry iterates these lazily from the loaded extensions.

Method Returns Consumed by
def workflows(self) -> list[type] Temporal @workflow.defn classes worker.py at startup via registry.iter_workflows()
def activities(self) -> list Temporal @activity.defn callables worker.py via registry.iter_activities()
def schedules(self) -> list[dict] {"id", "workflow", "activity", "interval", "note"} dicts schedules.py via registry.iter_schedules()
def search_indexes(self) -> list[dict] {"key", "label", "search_fn"} where search_fn is async (query, limit) -> list[dict] Cmd-K via registry.iter_search_indexes()
def models(self) -> list[str] Module paths like "vectis.extensions.ext_foo.models" _import_all_models in app.py / worker.py via ExtensionRegistry.scan_model_modules()
def graphql_queries(self) -> list[type] Strawberry Query mixin classes core/graphql.py at import time via ExtensionRegistry.scan_graphql_queries()
def graphql_mutations(self) -> list[type] Strawberry Mutation mixin classes core/graphql.py via ExtensionRegistry.scan_graphql_mutations()

Imports inside these methods are lazy on purpose — loading extension.py itself must not drag the Temporal SDK into the GraphQL API container. Always import workflow / activity / model modules inside the method body, not at module top.

scan_* are sync classmethods used before the async registry has finished discovery (model loading, GraphQL schema build); iter_* are instance methods used after discover_and_load() has activated everything.

3. Global singletons — strategies and event handlers, ledgered per extension

A few contributions go through a global singleton rather than the registry or manifest:

  • Strategiesstrategy_resolver.register(StrategyABC, impl, name=...) (re-exported from vectis_sdk.strategies).
  • Event handlersevent_bus.subscribe(event_type, handler) for in-process events (these also fire in the standalone Redpanda consumer — see Events and Messaging).
  • Other registration surfaces — payment webhook handlers, email/SMS providers, storefront search engines, import transforms, custom fields, label predicates.

The registration rule: ALL of these registrations belong in on_activate (or hot_reload). A registration ledger attributes every registration made inside those hooks to the activating extension via a contextvar — you no longer need to pass extension_name=self.name (though the explicit kwarg still works). Ledgered registrations are automatically unregistered when the extension is deactivated or uninstalled.

Ownership drives the duplicate semantics:

Situation Result
Two different extensions register the same (strategy type, name) key DuplicateRegistrationErrorhard activation failure; the existing registration is untouched
The same extension re-registers a key (e.g. from hot_reload) Clean overwrite
Core (or import-time / unattributed) registrations Keep the old last-registered-wins semantics and are never auto-unregistered

A registration made at import time or from any hook other than on_activate / hot_reload is attributed to CORE — it won't be cleaned up when your extension deactivates. Don't do that.

A typical extension.py declares the manifest at module level, then registers strategies in on_activate:

from vectis_sdk.extension import ExtensionManifest, ExtensionRegistry, VectisExtension
from vectis_sdk.strategies import PaymentProcessStrategy, strategy_resolver

manifest = ExtensionManifest(
    name="my_gateway",
    version="0.1.0",
    display_name="My Gateway",
    description="My payment gateway",
    category="payment",
    strategies=["my_gateway"],
    permissions=["ext.my_gateway.manage"],
    config_schema={
        "api_key": {"type": "string", "required": True, "secret": True},
    },
)


class MyExtension(VectisExtension):
    name = "my_gateway"
    version = "0.1.0"
    description = "My payment gateway"

    async def on_activate(self, registry: ExtensionRegistry) -> None:
        strategy_resolver.register(
            PaymentProcessStrategy,
            MyGatewayStrategy(),
            name="my_gateway",
        )

Extension UI Sync v2

Beyond admin slots, an extension can ship full SvelteKit routes and components — for the admin and the storefront — as package data. The sync mirrors four directories ("legs"):

Extension directory Mirrored to Purpose
admin/ admin/src/lib/extensions/<id>/ Slot components consumed by admin pages
admin_pages/ admin/src/routes/extensions/<id>/ Full admin routes
storefront/ storefront/src/lib/extensions/<id>/ Slot components consumed by storefront pages
storefront_pages/ storefront/src/routes/ext/<id>/ Full storefront routes
ext_my_gateway/
└── admin_pages/
    ├── +page.svelte          → /extensions/my_gateway
    ├── +page.server.ts
    └── settings/
        ├── +page.svelte      → /extensions/my_gateway/settings
        └── +page.server.ts

Run make sync-extensions (make sync-admin-extensions is kept as an alias). Under the hood it's a container-emit → host-apply pipeline in backend/scripts/sync_admin_extensions.py:

  • --emit — runs where vectis is importable (the api container); discovers extensions the same way the runtime does and emits the UI payload.
  • --apply — stdlib-only; runs on the host and writes the admin/storefront trees.
  • --check — read-only drift report; wired into make check as check-extensions-sync.

Each synced per-extension directory carries a .vectis-synced marker file, so orphan cleanup (an extension removed or a leg deleted) is surgical — the sync never touches directories it didn't create.

For production, use make prod-build instead of a bare docker compose -f docker-compose.prod.yml build: it builds the backend image, runs the sync against it, then builds admin + storefront with an EXTENSIONS_SYNC_HASH build arg. A bare compose build fails loudly when the sync is absent or stale.

Deep-link from the admin's Configure button by setting admin_settings_pages on the manifest — the first entry's path is the settings link.

Lifecycle & Install State

ExtensionRegistry._activate consults InstallStateService (defined in vectis/core/extension_lifecycle.py) before invoking on_activate. State is persisted in the extension_install_state table:

Column Purpose
extension_name Extension's name attribute
version Version at install time
installed_at Timestamp of the on_install call
uninstalled_at Timestamp of mark_uninstalled (null while installed)

InstallStateService.previous_version(name) returns the latest LIVE installed version (rows with uninstalled_at set are ignored). Behaviour:

  • First boot with the extension's entry point present → previous_version returns Noneon_install fires → mark_installed writes a row → on_activate fires.
  • Subsequent boots same version → only on_activate fires.
  • Version bumpon_upgrade(registry, prev_version) fires before on_activate; mark_installed updates the row.
  • Explicit uninstall via registry.uninstall(name)pre_uninstall_check() collects blockers (anything truthy blocks unless force=True); on pass, on_uninstall fires, mark_uninstalled stamps the row, registry drops the in-memory entry.

The lifecycle hooks (each optional except on_activate). Subclass VectisExtension (from vectis_sdk.extension) to get typed no-op defaults for all of them, or duck-type ExtensionProtocol:

Hook When What to do
async on_install(self, registry) First-ever activation of (name, version) per the ledger One-time setup: seed default settings, create webhook subscriptions, reserve channel-extension entries
async on_activate(self, registry) Every process boot Register strategies, attach event handlers, register custom fields — everything is ledgered to your extension here
async on_upgrade(self, registry, prev_version) Activation when persisted version differs from current Update settings shape (schema changes live in your per-extension Alembic chain, which runs automatically)
async on_deactivate(self, registry) Deactivation via admin UI Extra cleanup only — ledgered registrations are already unregistered by the registry
async on_uninstall(self, registry) Uninstall after pre_uninstall_check() passes Drop extension-owned tables, clear cached data, revoke API keys
async hot_reload(self, config) Admin saved new channel config Re-register anything derived from config (the ledger context is active, so re-registration is a clean overwrite)
async pre_uninstall_check(self) Before on_uninstall Return a list of blockers (or an UninstallBlockers object) — block uninstall when extension owns in-flight state (e.g., unsettled refunds)

Only the api process runs the lifecycle (discover_and_load(run_lifecycle=True)) — including per-extension migrations at boot. The worker, schedule runner, and event consumer boot with run_lifecycle=False and fail fast when an extension's recorded schema revision doesn't match its packaged migration head.

Worker Strategy-Registration Gate

On boot the Temporal worker iterates every payment method enabled in the DB and verifies a registered strategy exists for it. In strict mode (default) the worker exits non-zero if any enabled method has no registered strategy — catches the cluster-state bug where you remove an extension package but forget to disable its payment methods.

Override with the env var VECTIS_WORKER_STRICT_STRATEGY_CHECK=false (also accepts 0, no, empty string — case insensitive). Non-strict mode logs the issues as warnings and proceeds. The check lives in vectis/core/extension_readiness.py.

End-to-End Walkthrough: a New Payment Gateway

Here's the full sequence for landing a brand-new payment-gateway extension ext_my_gateway. Everything lives in the extension's own ext_my_gateway/ package directory — in its own repo (or a directory alongside your other extensions), dev-linked into the core via $VECTIS_DEV_EXTENSIONS:

1. File layout

ext_my_gateway/
├── __init__.py
├── pyproject.toml       # installable identity + entry point (see step 3)
├── extension.py         # module-level manifest + extension class
├── strategy.py          # PaymentProcessStrategy implementation
├── resolvers.py         # optional: extension-owned GraphQL
├── models.py            # optional: extension-owned tables (ext_my_gateway_* prefix)
├── migrations/          # required if you own tables: per-extension Alembic chain
│   └── versions/
├── workflows.py         # optional: Temporal workflows
├── activities.py        # optional: Temporal activities
└── admin_pages/         # optional: SvelteKit routes
    └── +page.svelte

2. extension.py — the entry point. Import from vectis_sdk only; declare the manifest at module level:

from __future__ import annotations

import logging

from vectis_sdk.extension import ExtensionManifest, ExtensionRegistry, VectisExtension
from vectis_sdk.strategies import PaymentProcessStrategy, strategy_resolver

logger = logging.getLogger(__name__)

manifest = ExtensionManifest(
    name="my_gateway",
    version="0.1.0",
    description="MyGateway payment gateway with tokenized profiles",
    display_name="My Gateway",
    category="payment",
    install_hint="Add the my_gateway entry point to pyproject.toml and rebuild.",
    docs_url="https://docs.vectisb2b.com/extensions/my-gateway/",
    strategies=["my_gateway"],
    permissions=["ext.my_gateway.manage"],
    config_schema={
        "api_key": {"type": "string", "required": True, "secret": True},
        "merchant_id": {"type": "string", "required": True},
        "sandbox": {"type": "boolean", "default": True},
    },
    admin_nav_items=[{
        "href": "/extensions/my_gateway",
        "label": "My Gateway",
        "section": "Payments",
        "icon_name": "CreditCard",
        "permission": "ext.my_gateway.manage",
    }],
    admin_settings_pages=[{
        "path": "/extensions/my_gateway/settings",
        "label": "Settings",
    }],
)


class MyGatewayExtension(VectisExtension):
    name = "my_gateway"
    version = "0.1.0"
    description = "MyGateway payment gateway with tokenized profiles"

    # Extension-owned tables. Lazy so loading the
    # extension module doesn't trigger model imports outside the
    # SQLAlchemy bootstrap path.
    def models(self) -> list[str]:
        return ["vectis.extensions.ext_my_gateway.models"]

    # GraphQL contributions.
    def graphql_queries(self) -> list[type]:
        from vectis.extensions.ext_my_gateway.resolvers import MyGatewayQuery
        return [MyGatewayQuery]

    def graphql_mutations(self) -> list[type]:
        from vectis.extensions.ext_my_gateway.resolvers import MyGatewayMutation
        return [MyGatewayMutation]

    # Temporal contributions.
    def workflows(self) -> list[type]:
        from vectis.extensions.ext_my_gateway.workflows import MyGatewayRefundWorkflow
        return [MyGatewayRefundWorkflow]

    def activities(self) -> list:
        from vectis.extensions.ext_my_gateway.activities import my_gateway_refund
        return [my_gateway_refund]

    async def on_install(self, registry: ExtensionRegistry) -> None:
        # One-time: seed default settings rows, register webhook subscription
        ...

    async def on_activate(self, registry: ExtensionRegistry) -> None:
        strategy_resolver.register(
            PaymentProcessStrategy,
            MyGatewayStrategy(),
            name="my_gateway",
        )
        logger.info("MyGateway extension activated")


extension = MyGatewayExtension

3. Entry point — the extension's own pyproject.toml, keyed by the bare canonical id:

[project.entry-points."vectis.extensions"]
my_gateway = "vectis.extensions.ext_my_gateway.extension:extension"

The entry point is what discovery resolves when the extension is pip installed as a wheel. During development you don't need to install anything: point $VECTIS_DEV_EXTENSIONS at the directory containing ext_my_gateway/ (the dev compose already dev-links the sibling enterprise-extensions checkout) and restart the API + worker.

4. Lock the install setmake check fails until the new extension is recorded:

make lock-extensions   # regenerates backend/extensions.lock — commit it

5. Migrations — if you own tables, name them ext_my_gateway_* and ship a hand-written revision in migrations/versions/ (see Per-Extension Migrations). Do not run make makemigration for extension tables — core autogenerate excludes them, and a make check guard rejects core revisions that touch them. The chain applies automatically at the next api boot.

6. UI sync — if you ship admin_pages/ (or any other UI leg):

make sync-extensions

The script mirrors ext_my_gateway/admin_pages/ into admin/src/routes/extensions/my_gateway/. Restart the admin dev server.

7. Enable per-channel

session.add(ChannelExtension(
    channel_id=1,
    extension_name="my_gateway",
    enabled=True,
    config={"api_key": "...", "merchant_id": "...", "sandbox": True},
))

Or use the admin Settings → Extensions page once the manifest is registered.

8. Verify — run make check (import ratchet, lock gate, sync gate, migration-ownership guard, tests), visit /settings/extensions in admin (extension should appear), check the worker startup log for "X core + 1 extension workflows", and trigger a test transaction.

Implementing ExtensionProtocol

The manifest is a module-level constant; the on_activate hook registers strategies at startup:

from vectis_sdk.extension import ExtensionManifest, ExtensionRegistry, VectisExtension
from vectis_sdk.strategies import PaymentProcessStrategy, strategy_resolver

manifest = ExtensionManifest(
    name="authorize_net", version="1.0.0",
    description="Authorize.Net payment gateway",
    category="payment",
    strategies=["authorize_net"],
    permissions=["payment.configure_authorize_net"],
    config_schema={
        "api_login_id": {"type": "string", "required": True},
        "transaction_key": {"type": "string", "required": True, "secret": True},
        "sandbox": {"type": "boolean", "default": True},
        "capture_mode": {"type": "string", "enum": ["authorize", "capture"], "default": "authorize"},
        "allowed_card_brands": {"type": "array", "default": ["visa", "mastercard", "amex", "discover"]},
        "supported_currencies": {"type": "array", "default": ["USD"]},
        "require_cvv": {"type": "boolean", "default": True},
        "require_billing_address": {"type": "boolean", "default": True},
    },
)


class AuthorizeNetExtension(VectisExtension):
    name = "authorize_net"
    version = "1.0.0"
    description = "Authorize.Net payment gateway"

    async def on_activate(self, registry: ExtensionRegistry) -> None:
        from vectis.extensions.ext_authorize_net.strategy import AuthorizeNetPayment
        strategy_resolver.register(
            PaymentProcessStrategy, AuthorizeNetPayment(),
            name="authorize_net",
        )

Discovery (Entry Points + Dev-Link)

The registry discovers extensions from three sources, in order:

1. Entry point in the extension's own pyproject.toml — the canonical mechanism for an installed extension:

[project.entry-points."vectis.extensions"]
authorize_net = "vectis.extensions.ext_authorize_net.extension:extension"

Conventions to follow exactly:

  • Key is the bare canonical id — the directory name with the ext_ prefix stripped (e.g. authorize_net, not ext_authorize_net). It must equal the extension's name attribute.
  • Value points at the module attribute extension — the class itself, exported with extension = MyExtension at the bottom of extension.py. The registry calls it (which is allowed for classes — ext() returns an instance) and checks isinstance(ext, ExtensionProtocol).
  • When the extension is pip installed (as a built wheel), discover_and_load resolves this entry point via importlib.metadata — see Extension Packaging & Extraction.

2. Package scan — the registry scans the vectis.extensions namespace package for ext_* directories with an extension.py and loads any that aren't already registered. In the core repo that directory holds only the namespace __init__.py, so this source finds nothing unless something has been placed there; it remains the fallback for stale entry-point metadata.

3. $VECTIS_DEV_EXTENSIONS dev-link — the development mechanism for out-of-tree checkouts. The env var is an os.pathsep-separated list where each entry is either an ext_<name>/ directory or a parent directory holding ext_<name>/ children — a parent is expanded to its ext_* children, so a single entry covers a whole extensions checkout. Each directory's parent is appended to vectis.extensions.__path__, so the extension keeps its vectis.extensions.ext_<name> import path with no install step. Dev-link directories are scanned last: an entry-point or in-tree extension with the same canonical id always wins the dedup. Malformed entries are logged and skipped, never fatal; production images never set the variable.

The dev compose wires this up out of the box — docker-compose.yml mounts the sibling enterprise-extensions checkout read-only and sets:

environment:
  - VECTIS_DEV_EXTENSIONS=/enterprise-extensions
volumes:
  - ../enterprise-extensions:/enterprise-extensions:ro

Whichever source loads a new extension, you still need make lock-extensions for make check to pass.

The name attribute on the extension class is not free-form: it must be the canonical id (^[a-z][a-z0-9_]{1,40}$, directory name minus ext_). Legacy variants like "ext-excise-engine" are coerced to the canonical id ("excise_engine") with a warning; a name colliding with an already-registered canonical id is refused. See Canonical Extension Ids.

The vectis_sdk Import Surface

New extensions import vectis_sdk.* only — never vectis.core.* or vectis.modules.*. The SDK is a frozen, semver-stable re-export layer, and it is now the sole import boundary: the extension→core allowlist is {}. For the full module list with Tier-1 (frozen) vs Tier-2 (read-stable) split and the check-sdk-frozen gate, see The Extension SDK & Testkit.

The commonly-used modules:

Module What it exposes
vectis_sdk.extension VectisExtension (typed lifecycle base class), ExtensionProtocol, ExtensionManifest, ExtensionRegistry, ChannelExtension, EXTENSION_CATEGORIES, DuplicateRegistrationError, the registration ledger
vectis_sdk.strategies Every strategy ABC + result dataclass (PaymentProcessStrategy, TaxCalculationStrategy, ShippingCalculatorStrategy, FraudScoringStrategy, EmailDeliveryStrategy, import-provider types, …), strategy_resolver, register_payment_webhook_handler, register_email_strategy, register_sms_strategy, register_storefront_search_engine
vectis_sdk.db get_session (async context manager), get_session_factory, Base, TimestampMixin, money_column — for extension-owned tables
vectis_sdk.models Core ORM models extensions commonly reference (Order, Cart, Account, PaymentMethod, …)
vectis_sdk.temporal get_temporal_client / close_temporal_client
vectis_sdk.events event_bus, Event, publish_event, validate_event
vectis_sdk.config get_settings, secret encrypt/decrypt helpers, and get_channel_config() — the typed, decrypted accessor for a channel's extension config
vectis_sdk.http Shared HTTP client helpers
vectis_sdk.migrations The per-extension Alembic runner surface (see below)
vectis_sdk.payment account_scope — saved-card account-restriction resolution
vectis_sdk.fulfillment Fulfillment helpers for carrier extensions
vectis_sdk.testing The extension contract helpers (assert_imports_clean, assert_manifest_valid, assert_lifecycle_clean, assert_strategy_satisfies, assert_webhook_vectors, assert_schema_composes) — see The Extension SDK & Testkit

The import ratchet: backend/scripts/check_extension_imports.py (part of make check as check-extension-imports) freezes every extension→core import into backend/scripts/extension_import_allowlist.json, a shrink-only allowlist snapshot. Every first-party extension was codemodded onto vectis_sdk.* and the allowlist driven to {} — so today any vectis.core / vectis.modules import inside an ext_* package (including dev-linked ones) fails make check. The ratchet can only tighten, and it is already fully closed. vectis ext lint [name ...] [--all] runs the same import gate (plus manifest and migration-ownership checks) against any discovered extension — including dev-linked ones — without a full make check.

The freeze gate: a companion gate check-sdk-frozen (scripts/check_sdk_frozen.py) diffs the live SDK surface against the committed snapshot backend/vectis_sdk/api_surface.json and fails on any removal or signature-narrowing — the enforcement arm of the SDK semver policy. See The Extension SDK & Testkit.

Per-Extension Migrations

Extensions that own database tables ship their own Alembic chain as package data:

ext_my_gateway/
└── migrations/
    └── versions/
        └── mg0001_initial_tables.py   # hand-written revision
  • Table naming is enforced: every table you create must use the ext_<name>_ prefix (ext_my_gateway_profiles). The migration runner rejects create_table outside your namespace, and a make check guard (check-ext-migration-ownership) rejects any new core revision touching extension-owned tables. Two legacy adopters are grandfathered by exact table name (ee_* for excise_engine, chat_logs for jai_chat) — new extensions get no such allowance.
  • Private version table: each chain records its head in alembic_version_ext_<name>, separate from the core alembic_version, under a Postgres advisory lock.
  • Runs automatically: the api applies pending extension revisions at boot (as part of discover_and_load(run_lifecycle=True), on the install/upgrade path). There is no per-extension env.py and no manual alembic upgrade step. The runner surface is vectis_sdk.migrations (run_extension_migrations, migrate_extension, …).
  • Adoption stamping: if your tables already exist but the version table doesn't (legacy installs, dev create_all flows), the runner stamps the head instead of re-running DDL.
  • Non-migrating processes fail fast: worker / schedule runner / event consumer boot with run_lifecycle=False and refuse to start when the recorded schema revision doesn't match the packaged head.

Do not use make makemigration (the core autogenerate flow) for extension tables — core autogenerate excludes them via include_object. See Migrations for the core flow.

extensions.lock

backend/extensions.lock is the source of truth for the install set — the JSON list of {id, version} for every discovered extension plus a set hash. Adding, removing, or version-bumping an extension requires regenerating it:

make lock-extensions   # regenerate and commit backend/extensions.lock

make check runs check-extension-lock (a prerequisite of check-schema-drift, since the committed schema.graphql files are a function of the same install set) and fails on drift. At boot, api/worker/schedules/consumer assert the discovered set against the lockfile — currently warn-only — so a container built from a different install set is visible in the logs.

Boot-Time Permission Sync

manifest.permissions codenames are upserted into the permissions table at every api boot (after discover_and_load()), replacing the old wait-for-make seed flow. The contract:

  • Additive-only — missing rows are inserted (INSERT .. ON CONFLICT DO NOTHING); existing rows are never updated or deleted.
  • Namespacing convention for new extensions: ext.<name>.<perm> (e.g. ext.my_gateway.manage). Existing manifest codenames are upserted verbatim.
  • No role wiring yet — newly created permission rows belong to no role until the admin role-wiring work ships (not yet built). super_admin's "*" grant is a seed-time expansion, so it does not automatically pick up permissions created after seeding.
  • Only the api runs the sync; worker/schedules/consumer do not.

Gateway Inference via Token Claims

Core no longer hardcodes payment-token shapes. Gateways declare claims_payment_token(payment_data) on PaymentProcessStrategy and SavedPaymentMethodStrategy — a pure, total shape check (no DB, no network, never raises; an unconfigured gateway still answers). resolve_gateway_by_token_claims() asks priority-ordered claimants first (checkout asks authorize_net first; the saved-card path asks nmi first), then the rest in sorted-name order; a claimant that raises is treated as not claiming so a broken extension can't wedge checkout.

Third-party gateway limitation

Until the storefront's pluggable payment UI ships (not yet shipped), checkout inference for third-party gateways relies on the generic payment_token field or an explicitly named payment_method — the built-in checkout only collects Authorize.Net- and NMI-shaped token fields.

Strategy Implementation

Payment gateway strategies implement the PaymentProcessStrategy ABC:

from decimal import Decimal
from vectis_sdk.strategies import PaymentProcessStrategy, PaymentResult

class AuthorizeNetCIMStrategy(PaymentProcessStrategy):
    @property
    def gateway_name(self) -> str:
        return "authorize_net"

    def claims_payment_token(self, payment_data: dict) -> bool:
        # Pure, total: "does this checkout payload's token shape belong to me?"
        return bool(
            payment_data.get("opaque_data_descriptor")
            and payment_data.get("opaque_data_value")
        )

    async def authorize(self, amount: Decimal, currency: str, payment_data: dict) -> PaymentResult:
        # Auth-only: holds funds without settling
        ...

    async def capture(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        # Settle a previously authorized transaction
        ...

    async def charge(self, amount: Decimal, currency: str, payment_data: dict) -> PaymentResult:
        # Auth + capture in a single call
        ...

    async def refund(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        # Return funds for a captured transaction
        ...

    async def void(self, transaction_id: str) -> PaymentResult:
        # Cancel an authorization before settlement
        ...

    async def approve_held_transaction(self, transaction_id: str) -> PaymentResult:
        # Release a transaction held by fraud filters (FDS)
        ...

Fraud Filter Handling

The Authorize.net strategy detects responseCode=="4" (held for review by FDS filters) and returns a PaymentResult with held_for_review=True. The PaymentService stores the transaction as status="held_for_review" and the order enters the HeldForReview state.

The approve_held_transaction method calls Authorize.net's updateHeldTransactionRequest API to release the hold. This is exposed as the adminReleaseFraudHold GraphQL mutation.

Email delivery (ext_ses)

The ext_ses extension declares an ExtensionManifest with config_schema for Amazon SES (aws_region, aws_access_key_id, aws_secret_access_key, default_from_email, default_from_name, optional configuration_set). The SesEmailStrategy class in vectis.extensions.ext_ses.strategy implements EmailDeliveryStrategy using aiobotocore and SendEmail. Install the optional extra pip install vectis[ses] (or add aiobotocore to your environment).

Notification SMS (Twilio, SNS)

The core module defines SmsDeliveryStrategy and SmsMessage in vectis.modules.notification.sms_strategies.

  • ext_twilio declares an ExtensionManifest with config_schema for account_sid, auth_token, from_number, and optional messaging_service_sid. On activate it calls register_sms_strategy("twilio", TwilioSmsStrategy).

  • ext_sns declares a manifest for aws_region, aws_access_key_id, aws_secret_access_key, optional sender_id, and sms_type (Transactional or Promotional, default Transactional). On activate it calls register_sms_strategy("sns", SnsStrategy). Install pip install vectis[sns] (or vectis[ses] — same aiobotocore dependency).

Email/SMS providers deliberately do not go through strategy_resolver: multiple delivery builders are registered simultaneously in the notification builder pool, and the admin's sms_provider (or email_provider) setting selects which one is constructed at send time. Registrations are claimed in the registration ledger — owned by the activating extension, cross-extension duplicates hard-fail, and they are unregistered automatically on deactivate/uninstall.

Admin SSO (ext_keycloak)

The ext_keycloak extension registers an ExtensionManifest with config_schema for server_url, realm, client_id, client_secret, admin_only (default true), and role_mapping (object: Keycloak realm role name → Vectis role slug). Use vectis.extensions.ext_keycloak.service.KeycloakService with ChannelExtension.config or explicit constructor arguments: get_authorize_url(redirect_uri) loads {server_url}/realms/{realm}/.well-known/openid-configuration and returns url + state; handle_callback(code, redirect_uri, session) exchanges the code, reads realm_access.roles from the id_token, maps roles, and creates or updates a staff User, OAuthAccount (provider="keycloak"), and global UserRole rows for mapped slugs. Wire routes/BFF to this service where admin OIDC login is enabled.

OnTrac Shipping (ext_ontrac)

The ext_ontrac extension registers a ShippingCalculatorStrategy for OnTrac Ground service. It calls the OnTrac ServicesAndCharges v3 JSON API for live rate quotes and falls back to weight-based static rates when credentials are absent or the API is unavailable.

Config

Key Type Default Description
wsid string (required) OnTrac Web Services ID
wskey string (required, secret) OnTrac Web Services Key

Services

Only Ground (GRND) is supported. The service code map is structured for future expansion but currently returns a single rate.

AfterShip Tracking (ext_aftership)

Multi-carrier shipment tracking and delivery notifications powered by AfterShip. Supports 1,200+ carriers with automatic tracking registration, webhook-based status updates, and scheduled polling fallback.

AfterShip does not register a ShippingCalculatorStrategy — it is a tracking-only extension that complements rate-quote providers like ShipStation, GoShippo, UPS, or OnTrac.

Config

Key Type Default Description
api_key string (required, secret) AfterShip API key
webhook_secret string (optional, secret) HMAC secret for webhook signature verification
auto_register boolean true Auto-register tracking numbers on fulfillment creation
tracking_poll_enabled boolean false Poll AfterShip for updates (fallback for webhooks)
poll_interval_minutes integer 30 Poll interval in minutes (minimum 15)
custom_domain string (optional) Custom domain for branded tracking pages
notify_customer_on_update boolean false Send Vectis notifications on status changes

Temporal Workflows

  • AfterShipRegisterTrackingWorkflow — on-demand, registers a tracking number with AfterShip when a fulfillment is created.
  • AfterShipTrackingPollWorkflow — scheduled (every 30 min), polls AfterShip for tracking status updates across all active trackings.

Files

In the enterprise-extensions repo:

  • ext_aftership/extension.py
  • ext_aftership/client.py
  • ext_aftership/activities.py
  • ext_aftership/workflows.py

ShipStation (ext_shipstation)

Multi-carrier rate quotes via ShipStation v2 API, plus order sync and tracking poll via v1 API.

Strategy: _ShipStationProxyStrategy — resolves credentials per-call from ShippingProvider.config where carrier_code = 'shipstation'.

Configuration (ShippingProvider.config JSONB):

Key Type Default Description
api_key string (required, secret) ShipStation v2 API key
origin_postal_code string Ship-from postal code
origin_country string "US" Ship-from country
auto_sync_orders boolean false Push orders on placement
tracking_poll_enabled boolean false Poll for tracking updates
sync_trigger string "order_placed" order_placed, awaiting_fulfillment, or manual
v1_api_key string (secret) Legacy v1 API key (order sync)
v1_api_secret string (secret) Legacy v1 API secret

GoShippo (ext_goshippo)

Multi-carrier rate quotes, labels, and tracking via the Shippo API.

Strategy: _ShippoProxyStrategy — resolves credentials per-call from ShippingProvider.config where carrier_code = 'goshippo'.

Configuration (ShippingProvider.config JSONB):

Key Type Default Description
api_token string (required, secret) Shippo API token
origin_postal_code string Ship-from postal code
origin_country string "US" Ship-from country
origin_state string Ship-from state
origin_city string Ship-from city

Shipping Extension Config Pattern

All shipping carrier extensions store their settings in ShippingProvider.config (JSONB), not in ChannelExtension.config or the extension manifest's config_schema. The strategy reads config from the provider where carrier_code matches, with env var fallback for development. The full convention lives in vectis/docs/conventions.md in the application repo.


Channel-Scoped Activation

Extensions are toggled per-channel via ChannelExtension. The config JSONB column stores per-channel settings:

from vectis_sdk.extension import ChannelExtension

channel_ext = ChannelExtension(
    channel_id=1, extension_name="authorize_net", enabled=True,
    config={"api_login_id": "xxx", "transaction_key": "yyy", "sandbox": True},
)
session.add(channel_ext)

ExtensionManifest Reference

See vectis/core/extension.py for the dataclass definition; the full set of fields:

Field Purpose
name, version, description Identity — mirrors the ExtensionProtocol attributes
display_name, icon_light, icon_dark Admin presentation
category Functional category: one of payment, shipping, tax, compliance, communication, authentication, marketing in the validated tuple. Other strings (fulfillment, ai, support) are accepted but not yet in EXTENSION_CATEGORIES
strategies Strategy names this extension registers via strategy_resolver
depends_on / conflicts_with Dependency and exclusivity declarations
config_schema JSON schema for the generic Configure modal
permissions Permission codenames introduced
admin_contributions Unified admin contribution bus — {slot, kind, payload, order?, permission?}, served over adminContributions; supersedes the individual admin_nav_items / admin_quick_actions / admin_page_tabs fields
storefront_contributions Customer-facing contribution bus — {slot, kind, payload, order?, when?}, served over storefrontContributions
admin_nav_items Sidebar entries {href, label, section, icon_name, permission?}
admin_quick_actions Cmd-K palette entries {href, label, icon_name, permission?}
admin_page_tabs Tabs grafted onto an existing admin page {page, label, href, icon_name?, permission?}
admin_settings_pages Deep-link target for the Configure button on /settings/extensions
admin_tabs Tabs on entity-detail pages
admin_pages, admin_widgets Slot contributions on dashboards and entity pages
admin_list_columns, admin_filters Extra columns / filters in admin list views
admin_action_bar_items, admin_bulk_actions Extra buttons on entity-list action bars
admin_form_fields Extra fields in entity edit forms
custom_field_definitions Custom fields per entity type — auto-registered with custom_field_registry

Testing

Tests can drive the registry directly without booting the FastAPI app:

import pytest
from vectis_sdk.extension import ExtensionRegistry
from vectis.extensions.ext_my_gateway.extension import MyGatewayExtension

@pytest.mark.asyncio
async def test_my_gateway_registers_manifest():
    registry = ExtensionRegistry()
    await registry._activate(MyGatewayExtension())
    manifest = registry.get_manifest("my_gateway")
    assert manifest is not None
    assert manifest.category == "payment"
    assert "my_gateway" in manifest.strategies

For workflow / activity / schedule contributions, call iter_workflows(), iter_activities(), iter_schedules() on the registry after activation:

@pytest.mark.asyncio
async def test_my_gateway_workflows():
    registry = ExtensionRegistry()
    await registry._activate(MyGatewayExtension())
    workflows = registry.iter_workflows()
    assert MyRefundWorkflow in workflows

End-to-end tests (the package scan finds your extension) use the live test fixtures and let discover_and_load() activate everything.

Common Failure Modes

  • Worker doesn't pick up my workflow — verify workflows() returns the class itself, not an instance. Check the worker startup log for the "X core + Y extension" count.
  • Schedule didn't fire — run make schedules (python -m vectis.schedules); restarting the worker alone does not recreate schedules — nothing in the worker calls create_or_update_schedules().
  • Nav item didn't appear — confirm the extension is in the GraphQL installedExtensions response and the iconName is in +layout.svelte's resolveExtensionIcon map (the map is explicit on purpose, for deterministic Vite tree-shaking).
  • Admin route didn't appear — run make sync-extensions; confirm admin/src/routes/extensions/<name>/+page.svelte exists; restart the admin dev server.
  • make check fails on check-extension-lock — you added/removed/bumped an extension without regenerating the lockfile. Run make lock-extensions and commit backend/extensions.lock.
  • make check fails on check-extension-imports — new extension code imports vectis.core / vectis.modules. Import the vectis_sdk.* equivalent instead; the allowlist is shrink-only and will not accept new entries.
  • Activation fails with DuplicateRegistrationError — another extension already owns that (strategy type, name) key. Register under your own name (per-provider naming) instead of a shared key.
  • Extension activates twice — usually means both the entry point and the package scan found it under different name variants. Declared names are coerced to the canonical id and collisions are refused with a warning; check the activation log line.
  • on_install re-fires on every restart — the session factory isn't reaching the registry. The ExtensionRegistry(session_factory=...) constructor must be passed get_session_factory() from the app lifespan; tests that omit it fall back to in-memory tracking only.

AgeChecker.Net Extension (ext_agechecker)

Age verification powered by AgeChecker.Net. Registers an AgeVerificationStrategy under the name "agechecker".

Strategy ABC

Defined in vectis/modules/auth/strategies.py:

  • AgeVerificationStrategy.verify_age(customer_data) — seamless server-side check
  • AgeVerificationStrategy.validate_token(token) — validates popup SDK tokens
  • AgeVerificationStrategy.get_popup_config(channel_config) — returns storefront config

GraphQL

Operation Name Description
Query ageCheckerConfig Returns popup config (null when disabled)
Mutation verifyAge Seamless verification with customer data
Mutation validateAgeToken Validates AgeChecker.Net popup token

Config Schema

Key Type Default Description
merchant_id string (required) AgeChecker.Net merchant ID
api_key string (required, secret) API key for server calls
sandbox boolean true Use sandbox environment
mode enum "both" seamless, popup, or both
trigger enum "checkout" store_wide, checkout, or disabled
minimum_age integer 21 Minimum age for verification
require_dob_on_checkout boolean false Show DOB field on checkout

Storefront Integration

  • Checkout gate (trigger: "checkout"): verification step after payment, before order placement. Seamless API called first; popup shown if photo ID needed.
  • Store-wide gate (trigger: "store_wide"): full-screen overlay in root layout gates all content until the visitor verifies their age.

Files

  • ext_agechecker/extension.py, ext_agechecker/strategy.py, ext_agechecker/resolvers.py (in the enterprise-extensions repo)
  • vectis/storefront/src/lib/components/AgeCheckerPopup.svelte (core storefront)

Fraud Scoring Extensions

Four fraud scoring extensions implement FraudScoringStrategy. Each registers under its own per-provider name (ipqs, maxmind, signifyd, riskified) — the old shared "fraud_scoring" key was removed as a cross-extension registration collision. The fraud consumer resolves providers with channel-enablement awareness, so which provider scores an order is controlled by which fraud extension is enabled on the channel.

ext_ipqs (IPQualityScore)

Pre-payment IP reputation and email validation. Calls two REST endpoints per checkout: Proxy/VPN Detection and Email Verification.

Key Type Default Description
api_key string (required, secret) IPQS API key
strictness integer 1 0=lenient, 1=moderate, 2=strict
review_threshold integer 75 Score (0-100) to hold for review
block_threshold integer 90 Score (0-100) to block checkout

ext_maxmind (MaxMind minFraud)

Full-featured minFraud v2.0 integration with three API tiers, comprehensive request enrichment, and the Report Transaction feedback loop.

Scoring: Pre-payment (score_checkout), post-payment (score_post_payment), and login (score_login) scoring. Each uses the correct minFraud event.type (purchase vs account_login) so MaxMind's ML models apply the right behavioral baseline.

Request enrichment: Sends all available checkout context to minFraud — device (IP, user agent, session, Accept-Language), email (MD5-hashed), billing/shipping addresses (full name, address, region, phone), credit card (BIN, last 4, AVS, CVV, 3-D Secure), payment (method, processor, authorization outcome), order (amount, currency, discount code), shopping cart (item IDs, categories, prices, quantities), and account ID.

Response parsing (Insights / Factors tiers): Extracts IP risk, anonymous IP flags, email intelligence (disposable, free, first seen), device intelligence (device ID, confidence, last seen), billing/shipping address intelligence (distance to IP, in-IP-country), credit card issuer match, risk score reasons (Factors), disposition (custom rules), and API warnings.

Report Transaction: Implements report_decision() to send chargeback, false-positive, and suspected-fraud feedback to MaxMind via the /minfraud/v2.0/transactions/report endpoint. MaxMind reports that this feedback loop improves scoring accuracy by 10–50%.

Key Type Default Description
account_id string (required) MaxMind account ID
license_key string (required, secret) License key
tier enum "score" score, insights, or factors
review_threshold integer 50 risk_score (0-99) for review
block_threshold integer 80 risk_score (0-99) for block

Files:

  • ext_maxmind/extension.py, ext_maxmind/strategy.py (in the enterprise-extensions repo)

ext_signifyd (Signifyd)

Post-authorization chargeback guarantee. score_post_payment is the primary method — login and checkout return allow by default.

Key Type Default Description
api_key string (required, secret) Signifyd API key
team_id string optional Signifyd team ID
review_threshold integer 500 Score (0-1000) below which to review
block_threshold integer 250 Score (0-1000) below which to block

ext_riskified (Riskified)

Post-authorization chargeback guarantee with HMAC-SHA256 authentication.

Key Type Default Description
shop_domain string (required) Shop domain registered with Riskified
auth_token string (required, secret) HMAC authentication token
sandbox boolean true Use sandbox environment

Storage Extensions

Storage extensions implement FileStorageStrategy (defined in vectis/core/storage.py) and are registered via strategy_resolver.register(FileStorageStrategy, ...).

ext_s3 (Amazon S3)

Amazon S3 cloud object storage using the shared S3Service (SigV4 + httpx, no boto3).

Key Type Default Description
s3_region string (required) AWS region
s3_access_key string (required, secret) Access Key ID
s3_secret_key string (required, secret) Secret Access Key
s3_bucket string (required) Bucket name
s3_public_url string Optional CloudFront CDN URL

ext_minio (MinIO)

Self-hosted S3-compatible storage, also uses S3Service.

ext_digitalocean_spaces (DigitalOcean Spaces)

S3-compatible storage on DigitalOcean with optional CDN.

ext_dropbox (Dropbox)

File storage backed by a Dropbox account via the HTTP API v2 with OAuth2 refresh-token auth. No SDK dependency — uses httpx directly.

Key Type Default Description
app_key string (required) Dropbox App Key
app_secret string (required, secret) Dropbox App Secret
refresh_token string (required, secret) OAuth2 Refresh Token
access_token string Short-lived access token (auto-refreshed)
base_folder string /vectis-uploads Root folder path in Dropbox

Setup: Create a Dropbox App at https://www.dropbox.com/developers/apps, generate a refresh token via the OAuth2 flow, then configure the extension in Settings > Extensions.

ext_priority1 (Priority1 LTL Freight)

Live LTL freight rate quotes from Priority1, a freight broker returning competing carrier quotes identified by SCAC code. Includes built-in pallet calculation with two modes (simple weight-based and per-product dimensions), NMFC freight class support via product traits, and carrier filtering.

Strategy: Priority1ShippingStrategy — registered as ShippingCalculatorStrategy with name="priority1".

API: Priority1 v2 — POST /v2/ltl/quotes/rates with X-API-KEY header. Responses are cached in Valkey for 10 minutes.

Configuration (ShippingProvider.config JSONB):

Note

All shipping extension settings live on ShippingProvider.config, not on ChannelExtension.config or the extension manifest. Edited from Settings > Shipping > Providers & Methods > [Provider] > Edit.

Key Type Default Description
api_key string (required, secret) Priority1 API key
environment string "dev" dev or live
api_timeout integer 30 API timeout in seconds
default_freight_class string "70" NMFC freight class fallback
pallet_mode string "simple" simple or per_product_dims
pallet_length number 48 Pallet length in inches
pallet_width number 40 Pallet width in inches
max_pallet_weight number 2500 Max weight per pallet (lbs)
pallet_tare_weight number 45 Empty pallet weight (lbs)
pallet_min_weight number 0 Min weight sent to API (0=none)
pallet_min_height number 0 Min height sent to API (0=default)
max_pallet_height number 94 Max stack height in inches
min_weight number 0 Min cart weight to offer LTL (0=always)
max_rates integer 0 Max carrier rates to show (0=all)
transit_days_padding integer 0 Extra days added to display
allowed_carriers string "" Comma-separated SCAC codes (blank=all)
fallback_enabled boolean false Show fallback rate on API failure
fallback_rate number 0 Fallback rate amount ($)
fallback_label string "LTL Freight Shipping" Fallback rate label

Carrier Discovery: The admin provides discoverPriority1Carriers and testPriority1Connection GraphQL mutations. The discover mutation runs test quotes across 7 diverse US routes to collect all carrier SCAC codes, merged with the KNOWN_LTL_CARRIERS dict. The admin UI renders a checkbox picker for selecting allowed carriers.

Per-product freight class: Assign NMFC freight classes to individual products using the "LTL Freight Class" trait (seeded on extension activation). Products without the trait use the default_freight_class from config.

Pallet calculation modes:

  • Simple: Aggregates all items onto pallets by weight. Uses cubic volume to calculate realistic stack height on the configured pallet footprint. Splits to a new pallet when weight or height limits are exceeded.
  • Per-product dimensions: Same as simple, but also forces a new pallet when an individual item exceeds the pallet footprint dimensions.

Fee Extensions

ext_package_protection (Package Protection)

Checkout fee for shipment protection with a claims workflow for damaged or missing items. Supports all five built-in calculation types: flat rate, percentage, flat + percentage, per weight, and per box.

Strategy: PackageProtectionCalculator — registered as FeeCalculationStrategy with name="package_protection".

Claim Reasons (seeded by default):

Reason Slug Customer Message
Damaged damaged Apology + prompt to submit
Missing - All Quantity missing-all Video-verified packing notice
Missing - Partial Quantity missing-partial Video-verified packing notice

Configuration (ChannelExtension.config):

Key Type Default Description
auto_attach boolean true Auto-add fee to eligible carts
default_calculation_type string "flat" One of: flat, percent, flat_plus_percent, per_weight, per_box
default_flat_amount string "4.99" Default flat fee amount
claim_header_message string see code Message shown at top of claim form
claim_footer_message string see code Message shown at bottom of claim form
reason_messages object see code Per-reason override messages keyed by slug

Claim Resolution Workflow:

  1. Admin clicks "Approve" on a submitted claim
  2. System issues store credit for the claimed item value
  3. System creates a replacement order from the claimed line items
  4. Store credit is applied to the replacement order
  5. Claim status transitions to complete

Support Chat (ext_jai_chat)

J'AI Chat ships as a fully self-contained extension — there is no support_chat core module; removing the extension cleanly removes the chat surface. The extension contributes:

  • ModelChatLog (chat_logs table, a grandfathered name — new extensions must use the ext_<name>_ prefix) registered via models(), with its own per-extension migration chain
  • Strategy ABCsOrderLookupStrategy and LiveAgentStrategy, the plug-in points that ERP / live-agent providers implement separately; on_activate registers the built-in LocalOrderLookupStrategy under name local
  • GraphQLSupportChatQuery / SupportChatMutation via graphql_queries() / graphql_mutations(): chatSettings, chatOrderStatus, sendChatMessage, requestLiveAgent
  • Privacy exporter — chat conversations ride the GDPR subject-access export bundle as the support_chat section
  • Admin surface — a Settings-section nav item and an admin_settings_pages deep link to /settings/support-chat

The manifest deliberately has no config_schema — configuration lives on the extension's own rich settings page at /settings/support-chat (terms, disclaimers, welcome message, AI provider, system prompt override), persisted as support_chat_* keys in the platform Setting table. The AI provider used for replies is selected from any installed AI extension (ChatGPT, Claude, Grok).

Excise Tax (ext_excise_engine)

Canonical id excise_engine; owns the ee_* tables via its own migration chain (grandfathered names — new extensions must use the ext_<name>_ prefix). Self-registers a TaxCalculationStrategy with stage 200 so it runs after sales tax (100). Slot integration (from the module-level manifest):

  • Admin nav item ExciseEngine in the Tax section, linking to /extensions/excise_engine
  • Admin Cmd-K quick actions: "Sync ExciseEngine Now", "ExciseEngine Tax Calculator", "ExciseEngine Tax Classes"
  • Settings deep-link via admin_settings_pages/extensions/excise_engine

Excise classification (tax classes, rates, rules, jurisdictions) is consumed from the separate Excise Engine service; the extension syncs classes/regions/rates into its ee_* tables.

TaxJar (ext_taxjar)

Replaces the pre-2026 stub. The extension implements:

  • CalculationTaxJar.taxForOrder for cart and order subtotals, with line-level expected_tax
  • Filing — a TaxFilingStrategy implementation. Report/summary generation works against TaxJar data; submit_filing records locally only — it does not push filings to TaxJar (AutoFile enrollment happens in TaxJar's own dashboard).

Strategy stage is 100 (sales-tax tier); ext_excise_engine runs at stage 200 against the order subtotal returned by TaxJar.

Webhook Signature Secrets

Webhook signature secrets are Settings-first: each gateway's key lives in the settings table under payment.<gateway>.webhook_signature_key, Fernet-encrypted. Storage-level rotation uses:

  • SECRETS_MASTER_KEY (current) — used for new writes and reads
  • SECRETS_MASTER_KEY_ROTATING_FROM (optional, list) — additional keys tried during read; lets you rotate the master key without downtime

Legacy per-gateway env vars are a deprecated fallback: at api boot, any env-provided secret whose Setting row is absent is backfilled into Settings (with a deprecation warning), so the Settings row becomes canonical. For inbound webhooks (Authorize.Net, NMI), the per-gateway dispatcher supports a multi-secret rotation window — it tries each registered secret in priority order and accepts the first that verifies the signature.

Webhook secrets are managed from the admin Settings → Payments page (WebhookSecretPanel), backed by the permission-gated setWebhookSecret / rotateWebhookSecret / revokeWebhookSecret GraphQL mutations and the webhookSecretStatus query over the Fernet-encrypted Setting store. Stored secrets are masked and write-only — the API never returns the ciphertext.