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_VERSION1) and may still change before the first release.
Installing a plugin
Section titled “Installing a plugin”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
.jsor.mjsfile, or - a folder with a
package.jsonwhoseexportsormainpoints at the plugin, or with anindex.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.
Writing a plugin
Section titled “Writing a plugin”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.
Handling events
Section titled “Handling events”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):
1290is 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.
What a handler can use
Section titled “What a handler can use”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.
Routes
Section titled “Routes”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.
Settings
Section titled “Settings”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.
Scheduled jobs
Section titled “Scheduled jobs”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.
Items for the POS app
Section titled “Items for the POS app”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,numberorphone, 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.
Server endpoints
Section titled “Server endpoints”| 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 |
Limits
Section titled “Limits”- 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.
Licensing
Section titled “Licensing”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