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:

Modules

You can split code across files. Inside any script:

var helpers = require('./helpers.js');

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

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:

  1. Set "url": "https://github.com/your-name/your-plugin" in
    plugin.json.
  2. Tag a release (v1.0, 1.0—both work; Billy strips the leading v and compares numerically against version).
  3. Attach a single asset named <anything>.billyplugin.zip containing your .billyplugin folder. 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.