Plugin Specification
Billy plugins are JavaScript modules that import, export, or update invoices, or read data from other apps the user has installed. They run in an isolated JavaScriptCore context and are installed by the user from Settings → Plugins.
Folder Layout
A plugin is a folder with the .billyplugin extension:
My Exporter.billyplugin/
├── plugin.json # manifest (required)
├── main.js # entry point (required)
└── helpers.js # any additional modules (optional)
Plugins live in Billy’s plugins folder. To install one during development, add it via Settings → Plugins → Add Plugin…—or just drag it onto the list.
Plugin Manifest
Every plugin needs a plugin.json file at the root of its folder.
| Field | Type | Required | Notes |
|---|---|---|---|
specVersion |
number | yes | Currently 2. Use 2 for host commands (billy.command.run) or updateInvoices; 1 plugins keep working. |
name |
string | yes | Display name shown in menus |
description |
string | no | Short description shown in settings |
version |
string | no | Plugin version, free-form (e.g. 1.0) |
url |
string | no | Plugin website / repo / docs—opens via context menu |
author |
string | no | Author name |
authorUrl |
string | no | URL shown as a link on the author name |
fileExtension |
string | no | Extension Billy uses for save panel / import filter |
minBillyVersion |
string | no | Minimum Billy version required. Plugins below this aren’t loaded. |
capabilities |
string[] | no | Host capabilities the plugin opts into, e.g. ["commands"]. Without the matching entry the host object stays hidden. See Reading from Other Apps. |
timer |
number | no | Runs updateInvoices every N minutes while Billy is open. See Running on a Timer. |
Plugin API
A plugin is a CommonJS module that declares behavior by assigning functions to exports. Define any subset—Billy shows the plugin in whichever menus its functions support, and one plugin can mix roles (e.g. import plus an export).
Export one invoice, shown in File → Export… and File → Share…:
exports.exportInvoice = function (invoice, profile) {
return "string contents of the exported file";
};
Export many invoices, shown in Profiles → Export Invoices…:
exports.exportInvoices = function (invoices, profile) {
return "string contents of the exported file";
};
Import invoices, shown in Profiles → Import Invoices…—returns one patch object per invoice to create (see Importing):
exports.importInvoices = function (fileContent, profile) {
return patches;
};
Update existing invoices, shown in Profiles → Update Invoices…—returns one status change per invoice to update (see Updating Invoices):
exports.updateInvoices = function (invoices, profile) {
return changes;
};
You can also read data from other installed apps with billy.command.run from any of these functions—see Reading from Other Apps.
Exporting
invoice (or invoices) and profile are plain JS objects produced by JSON.parse of Billy’s canonical encoding. Return the file contents as a UTF-8 string. See the full schema at usebilly.app/support/data-format-spec.
Importing
importInvoices receives the raw file as a UTF-8 string plus the active profile, and returns an array of patch objects—one per invoice to create.
You do not build full invoices from JSON. For each patch, Billy creates a brand-new invoice exactly like the New Invoice command—assigning the next invoice number and filling in sender details, currency, and payment terms from the active profile—then overrides the fields your patch provides. So numbering and profile defaults are always correct; you only supply what the source file knows. Returning multiple patches creates multiple invoices with sequential numbers.
When the source file lacks a value, read it from profile instead of hardcoding. For example, a file with no tax info can still use the user’s default VAT rate:
var vat = (profile && typeof profile.vatPercentage === 'number') ? profile.vatPercentage : 0;
Commonly useful profile fields (see the full schema for everything):
| Field | Type | Notes |
|---|---|---|
currency |
string | ISO currency code, e.g. "EUR" |
vatPercentage |
number | Default VAT rate, e.g. 19 |
vatExemptionReason |
string | "none" unless the user is VAT-exempt |
daysToPay |
number | Default payment term in days |
hourlyRate |
number | Only present when set (> 0) |
dailyRate |
number | Only present when set (> 0) |
weeklyRate |
number | Only present when set (> 0) |
language |
string | "en" or "de" |
Supported patch fields (all optional; anything omitted keeps the new-invoice default):
| Field | Type | Notes |
|---|---|---|
recipient.name |
string | Client name |
recipient.email |
string | |
recipient.address.lineOne |
string | |
recipient.address.lineTwo |
string | |
recipient.address.postcode |
string | |
recipient.address.city |
string | |
recipient.address.state |
string | |
recipient.address.country |
string | ISO country code, e.g. "DE" |
items |
array | Line items (see below); replaces the default empty list |
currency |
string | ISO currency code, e.g. "EUR" |
serviceDateStart |
string | YYYY-MM-DD |
serviceDateEnd |
string | YYYY-MM-DD |
status |
string | One of "Draft", "Sent", "Paid", "Cancelled"; defaults to a new draft |
statusDate |
string | YYYY-MM-DD the status took effect (e.g. the paid date); defaults to today |
Each entry in items:
| Field | Type | Notes |
|---|---|---|
description |
string | Item label |
quantity |
number | |
unit |
string | One of "Piece", "Hour", "Day", "Week", "Lump Sum" |
unitPrice |
number | Net price per unit |
vatPercentage |
number | e.g. 19, or 0 |
Fields not listed (invoice number, sender, invoice date, discounts…) can’t be set from a patch yet—they come from the profile / new-invoice defaults. If you need one of them, let me know.
Reading from Other Apps
Plugins can read data from other apps the user has installed—for example, bank transactions from MoneyMoney. Because Billy is sandboxed, a plugin can’t script another app itself; instead it activates a command that Billy ships, and Billy runs it out-of-process. Plugins never provide their own AppleScript, and can only run the commands listed below.
Opting In
Declare the capability in plugin.json:
"capabilities": ["commands"]
Without "commands", the billy.command object isn’t exposed to your plugin.
Running a Command
Call billy.command.run(name, args) from your plugin code. It takes a command name and an array of string arguments, runs the command, and returns its result as a string. On failure it throws, so wrap it in try/catch if you want to handle errors yourself.
var text = billy.command.run("file.readText", ["Downloads/paid.txt"]);
var numbers = text.split("\n");
Billy hands you the raw string and stays out of the way—parse and use it however you like. Call it from whichever of your plugin’s functions fits, e.g. inside updateInvoices to mark matching invoices paid, or importInvoices to turn fetched data into invoices.
The first time a command reaches another app, macOS asks the user to allow Billy to control it. Commands run through Billy’s connector, which the user installs once.
Available Commands
| Command | Arguments | Returns |
|---|---|---|
file.readText |
[path] |
file contents (UTF-8) |
moneymoney.exportTransactions |
[account, fromDate, toDate] |
CSV string |
moneymoney.exportAccounts |
[] |
property-list XML |
file.readText reads a text file at a path relative to your home folder (e.g. Downloads/paid.txt)—absolute paths, ~, and .. are rejected, so it stays inside home. For moneymoney.exportTransactions, account is an IBAN, account name, or account number; dates are YYYY-MM-DD and toDate may be "".
Requesting a Command
Billy ships every command itself, so if the one you need isn’t listed, request an AppleScript command. Only read-only commands can be integrated.
Logging
console.log(value) is always available (no capability needed) and writes to Billy’s log—handy while developing.
Updating Invoices
Plugins can change existing invoices—for example, mark them paid after matching a bank transaction. Define updateInvoices; it appears under Profiles → Update Invoices…. Billy passes the active profile’s invoice summaries and applies the status changes you return—you never mutate invoices directly, exactly like import.
exports.updateInvoices = function (invoices, profile) {
return invoices
.filter(function (i) { return i.status === "Sent"; })
.map(function (i) { return { id: i.id, status: "Paid", date: "2026-08-29" }; });
};
Each summary in invoices has id, number, formattedNumber (the invoice number as shown), status, total (gross), currency, date, reference, and name—enough to match against.
Return an array of changes. Each needs id and status ("Draft", "Sent", "Paid", or "Cancelled"), plus an optional date (YYYY-MM-DD, defaults to today). Billy applies them all as one undoable step.
To reconcile payments, combine it with billy.command.run: read transactions from the bank app, match them against invoices, and return the matches as "Paid".
Running on a Timer
By default a plugin runs only when the user picks it from a menu. Add timer (in minutes) to also run it on a timer:
"timer": 60
Billy then runs the plugin’s updateInvoices every timer minutes (minimum 1) against the active profile—so this is meant for reconcile-style plugins. Good to know:
- Only while Billy is open. The timer is in-process; nothing runs while the app is quit.
- Runs once shortly after Billy opens (a few seconds, so the window is up first), then repeats every
timerminutes. - The user stays in control. Disabling the plugin in Settings stops its timer—and, like any disabled plugin, removes it from the menu too.
- The first timer run may trigger a permission prompt (Automation or file access), just like a menu run.
Modules
You can split code across files. Inside any script:
var helpers = require('./helpers.js');
- Paths are resolved relative to the plugin’s root directory, not the calling file. So
require('./helpers.js')always means
<plugin>/helpers.js, even when called from a script in a subdirectory. (This differs from Node.js.) - The
.jsextension is appended if the file isn’t found and no extension was given. - Required modules are cached per plugin runtime—subsequent
requirecalls return the same exports. - Modules outside the plugin’s directory cannot be required (path traversal is blocked).
Loading Model
Top-level code in main.js runs once when the plugin is first loaded. Any state your module keeps between calls is preserved across invocations.
Tip—hot reload. Billy watches the plugins folder while running. Editing
main.js,plugin.json, or any required module reloads the plugin immediately. No app restart needed.
Limitations
- Text I/O only. Export functions must return a UTF-8 string;
importInvoicesreceives the file as a UTF-8 string and returns patch objects. Binary formats (PDF, ZIP, images) aren’t supported. - Curated host access only. Plugins get no direct network, filesystem, or system access. The one exception is
billy.command.run(see Reading from Other Apps), which runs only the commands Billy ships and requires thecommandscapability. - Synchronous execution. There’s no async/await or promises—each function runs to completion, and
billy.command.runblocks until its command finishes.
These are all soft limits—if you’re building something that bumps against them, let me know and I’ll consider what to expose next.
Distribution & Updates
To allow for one-click updates, publish your plugin as a GitHub, GitLab or Gitea (e. g. Codeberg or self-hosted) release. Billy checks the repo named in your manifest’s url field and shows users when a newer version is available; they then click the version badge to update.
Recipe:
- Set
"url": "https://github.com/your-name/your-plugin"in
plugin.json. - Tag a release (
v1.0,1.0—both work; Billy strips the leadingvand compares numerically againstversion). - Attach a single asset named
<anything>.billyplugin.zipcontaining your.billypluginfolder. This is the preferred packaging—Billy downloads, unzips, and installs it directly.
If you don’t attach a zip, Billy falls back to the repo’s zipball and expects to find plugin.json at the repo root.
Bump the version field in plugin.json before each release so users see the update.