This repository is a template: the code under backend/mpt_extension_python_template/ and
frontend/src/ is a set of small, working examples of the extension building
blocks. This document is a guided tour of those examples and where to find them.
It describes what this repository demonstrates, not the SDK APIs themselves. For the library APIs, see the SDK usage guides linked from README.md.
backend/docs/postman_collection.json
is a ready-made Postman collection for a locally running backend (default
base_url http://localhost:8080, the port exposed by make run-local). Auth
is wired automatically through a collection-level pre-request script. It contains
sample requests for the example routes below — the /bypass operational routes
(health, live, ready), the order and agreement events, the agreement API
(including list pagination, get, not-found, and sync), a schedule delivery, and
the two draft order validation webhook calls (a valid and an invalid
agreementType) — so you can exercise the template without writing requests by
hand.
backend/mpt_extension_python_template/app.py creates the
ExtensionApp and registers every example router:
events_orders_router— order event handlingevents_agreements_router— agreement event handlingapi_agreements_router— agreement API endpointsschedules_agreements_router— the scheduled agreement synchronizationwebhooks_orders_router— the draft purchase order validation webhookplug_portal_router— top-level portal plugs (thelearn-extensionsnavigation group with the examples app and guide nested under it, plus theaddshowcase)plug_modals_router— socketless modal plugs (dialogandwizard) opened by id from the examples "Modals" viewplug_agreements_router— agreement plugs (the agreement tab example plus the agreementsaddshowcase)plug_orders_router/plug_subscriptions_router/plug_assets_router/plug_accounts_router— per-entityaddshowcase plugs
Each router below is an independent example and can be read on its own.
backend/mpt_extension_python_template/routers/api/agreement.py
exposes an APIRouter under /api/v2/agreements:
| Route | Name | Demonstrates |
|---|---|---|
GET / |
agreements-list |
Paginated reads with ctx.request.pagination and PaginatedResult / APIResponse.paginated |
GET /{agreement_id} |
agreements-get |
Reading a single agreement via ctx.mpt_api_service, returning APIResponse.ok, and turning a Marketplace 404 into an SDK NotFoundError so the route answers 404 |
POST /sync |
agreements-sync |
A deferrable route with a body_validator ({"agreementId": ...}): a read as the calling account followed by a write back as the extension vendor (see Two Account Identities), served as a direct call or as a platform Task (see Deferrable Sync) |
These handlers read Marketplace data through ctx.mpt_api_service.agreements.
The sync route is the backend half of the frontend "Sync now" and "Sync in
background" example (see Plugs).
agreements-sync is declared with deferrable=True, so the SDK publishes it in
the deferrables block of meta.yaml and one handler serves two call modes:
- Direct call — a plain
POST.ctx.taskisNone, and the handler answers with the agreement throughAPIResponse.ok. - Deferred call — the same
POSTwith theMPT-Async: trueheader. The Extension Service answers202 AcceptedwithMPT-Task-Iditself, then delivers the request to the extension as a Task.ctx.taskis set, and the handler returnsTaskResult.reference(...)pointing at the agreement, which the SDK stores as the Task result before completing it.
The deferred branch sleeps before it syncs, reporting progress 1 % … 99 % every
PROGRESS_INTERVAL_SECONDS (4 s) and 100 % once the sync is done. The sleep is
deliberate: it outlasts the Delay of at least 300 s the SDK answers the first
delivery with, so the Extension Service delivers the task again while the handler is
still running — the redelivery an extension has to tolerate. The SDK answers that
second delivery with another Delay instead of running the handler twice. The route
declares a max_task_processing budget covering the sleep plus two minutes, one of
which the SDK keeps as its own safety margin.
The plug follows the Task on the Marketplace API: after "Sync in background" it polls
GET /public/v1/system/tasks/{id} every 5 seconds with the caller's token — the account
that triggered the call is in the Task's access — and keeps both buttons disabled until
the Task is final. After MAX_POLL_FAILURES failed reads in a row it stops following and
says so, while the Task keeps running.
The plug follows a Task only while it is on screen. It does not survive a page reload, and another browser or device does not see the running sync either: the Tasks API cannot be asked for "the open task of this agreement", so there is nothing to resume from. An extension that needs that has to keep the task id itself.
A deferrable route cannot declare path parameters, because the platform does not resolve them for a deferred call, so the agreement id travels in the request body. See the SDK deferrable endpoints guide for the full contract.
Every SDK context carries two Marketplace services: ctx.mpt_api_service,
authenticated as the account in the incoming request token, and
ctx.vendor_mpt_api_service, authenticated as the vendor account that owns the
extension. Use the first by default; reach for the second only when a flow has
to act as the extension owner regardless of who triggered it.
The agreements-sync route shows both in one step, shared by its two call modes —
it reads as the caller, then writes back as the vendor:
agreement = await ctx.mpt_api_service.agreements.get_by_id(agreement_id)
await ctx.vendor_mpt_api_service.agreements.update(
agreement_id, {"externalIds": {"vendor": agreement.external_ids.vendor}}
)externalIds.vendor is deliberate: each actor owns one key of that object, so
the calling account could not perform this write at all. The value written is
the one already there, so the example changes no data. See
docs/architecture.md for when to reach for each
service.
The schedule below is the other case: it has no calling account to inherit, so it reads as the vendor.
backend/mpt_extension_python_template/routers/schedules/agreement.py
registers a ScheduleRouter task. The SDK runs no in-process scheduler: it
publishes the cron configuration in the extension metadata (schedules: in
meta.yaml) and the Extension Framework delivers each occurrence as a task
event.
| Route | Id / name | Demonstrates |
|---|---|---|
POST /schedules/v1/agreements/sync |
agreements.sync / agreements-sync-schedule |
A @task(...) registration with a five-field cron, the delivery metadata in ctx.meta, vendor-scoped reads, and ctx.task.progress(...) |
The handler reads the first page of vendor agreements, logs each one and reports progress; it changes no agreement state, since this is an example.
docs/architecture.md covers the delivery model the handler has to live with, and the SDK schedules guide documents the delivery protocol, the watchdog cadence and the task lifetime limits.
backend/mpt_extension_python_template/routers/webhooks/order.py
registers a WebhookRouter webhook with one criteria per product in
MPT_PRODUCTS_IDS. The SDK publishes one webhooks: entry per product in
meta.yaml, all with the same path, and the Extensions Service creates the
platform webhooks.
| Route | Type | Demonstrates |
|---|---|---|
POST /webhooks/v1/orders/validate-purchase-draft |
validatePurchaseOrderDraft |
A @webhook(...) registration with a list of criteria, the typed ctx.order, without_errors(), with_ordering_error(...), with_error(...), and WebhookResponse.ok(...) |
The handler first clears the errors of the previous answer with
without_errors(), otherwise the wizard keeps showing them after the user fixes
the value. It then checks the agreementType ordering parameter against
VALID_AGREEMENT_TYPES and returns the order either unchanged or carrying the
rejection. An order whose payload has no agreementType parameter is accepted,
because there is nothing to validate.
A rejection is reported at both levels the SDK offers, so the template shows
each one: with_ordering_error(...) puts the error on the parameter the user
has to fix, and with_error(...) adds a summary on the order itself. Use the
parameter level for a bad value and the order level for a problem with the
order as a whole.
A rejection is still a 200: the platform reads the validation result from the
returned order, not from the status code, and the wizard shows the error on the
offending parameter. WebhookResponse.error(...) is for the handler failing,
not for an order being invalid.
The platform waits for the answer while the user is in the order wizard, so a real validation must stay fast. The SDK webhooks guide documents the contexts, the responses and the error mapping.
The webhook body is the order object itself, not an event envelope. The two
webhook / validate-purchase-draft requests in the
Postman collection post one: the first sends a valid
agreementType carrying the errors of a previous answer and gets them back
cleared, the second sends an invalid one and gets the order back with the error
on the parameter and a summary on the order. with_ordering_error(...) also
unhides the parameter and marks it required, so the wizard shows the field the
user has to fix.
The template shows the two event styles supported by the SDK:
- Task event —
routers/events/order.pyregisters@orders_router.task("/purchase")forplatform.commerce.order.status_changed, filtered by aconditionon the configuredproduct.ids. It receives a standardOrderContextand runs thePurchasePipeline. - Non-task event with a custom context —
routers/events/agreement.pyregisters@agreements_router.event("/complete")forplatform.commerce.agreement.status_changed, with a richer condition (...,eq(status,Active)) and acontext_adapter_type=EventAgreementContext. It runs theCompleteAgreementPipeline.
Both conditions are built from get_extension_settings().product_ids, showing
how event registration uses extension settings.
Event handlers delegate to small pipelines, demonstrating the pipeline/step composition pattern:
flows/pipelines/orders/purchase.py(PurchasePipeline) →StartOrderProcessingandCompleteOrderfrommpt-extension-contrib-order-statusaroundflows/steps/log_order.py(LogOrderStep, logs the order id). The contrib steps switch the order toProcessingandCompletedusing the product's default status templates (passtemplate_name=...to select a named template instead).flows/pipelines/agreements/complete.py(CompleteAgreementPipeline) →flows/steps/log_agreement.py(LogAgreementStep, logs the agreement id and the custom context field).
A pipeline is a BasePipeline whose steps property returns an ordered list of
BaseSteps. PurchasePipeline shows both kinds of steps: shared steps reused
from a contrib library and a local step (LogOrderStep) standing in for the
extension's own fulfillment logic.
backend/mpt_extension_python_template/context/agreement.py
defines EventAgreementContext, an example context adapter that extends the SDK
AgreementContext with an extra mock_field and overrides from_context. It is
wired into the agreement event (context_adapter_type=...) and consumed by
LogAgreementStep, showing how to carry extension-specific data through a
pipeline.
Plugs are qualified with the extension id (SDK_EXTENSION_ID) through two
helpers in
routers/plugs/common.py:
| Helper | Applies to | Result |
|---|---|---|
plug_name(...) |
every plug | Plug here (EXT-1111-1111) |
plug_id(...) |
socket-mounted plugs only | agreements-agreement-ext-1111-1111 |
Several deployments of this template can be registered against the same account, one per developer or per environment. The Portal renders the plug name on its own and aggregates the plugs of every installed extension into the same socket, so without the qualifiers two deployments produce indistinguishable buttons and colliding ids.
Worth knowing:
- Modal plug ids stay bare: this extension's own frontend resolves them with
useMPTModal().open('dialog')and has no access toSDK_EXTENSION_ID. - Bundle paths stay bare:
add_plugqualifies the id but leaveshrefon/static/add-<socket>/index.js, which maps to a built directory. NavigationPlugderives<socket>.<id>, so its children targetportal.learn-extensions-ext-1111-1111and each deployment gets its own navigation group.- The helpers read the id from the extension settings, not from
get_runtime_settings(): loading the runtime settings is what builds the metadata that asks for the plugs.
The tables below show ids in their qualified form, with <extension-id>
standing in for the lower-cased SDK_EXTENSION_ID.
backend/mpt_extension_python_template/routers/plugs/agreements.py
declares a Marketplace Portal plug through a PlugRouter. The plug points
at a frontend bundle via href and binds to a Portal socket. The frontend
module name matches the bundle path one-to-one:
| Plug id / socket | Frontend module | Demonstrates |
|---|---|---|
agreements-agreement-<extension-id> — portal.commerce.agreements.agreement |
modules/agreements-agreement/ |
A full agreement tab: sync status, useAgreementSync, status chips, a "Sync now" action calling the sync API directly, and a "Sync in background" action sending the same call with MPT-Async: true and following the returned task, with its progress, until it is final |
backend/mpt_extension_python_template/routers/plugs/modals.py
declares two ModalPlugs. A modal plug has no socket: the platform never
renders it as a page action, and it exists only to be opened programmatically
by id via useMPTModal().open('<plug-id>'). The examples app "Modals" view
(modules/examples/views/Modals.tsx)
opens both plugs by id, passes a context payload to them, and displays the
result each modal reports back through close(data) (delivered to the opener's
onClose callback):
| Plug id | Frontend module | Demonstrates |
|---|---|---|
dialog |
modules/dialog/ |
A confirmation dialog that renders the opener's question from useMPTContext() and returns { confirmed: true/false } |
wizard |
modules/wizard/ |
A multi-step Wizard that echoes the opener context and returns { completed: true/false } |
The result objects above are what these modals pass to close(data). When a
modal is dismissed without an explicit close — for example through the
platform's modal chrome — onClose receives undefined, so openers must guard
against an undefined result before reading fields like confirmed or
completed.
Each module's index.tsx follows the same entry-point pattern: import the
safe-storage shim, call setup() from @mpt-extension/sdk, and mount the
App with createRoot. The shared building blocks the agreement tab reuses
(useAgreement, useAgreementId, AgreementDetailsList, model.ts) live under
frontend/src/shared/; see
docs/architecture.md for the frontend structure.
These plugs demonstrate the UI SDK breadth rather than a specific business flow.
Like the agreement plugs, they are organised per entity — one PlugRouter
file each: portal.py
(top-level: the learn-extensions group with examples and guide, add),
orders.py,
subscriptions.py,
assets.py,
accounts.py, and the
agreements add plug in agreements.py:
| Plug id / socket | Frontend module | Demonstrates |
|---|---|---|
learn-extensions-<extension-id> — portal |
— (no bundle) | A NavigationPlug container: a href-less navigation grouping node whose id derives the nested socket portal.learn-extensions-<extension-id> for the two plugs below |
examples-<extension-id> — portal.learn-extensions-<extension-id> |
modules/examples/ |
A multi-tab React app (react-router) touring the UI SDK: Introduction, Basics, Context (useMPTContext), API calls (the http client feeding a server-driven Grid via useGridWithRql + RQL), UI elements (buttons, inputs, selects, toggles, date pickers, grids, entity references), and Modals (the socketless dialog / wizard round-trip via useMPTModal().open()) |
guide-<extension-id> — portal.learn-extensions-<extension-id> |
modules/guide/ |
Rendering bundled Markdown with InlineMarkdown (esbuild .md text loader) |
add-<socket>-<extension-id> (one per socket) |
modules/add-*/ |
A "plug here" scaffold shown on every remaining Portal socket, so the full set of sockets is covered |
The add-* plugs cover many near-identical sockets without duplicating logic:
each is a thin module directory whose index.tsx mounts the shared
AddPlugShowcase
component with its socket passed as a prop, and each maps to one add_plug(...)
line in the matching per-entity router (the helper lives in
plugs/common.py).
backend/migrations/ contains example data and schema
migrations. They are intentionally fake placeholders; see
docs/migrations.md.