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:
- Canonical ids — the extension id is the directory name minus
ext_; entry-point keys use the bare id. - Module-level manifest —
manifest = ExtensionManifest(...)must be a module-level constant inextension.py. - Register in
on_activate— all registrations are ledgered to the activating extension and auto-unregistered on deactivate/uninstall. - Import
vectis_sdk.*only — new extensions may not importvectis.core/vectis.modules(frozen shrink-only allowlist). - Own your migrations — extension-owned tables use the
ext_<name>_prefix and ship a per-extension Alembic chain as package data. extensions.lock— adding/removing/version-bumping an extension requiresmake lock-extensions.- Sync v2 — extension admin and storefront UI is mirrored by
make sync-extensions; production builds go throughmake 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.

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

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_shopify → shopify, ext_authorize_net → authorize_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:
- The directory name (
ext_<id>/). -
The entry-point key in the extension's own
pyproject.toml— the bare id, not theext_-prefixed directory name: -
The extension class's
nameattribute and the manifest'snamefield.
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 atbackend/scripts/check_no_core_extension_imports.py. - Extensions must import
vectis_sdk.*only —backend/scripts/check_extension_imports.pyfreezes legacyvectis.core/vectis.modulesimports to a shrink-only allowlist (see Thevectis_sdkImport 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:
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:
- Strategies —
strategy_resolver.register(StrategyABC, impl, name=...)(re-exported fromvectis_sdk.strategies). - Event handlers —
event_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 |
DuplicateRegistrationError — hard 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 wherevectisis 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 intomake checkascheck-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_versionreturnsNone→on_installfires →mark_installedwrites a row →on_activatefires. - Subsequent boots same version → only
on_activatefires. - Version bump →
on_upgrade(registry, prev_version)fires beforeon_activate;mark_installedupdates the row. - Explicit uninstall via
registry.uninstall(name)→pre_uninstall_check()collects blockers (anything truthy blocks unlessforce=True); on pass,on_uninstallfires,mark_uninstalledstamps 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 set — make check fails until the new extension is recorded:
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):
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, notext_authorize_net). It must equal the extension'snameattribute. - Value points at the module attribute
extension— the class itself, exported withextension = MyExtensionat the bottom ofextension.py. The registry calls it (which is allowed for classes —ext()returns an instance) and checksisinstance(ext, ExtensionProtocol). - When the extension is
pip installed (as a built wheel),discover_and_loadresolves this entry point viaimportlib.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:
- Table naming is enforced: every table you create must use the
ext_<name>_prefix (ext_my_gateway_profiles). The migration runner rejectscreate_tableoutside your namespace, and amake checkguard (check-ext-migration-ownership) rejects any new core revision touching extension-owned tables. Two legacy adopters are grandfathered by exact table name (ee_*forexcise_engine,chat_logsforjai_chat) — new extensions get no such allowance. - Private version table: each chain records its head in
alembic_version_ext_<name>, separate from the corealembic_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-extensionenv.pyand no manualalembic upgradestep. The runner surface isvectis_sdk.migrations(run_extension_migrations,migrate_extension, …). - Adoption stamping: if your tables already exist but the version table doesn't (legacy installs, dev
create_allflows), the runner stamps the head instead of re-running DDL. - Non-migrating processes fail fast: worker / schedule runner / event consumer boot with
run_lifecycle=Falseand 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 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
ExtensionManifestwithconfig_schemaforaccount_sid,auth_token,from_number, and optionalmessaging_service_sid. On activate it callsregister_sms_strategy("twilio", TwilioSmsStrategy). -
ext_sns declares a manifest for
aws_region,aws_access_key_id,aws_secret_access_key, optionalsender_id, andsms_type(TransactionalorPromotional, defaultTransactional). On activate it callsregister_sms_strategy("sns", SnsStrategy). Installpip install vectis[sns](orvectis[ses]— sameaiobotocoredependency).
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.pyext_aftership/client.pyext_aftership/activities.pyext_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 callscreate_or_update_schedules(). - Nav item didn't appear — confirm the extension is in the GraphQL
installedExtensionsresponse and theiconNameis in+layout.svelte'sresolveExtensionIconmap (the map is explicit on purpose, for deterministic Vite tree-shaking). - Admin route didn't appear — run
make sync-extensions; confirmadmin/src/routes/extensions/<name>/+page.svelteexists; restart the admin dev server. make checkfails oncheck-extension-lock— you added/removed/bumped an extension without regenerating the lockfile. Runmake lock-extensionsand commitbackend/extensions.lock.make checkfails oncheck-extension-imports— new extension code importsvectis.core/vectis.modules. Import thevectis_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
namevariants. Declared names are coerced to the canonical id and collisions are refused with a warning; check the activation log line. on_installre-fires on every restart — the session factory isn't reaching the registry. TheExtensionRegistry(session_factory=...)constructor must be passedget_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 checkAgeVerificationStrategy.validate_token(token)— validates popup SDK tokensAgeVerificationStrategy.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 theenterprise-extensionsrepo)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 theenterprise-extensionsrepo)
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:
- Admin clicks "Approve" on a submitted claim
- System issues store credit for the claimed item value
- System creates a replacement order from the claimed line items
- Store credit is applied to the replacement order
- 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:
- Model —
ChatLog(chat_logstable, a grandfathered name — new extensions must use theext_<name>_prefix) registered viamodels(), with its own per-extension migration chain - Strategy ABCs —
OrderLookupStrategyandLiveAgentStrategy, the plug-in points that ERP / live-agent providers implement separately;on_activateregisters the built-inLocalOrderLookupStrategyunder namelocal - GraphQL —
SupportChatQuery/SupportChatMutationviagraphql_queries()/graphql_mutations():chatSettings,chatOrderStatus,sendChatMessage,requestLiveAgent - Privacy exporter — chat conversations ride the GDPR subject-access export bundle as the
support_chatsection - Admin surface — a Settings-section nav item and an
admin_settings_pagesdeep 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:
- Calculation —
TaxJar.taxForOrderfor cart and order subtotals, with line-levelexpected_tax - Filing — a
TaxFilingStrategyimplementation. Report/summary generation works against TaxJar data;submit_filingrecords 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 readsSECRETS_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.