This guide describes the expected structure of an LNbits WASM extension so AI agents can create, review, or modify extensions with minimal guessing.
Use this file as the first source of truth when editing a WASM extension. If it
conflicts with config.json, config.json defines what LNbits actually loads.
If it conflicts with the source code, inspect the code before changing behavior.
Extensions are installed under {LNBITS_EXTENSIONS_PATH}/extensions/{ext_id}.
LNBITS_EXTENSIONS_PATH defaults to ./lnbits/, but deployments often set it
to another directory such as ./data. In this guide, <extension-root> means
the actual {LNBITS_EXTENSIONS_PATH}/extensions/{ext_id} directory. Paths in
config.json are always relative to that extension root.
config.json # LNbits runtime contract
manifest.json # extension repository listing metadata
wasm/
module.wasm # generated WASM component loaded by LNbits
lnbits-extension.wit # host/import/export interface
openapi.json # optional OpenAPI operation fragments for API docs
ui/
admin.html # configured private iframe entrypoint
public.html # configured public iframe entrypoint
static/
admin.js # private page browser code
public.js # public page browser code
app.css # extension styling
lnbits-extension-sdk.js # browser postMessage bridge SDK
assets/
icon.png # icon used by LNbits for WASM extensions
storage/
schema.json # current storage schema
migrations/
0001_*.json # ordered storage migrations
dev/
<language build files> # package/toolchain files for the source language
src/ # extension source code
sdk/ or bindings/ # optional language-specific host wrappers
dist/ or target/ # generated build output; do not edit by hand
Core runtime files:
config.json: declares routes, exports, permissions, and events.manifest.json: declares extension repository listing metadata.wasm/module.wasm: compiled extension module loaded by LNbits.wasm/lnbits-extension.wit: host/import/export interface used to build the WASM component.wasm/openapi.json: optional API documentation artifact consumed by LNbits whenconfig.jsonpoints at it with top-levelopenapi.ui/*.html: sandboxed iframe entrypoints served by LNbits.static/*: browser assets served under/ext-assets/{ext_id}/....storage/schema.json: current storage schema.storage/migrations/*.json: ordered storage migrations.
LNbits core UI assets used by iframe pages must be loaded through the approved
extension asset proxy, not through /static. Use
/ext-assets/{ext_id}/_lnbits/{asset_name} for approved shared assets such as
bundle.min.css, material-icons.css, vue.global.prod.js,
quasar.umd.prod.js, and qrcode.vue.browser.js.
Development files:
- The source language and build tool are template-specific.
- Source code usually lives under
dev/src/, but the exact layout is owned by the selected language template. - Host wrappers or SDK bindings may live under
dev/sdk/,dev/bindings/, or another template-defined path. - Generated build output under
dev/dist/,dev/target/, or language build directories should only be edited by the build tool. wasm/module.wasmis generated. Change source underdev/, then rebuild.static/admin.html,static/public.html, andstatic/index.htmlmay exist for compatibility or older layouts. The active iframe entrypoints are the files referenced byui_routes[*].entrypoint.
manifest.json is repository listing metadata used for extension discovery. It
is not the runtime extension config.
For a single extension repository, use this shape:
{
"repos": [
{
"id": "<extension-id>",
"organisation": "<github-org-or-user>",
"repository": "<github-repository>"
}
]
}The id value must match the extension folder name and config.json id. Do not
put runtime fields such as name, short_description, version, tile,
routes, WASM exports, events, or permissions in manifest.json; those belong in
config.json.
- Keep changes inside the extension unless the user explicitly asks for LNbits core changes.
- Do not edit generated files by hand:
wasm/module.wasm,dev/dist/*, build cache folders, or package lockfiles unless the build command updates them. - If backend behavior changes, edit the language source under
dev/and rebuildwasm/module.wasm. - If host functions are needed, add or update a thin wrapper in the language-specific SDK/bindings layer instead of scattering raw host imports through business logic.
- If browser behavior changes, edit
static/admin.js,static/public.js,static/app.css, or the configured files underui/. - Do not add or reference SVG files or SVG-rendered UI. Use PNG/WebP/JPEG for image assets and canvas or plain HTML/CSS for generated graphics; SVG is not allowed because it is a security-sensitive format.
- Browser JS must be compatible with the sandbox iframe CSP. Do not introduce
runtime template compilation,
eval,new Function, inline event handlers, or other code paths that requireunsafe-eval. - The iframe sandbox does not grant
allow-forms. Do not use native<form>submission or submit buttons in extension iframe pages. Use inert containers andtype="button"controls, then handle actions through JavaScript and the bridge SDK. - If routes, exports, events, permissions, or public fields change, update
config.json. - If stored data shape changes, update
storage/schema.jsonand add a new migration file. Do not edit old migrations for already released versions. - Keep public pages public: no account-only data, balances, outgoing payments, raw wallet ids, owner ids, API keys, or access tokens in public responses.
config.json is the main contract between LNbits core and the extension.
Required high-level fields:
id: extension id. Must match the extension folder name.name: display name.short_description: short UI text.version: extension version.min_lnbits_version: minimum LNbits version expected by the extension.extension_type: must be"wasm".wasm.module: relative path to the compiled module.wasm.exports: list of exported WASM functions LNbits may call.ui_routes: UI pages served under/ext/....api_routes: REST-like routes served under/api/v1/ext/{id}/....permissions: capabilities requested at install time.
Common optional fields:
events.onInvoicePaid: export to call when an invoice for this extension is paid.wasm.resource_limits.max_response_bytes: maximum serialized WASM response size.openapi: relative path to a JSON OpenAPI documentation artifact, usuallywasm/openapi.json.
Do not put build-only metadata in config.json unless LNbits core consumes it.
Build commands belong to the selected language template, for example in a
toolchain file, a Makefile, or developer docs.
WASM exports receive and return JSON strings, so LNbits cannot infer rich
request and response schemas from lnbits-extension.wit alone. The WIT file is
useful for finding export names, but field-level OpenAPI schemas must be
derived from the extension source, validation helpers, storage schema, and
public response objects.
Put the documentation artifact under the extension root, conventionally at
wasm/openapi.json, and reference it from top-level config.json:
{
"openapi": "wasm/openapi.json",
"api_routes": [
{
"method": "POST",
"path": "/items",
"export": "create-item",
"auth": "user"
}
]
}The artifact is not a full OpenAPI document. It is an LNbits metadata file with reusable schemas and OpenAPI Operation Objects:
{
"schemas": {
"CreateItemRequest": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"}
}
}
},
"commonResponses": {
"400": {
"description": "Invalid request payload.",
"content": {
"application/json": {
"schema": {"type": "object", "properties": {"detail": {"type": "string"}}}
}
}
}
},
"routes": {
"create-item": {
"summary": "Create item",
"description": "Creates an item for the authenticated user.",
"operationId": "myextension_create_item",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {"$ref": "#/schemas/CreateItemRequest"}
}
}
},
"responses": {
"200": {"description": "Created item."},
"400": {"$ref": "#/commonResponses/400"}
}
}
}
}Route documentation is inferred from api_routes[*].export. LNbits first looks
for #/routes/{export} in the configured document. It also supports a
normalized fallback where non-alphanumeric characters become underscores, but
new artifacts should use the exact export name as the route key.
Use a route-level openapi override only when inference is ambiguous, for
example when the same export is intentionally mounted on multiple routes:
{
"method": "GET",
"path": "/items/{item_id}/history",
"export": "list-items",
"auth": "user",
"path_params": {"item_id": "itemId"},
"openapi": "#/routes/list-item-history"
}A route-level value may be a fragment in the top-level document
(#/routes/list-item-history) or a relative JSON file reference
(wasm/admin-openapi.json#/routes/list-item-history). Keep references local to
the extension root. Missing files, missing route keys, and invalid fragments are
ignored by LNbits so extension install and loading continue with generic docs.
Operation fragments should include:
summary: short action label shown by Swagger UI.description: behavior details and important validation constraints.operationId: stable unique id, usually{extension_id}_{export_name}with separators normalized to underscores.parameters: path and query parameters. Use URL parameter names such asitem_id, not payload names such asitemId.requestBody: JSON body schema forPOST,PUT, andPATCHroutes.responses: at least a200response. Include the extension's{ok, data, error}envelope if the handler returns one.
Do not set tags in artifacts. LNbits supplies the extension display name as
the OpenAPI tag so all endpoints for one extension are grouped together.
Use local $ref values such as #/schemas/Thing and
#/commonResponses/400. LNbits inlines local refs into the FastAPI route
metadata so Swagger UI and ReDoc can display useful schemas without depending
on external documents.
Each entry in wasm.exports must match an export in wasm/lnbits-extension.wit
and the compiled wasm/module.wasm.
Language toolchains may map WIT names to source-language function names. Do not
guess the mapping. Follow the selected language template and make sure the final
compiled component exports the WIT names declared in config.json.
When adding an export, update all required contract points:
wasm.exportsinconfig.json.- The
export ...line inwasm/lnbits-extension.wit. - The matching source-language function or method under
dev/. - The route or event entry that invokes the export, if any.
Visibility values:
authenticated: callable only with an authenticated LNbits account.public: callable from public extension pages.event: callable by LNbits background/event dispatch.
Exports should follow this runtime shape:
exported_function(request_json: string) -> string
Every export should return a JSON string with a consistent {ok, data, error}
envelope. Keep envelope creation in shared helpers where the language template
supports it, so validation failures become safe, predictable responses.
UI routes:
path: public LNbits wrapper path under/ext.entrypoint: HTML file fromui/.auth:userorpublic.path_params: explicit mapping from URL params to payload names.
API routes:
method: HTTP method exposed by LNbits.path: route path under/api/v1/ext/{id}.export: WASM export name to invoke.auth:userorpublic.path_params: explicit mapping from URL params to payload names.
Example:
{
"method": "GET",
"path": "/items/{item_id}",
"export": "get-public-item",
"auth": "public",
"path_params": {
"item_id": "itemId"
}
}Do not rely on implicit name conversion. If a route contains {item_id}, map it
explicitly.
Only expose public routes for data and actions that are safe without an
authenticated account. Keep wallet balance reads, outgoing payments, admin
actions, and private storage behind user routes.
The extension UI runs inside a sandboxed iframe. It should not call LNbits core APIs directly or rely on LNbits cookies.
Browser flow:
- User opens an extension wrapper path such as
/ext/{ext_id}. - LNbits serves a wrapper page.
- The wrapper loads the sandboxed iframe entrypoint from
ui/. - The iframe loads browser assets from
/ext-assets/{ext_id}/.... - The frontend SDK talks to the wrapper through
postMessage. - The wrapper performs allowed API calls and sends responses back.
Do not load LNbits core browser files directly from /static inside extension
HTML. Shared LNbits assets must use
/ext-assets/{ext_id}/_lnbits/{asset_name} so the frame CSP can remain narrow
and extension pages do not depend on the full LNbits app page context.
Use the browser bridge SDK shipped by the extension template instead of raw
fetch() for extension API calls. The SDK should use the parent wrapper bridge,
check message sources, and keep the iframe sandbox model intact.
Do not rely on LNbits cookies inside the iframe. The iframe may be sandboxed without same-origin cookie access.
The iframe CSP intentionally does not allow unsafe-eval. Do not use Vue
runtime templates such as Vue.createApp({ template: "..." }), DOM APIs that
compile strings as code, eval, new Function, or inline onclick/event
handler attributes. If using Vue in iframe pages, use render functions,
precompiled templates, or the template pattern already used by the selected
extension scaffold. Plain DOM rendering is also fine.
The iframe sandbox intentionally does not allow native form submission. Avoid
<form> elements, submit buttons, and submit event workflows in extension UI.
Use <div> containers, explicit type="button" buttons, and JavaScript click
handlers that call the extension bridge instead.
Opening a new browser tab from inside the iframe must go through the parent
bridge. Do not call window.open() directly from extension UI code. Use the
browser SDK helper client.openNewTab(url), which sends
navigation.open_new_tab to the LNbits wrapper. The wrapper validates HTTP(S)
URLs, shows the user a confirmation dialog with the visible URL, and warns when
the target is not on the same domain as the parent LNbits page. Treat rejection
as a normal user cancellation path and surface errors with the existing
notification helper.
The WASM module does not directly access LNbits internals. It imports host
functions through lnbits-extension.wit, wrapped by language-specific SDK code.
Common host namespaces:
storage: read/write extension-owned storage.wallet: list wallets, inspect balances, create invoices, and pay invoices when permission allows.extension: call allowed APIs from other installed extensions.utils: currency and Lightning helpers.system: IDs, timestamps, and logs.http: allow-listed external HTTP requests.
The host enforces permissions and owner scoping. Do not store LNbits user ids, access tokens, or owner ids in extension data; LNbits core owns isolation.
Import raw host functions only in the language-specific SDK/bindings layer.
Extension business logic should call wrapper methods such as storage,
wallet, extension, http, system, and utils rather than binding
directly to low-level host imports everywhere.
Define storage in storage/schema.json.
Rules:
- Every table needs an
idfield. - Use migrations for changes after the initial schema.
- Use snake_case field names in storage rows.
- Public data must be explicitly exposed through
ext.storage.read_public. - Do not expose wallet ids or private operational fields through public fields.
- Do not write or query reserved internal owner fields.
- Authenticated storage calls are scoped by LNbits to the current user.
- Public storage reads are item-by-id only and return only allow-listed fields.
Storage is accessed from WASM through the language SDK:
storage.set(table, data)
storage.get(table, id)
storage.get_public(table, id)
storage.get_paginated(table, filters, search, sort, pagination)
Use storage schemas as the source of truth. Reject unknown fields before storing records, and keep public response objects small.
Permissions in config.json are shown to the installer and enforced by core.
Common permissions:
ext.storage.read: read authenticated extension storage.ext.storage.read_public: read allow-listed public fields.ext.storage.write: write extension storage.wallet.list: list wallets for the authenticated user.wallet.balance.read: read wallet balances for the authenticated user.wallet.create_invoice: create invoices from authenticated context.wallet.create_invoice_public: create invoices from configured public storage sources.wallet.pay_invoice: pay invoices from user-owned wallets.extension.api.request: call allow-listed installed extension APIs.http.request: call allow-listed external HTTPS origins from authenticated WASM exports.utils.basic: use common LNbits utility helpers.ui.camera.scan_qr: request camera scanning through the parent UI bridge.
Policy examples:
{
"id": "ext.storage.read_public",
"description": "Read public fields for public pages.",
"policies": [
{
"table_name": "entity_table",
"public_fields": ["id", "title", "description"]
}
]
}{
"id": "wallet.create_invoice_public",
"description": "Create incoming Lightning invoices from public sources.",
"policies": [
{
"table": "entity_table",
"wallet_field": "wallet_id"
}
]
}{
"id": "extension.api.request",
"description": "Call an allow-listed installed extension API.",
"policies": [
{
"id": "{target_extension}",
"access": ["read"]
}
]
}{
"id": "http.request",
"description": "Call an allow-listed external HTTPS origin.",
"policies": [
{
"host": "https://api.example.com"
}
]
}Do not add a permission unless the extension actually calls the matching host API.
For ext.storage.read_public, policy entries must use:
table_name: the table that public pages may read by id.public_fields: fields that may be returned to public pages.
Do not expose wallet_id, wallet_name, watchonly_wallet_id,
watchonly_wallet_name, slug, created_at, or updated_at publicly unless
there is a deliberate product and security reason.
For wallet.create_invoice_public, policy entries must use:
table: the table that contains the public source record.wallet_field: the field on that table containing the wallet id.
Public pages pass sourceId; they do not pass wallet ids.
For extension.api.request, policy entries must use:
id: target extension id.access:["read"],["write"], or both.
For http.request, each policy entry must be either an HTTPS origin string or
an object with a host field. The host policy is origin-based, so a policy for
https://api.example.com allows requests to that origin, not arbitrary hosts.
Outbound HTTP constraints:
- URLs must use
https. - URL credentials are rejected.
- Localhost and internal/private network addresses are rejected.
- Redirects are not followed.
- Request bodies are capped at 65,536 bytes.
- Response bodies are capped at 262,144 bytes.
- Timeout is 10 seconds.
- Hop-by-hop and sensitive headers such as
cookie,host,content-length, andset-cookieare stripped.
Use http.request only for small server-side API calls that are safe to proxy
through the WASM host. Browser UI should still use the sandbox bridge for
extension API calls.
Public invoice flow:
- Public page reads allow-listed source data with
storage.get_public. - Public page calls a public extension API route.
- WASM calls
wallet.create_invoice_public(source_id, amount, currency, memo). - LNbits core finds the source row, reads the configured
wallet_field, and creates the invoice. - If configured, LNbits dispatches
events.onInvoicePaidwhen the invoice is paid. - The event export updates extension storage.
The public page must not choose the wallet id directly. The wallet comes from
extension storage and the wallet.create_invoice_public policy.
Authenticated payment flow:
- Authenticated page lists user wallets with
wallet.list. - WASM reads balances or creates invoices only after checking that permissions and route auth require a user context.
- WASM validates amounts, invoices, and target records before making wallet calls.
- If outgoing payment is needed, WASM calls
wallet.pay_invoiceonly from an authenticated route/export. - WASM stores status updates in extension-owned storage.
Never expose outgoing payment capability or wallet balance data to public routes.
Build steps are owned by the selected language template. The build must produce
wasm/module.wasm as a WebAssembly component that matches
wasm/lnbits-extension.wit.
Generic build flow:
cd <extension-root>/dev
<language-check-command>
<language-build-command>Use the toolchain declared by the selected template. The only required output is a WASM component compatible with the WIT file.
If exports, host imports, WIT definitions, dependencies, or backend logic
change, rebuild wasm/module.wasm.
- Update storage schema/migrations if data shape changes.
- Add or update WASM export code in the language source under
dev/. - Add host helper wrappers only when the language SDK/bindings layer needs them.
- Add or update
config.jsonexports, routes, events, and permissions. - Update
ui/*.html,static/*.js, andstatic/app.cssfor browser behavior. - Rebuild
wasm/module.wasm. - Validate JSON and run the closest relevant tests/checks.
Useful checks:
python -m json.tool <extension-root>/config.json
python -m json.tool <extension-root>/storage/schema.json
python -m json.tool <extension-root>/wasm/openapi.json
cd <extension-root>/dev && <language-check-command>
cd <extension-root>/dev && <language-build-command>Use the check and build commands from the selected language template. If core-facing config changed, reinstall or reload the extension in LNbits before manual testing.
For core changes that affect all WASM extensions, prefer generic fixture-based tests that create a synthetic WASM extension under a temporary extension root. Do not depend on a specific bundled example being installed during tests.
- Keep LNbits core extension-agnostic.
- Keep public pages unauthenticated, not untrusted-by-default.
- Use explicit config fields; avoid fallback names or hidden conventions.
- Public data must be allow-listed.
- Browser UI must go through the sandbox bridge.
- WASM code must go through host functions and declared permissions.
- Do not expose access tokens, wallet ids, owner ids, or raw private storage in public responses.
- Keep generated files generated; change source and rebuild.