BillKit/DocsConsole
Operations guide

Develop locally with the CLI

Install billkit, log in, stream events to localhost, and trigger test events without waiting for a real payment.

13 Sept 2026·Beginner·9 min read

billkit exists mainly for one job. BillKit does not deliver webhooks to a private address, so localhost can never be a registered endpoint. The CLI streams your account’s events over an outbound connection and POSTs them to your machine instead.

Reference for every command and flag is on the CLI page. This guide is the workflow.

Install

On macOS, Homebrew is the shortest route.

brew tap billkit-eu/tap && brew install billkit

On Linux, and on macOS without Homebrew, the install script picks the right build for your OS and architecture and installs it to /usr/local/bin. Set BILLKIT_INSTALL_DIR to put it somewhere else (the directory is created if it does not exist yet), or BILLKIT_VERSION to install a specific release.

curl -fsSL https://raw.githubusercontent.com/billkit-eu/billkit-cli/main/install.sh | sh

On Windows, download the build yourself: every release on the releases page has a .zip for windows_amd64 and windows_arm64. Unpack it and put billkit.exe on your PATH.

If you already have a Go toolchain, build it from source instead. It arrives as billkit, the same name every other route installs.

go install github.com/billkit-eu/billkit-cli/cmd/billkit@latest
billkit --version

Log in

billkit login

It prompts for a key, checks it against the API before storing anything, and saves the profile to config.json in the CLI’s config directory: ~/.config/billkit/ on macOS and Linux (or $XDG_CONFIG_HOME/billkit/), %APPDATA%\billkit\ on Windows. billkit config path prints it. A live key itself goes into the operating system’s keychain, and the file only refers to it; a test key is kept in the file. Where no keychain is available, such as a headless Linux box or a CI runner, a live key is kept in the file too, and login says so. The key’s prefix picks the profile name, so a bk_test_ key lands in a profile called test and a bk_live_ key in one called live. Pass --profile staging to store it under a name of your own.

The prompt does not show what you type, so a pasted key stays out of your scrollback, terminal logs and screen recordings. Echo is only turned off when you are at a terminal, so piping still works.

On macOS and Linux the file is mode 0600, rewritten atomically and re-tightened on every save. On Windows a file mode carries no meaning, so the file is protected by the permissions on your user profile folder instead. The CLI warns you if either of those stops being true.

The profile you just logged into becomes the default, so the mode login reports is the mode your next command runs in. Log in with a test key while a live key is stored and you are in test mode, which is what the success line says.

✓ Logged in (test mode). Profile "test" is now the default. Key stored in /home/you/.config/billkit/config.json

Your live key is only ever sent over https. Plain http is accepted for a loopback host with a test key, which is how you point the CLI at a local mock, and refused everywhere else. Anything stored in base_url has to be a real http or https URL, and a CLI pointed away from api.billkit.eu says so on stderr before it sends anything.

Without a prompt, in a script

A script has no one to answer a prompt. Put the key in the environment and every command picks it up, with nothing written to disk first.

shell
export BILLKIT_API_KEY=bk_test_...
billkit api GET /v1/customers

Three variables are read: BILLKIT_API_KEY, BILLKIT_BASE_URL and BILLKIT_PROFILE. Each one is the same setting as its flag. The order is flag, then environment, then the stored profile, so what you type on this invocation overrides a variable your shell has been carrying since login. A fourth, BILLKIT_CONFIG_HOME, moves the directory that holds config.json away from its default.

Do not pass a live key with –api-key. Everything on a command line is visible to every other process on the machine while it runs, CI logs echo it, and your shell writes it to its history file. Use the variable, or pipe the key in with cat key.txt | billkit login.

An environment key is not trusted any further than a flag one. A live key aimed at a plain http host is refused before anything is sent, whichever way it arrived.

billkit config list

Shows the profiles, a masked prefix of each key, and the API host. The * marks the default. billkit config path prints the file location.

billkit config use live

