Skip to content

Plugins

Plugins add features to an OpenKedai server without changing OpenKedai itself. Examples: syncing sales to an accounting package, awarding loyalty points, printing a feedback QR code on receipts, or asking for a car plate at a drive-through. A plugin runs inside the server, reads the shop’s sales as they happen, and can add its own web routes, settings, data and payment types.

Plugins run on the server (the hub). The POS app never runs plugin code, so a sale never waits on a plugin, and plugins can’t change prices, tax, discounts or rounding. Those come from the shop’s settings, which every device applies the same way, even offline.

Status: the plugin interface is new (PLUGIN_API_VERSION 1) and may still change before the first release.

A plugin is a JavaScript module. To install one, put it in the server’s plugins folder, <data folder>/plugins/ (/data/plugins/ in Docker), and restart the server. It can be:

  • a single .js or .mjs file, or
  • a folder with a package.json whose exports or main points at the plugin, or with an index.js.

You can also install plugins from npm next to the server and list them in OPENKEDAI_PLUGINS. That suits a server run from a clone of the repository. With the Docker image or the Linux installer, use the plugins folder: it is in the data folder, so plugins stay through updates.

Environment variable What it does Default
OPENKEDAI_PLUGINS_DIR Folder to load plugins from <data folder>/plugins
OPENKEDAI_PLUGINS Extra plugins: package names or file paths, comma-separated none
OPENKEDAI_PLUGINS_DISABLED Plugin ids not to start, comma-separated none
OPENKEDAI_PLUGIN_<ID>_<KEY> A plugin setting, e.g. OPENKEDAI_PLUGIN_DAILY_TALLY_FEEDBACK_URL none

A plugin that fails to load is logged and skipped. The server still starts.

Plugins run with the server’s full permissions, so only install plugins you trust.

Install the SDK, @openkedai/plugin-sdk. It is MIT licensed and has no dependencies. Export a plugin definition as the module’s default export:

import { definePlugin, PLUGIN_API_VERSION } from "@openkedai/plugin-sdk";
export default definePlugin({
id: "hello", // 2–40 lowercase letters, digits and single hyphens
name: "Hello", // or { en: "Hello", ms: "Helo" }
version: "1.0.0",
apiVersion: PLUGIN_API_VERSION,
setup(api) {
api.onEvent("order.closed", (event, ctx) => {
ctx.log.info("Order closed", { orderId: event.orderId });
});
},
});

Bundle it into one file (for example with esbuild --bundle --format=esm --platform=node) and drop it in the plugins folder. examples/plugins/daily-tally is a complete example to copy.

api.onEvent(patterns, handler) receives events after they are stored. A pattern is an exact type (order.closed), a prefix ending in .* (order.*, ext.loyalty.*), or * for everything.

  • Events arrive in the order they were stored, one at a time.
  • Each event is delivered at least once. The server remembers how far each plugin got, so after a restart it carries on from there. After a crash it may deliver the last few events again, so make handlers safe to repeat: remember what you have done, keyed by event id, payment id or order id.
  • If a handler throws, the server retries it after 1 s, 2 s, 4 s… (up to every 5 minutes). Later events wait, so the plugin never sees them out of order. Sales are never held up. The owner can see the error and, as a last resort, skip the failing event.
  • Money is in sen (integer cents): 1290 is RM12.90.

Core event types: order.opened, order.item_added, order.item_changed, order.item_voided, order.discount_applied, order.service_charge_set, order.sent_to_kitchen, order.fields_set, order.payment_recorded, order.closed, order.refunded, shift.opened, shift.cash_movement, shift.closed. Their payloads are defined in packages/core and validated by packages/events.

The ctx passed to handlers, routes and jobs is scoped to one shop (tenant):

ctx.settings This shop’s settings for the plugin, with defaults filled in
ctx.storage The plugin’s own SQLite database for this shop: exec, run, all, get, transaction, and a small kv store. It is a separate file, so a plugin can’t damage the shop’s data
ctx.emit(name, payload) Stores one of the plugin’s own events, ext.<plugin id>.<name>, declared first with api.defineEvent(name, schema?)
ctx.events.get(id), ctx.events.forOrder(orderId) Read the shop’s event log
ctx.log Structured logs, tagged with the plugin and shop

