inmake DOCS
Docs / Modules / Create your own module

Create your own module

A step-by-step walkthrough: manifest, code, pack, upload, install and verify — then managed and WebAssembly variants.

This walkthrough builds a small connected module from an empty folder to an installed, verified module on one of your projects. The module, hello:

  • adds a Greeting setting to its configuration card and checks it on Verify;
  • stamps every new order with modules.hello.seen_at;
  • answers GET /m/hello/ping on the project's website.

That covers the parts most modules need: the manifest, configuration, events, writing back to the project, and a website route. The reference has every option. Types of modules explains when to choose a hosted (WebAssembly) module or let the platform deploy your code instead. Both are covered briefly at the end.

Before you start#

  • The developer role. Uploading a package needs an account with the developer platform role and membership of a project in your space. Ask the platform team to enable it. Platform admins can upload on your behalf.
  • A test project. Install on a project you can experiment on, preferably in its Demo environment.
  • The starter. Download it from Starter › Download & unpack. It contains drop-core-server (the module router), drop-core-sdk (the API client) and npm run module:pack.
  • Node.js 20+ and a place to run a public HTTPS server (see step 5).

1 Lay out the folder#

my-modules/
└── hello/
    ├── manifest.json      # what the module is, needs and adds
    ├── README.md          # its documentation — required
    └── server.ts          # the code (stays on your server, not in the package)

The package you upload contains only manifest.json, README.md and any optional assets (Packages). For a connected module, the code lives on your own server.

2 Write the manifest#

{
  "id": "hello",
  "name": {"en": "Hello", "cs": "Hello"},
  "description": {"en": "Stamps new orders and greets from the website.", "cs": "Označí nové objednávky a pozdraví z webu."},
  "category": "tools",
  "version": "1.0.0",
  "vendor": {"name": "Your company", "url": "https://example.com"},
  "hosting": "connected",
  "endpoint": "https://hello.example.com",
  "access": {"admin": {"read": true, "update": true}},
  "scope": {"orders": {"*": true}},
  "events": [{"kind": "orders", "on": ["create"]}],
  "data": {"entity_fields": ["orders"]},
  "routes": {"web": true},
  "config": {
    "verify": true,
    "fields": [
      {"key": "greeting", "kind": "text", "required": true,
       "label": {"en": "Greeting", "cs": "Pozdrav"}}
    ]
  },
  "uninstall": "keep"
}

What each part does:

