manifest.json is the only required file of a package and the whole
contract between a module and the platform. Types live in core-lib
entities/module.ts (mirrored into
admin/src/components/form/schemas.ts); the validator is
validateManifest in core-lib utils/module_manifest.ts, run by the
server, the console's upload pre-flight and the starter's module:pack
alike (§7).
2.1 ModuleManifest#
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
string |
yes | MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/ (3–32 chars, a DNS label, never m). Immutable — it keys the install doc, custody and the /m/{id} prefix |
name |
Record<locale, string> |
yes | per admin locale (cs, en); free of HTML |
description |
Record<locale, string> |
no | one paragraph per admin locale — the catalog card; free of HTML |
category |
ModuleCategory |
yes | exactly one, from the closed list in §2.3; immutable across versions (a change of area is a new module id) |
tags |
string[] |
no | ≤ MODULE_TAGS_MAX = 5, each /^[a-z0-9-]{2,24}$/; free text for catalog search |
version |
string |
yes | semver /^\d+\.\d+\.\d+$/; strictly greater than every existing version of the registry key |
vendor |
{name: string; url?: string; email?: string} |
yes | shown on the catalog card and the detail rail |
hosting |
'connected' | 'hosted' | 'platform' | 'none' |
yes | the tier (§1). All three code tiers install where the env has a module runner (§8.8); without one hosted / platform are hosting_unavailable. A platform-catalog package requires hosted / platform — or connected on a platform-operated endpoint (MODULE_PLATFORM_ENDPOINTS) — or none. 'none' = an elements module (below): kits only, installable everywhere, isElementsModule in core-lib is the one predicate |
endpoint |
string |
iff hosting === 'connected' |
https base URL, no trailing slash, no credentials, a hostname (no IP literal), port 443 or none, none of the platform's own hosts (MODULE_OWN_HOST_SUFFIXES: .inmake.dev, .inbs.dev, .googleapis.com, .firebaseio.com, .run.app, .web.app, .firebaseapp.com, .cloudfunctions.net); passes assertPublicEndpoint at upload, install and every call. the origins of MODULE_PLATFORM_ENDPOINTS (https://drop-core-board.firebaseapp.com, https://board.inmake.dev — exact origins, modules the platform itself runs) are exempt from the own-host rule for a target: 'platform' upload only; an organization package naming one is endpoint_invalid (reason platform_only) |
access |
AccessData |
yes (absent or {} for none) |
the install token's route tree (which endpoints); intersected with MODULE_MAX_ACCESS at install (§5) |
scope |
ScopeData |
yes (absent or {} for none) |
the install token's path tree (which store paths); validated against the deny list (§5). modules: {[id]: true} is what reaches the module's own state/ and its records collections (§5.5) |
events |
Array<kind | {kind; collection?: string[]; filter?: Record<string, any[]>; on?: HookChange[]}> |
no | kind ∈ MODULE_EVENTS = orders | invoices | data | forms | files | module_records — never templates / elements (render signals). The long form expands to one hook entry per distinct (filter, collection) (§6) |
events_mask |
string[] |
no | HookConfig.mask — the fields masked out of event payloads |
callbacks |
ModuleCallbackName[] |
no | ⊆ MODULE_CALLBACKS = order.validate, shipping.rates, shipping.points, shipment.createShipment, shipment.label, shipment.track, shipment.cancel, shipment.manifest. order.validate is live (callbacks_policy default open); the shipping.* / shipment.* names are the carrier target (§2.4) — declared with a provides: [{kind: 'carrier'}] entry |
callbacks_policy |
{order_validate?: 'open' | 'closed'} |
no (see meaning) | default open — a failing module never stops the store selling. Must be explicit when category === 'booking' (callbacks_policy_required) |
provides |
ModuleProvider[] |
no | ≤ 1 entry per kind; v1 kind: carrier (§2.4) |
routes |
{web?: boolean; admin?: boolean; guarded?: string[]; inbound?: Array<{path; auth: 'seed' | 'url_key'; methods?; body?: 'json' | 'form'; headers?: string[]}>} |
no | web = the storefront leg /m/{id}/**; guarded = storefront paths behind the tenant FormGuard chain (≤ 20 entries, segments of [A-Za-z0-9._~-], leading/trailing / optional, needs web: true; an entry guards itself and everything below it — book guards /book and /book/confirm, never /booking; / guards all — see Guarded paths under §4 web); inbound = third-party webhooks through the platform (≤ 8 routes, path one [a-z0-9-]{1,32} segment, headers ≤ 5 lowercase entries, authorization / cookie / access-seed denied). uninstall: true (connected only — else routes_invalid with detail.field: 'uninstall') asks for the one best-effort uninstall call (§4) |
schedule |
{every: '5m' | '15m' | '1h' | '6h' | '1d'} |
no | the schedule point cadence (§4), any hosting mode. Not run automatically since 2026-10-24 — the moduleScheduler cron was removed; a master runs a tick with POST /system/module/schedule/tick (fleet-wide cap of 50 calls per tick). A module that needs timed work runs it itself |
admin |
ModuleAdminMounts |
no | {forms?, actions?, cards?, pages?, widgets?} — the declarative console mounts (§4.3) and the iframe pages (§4.2). ≤ 8 pages, ≤ MODULE_ADMIN_MOUNTS_MAX = 12 entries of every other kind, ids [a-z0-9-]{1,32} unique per kind. A forms / actions / cards entry needs routes.admin: true (admin_routes_required). A page is {id, label, icon?, url, capability?, roster?} — capability one of commerce | content | community | web | develop (absent = develop), roster a boolean (§4.2) |
config |
{fields: ModuleFieldSpec[]; verify?: boolean} |
no | the config card (§2.2); verify: true = the verify point runs after install / update / configure and stamps verified_at |
data.collections |
Array<{kind: 'content' | 'records'; collection: DataCollection; schema: SchemaEntity}> |
no | registered at install with DataCollection.module = id; every named collection needs its schema in schemas/ (§5, §7). A content collection is named {id}_{name} (or the bare {id}) — reservations_services, never services — so a module never takes a common name in the store's shared data/{locale}/ and schema namespaces (collection_prefix, isModuleContentKey in core-lib); records collections live under modules/{id}/ and need no prefix. collection.keys = the collection's query keys, Record<name, string[]> — one named, ordered field list per listing that filters and sorts ({"status_start": ["status", "start_ms"]}); the platform stamps _k.{name} on every document and rewrites a matching query onto it, so no per-cloud index is needed. Rule of thumb: equality on the leading fields, range or sort on the last. ≤ 8 keys, 1–4 fields each (dotted paths allowed), names ^[a-z][a-z0-9_]{0,31}$, never id or _k (validateKeys in core-lib) |
data.entity_fields |
Array<'orders' | 'products' | 'customers'> |
no | the core entities on which the module may write {entity}.modules.{id} (§5) |
data.indexes |
Array<{collection; fields: Array<{field; order: 'asc' | 'desc'}>}> |
no | deprecated (indexes_deprecated warn) — composite indexes over records collections gave way to collection.keys above, which every package (platform and organization) gets. Still accepted for now; declare keys instead |
kits |
string[] |
no (required for none: a non-empty list, kits_required) |
element-kit ids inside the package (kit/{kitId}/, the templates/README.md format, every element id {moduleId}-…), any hosting; mounted at install as store/{sid}/elements/{moduleId}-{elementId} with module + origin: {module, version, hash} plus the setup/elements order. These are module-owned elements (§8.2a): the store may edit them, an update overwrites the pristine ones and skips the customized ones, and every uninstall (keep or purge) removes them. A kit may declare its global files — template.json → "global": {"css": "global.css", "js": "global.js"} (each a file at the kit root, ≤ 128 KB; "runtime": "runtime.js" is the legacy spelling of global.js, declaring both is kit_runtime_conflict) — mounted into the module's runtime doc elements/{moduleId}_runtime (js, css) and loaded on every page that places an element of the module, ahead of the element css / js. A kit's assets are not mounted (kit_assets_ignored warn). A kit id the package does not carry is skipped with a warning — for none it is the package error kits_missing |
templates |
string[] |
no | e-mail template ids the package seeds into data/{locale}/templates/{id} from templates/{id}.{locale}.hbs — only where absent ({name: id, data: <source>, origin: {module, version}}); a store's edits are never overwritten, an uninstall never removes them |
egress |
string[] |
no | hosted / platform only: the hosts the module may call (§7 egress rules) — the runner refuses every other host (egress_denied) |
limits |
{fuel?; memory_pages?; timeout_ms?; http_per_call?; calls_per_day?; http_response_headers?} |
no | hosted / platform only, clamped to the tier profile (a clamped value is a warn) and applied by the runner on every call; calls_per_day is metered by the gateway (429 past the cap) |
deploy |
'self' | 'managed' |
no | connected only (deploy_invalid otherwise). self (the default) = the client deploys their own code and puts its URL in endpoint; managed = the platform deploys the package's src/ as a function into the store's space cloud and the function's URL is the endpoint (§8.7) — the manifest names no endpoint (one given is ignored, endpoint_unexpected warn) and the package must ship src/package.json (deploy_source_missing error). admin.pages / admin.widgets need an endpoint origin at validation, so a managed module cannot declare them yet |
uninstall |
'keep' | 'purge' |
no | default keep; when absent the console asks at uninstall (§8) |
Two non-fields worth knowing: the module id becomes the install doc id on a
store, so a store cannot hold two modules of one id from different owners
(409 id_installed); and the registry key is {id} for a platform
package and {org}~{id} for an organization package (~ is outside
MODULE_ID_PATTERN, so keys never collide across owners).
hosting: 'none' — an elements module#
A module that is only an element library (.dev/plans/modules/elements-modules.md).
validateManifest gives it the identity rows (id, name, description,
category, version, vendor, tags) and these only:
| Field | Rule (row) |
|---|---|
kits |
required, a non-empty string array (kits_required, error); each kit must be in the package (kits_missing, §7.3) |
access, scope |
absent or {} (static_grants, error) — there is no token to grant anything to |
endpoint, deploy, events, events_mask, callbacks, callbacks_policy, provides, routes, schedule, admin, config, data, templates, egress, limits, uninstall |
forbidden (MODULE_STATIC_FIELDS): each present one is static_field {field} (error) |
category |
any; elements is its default home (§2.3) |
{
"id": "acme-blocks",
"name": {"cs": "Acme bloky", "en": "Acme blocks"},
"description": {"en": "Hero, pricing and FAQ sections in the Acme style."},
"category": "elements",
"version": "1.0.0",
"vendor": {"name": "Acme Studio", "url": "https://acme.example"},
"hosting": "none",
"kits": ["blocks"]
}The package is a subset of §7.1 — manifest.json, README.md /
docs/{locale}.md, docs/elements.md (expected when a kit ships a
global.js), CHANGELOG.md, kit/{kitId}/…, translations/ — and nothing
that runs (static_file, §7.3). What the platform does with it: the check
stops at stage 2 (no dry run); the install mounts the kits, the runtime doc
and the translations and writes the install doc — no grants step, no
handshake, no token, no custody, no setup/hooks entries, no
setup/modules (§8.2); configure / verify / reroll and the API call
verb answer 409 {error: 'not_applicable', hosting: 'none'}, logs an
empty list; every entry point that would route to a module (Gateway.call,
the storefront web leg behind /m/{id}, the hook ingress event leg)
answers 404 module_static — except the unauthenticated inbound-webhook leg
(/hook/module/{sid}/{id}/inbound/…), which answers the same
404 {error: 'not_found'} as an unknown module so a caller learns nothing
about what is installed — and the scheduler / outbox / notifier skip such an
install — so a hand-edited install doc can route nowhere. The hosting is
fixed per module: a version that changes it is refused at upload
(hosting_immutable, §7.3) and by update (409 hosting_changed, §8.3). enable /
disable work as for every module and drive the render gate (§8.2a).
2.2 ModuleFieldSpec — config fields#
| Field | Type | Required | Meaning |
|---|---|---|---|
key |
string |
yes | unique within config.fields; a secret field is stored as module_{id}_{key} (§5) |
kind |
'text' | 'secret' | 'number' | 'boolean' | 'select' | 'locale_text' | 'collection' | 'url' |
yes | secret = write-only, never on the envelope |
label |
Record<locale, string> |
yes | per admin locale |
help |
Record<locale, string> |
no | under the input |
required |
boolean |
no | the card opens on required fields still empty after install |
options |
Array<{value: string; label: Record<locale, string>}> |
no | for select |
options_from |
string |
no | select options fetched live from the module (POST /api/module/call {path} → {rows: [{label, value}]}); one admin path ([a-z0-9_-] segments, ≤ 64 chars), never together with options, and a select needs one of the two. The card falls back to a text input when the module cannot answer |
default |
string | number | boolean |
no | the dry run's and the card's initial value |
public |
boolean |
no | exposed to the storefront through setup/modules (POST /public/setup {name: 'modules'}); never on a secret (validator) |
Non-secret values land on ModuleInstall.config and ride every envelope
as config (§3), so a module never reads its own install doc per call.
Secret values are stored write-only and the module reads them itself (§5).
2.3 Categories#
One category per module, from a closed list, so the catalog groups stably
and the console can translate it (modules.category.* in both locale
files). A module that spans two areas picks the one its primary extension
point serves and puts the other in tags. The category has exactly one
functional use — the setup pages' from modules shortcuts list the installed
modules of that category; it grants nothing.
category |
What belongs | Examples | Typical extension points | Setup shortcut |
|---|---|---|---|---|
shipping |
carriers, pickup points, labels, tracking, packaging | the carriers plan (PPL, Zásilkovna, DPD, GLS, Balíkovna, Česká pošta), a client's own courier | shipping.* / shipment.* callbacks, orders events, web (/m/{id}/points) |
Delivery setup |
payments |
payment providers, BNPL, gift cards, payment reconciliation | GoPay, Comgate, ThePay, Adyen; a bank-transfer matcher | provides: [{kind: 'payment'}] + the payment.* callbacks (§2.4 — a redirect gateway beside the built-in Stripe, picked per payment method) |
Payments setup card |
accounting |
invoicing/accounting exports, ERP accounting legs, tax reporting feeds | Pohoda, Money S3, ABRA, Fakturoid, iDoklad, Helios | provides: [{kind: 'invoicer'}] (+ orders for an ERP, §2.4), invoices + orders events, schedule, records (sync ledger), admin actions |
— |
data |
ERP/CRM/PIM synchronisation, import/export pipelines, generic connectors | a client's ERP, Airtable / Sheets sync, CSV drops | data events, schedule, state/ cursors, /admin/commit, content collections |
Data setup |
products |
catalog enrichment, pricing rules, stock sources, PIM, variants, feeds into the store | supplier stock feeds, price-list rules, a PIM push, image enrichment | products.modules.{id}, data events on products, schedule |
Products (taxonomy pages) |
orders |
fulfilment, OMS/WMS, returns, order workflows, warehouse | a 3PL, a returns portal, order-status automations, packing lists | order.validate, orders events, orders.modules.{id}, admin actions + cards on the order |
— |
customers |
CRM, loyalty, reviews, support desk, B2B accounts | HubSpot / Pipedrive sync, a loyalty ledger, review requests, Zendesk | customers.modules.{id}, orders events, identity-scoped web routes |
— |
mailing |
e-mail/SMS/push marketing, transactional mail providers, automation | Mailchimp, Brevo, Ecomail, SmartEmailing, an SMS gateway | forms + orders + customers events, inbound webhooks, schedule, admin cards |
Mailing setup (setup/smtp page) |
marketplaces |
marketplaces and comparison engines, order import from them | Amazon Seller, Heureka, Zboží, Google Merchant, Kaufland, Allegro | schedule (feeds), orders create through the install token, products events, records |
— |
booking |
reservations, appointments, rentals, ticketing, capacity | Reservations, a table-booking connector, event tickets | order.validate (closed), content + records collections, web + guarded routes, kit elements |
— |
content |
CMS sync, SEO/redirect tooling, translation services, media/DAM, site search | DeepL translation pass, Algolia/Meilisearch indexing, a DAM mirror, redirect importers | data events, content collections, web routes (/m/{id}/search), schedule |
Web setup |
forms |
form back-ends and lead routing, anti-spam, e-signature, surveys | a CRM lead push, Turnstile/hCaptcha scoring, DocuSign, a survey tool | forms events, guarded web routes, form-level admin actions |
Forms |
analytics |
tracking, attribution, consent, BI exports, dashboards | a consent manager, Meta/Google conversion API relays, a BI export (GA4 itself is core) | orders events, web (pixels endpoint), schedule, overview widgets |
Statistics |
ai |
AI features beyond the core agents: assistants, generation, classification | a product-description generator on the client's model, a support bot, image tagging | admin actions + forms, web (/m/{id}/chat), products events |
— |
compliance |
GDPR/data-act tooling, VAT/OSS, age or identity verification, EPR/GPSR data | a DSAR export relay, an OSS VAT calculator, age gates, EPR registrations | order.validate, customers events, admin actions, records |
— |
tools |
developer and operations utilities that fit nowhere above | a webhook relay/inspector, backups, the module-echo fixture, a bulk-edit helper |
anything | — |
elements |
element libraries — the default home of an elements module (hosting: 'none', §2.1) |
a design library (Make Design), an agency's component set, a section pack | kits (+ their global css / js) only |
— |
Rules: an unknown id is 422 manifest_invalid: category; the catalog
(POST /api/module/catalog {category?}) filters and the Modules page groups
by it; the catalog card shows category + tags. Adding a category = one entry
in MODULE_CATEGORIES, two i18n keys, one row in the plan's table and one
here; no data migration (installs snapshot the manifest).
2.4 provides — targeting a whole core contract#
Most manifest keys are seams: one place in the core that calls one point
of the module. A carrier is not a seam, it is a contract — a setup card
in a specific tab, a tag that delivery methods and orders carry, five verbs
the order page calls in a fixed order, a poller with a per-carrier budget, a
status machine that must not be bypassed. category: 'shipping' cannot
stand in for it because the category is inert by design. provides[] is the
manifest's way to say I implement contract X, so the core mounts the module
wherever a built-in implementation of X would be mounted. One entry per
kind; four kinds exist — carrier
(built, below), invoicer (issuing built — Part A2, below), orders
(the push / cancel / events runtime built — Part O1, below), payment
(a redirect gateway — Part G2, below).
The tag a store selects is always module:{id}.
| Kind | category |
Required callbacks | Flag ↔ callback (exactly when) | Also required | Validator rows |
|---|---|---|---|---|---|
carrier |
shipping |
shipment.createShipment, shipment.label, shipment.track |
cancel ↔ shipment.cancel, points ↔ shipping.points, rates ↔ shipping.rates, manifest ↔ shipment.manifest |
services[], label_formats, tracking.poll.interval; points for a point / box service |
provider_category, provider_callbacks, provider_services, provider_points_required |
invoicer |
accounting |
invoice.issue |
send ↔ invoice.send, pdf ↔ invoice.pdf, cancel ↔ invoice.cancel, proforma ↔ invoice.proforma + invoice.proforma_cancel |
— | provider_category, provider_callbacks |
payment |
payments |
payment.create, payment.status |
cancel ↔ payment.cancel; any method with capture: true ↔ payment.capture |
methods[] (≥ 1; unique code ^[a-z][a-z0-9_]{0,30}$, name {locale}, kind card / bank / wallet / bnpl / other, ISO currencies, optional ISO countries, limits {ISO: {min?, max?, step?}} ≥ 0), flows: ['redirect'], notify naming a routes.inbound[] entry with auth: 'url_key' |
+ provider_methods, provider_notify (MODULE_PROVIDER_KINDS_UNAVAILABLE is empty since Part G shipped — no kind answers provider_unsupported today) |
orders |
orders, accounting or data |
order.push |
cancel ↔ order.cancel |
reports ⊆ `accepted |
packed |
A module providing both invoicer and orders (an ERP) is accounting.
category still grants nothing; what a kind earns is decided by provides
alone:
- Grants — the install token's
access.moduleis never taken frommanifest.access(it is stripped before the intersect); the install handler adds one sub-key per declared kind:shipment.events(carrier),invoice.{events, pending}(invoicer),payment.events(payment),order.events(orders). An update that adds a kind widens the grants (409 grants_pendinguntil accepted). - Selection — a store may pick a module provider only while it is
installed, provides the kind, is enabled and verified:
provider_tag_invalid400,{kind}_unknown400 (carrier_unknown),{kind}_unsupported501,provider_disabled409,provider_unverified- For carriers this guards every enabled row of
setup/delivery.methods(POST /api/admin/shipping/setup {methods}; a row savedenabled: falseis parked and not checked) and itsservicemust be one of the entry'sservices[].code(400 carrier_service_unknown).
- For carriers this guards every enabled row of
- Call time — a disabled or unverified module provider fails closed
before any call (
409 module_disabled); the storefront's delivery options (POST /api/public/shipping/options, the tenant'sPOST /delivery/:locale) omit the methods of a module carrier that is not selectable, and an order naming one is409 carrier_unavailable. Not covered: a client that reads the publicsetup/deliverydoc directly, and the Studio preview — both still fail closed at order time. - Gateway — the ten provider callbacks run with a 20 s deadline, a
64 KiB cap (
invoice.pdf: 4 MiB, the label cap), theerrorfallback and no cache. Failures map through one table (refusalOf): a disabled / not installed module →module_disabled409, a plan lapse →tier_required403, a transport failure or any other platform refusal →module_failed502, a body{ok: false, error?, message?, retry?}→ the domain's own error (carrier_errorfor carriers). - Disable / update / uninstall — see §8.3–§8.5: a disable answers warnings, an update may not drop a selected kind, an uninstall is refused while work the module owns is open.
The carrier entry:
{kind: 'carrier',
services: Array<{code; name: Record<locale, string>; kind: 'home' | 'point' | 'box'; countries?: string[]; cod?: boolean; point_types?: string[]}>,
label_formats: Array<'pdf' | 'zpl'>, // what shipment.label can return
points?: boolean, rates?: boolean, cancel?: boolean, manifest?: boolean, // which optional verbs it implements
tracking: {poll: {interval: '30m' | '1h' | '6h'; daily_quota?: number}; push?: boolean}}A carrier entry requires category: 'shipping', callbacks ⊇ {shipment.createShipment, shipment.label, shipment.track},
and shipment.cancel / shipping.points / shipping.rates /
shipment.manifest exactly when cancel / points / rates /
manifest are set; points is required when any service is point / box.
The carrier tag on orders and delivery methods is module:{id}. Every
shipment.createShipment request carries key: '{oid}:s{n}' (n = the
order's shipping.attempts, bumped by every cancel) — a carrier module
must dedupe on it: a retried create with the same key answers the shipment
it already made. A create that lost the race to a concurrent one cancels its
own (different) shipment best-effort when the carrier has cancel. Named but
not designed: mailer — a new kind is a plan amendment first.
How the core calls a carrier module (functions/src/modules/shipping/module_adapter.ts
— ModuleCarrierAdapter, resolved by the carriers registry for any module:{id} tag).
Each verb is ONE gateway callback, the carriers plan's own shapes as input, the
answer as the top-level body:
| Verb (the order page / tracker) | Callback + input |
The module answers |
|---|---|---|
| Verify | the verify point |
{ok, message?} |
| Create shipment | shipment.createShipment {request: ShipmentRequest} |
ShipmentResult {id, tracking?, tracking_url?, parcels?, carrier?, status?} — carrier is echoed back to every later call as shipment.carrier |
| Print label | shipment.label {shipment: ShipmentRef, options: {format, page?, position?}} |
{format: 'pdf' | 'zpl', content_base64} — a format it declares in label_formats; the 4 MiB callback cap applies; the core stores the object (never the files tree) |
| Refresh / the tracker | shipment.track {shipment} |
{events: TrackingEvent[]} — {id?, code, status, text?, at (ms), permanent?}, status from created | accepted | in_transit | out_for_delivery | at_point | delivered | returned | cancelled | exception |
Cancel (only with cancel: true) |
shipment.cancel {shipment} |
{ok: true} |
Close-of-day (only with manifest: true) |
shipment.manifest {shipments} |
{id, content_base64?} |
Checkout / admin point picker (only with points: true) |
shipping.points {query} |
{points: PickupPoint[]} — cached 10 min; a failure is an empty list |
A body {ok: false, error?, message?} is the module's own refusal (the order card shows
message); a timeout / network failure is 502 module_failed, a disabled install
409 module_disabled. ShipmentRequest.items[].meta carries this module's own
products.modules.{id} fields when the manifest lists products in
data.entity_fields. Push tracking: POST /api/module/shipment/events {order, events} on the install token (the only /api/module/* route a module token calls —
the install grants module.shipment.events to a carrier); the module id is read from
the token, the order must carry shipping.service === 'module:{id}' (403 not_own_shipment), and the events go through the same tracker sink as a poll. The
tracker polls a carrier module on tracking.poll.interval (the first rung of the
30 min → 1 h → 2 h → 6 h ladder; every 6 h with push: true) within daily_quota
per store (429 quota_exhausted past it). Uninstall answers 409 shipments_open {module, orders ≤ 50} while an order it owns is in shipping / delivery
(the composite index orders (shipping.service, status)), and disables (never deletes)
the delivery methods that name it.
How the core calls an invoicer module (functions/src/modules/invoicing/ —
ModuleInvoicer, Part A2). A store switches with POST /api/admin/invoicing/provider {provider: 'module:{id}'} (owner / master; the
module must be selectable). From then on every issuing transition of an order
(completed → period: 'once'; a subscription's start → its period end in
seconds, else 'start'; a renewal → the new period end) writes a pending entry
order.invoicing.{period} = {provider, key, reason, state: 'calling', attempts, at, covers?} and the token invoicer:module:{id} into
order.providers_pending — in the same write as the status move — and ONE
call runs from the providerCallTask queue (never inside a trigger, never
retried by the platform):
| Verb | Callback + input |
The module answers |
|---|---|---|
| Issue | invoice.issue {request: IssueRequest} — {key, period, reason, order (masked: no Stripe secret, only your own modules.{id}), vendor?, delivery, paid?: {on, amount (MAJOR), method}, due?, covers?: {from, to}} |
IssueResult {number, label?, ref, url?, pdf?, app_url?, status: issued | paid, vendor?, payment?, warnings?} |
Send (send: true, delivery: 'provider') |
invoice.send {invoice: {number, ref}, to, locale?} |
{} |
PDF (pdf: true) |
invoice.pdf {invoice} |
{content_base64} (4 MiB) |
Proforma (proforma: true) |
invoice.proforma {request: IssueRequest} — reason: 'proforma', period: 'proforma', key {oid}:proforma, due (YYYY-MM-DD, fixed by the platform), never paid |
IssueResult with status: 'issued' and payment {amount, currency, vs, iban | account, due} (required) |
Proforma cancel (proforma: true) |
invoice.proforma_cancel {order, number, ref?} — the order closed unpaid (best effort, no pending entry) |
{} |
pdf and send are called on demand by POST /api/public/invoice: invoice.pdf
once, on the first PDF request for a document whose IssueResult.pdf was true
(the platform keeps the copy), invoice.send when the invoice mail is requested
and the document was issued with delivery: 'provider'. Every other surface of
your document hands off to IssueResult.url — the buyer never sees a platform
rendering of it.
Proformas — the advance document of a bank-transfer
order. It is NOT an invoice: the answer becomes order.proforma (never invoices/ or
order.invoices). It rides the same pending machinery under the period 'proforma'
(retry, invoice/pending with request.reason === 'proforma', the issued event).
Report its state with document: 'proforma' + order on the state events
({event: 'paid' | 'unpaid' | 'cancelled' | 'restored', number, document: 'proforma', order, paid_on?, amount?, currency?}) — paid settles the order, which then turns
ready and asks for the final invoice at once with request.proforma = {number, ref?, paid_on?}: link or adopt your own final document (Fakturoid issues a paid
final_invoice from the paid proforma itself). A proforma paid after the order
closed only raises payment_alert: paid_after_close.
Dedupe on key ({oid}:{period}, …:r{n} after a manager's Reassign):
a repeated key must answer the first document. number becomes the invoice
doc id and order.invoices[] — ^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$ (put a
printed form like 2026/0001 in label), ref ^[A-Za-z0-9_:-]{1,128}$,
urls https; anything else is a failed issue (invoice_number_invalid,
retry: false). A number another sale already holds is invoice_number_taken.
{ok: false, error, message?, retry?} is your refusal (the entry shows
error, retry is a hint on the order card, not a schedule). The platform
then creates invoices/{number} with the whole document (provider: {id, ref, number, url, app_url, delivery, payment, warnings}) and clears the entry.
Recovery is yours or staff's: a timed-out or failed entry stays on the
order (state: 'calling' | 'failed', provider_alert: true while failed).
Your install token has two routes for it: POST /api/module/invoice/pending {limit?} → {pending: [{order, period, key, state, attempts, at, request}]}
(your entries only; a calling entry younger than 120 s is left out; the
request rebuilt by the platform), and POST /api/module/invoice/events {events ≤ 50} → {ok, applied, skipped: [{index, reason}]} with {event: 'issued', order, key, result} (accepted for your entry with that key in
either state, else not_pending) and {event: 'paid' | 'unpaid' | 'cancelled' | 'restored', number, paid_on?, amount?} on documents you issued (else
invoice_not_owned) — paid / unpaid also settle / unsettle an order paid
offline (cash, on_delivery, transfer); a card order is left as it is.
Events for your documents keep working after the store switched away from you.
Staff have Retry now (POST /api/admin/invoicing/retry, the same key to
your module) and Reassign (to the store's current provider). Uninstall
answers 409 invoice_provider_active while the store's setup/invoice.provider
names you and 409 provider_pending while an order still waits on you.
How the core calls an order-system module (functions/src/modules/store/order_processor.ts
— ModuleOrdersAdapter, Part O1). The orders entry:
{kind: 'orders',
reports: Array<'accepted' | 'packed' | 'shipped' | 'delivered' | 'returned' | 'cancelled'>, // what you can report; may be []
cancel?: boolean} // you implement order.cancelIt needs callbacks ⊇ {order.push} (+ order.cancel exactly when cancel)
and events subscribed to orders — everything after the push (items,
address, note, payment state — a COD order settled later) reaches you only
through that asynchronous event; diff what you care about. A store switches
with POST /api/admin/orders/processor {processor: 'module:{id}'} (owner /
master; selectable). From then on every order is pushed once: the trigger
pass that first sees it ready or active with its number writes
order.processing = {provider, key: '{oid}', reason: 'ready', state: 'calling', attempts: 1, at} + the token orders:module:{id} and ONE call runs from the
providerCallTask queue. Orders already past ready when the store switched,
and orders created ready with items, are pushed only by staff (Push now).
| Verb | Callback + input |
The module answers |
|---|---|---|
| Push | order.push {key: '{oid}', reason: 'ready' | 'retry' | 'manual', order (masked by your events_mask), customer?: uid} |
{ref, number?, url?, warnings?} — ref ^[A-Za-z0-9_:-]{1,128}$ (else the push fails bad_ref), number ≤ 128 (display only — the order number stays the platform's), url https ≤ 500 (console only), ≤ 10 warnings × 200 |
Cancel (cancel: true) |
order.cancel {order: {id, ref}, reason?} — when a pushed order is closed |
{warnings?} (say so when cancel means "deleted") |
The answer is stored as order.processing.{ref, number, url, status: 'pushed', status_at, pushed_ms, warnings}. Dedupe on key (the order id — a retry,
a staff Retry now and a Reassign back to you all reuse it): a repeated
key must answer the order you already created (BaseLinker: keep it in an extra
field of a dedicated source and look it up before addOrder). Map VAT
percent to your rate classes and report what did not fit (truncated text,
rounded quantity, an unmatched SKU, a pickup point you cannot hold) as
warnings. A failure never blocks the store — the order is already ready;
the entry shows failed with your error and retry hint and
provider_alert: true.
Your channel back — POST /api/module/order/events {events: 1..50} with
your install token (module.order.events), each {order, event, at (ms), note?, ref?, number?, url?, packages?, warning?} → {ok, applied, skipped: [{index, reason}]}:
event |
Effect | Allowed when |
|---|---|---|
ref |
completes a pending push exactly like a push answer (use it after a timeout: you created the order, the platform did not hear back); on a pushed order replaces ref / number / url (merged / split orders) — ledger ref_changed |
the order's processing.provider is you; not subject to reports |
warning |
appends warning (≤ 200 chars, newest 10 kept) |
a pushed order; not subject to reports |
accepted packed shipped delivered returned cancelled |
a processing ledger row; the newest event above processing.status_at becomes processing.status (+ its packages, ≤ 20 {carrier?, number, url?, status?, return?}, replaced whole) |
a pushed order, the event in your reports |
Each event is recorded once ({event}:{at} — resend freely). The sink never
writes order.status; the order moves by processingStatusTransition —
shipped: ready → shipping; delivered: ready | shipping | delivery → completed; returned: shipping | delivery → returned; accepted,
packed and cancelled never move (a cancelled system order alerts the
store; the merchant decides) — and only when the order has no platform
shipment and setup/order.processor_auto_status !== false. Report
delivered only for delivery to the customer (an ERP's "dispatched" flag is
shipped). Skips: order_not_found, not_own_order, not_pushed,
event_undeclared, bad_event, bad_at (outside now − 90 d … now + 10 min),
duplicate, bad_ref, unchanged, bad_packages, bad_warning; a disabled
install is 409 module_disabled for the whole request. A store hears of a
cancelled system order through an orders notification hook with filter: {'processing.status': ['cancelled']}, on: ['update']. Staff have Retry
now / Push now / Reassign (POST /api/admin/orders/processing/retry).
Uninstall answers 409 order_processor_active while setup/order.processor
names you and 409 provider_pending while an order still waits on you.
The parts map — which key targets which part of a module:
| Part (what the author wants) | Manifest key | Where it lands |
|---|---|---|
| Setup — credentials, account ids, defaults | config.fields (+ config.verify) |
the config card on the module page; secret fields write-only under module_{id}_*; Verify → the verify point |
| Setup — values the storefront may read | config.fields[].public |
setup/modules through POST /public/setup {name: 'modules'} |
| Setup — picking from the vendor's lists | ModuleFieldSpec.options_from |
the card fetches options through POST /api/module/call |
| Order integration — veto / annotate at checkout | callbacks: ['order.validate'] (+ callbacks_policy) |
Order.prepare, before the write |
| Order integration — the shipment lifecycle | provides: [{kind: 'carrier'}] + the shipment.* / shipping.* callbacks |
the whole carrier surface (order Shipment card, tracker, delivery-methods editor, checkout planner) |
| Order integration — the module's own fields on the order | data.entity_fields: ['orders'] |
orders.modules.{id}; the read-only Modules section on the order form |
| Order integration — buttons, panels, forms on the order page | admin.actions[] / cards[] / forms[] with entity / placement: 'orders' |
the order detail toolbar, row menu, a card, a form; the envelope carries entity: {kind: 'orders', id} |
| Product / customer integration | the same keys with products / customers, plus data.entity_fields |
the product / customer detail; {entity}.modules.{id} |
| Hooks out — the platform tells the module | events[] (+ events_mask) |
a managed setup/hooks entry module_{id}*, signed, retried through the outbox (§6) |
| Hooks in — a third party tells the module | routes.inbound[] |
GET|POST /hook/module/{sid}/{id}/inbound/{path}[/{url_key}] — the third party's webhook URL, never the module's origin |
| Hooks in — the module tells the platform | (none: an API call on the install token) | /admin/* under scope, /admin/commit; a carrier's POST /api/module/shipment/events; an invoicer's POST /api/module/invoice/{events,pending} |
| Storefront — an endpoint on the store's origin | routes.web, routes.guarded[] |
/m/{id}/** through the tenant server and the Studio bridge |
| Storefront — UI | kits[] (+ the kit's runtime, docs/elements.md), templates[], translations/ |
module-owned elements + the shared runtime doc (§8.2a), seeded e-mail templates, merged store translations |
| Storefront — delivery methods + pickup points | provides.carrier.services[], shipping.points, shipping.rates |
setup/delivery.methods[], the delivery planner (POST /delivery/:locale), POST /public/shipping/points (shipping.rates is declared but not yet consulted — methods carry fixed prices) |
| Timed work | schedule.every |
the schedule point — no platform cron since 2026-10-24 (a master tick only); not for tracking polls (a carrier's polling is the core's tracker under provides.carrier.tracking) |
| Own data | data.collections[] (content / records), state/ |
data/{locale}/{collection} or modules/{id}/{collection}/{docId} (a records collection is a direct subcollection of the install doc, §5.5); modules/{id}/state/{key} |
| Admin — a page, a widget, a menu item | admin.pages[], admin.widgets[] |
/store/:sid/modules/:id/pages/:page (iframe), the overview kinds, the module menu kind |
| Catalog placement | category, tags |
grouping + the setup pages' from modules shortcuts — grants nothing |
How the core calls a payment module (functions/src/modules/payments/module_payments.ts
— ModulePaymentAdapter, Part G2). The payment entry:
{kind: 'payment',
methods: [{code, name: {locale: label}, kind: 'card' | 'bank' | 'wallet' | 'bnpl' | 'other',
currencies: ['CZK', …], countries?, limits?: {CZK: {min?, max?, step?}}, // MAJOR units; step 1 = a whole-unit currency
capture?: boolean}], // authorize now, capture later
flows: ['redirect'], // v1: exactly this
notify: 'notify', // one of your manifest's inbound routes, auth 'url_key'
requires?: ['email' | 'name' | 'phone'], // customer fields your gateway demands
cancel?: boolean} // you implement payment.cancelIt needs callbacks ⊇ {payment.create, payment.status} (+ payment.cancel
exactly when cancel, payment.capture exactly when a method has capture)
and no orders scope — the callbacks carry everything. A store offers
your methods by adding rows to its payment catalog
(POST /api/admin/payments/setup {op: 'set', methods: [{id, service: 'module:{id}', method: '<code>', name, …}]}); a checkout then sends the
row's id as payment_method. Amounts in the contract are integer minor
units with currency and unit.
| Callback | Input → output | Notes |
|---|---|---|
payment.create |
{request: PaymentRequest} → PaymentSession {ref, status: 'pending' | 'succeeded' | 'requires_capture', redirect_url?, expires?} |
request = {key, attempt, reference, method, methods?, amount, currency, unit, lines?, order: {id, number?}, customer, return_url, cancel_url?, notify_url, expires?, capture: 'auto' | 'manual'}. key ({order}:p{n}) is your idempotency handle — a repeated key answers the same session; never send it to the gateway as its reference (use reference, the same for every attempt of an order; attempt tells attempts apart). ref is your gateway id (^[A-Za-z0-9_:-]{1,128}$, no .); redirect_url is https and required while pending. lines is present only when its totals add up to amount. notify_url is YOUR inbound route, built by the core — hand it to the gateway |
payment.status |
{payment: {ref, key, order}} → PaymentState |
may first complete a payment the buyer already approved (capture / authorize at the gateway); idempotent per key — the core calls it on the buyer's return and on Refresh |
payment.capture |
{payment, amount} → PaymentState |
only for a method with capture |
payment.cancel |
{payment} → PaymentState | {} |
void an authorization or cancel an unpaid payment, whichever the gateway has; {} = it has neither |
PaymentState = {status: 'pending' | 'requires_capture' | 'succeeded' | 'canceled' | 'denied' | 'expired' | 'refunded', amount?, currency?, partial?, ref?, method?, reason?, paid_on?} — a paid state (succeeded,
requires_capture) and refunded must carry amount + currency; for a
paid state they must equal what payment.create asked for, exactly
(otherwise the attempt is marked error and the order gets a
payment_alert).
The flow. The buyer is redirected to redirect_url (stored as
order.payment.service_intent.session) and returns to return_url
(?o={order} appended). The return URL carries no trusted outcome: the
storefront calls POST /api/process/payment/retrieve {order}, the core calls
payment.status and answers {provider, state}. Your gateway's webhook
reaches you on your inbound route; query the gateway, then report what the
query says through POST /api/module/payment/events {events: [{order, key, state}]} (your install token; ≤ 50 events) → {ok, applied, skipped: [{index, reason}]}. Both legs go through one writer, so a late or repeated
report is harmless: states are ranked (a paid payment is never un-paid by a
canceled), a payment paid on an older attempt re-points the order to it,
and money the core cannot act on is made visible on the order as
payment_alert — paid_after_close, paid_unrecorded (the answer to your
payment.create was lost: report the paid state with ref), refunded,
amount_mismatch — never dropped. There is no refund verb: the merchant
refunds in your gateway's portal. A failed payment.create is not retried by
the core — the buyer picks another method. Capture and cancel reach you as
one call each from the provider call queue (finalizing an order captures an
authorized payment; closing an order cancels an attempt still waiting for the
buyer). After the store's plan stops admitting your module, platform-catalog
packages still receive inbound, schedule, payment.status,
payment.capture and payment.cancel — payments in flight finish — while
payment.create is refused and your methods are no longer offered.
2.5 A minimal connected manifest#
Everything a phase-1 module needs, and nothing that is not yet available.
The canonical example is the echo fixture's own manifest.json
(examples/module-echo/, §11); this is the shape:
{
"id": "echo",
"name": {"en": "Echo", "cs": "Echo"},
"description": {"en": "Logs every envelope it receives.", "cs": "Zaznamená každou obálku."},
"category": "tools",
"tags": ["fixture"],
"version": "1.0.0",
"vendor": {"name": "inmake", "url": "https://inmake.dev"},
"hosting": "connected",
"endpoint": "https://echo.example.com",
"access": {"admin": {"read": true, "list": true, "update": true, "create": true, "delete": true, "commit": true}, "secure": {"secret": true}},
"scope": {"modules": {"echo": true}, "orders": {"*": true}}, // orders write: it annotates orders.modules.echo (entity_fields below)
"events": ["orders", {"kind": "data", "collection": ["articles"], "on": ["create", "update"]}],
"data": {"entity_fields": ["orders"]},
"config": {
"verify": true,
"fields": [
{"key": "account", "kind": "text", "label": {"en": "Account", "cs": "Účet"}, "required": true},
{"key": "api_key", "kind": "secret", "label": {"en": "API key", "cs": "API klíč"}, "required": true},
{"key": "widget", "kind": "text", "label": {"en": "Widget key", "cs": "Klíč widgetu"}, "public": true}
]
},
"uninstall": "keep"
}What the platform does with it at install: mints a token whose routes are
access ∩ MODULE_MAX_ACCESS and whose paths are scope; writes one managed
hook entry per distinct (filter, collection) of events; lets the module
write orders.modules.echo and its own modules/echo/state|records; renders
the three config fields on the card (the secret write-only, widget public
through setup/modules); and calls verify with config after the
handshake.