inmake DOCS
Docs / Modules / The manifest

The manifest

ModuleManifest field by field: config fields, categories, provides, a minimal connected manifest.

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.module is never taken from manifest.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_pending until accepted).
  • Selection — a store may pick a module provider only while it is installed, provides the kind, is enabled and verified: provider_tag_invalid 400, {kind}_unknown 400 (carrier_unknown), {kind}_unsupported 501, provider_disabled 409, provider_unverified
    1. For carriers this guards every enabled row of setup/delivery.methods (POST /api/admin/shipping/setup {methods}; a row saved enabled: false is parked and not checked) and its service must be one of the entry's services[].code (400 carrier_service_unknown).
  • 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's POST /delivery/:locale) omit the methods of a module carrier that is not selectable, and an order naming one is 409 carrier_unavailable. Not covered: a client that reads the public setup/delivery doc 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), the error fallback and no cache. Failures map through one table (refusalOf): a disabled / not installed module → module_disabled 409, a plan lapse → tier_required 403, a transport failure or any other platform refusal → module_failed 502, a body {ok: false, error?, message?, retry?} → the domain's own error (carrier_error for 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.cancel

It 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.cancel

It 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.