Plugin events are part of the shop’s permanent log. They never belong to an order: refer to orders in the payload ({ orderId: "…" }). A payload must be a JSON object of at most 16 KB.

api.onTenantStart(ctx => …) runs once per shop before any handler, for example to create tables. Make it safe to run again.

api.route({
method: "GET",
path: "/orders/:orderId", // served at /api/ext/<plugin id>/orders/:orderId
access: "owner", // public | device | staff | owner (default)
body: schema, // optional, for POST/PUT/PATCH: any Standard Schema (Zod, Valibot…)
handler: async (request, ctx) => ({ orderId: request.params.orderId }),
});

Return a value to send it as JSON, respond(status, body, headers) for anything else, or nothing for 204 No Content. Throw new PluginHttpError(404, "No such order") to send an error. Any other error becomes a plain 500, and its details go to the log only.

Paired POS devices can call device routes with their token. owner routes answer an owner or manager signed in to the back office, and staff routes either of those or a paired device (see ADR 0020). Treat public routes as reachable by anyone who can reach the server. On a hub with several shops, a public route picks the shop with ?shop=<shop id>; for the others, the device or the session picks it.

Declare settings as fields, and the back office draws the form:

settings: [
{ key: "api_key", type: "text", label: "API key", secret: true },
{ key: "every", type: "number", label: "Every", default: 100, min: 1, integer: true },
{ key: "enabled", type: "boolean", label: "Enabled", default: true },
{ key: "mode", type: "select", label: "Mode", options: [{ value: "a", label: "A" }] },
],

A value comes from what the owner saved, then from OPENKEDAI_PLUGIN_<ID>_<KEY>, then from the default. Secret values are never sent back to a browser.

api.job({ id: "nightly-export", schedule: { dailyAt: "03:30" }, run: async (ctx) => { … } });
api.job({ id: "sync", schedule: { everyMinutes: 15 }, run: async (ctx) => { … } });

Jobs run per shop, on the server’s local time. A run that is still going when the next is due is skipped.

A plugin can add items that the POS app shows and caches, so they work offline:

contributes: (settings) => ({
tenders: [{ id: "grabpay", name: "GrabPay", method: "ewallet", reference: "optional" }],
receiptBlocks: [{ id: "survey", text: "Rate us", qr: "https://example.my/r/{receiptNumber}" }],
orderFields: [
{ id: "plate", label: "Car plate", type: "text", channels: ["takeaway"], print: { receipt: true } },
],
}),
  • Tenders are extra payment types. Each is counted as a built-in method (card, qr, ewallet, other), so reports and the cash count keep working. The receipt prints its name.
  • Receipt blocks print text and/or a QR code under the shop’s footer. Placeholders: {receiptNumber}, {outletName}, {total} (e.g. RM12.90), {totalSen} (e.g. 1290), {date} (YYYY-MM-DD).
  • Order fields are extra values taken with an order: text, number or phone, optionally required, and optionally printed on the receipt or kitchen ticket.

Ids are unique within the plugin. The server prefixes them with the plugin id, e.g. my-plugin:grabpay. Devices fetch the merged list from GET /api/plugins/extras.

Endpoint Access
GET /api/plugins owner Plugins, their state per shop, failing events, jobs, and plugins that failed to load
GET /api/plugins/extras device The POS items of every running plugin
GET /api/plugins/:id/settings owner Setting fields and values (secrets shown as set or not)
PUT /api/plugins/:id/settings owner Save settings; null clears a value
POST /api/plugins/:id/skip-failed-event owner Skip the event a plugin keeps failing on
  • Plugins run in the server’s process. A plugin that loops forever or uses a lot of memory slows the whole server. The resource budget is measured without plugins.
  • A plugin can’t change totals, edit or delete events, or run on the POS app.
  • The plugin interface follows PLUGIN_API_VERSION. A server only loads plugins built for its version.

OpenKedai is AGPL-3.0. The LICENSE file adds a plugin exception: a plugin that talks to OpenKedai only through @openkedai/plugin-sdk may use any license, including a closed-source one. The SDK itself is MIT. (The exception’s wording is still to be reviewed by a lawyer.)

For AI tools: llms.txt ·llms-full.txtopenkedai.com