CLI
Forward live events to localhost, fire test events, and hit any route from your terminal.
A single static Go binary. It speaks BillKit’s own API, auth and BillKit-Signature scheme, so what you see locally is what your server will see.
New to it? Develop locally with the CLI walks through install, login and the listen loop end to end. This page is the reference for the command surface.
Install
Homebrew is the shortest route on macOS, and it keeps the CLI up to date alongside everything else you have installed.
The cask is macOS only. The macOS builds are signed with a Developer ID and notarized by Apple, so Gatekeeper accepts them without any workaround.
On Linux, and on macOS without Homebrew, the install script picks the right build for your OS and architecture and drops it in /usr/local/bin. Override the destination with BILLKIT_INSTALL_DIR (created if it does not exist yet), or pin a release with BILLKIT_VERSION.
On Windows, download the build yourself. Every release on the releases page carries a .zip for windows_amd64 and windows_arm64, plus .tar.gz archives for macOS and Linux and a checksums.txt to check them against. Unpack it and put billkit.exe on your PATH.
If you already have a Go toolchain, you can build it from source instead.
Go names the binary after the package directory, which is cmd/billkit, so it arrives as billkit like every other install route.
Log in
billkit login # prompts for a bk_test_… or bk_live_… key
billkit login --profile staging # store it under a name you choose
billkit config list # the profiles, with * on the default
billkit config use live # switch the default, keeping both keys
billkit config path # where the config file is
billkit logout # remove the default profile, or --profile NAME / --alllogin checks the key against the API before it stores anything, then says where the key went, on stderr. The prompt does not echo the key. Echo is only suppressed when stdin is a terminal, so a piped key, as in cat key.txt | billkit login, still works. If BILLKIT_API_KEY is set, login stores that key and does not prompt.
Without --profile, the key prefix names the profile, test or live, so test and live keys never share a slot; login also refuses to store a key under the other mode’s name. A live key goes in your operating system’s credential store, and a test key in the config file; Configuration and keychain has the detail.
The profile you just logged into becomes the default, so the mode login reports is the mode your next command uses. billkit config use <profile> moves the default later without deleting credentials. Logging out of the default profile leaves no default unless exactly one profile remains; pick one with config use. logout removes the key from the keychain as well as from the file. Naming a profile that does not exist, to logout, config use or --profile on any command, is a bad invocation and exits 2.
Credentials in a script
Every command reads BILLKIT_API_KEY, BILLKIT_BASE_URL and BILLKIT_PROFILE, so a CI job needs no config file and no prompt.
export BILLKIT_API_KEY=bk_test_...
billkit api GET /v1/customersEach variable is the same setting as the flag of that name: --api-key, --base-url, --profile. The order is flag, then environment, then the stored profile. A flag is what you typed on this invocation, so it overrides a variable the shell has been carrying since login.
Prefer the variable to –api-key for anything live. A command line is readable by
other processes while it runs, CI logs echo it, and your shell records it in its history file.
A variable is none of those.
A key from the environment gets no more trust than one from a flag: both are checked before a request is made, so a live key aimed at a plain http host is refused either way.
A live key is only sent over https. Plain http is accepted for a loopback host with a test key, which covers local mocks and integration tests, and refused anywhere else. A CLI pointed at a host other than api.billkit.eu announces that on stderr before it sends.
Forward webhooks to localhost
This is the reason the CLI exists. No tunnel, no ngrok.
listen opens a live event stream and POSTs each event to your local URL, signed with a secret it generates for this session and prints on start.
> Your webhook signing secret is bkwhsec_…Set that as your app’s webhook secret and verify with any BillKit SDK. Each POST carries the same BillKit-Signature, BillKit-Event-Id, BillKit-Event-Type and BillKit-Delivery-Attempt headers a real delivery does.
| Flag | Effect |
|---|---|
| --forward-to | Local http or https URL to POST each event to. Without it, each event is only listed. |
| --events | Comma-separated event types, filtered by the server. An unknown type is refused before the stream opens. |
| --print-json | Print each event body to stdout. The one-line summaries move to stderr, so stdout carries the JSON and nothing else. |
A forward that never reaches your app, such as a refused connection while your dev server restarts, is retried three times over about four seconds. Any HTTP response is final, a 500 included, because a real endpoint gets no second copy either. A forward your app received but did not answer within 15 seconds is not retried, for the same reason, and is reported as unconfirmed. The stream stays open while a forward is in flight, so a slow handler costs no events. An event that could not be forwarded, or whose delivery is unconfirmed, is printed with the billkit events retrieve command that reads it back.
A 307 or 308 from your app to another path on the same scheme, host and port, such as the trailing-slash redirect many frameworks answer /webhooks with, is followed with the same method, body and signature headers. Any other redirect is not followed: the event is reported as not delivered, with the address it pointed at, so you can point --forward-to there instead.
The stream reconnects itself, backing off further after each failed attempt. It resumes from the last event it handled, so events created while it was down are replayed before the live tail continues, in order, without duplicates, and under the same --events filter. When the resume point has aged out of the event log, the CLI says so and points at billkit events list. A connection that sends nothing for 45 seconds is treated as dead and re-dialled; the server sends a keep-alive every 15.
A key the server rejects ends the command. A 401 or a 403 is not retried, the CLI names what to change, and the process exits non-zero (4 for a 401), so a CI step that wraps listen fails instead of passing on a connection it never made. A key revoked while the stream is open ends it too, with the server’s message and exit code 4. Reading the stream needs the events:read scope.
Fire a test event
billkit trigger customer.created # makes the real test-mode API call
billkit trigger # list the supported events, one per line
billkit trigger --json # the same list as JSONtrigger makes the real test-mode API calls that emit the event, so what reaches your handler is a real payload with a real object behind it. It prints the final API response on stdout and refuses a live key. An event type it cannot trigger is a bad invocation and exits 2. These events can be triggered:
customer.created,customer.updated,customer.deletedproduct.created,product.updated,product.archivedprice.created,price.updated,price.archivedcoupon.created,coupon.updatedwebhook_endpoint.created,webhook_endpoint.updated,webhook_endpoint.deleted
An update, archive or delete creates its object first, so you see the .created event before it. customer.deleted creates a throwaway customer and deletes it.
Events that need a real payment, such as invoice.paid, payment.failed and the subscription.* events, cannot
be triggered. Run a test checkout and choose an outcome on Mollie’s test page, or advance a
test clock.
Refunds and one-off charges
billkit refunds create --payment pay_9XKp2vQ1 --amount 500 --reason "duplicate"
billkit refunds create --one-shot osp_9XKp2vQ1 # full refund, omit --amount
billkit refunds list --limit 20
billkit refunds retrieve re_9XKp2vQ1
billkit one-shots create \
--customer cus_9XKp2vQ1 --amount 1999 --payment-method ideal \
--success-url https://example.com/thanks
billkit one-shots list --customer cus_9XKp2vQ1
billkit one-shots retrieve osp_9XKp2vQ1Amounts are integers in the currency’s minor units, as everywhere in the API: --amount 1999 in EUR is EUR 19.99, and in JPY it is JPY 1999. The CLI shows them back to you formatted with the currency’s own exponent, EUR 19.99, in the mode line and the live-mode banner. For a partial refund it reads the payment’s currency first; if your key cannot read the payment, it shows the amount as minor units rather than guess.
refunds create takes exactly one of --payment, --one-shot or --subscription, the last meaning that subscription’s most recent charge. Omit --amount to refund the full remaining balance. Zero or two targets, or an --amount that is not positive, is a bad invocation and exits 2. refunds list and one-shots list take --limit, from 1 to 100, and --starting-after to page back; one-shots list also filters by --customer and --status.
one-shots create creates a single charge with no stored mandate and prints the payment. --customer, --amount and --success-url are always required. By default the payment is hosted: --payment-method is required too, and the response carries the redirect_url to send the customer to. With --ui-mode embedded the customer picks the method in the BillKit payment element on your own page, so --payment-method is refused, and the response carries a short-lived client_secret for that element instead of a redirect URL. Getting that pairing wrong is a bad invocation and exits 2.
| Flag | Effect |
|---|---|
| --ui-mode | hosted (the default) or embedded. |
| --payment-method | One of creditcard, ideal, bancontact, eps, applepay, paypal, banktransfer. Hosted only. directdebit is refused and exits 2, because SEPA Direct Debit needs an existing mandate and a one-off charge has none. |
| --currency | ISO 4217 code. Default EUR. |
| --cancel-url | Where the customer lands if they cancel. |
| --description | Shown to the customer. |
| --refund-window-days | Days the charge stays refundable. 0 disables refunds. |
| --tax-behavior | inclusive or exclusive: whether --amount is gross or net. Omit it and the tax rate for the customer's country decides (inclusive when no rate applies); read amount_cents in the response for what was charged. |
| --metadata | key=value, repeatable. |
Every mutating call carries an Idempotency-Key, the same contract every BillKit SDK uses. Pass --idempotency-key to choose it, or let the CLI mint one. Either way it is printed on stderr before the request, because a key you never saw cannot make your retry safe.
> test mode (profile "test" at https://api.billkit.eu): refund EUR 5.00 of pay_9XKp2vQ1
> Idempotency-Key: cli_8fQ2… (auto-generated; reuse it if you retry)Connection errors, timeouts, 429, 5xx and 409 idempotency_in_progress are retried twice with that same key, backing off with jitter and honouring Retry-After. Any other 4xx is never retried. If the outcome is still unknown, the CLI says the write may already have happened, names the command that would show it (billkit refunds list, or billkit one-shots list --customer <id>), and prints the exact command to rerun safely.
Writes get 80 seconds to complete, reads 30. A charge or refund waits on the payment provider inside the request, and a client that gives up early is what leaves the outcome unknown.
Money commands in live mode need confirmation: refunds, one-off charges, and billkit api writes to
refunds, checkout, or a subscription’s update, cancel or
reauthorize_payment_method. On a terminal the CLI shows a live-mode banner and asks. With no terminal,
as in CI or behind a pipe, it refuses before sending anything, tells you to pass –yes, and exits 2.
Test mode never asks.
Talk to the API
billkit events list --type customer.created
billkit events retrieve evt_9XKp2vQ1
# Escape hatch for any route:
billkit api GET /v1/customers
billkit api POST /v1/customers --data email=ada@example.com --data name=Adaevents list takes --type (one event type, or a comma-separated list such as invoice.paid,invoice.voided), --limit (1 to 100) and --starting-after; an unknown event type is refused before anything is sent. --data sends flat values only: it coerces true, false, null and whole numbers, and everything else stays a string. For arrays and nested objects, pass the whole body as a JSON object with --input, naming a file, or - to read it from stdin, as gh api --input does. The body is sent byte for byte, so an integer past 2^53 is not rounded. --data and --input cannot be combined, and neither is allowed on a GET.
billkit api POST /v1/api_keys --input - <<< '{"label":"ci","scopes":["events:read"]}'billkit api sends an Idempotency-Key with anything that is not a GET, HEAD or OPTIONS, and takes --idempotency-key too, so the escape hatch cannot become the one unsafe way to POST /v1/refunds.
Output and completion
The contract, which 1.0 keeps:
- stdout carries a command’s output and nothing else: the API’s JSON for every command that calls the API, the profile table from
config list, the path fromconfig path, the event types from a baretrigger, and the version line. Under the global--jsonflag,config list,triggerandversionprint JSON too. - stderr carries every message: the mode line and live-mode banner, the idempotency key, the non-default-host notice, confirmations from
login,logoutandconfig use, warnings, the “outcome unknown” guidance, and errors. Under--jsonthe informational notices (the lines starting>and the confirmations) are not written at all. Warnings that need a person still are (lines starting!, the live-mode banner and its prompt, the “outcome unknown” guidance, which repeats the idempotency key), and a failed command’s JSON error is always the last line. listenis the one long-running command. One line per event on stdout is its output:HH:MM:SS <type> <id>without--forward-to, and<type> <id> -> <status>with it. With--print-json, stdout carries each event’s JSON, one object per line, and those summary lines move to stderr. The start-up notices, the signing secret, reconnects and warnings are always on stderr.
JSON is pretty-printed, key order preserved, and coloured only when stdout is a terminal. Force it with --color always|never|auto; NO_COLOR is honoured, and any other value is a bad invocation.
An error is one line on stderr, Error: <message>. For an API error the message is <code> (HTTP <status>): <message>, followed by [reason: …, param: …] when the error envelope carries either. With --json the error is written to stderr as one JSON object on one line instead: the API’s own error envelope, field for field, plus http_status when an HTTP answer was involved and the process exit_code. Errors the CLI raises itself have type cli_error and a code of invalid_usage, authentication_required or command_failed.
{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such refund.","param":"id","http_status":404},"exit_code":1}The CLI follows an API redirect only to an https address, and drops your API key whenever the redirect leaves the API host. That is how a PDF download that redirects to a presigned storage URL works without the key ever reaching the storage host. A redirect from https to plain http is refused, because it would carry your key unencrypted: the CLI names where it pointed and exits 1, so check the path and --base-url. listen’s event stream follows no redirect at all.
billkit completion zsh > "${fpath[1]}/_billkit"bash, zsh, fish and powershell are all supported.
Exit codes
| Code | Meaning |
|---|---|
0 | The command succeeded. A command group on its own, such as billkit refunds, prints its help and exits 0. listen stopped with Ctrl-C or SIGTERM after it had reached the event stream is a clean stop and exits 0. |
1 | The command ran and failed: an API error other than a 401 (including a 402 payment_refused, the payment provider declining a charge or refund), a network failure or timeout, a refused redirect or transport, a declined confirmation. A listen that never reached the event stream exits 1 however it is stopped, so a CI step that wraps it cannot read "never connected" as success. |
2 | Bad invocation, and nothing was sent: an unknown command or flag, a stray argument, a missing required flag, a malformed or out-of-range value (--limit, --amount, --color, --payment-method, an unknown event type), an empty or unreadable api --input, a profile name that is not configured, or a live-mode money command with no terminal and no --yes. |
4 | No usable credential: no key from any source, a key the API answered 401 to, a value that is not a BillKit key, or a key the OS keychain will not give back. Run billkit login. 4 is the code gh uses for the same thing. |
A script can tell a mistake it should fix (2) and a missing login (4) from a call that may be worth retrying (1).
Configuration and keychain
The config file lives where your platform keeps configuration: $XDG_CONFIG_HOME/billkit/config.json, which is ~/.config/billkit/config.json by default, on macOS and Linux, and %APPDATA%\billkit\config.json on Windows. BILLKIT_CONFIG_HOME moves it to config.json under any directory you choose. billkit config path prints where it is.
Versions before 1.0 kept it in ~/.billkit/config.json. The first run of 1.0 moves it: the new file is written atomically at mode 0600, live keys go to the keychain on the way, the old file is deleted, and a MOVED.txt in ~/.billkit says where it went. Nothing is moved when BILLKIT_CONFIG_HOME is set.
A live (bk_live_) key is stored in the OS credential store: the macOS Keychain, Windows Credential Manager, or the Secret Service on Linux (GNOME Keyring, KWallet). The config file records only which entry holds it, under the service name billkit-cli. Where there is no credential store, as on a headless Linux box or a CI runner, login stores the key in the file instead and says so. login --insecure-storage does that on purpose. Test keys always go in the file, so CI never needs a keychain.
What the keychain buys is that a live key is not in a plain file on disk or in your dotfiles. It is not a barrier against other programs you run: on macOS the entry is created through /usr/bin/security, which can read it back without a prompt, and so can anything running as you that calls it.
Every change to the config (login, logout, config use, the move from ~/.billkit) holds a lock on a .lock file beside it, so two billkit processes started together never write it at once. If the move from ~/.billkit cannot write the new location, the CLI keeps using the old file where it is, saves any change back there, and tries the move again on the next run; it never writes a live key in plain text at the new location.
billkit config list --json # profiles as JSON; keys are masked here tooOn macOS and Linux the file is mode 0600, re-tightened on every save, and a config anyone else on the machine can read is called out on stderr. The CLI tightens its own directory to 0700, but leaves a directory you named with BILLKIT_CONFIG_HOME as it found it, with a warning if it is open. On Windows a file mode means nothing, so the protection is the permissions on your user profile folder, and the CLI tells you if BILLKIT_CONFIG_HOME has moved the file outside it.
The file carries a schema version. Fields this build does not know are kept when it rewrites the file, so going back to an older 1.x does not lose settings a newer one wrote, and a file written by a newer schema is read but never rewritten.
Version, updates and telemetry
billkit version # version, commit, build date, Go version and platform
billkit version --json # the same as an objectbillkit --version prints the same line. -v is not a version flag.
The CLI collects no telemetry and never checks for updates. It makes network requests to one host, the API at api.billkit.eu or whatever --base-url names, and when you ask it to, to the --forward-to address you give listen. version, config, completion and help make no request at all. Update through the route you installed with: brew upgrade billkit, the install script again, or go install again.
Upgrading from 0.x
1.0 freezes the command surface, flags, output, exit codes and config format. Getting there broke a few things; the changelog has the full list.
- Config location.
~/.billkit/config.jsonmoves to the location under Configuration and keychain on the first run. Usebillkit config pathrather than a hard-coded path.BILLKIT_CONFIG_HOMEstill wins and turns the move off. - Live keys in the keychain.
loginand the move putbk_live_keys in the OS credential store, so they are no longer in the config file, andconfig listshows them asbk_live_… (keychain).login --insecure-storagekeeps one in the file. - Renamed commands.
checkout one-shotisone-shots createandcheckout retrieveisone-shots retrieve, with no aliases;one-shots listis new. - Renamed flags.
billkit api --json <body>isbillkit api --input <file|->. Inline JSON is no longer accepted, and--jsonis now the global “print JSON” flag.one-shots create --methodis--payment-method, after the API field it sets, with no alias. listenneeds API 1.0. It sends its--eventsfilter as the stream’stypeparameter, which wastypesbefore.- Exit codes. A missing or rejected credential exits 4, not 1. Every invalid value, an unknown command (top-level included), and a stray argument to
listenorevents listexit 2. - stdout is output only.
login,logoutandconfig useconfirm on stderr, and a baretriggerprints one event type per line. - Version.
-vis gone; use--versionorbillkit version. The line now carries the commit, build date, Go version and platform, so readbillkit version --jsoninstead of parsing it. - Amounts in messages read
EUR 19.99, not1999 EUR.
BillKit