Field Here
id lowercase, 3–32 characters, and permanent. It names the install, the /m/hello/… web prefix and the modules.hello fields.
category one from the list. It groups the catalog and grants nothing.
version semver. Every upload must be higher than the last.
endpoint your server's public HTTPS base URL (step 5).
access the API routes the module's token may call: read and update through /admin.
scope the data paths it may touch: any order.
events the writes it wants to hear about: orders, on create only.
data.entity_fields lets it write its own modules.hello.* fields on orders, and nothing else on them.
routes.web turns on /m/hello/** on the project's website.
config the fields on the configuration card. verify: true calls the module to check them.

Ask for as little as you need. The manager sees access and scope on the Grants step before installing, and a later version that asks for more waits for their approval. All fields: The manifest.

3 Write the code#

Add express to the starter's workspace (drop-core-server and drop-core-sdk are already in its vendor/):

// server.ts
import express from 'express';
import {existsSync, readFileSync, writeFileSync} from 'node:fs';
import {expressModuleRouter, type ModuleCall, type ModuleHandlers, type ModuleKeys, type ModuleTokenGrant} from 'drop-core-server';
import {initDropCore} from 'drop-core-sdk';

// What the platform hands over at install. Keep it in a real secret store in
// production — a restart without it means rerolling the keys in the console.
type Identity = {keys?: ModuleKeys; grant?: ModuleTokenGrant};
const FILE = 'identity.json';
let identity: Identity = existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {};
const persist = () => writeFileSync(FILE, JSON.stringify(identity));

// The platform API, as this install (its scoped token, its project).
const platform = (call: ModuleCall) => initDropCore({
    store: call.store,
    access_token: identity.grant!.token,
    env: call.env === 'demo' ? 'demo' : 'prod',
}, call.store);

const handlers: ModuleHandlers = {
    // Verify on the module page: check the configuration.
    verify: (call) => call.config.greeting
        ? {ok: true, message: 'Ready'}
        : {ok: false, message: 'Set a greeting first'},

    // A new order (label `orders/{id}`): stamp it with our own field.
    event: async (call) => {
        const id = call.label.split('/')[1];
        await platform(call).admin.store.repo.update(`store/${call.store}/orders/${id}`, {
            value: {'modules.hello.seen_at': Date.now()},   // dotted path — never a nested object
        });
        return {ok: true};
    },

    // GET https://{project site}/m/hello/ping
    web: (call) => ({
        status: 200,
        headers: {'content-type': 'application/json'},
        body: JSON.stringify({hello: call.config.greeting}),
    }),
};

const app = express();
app.use(express.json({limit: '1mb'}));
app.use('/', expressModuleRouter(handlers, {
    version: '1.0.0',                                   // keep equal to manifest.version
    moduleKey: () => identity.keys?.module_key,
    sessionKey: () => identity.grant?.session_key,
    onKeys: (keys) => { identity = {keys}; persist(); },  // a new handshake replaces the old token
    onToken: (grant) => { identity.grant = grant; persist(); },
}));
app.listen(Number(process.env.PORT ?? 8080));

What the router does for you:

  • Verifies every request. Each call carries a signature made with the install's module_key. A request that fails gets 401 before any handler runs, so nothing reaches your code unverified.
  • Runs the install handshake. The platform first sends the keys. The router stores them through onKeys and proves possession. Only then does the platform send the token, which arrives through onToken.
  • Drops duplicate events. A retried delivery carries the same request id and is answered without calling your handler again.

A few rules that matter:

  • Write fields on orders, products and customers as dotted paths (modules.hello.seen_at). A nested {modules: {hello: …}} object is refused, because it would wipe other modules' fields.
  • call.config is the non-secret configuration and arrives on every call. A secret field never does. Read it yourself with secure.secret.get('module_hello_{key}'), which needs "secure": {"secret": true} in access.
  • Your own write fires no create event here, so the module never loops. If you subscribe to updates too, ignore your own changes.
  • One expressModuleRouter holds one install's keys. To serve several projects from one server, keep the keys per store (the x-drop-store header) and check each request with verifyModuleEnvelope(headers, key).

Not on Node.js? Any stack works. Verify the access-seed header as described in The envelope, then call the REST API with the token.

4 Document it#

README.md is what managers read in the catalog and on the module's Documentation tab. Explain what the module does, how to set it up, and what it writes and keeps on uninstall. It is required, and a package without it is refused. Add docs/cs.md / docs/en.md for translations.

5 Run it on HTTPS#

The platform only calls public https:// addresses on port 443 with a real hostname. Deploy server.ts to any host, for example a VPS, Fly.io or Render, and put its base URL in endpoint.

Default platform-style addresses are refused as endpoints: *.run.app, *.web.app, *.cloudfunctions.net, *.firebaseio.com, *.googleapis.com and the platform's own domains. Put your own domain in front of such a host, or let the platform deploy the code for you (below).

6 Pack and check#

npm run module:pack -- ./hello        # → hello-1.0.0.zip

This runs the same checks as the upload: manifest rules, element kits, documentation and the package hash. It writes the zip only when there is no error. Fix what it reports and pack again. What each row means: Packages.

7 Upload#

In the console, open a project of your space → Modules › Upload package → pick the zip. The page checks it again and shows the check report: errors stop you, warnings don't.

  • As a developer, the package is submitted for review. A platform admin approves it (or rejects it with a note) in Module review, and then it becomes installable on every project of your space.
  • A platform admin's upload for a space is published at once.

8 Install and verify#

Once published, the module appears in the project's catalog as Private:

  1. Install → review the Grants → accept. The platform calls /install twice (keys, then token), and your identity.json fills in.
  2. On the module page, set Greeting → Save → Verify. The chip turns verified.
  3. Place a test order. It gains modules.hello.seen_at, visible read-only in the order's Modules section.
  4. Open https://{project site}/m/hello/ping.

The module page's Health table shows the last result of each call, and Logs lists event deliveries still being retried or given up on (five attempts).

9 Change it#

  • Code only (the manifest unchanged): deploy your server again. Nothing needs uploading. Update the version you report so the console can show the drift.
  • Manifest changes (new fields, events, permissions, assets): raise version, pack, upload, then Update on the project. If the new version asks for more access or scope, a manager accepts the difference first.

Going further#

You want Add See
a button, form or info card on an order, product or customer routes.admin + admin.actions / forms / cards Points
a full admin page or an Overview widget admin.pages / admin.widgets Points
your own content collections or private records data.collections Identity and access
periodic work schedule: {every: '1h'} Points
webhooks from a third-party service routes.inbound Points
to check orders at checkout callbacks: ['order.validate'] Points
to be a carrier, invoicer, payment gateway or order processor provides The manifest
website elements, e-mail templates, translations kit/, templates/, translations/ Packages

No server: let the platform deploy it#

Set "deploy": "managed" and drop endpoint. Put the code in the package under src/, with a src/package.json whose main exports the handler as module. An Express app built with expressModuleRouter works as is. Dependencies install from the public npm registry, so bundle anything that isn't published there (such as drop-core-server). Installing deploys the function into your space's Firebase project (Space › Cloud) and hands it its identity, so onKeys / onToken have nothing to store. moduleIdentityFromEnv() in drop-core-server reads that identity. Details: Lifecycle.

Hosted: a WebAssembly module#

Set "hosting": "hosted" and ship module.wasm (≤ 4 MiB) in the package. It exports one function, call: the input is the envelope as JSON ({headers, body}, with the point in headers['x-drop-point']), and the output is {status, headers?, body?}. There is no handshake and no token in your hands. The module calls the platform through the host function platform_fetch(path, body) and keeps small state with kv_get / kv_set. It may only call the outside hosts listed in egress. Build it with any Extism plug-in kit. Details and limits: Lifecycle.

Test locally first#

Testing locally describes the echo module, a complete connected module that logs every call it receives. It is the quickest way to see the real shape of each envelope.