That switches the default without touching any credentials, so moving between test and live needs neither a logout nor a hand edit. To add another profile, run billkit login --profile <name>. billkit logout removes the default profile, or the one named with --profile, or every profile with --all.

Every command takes --profile to pick one and --base-url to point at a different host, and each of those has an environment variable of its own.

The webhook loop

billkit listen --forward-to http://localhost:3000/webhooks/billkit
> Ready! Streaming test-mode events (Ctrl-C to quit)
> Forwarding to http://localhost:3000/webhooks/billkit
> Your webhook signing secret is bkwhsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Put that secret in your local environment. It is generated on your machine for this session, so it is not the secret of any registered endpoint, and it changes every time you restart.

Each event is POSTed with the same headers a real delivery carries, including a valid BillKit-Signature computed with that secret, so your verification code runs for real.

FlagEffect
--forward-toLocal URL to POST each event to. Without it, events are only listed.
--eventsComma-separated event types, filtered by the server. An unknown type is refused before the stream opens.
--print-jsonPrint each event body to stdout as well as forwarding it. The one-line summaries move to stderr, so stdout stays pipeable.
billkit listen --events invoice.paid,invoice.payment_failed --forward-to http://localhost:3000/hooks

With --forward-to, each event gets one line with the status your app returned:

  customer.created  evt_Ck7Rn2vZ9xQ4mTpLbw  -> 200

Any HTTP response counts as delivered, a 500 included, as it would for a real endpoint. A forward that cannot connect at all, which is what a dev server restarting on save looks like, is retried three times over about four seconds and then reported with the billkit events retrieve command that reads the event back.

A forward your app received but did not answer within 15 seconds is not retried, because your app already has the event and a second copy is a duplicate. It is reported as unconfirmed, with the same billkit events retrieve command. A slow handler does not cost you the stream: it stays open while a forward is in flight. If you stop listen while one is, the event is printed as NOT forwarded with that command.

The stream reconnects on its own. Each failed attempt waits longer than the last, with jitter and a ceiling.

Events written while the stream was down still reach you. The CLI remembers the last event it handed to your app and asks the server to resume after it, so the reconnected stream replays everything recorded in the meantime before it goes live. Order is preserved, nothing is delivered twice, and the same --events filter applies to the replay as to the live tail. It tells you what it did:

> Reconnected. Replaying anything recorded after evt_Ck7Rn2vZ9xQ4mTpLbw.

It cannot replay when the event it was resuming from has aged out of your event log. It says so and names the command that reconciles the rest:

! The resume point evt_Ck7Rn2vZ9xQ4mTpLbw is no longer in the event log, so anything recorded since then was not replayed.
!   Reconcile with: billkit events list

A connection that goes silent is dropped and re-dialled. The server sends a keep-alive every 15 seconds, so 45 seconds of nothing means the socket is dead, for example after a laptop sleeps.

A rejected key ends listen rather than retrying it. On a 401 or a 403 the CLI prints what to change and exits non-zero, so it is safe to wrap in a script or a CI step. Reading the stream needs the events:read scope. A key revoked while the stream is open ends it the same way, with the server’s message and exit code 4. Connection errors, 429 and 5xx are treated as temporary and retried.

Triggering events

billkit trigger customer.created

trigger makes the real test-mode API calls that produce the event, so what reaches your handler is a real payload from a real object rather than a fixture. It prints the last API response. Run it with no argument to list what is available.

EventsWhat it does
customer.createdCreates a customer.
customer.updatedCreates a customer, then renames it.
customer.deletedCreates a customer, then deletes it.
product.created, product.updated, product.archivedCreates a product, then updates its description or archives it.
price.created, price.updated, price.archivedCreates a product and a monthly EUR 10.00 price, then updates the price's metadata or archives it.
coupon.created, coupon.updatedCreates a 10% coupon, then sets its redemption limit.
webhook_endpoint.created, webhook_endpoint.updated, webhook_endpoint.deletedCreates an endpoint pointing at https://example.com/billkit-cli-trigger, then updates or deletes it.

An update, archive or delete creates its object first, so you see the .created event before it. The objects stay in your test data; the webhook endpoints the .created and .updated triggers leave behind are subscribed to customer.created, so delete them when you are done.

Payment and subscription events cannot be triggered, because they need a real payment. To exercise those, run a test checkout and pick an outcome on Mollie’s test page, or advance a test clock.

trigger refuses a live key.

Everything else

The CLI has hand-written commands for refunds and one-off payments, and a raw escape hatch for the rest of the API.

billkit api GET /v1/subscriptions
billkit api POST /v1/customers --data email=ada@example.com --data name=Ada

--data splits on the first =. It coerces true, false, null and whole numbers; everything else stays a string. For arrays and nested objects, pass the whole body as a JSON object with --input, from a file or from - for stdin.

shell
echo '{"label":"ci","scopes":["events:read"]}' | billkit api POST /v1/api_keys --input -

billkit api sends an Idempotency-Key with anything that is not a GET, HEAD or OPTIONS, and takes --idempotency-key like the other write commands.

billkit refunds create --payment pay_Zt6Wq1yBn4Xm9RcKvo --amount 500 --reason 'goodwill'

Omit --amount and it refunds the whole remaining balance. Exactly one of --payment, --one-shot or --subscription is required.

Every write carries an Idempotency-Key, whether or not you pass one. Without it, a refund whose response is lost on the way back would be created twice by a rerun. So the CLI mints a key, prints it on stderr before it sends, and retries connection errors, timeouts, 429 and 5xx twice with that same key. If the outcome is still unknown it tells you what to check and prints the exact command to rerun safely.

> test mode (profile "test" at https://api.billkit.eu): refund EUR 5.00 of pay_Zt6Wq1yBn4Xm9RcKvo
> Idempotency-Key: cli_8fQ2… (auto-generated; reuse it if you retry)

The CLI reads the payment’s currency first so it can show the amount the way you would say it. If it cannot, the line says 500 minor units (currency unknown) rather than guessing.

Pass --idempotency-key yourself when you want to choose it, for example when a job retries the command on your behalf. Your key always wins.

Money commands say which mode and profile they resolved to before they act. In live mode they show a banner and ask you to confirm. With no terminal, such as in CI or behind a pipe, they refuse rather than wait on a prompt, and tell you to pass --yes.

billkit refunds create --payment pay_Zt6Wq1yBn4Xm9RcKvo --amount 500 --yes

Test mode never asks.

billkit events list --type invoice.paid --limit 20

Output is pretty-printed JSON, coloured when stdout is a terminal. --color never turns that off, and NO_COLOR in the environment does the same. Pipe it into jq and the colour is dropped automatically.

JSON prints to stdout. Errors, the mode banner, the idempotency key and the host notice all go to stderr, so piping into jq or a script stays clean. The exit code is 0 on success, 1 when the command ran and failed, 2 for a bad invocation such as an unknown flag or a missing argument, and 4 when there is no usable credential: no key, a key the API answers 401 to, or a value that is not a BillKit key, which the CLI refuses before sending it anywhere.

A working loop

shell
# Terminal 1: stream events to your app
billkit listen --forward-to http://localhost:3000/webhooks/billkit

# Terminal 2: set the secret it printed, then exercise the handler
export BILLKIT_WEBHOOK_SECRET=bkwhsec_...
billkit trigger customer.created

# Then the real thing: create a checkout and pay it at Mollie's test page
billkit api POST /v1/checkout/sessions \
--data customer_email=ada@example.com \
--data price_id=price_7Hd3Wm9pQx2vRt5Kna \
--data success_url=http://localhost:3000/welcome \
--data cancel_url=http://localhost:3000/pricing

Open the url from that last response, choose paid, and checkout.session.completed and subscription.created land on your handler seconds later.