BillKit/Docs Console
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 to solve one problem: BillKit refuses to 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, 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, validates it against the API before storing anything, and writes it to ~/.billkit/config.json. 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.

The prompt does not show what you type. A key you can read on screen is a key that stays in the scrollback, in a script or tmux log, and in the recording of whatever demo you were giving. 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. That matters because the mode login reports is then 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. Credentials saved to ~/.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. The flag wins because it is what you typed on this invocation, and it has to be able to override a variable your shell has been carrying since login.

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 you no longer have to log out or edit the JSON to move between test and live. There is still no config set; to add a profile under a different name, edit the file.

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 fresh on your machine for this session, so it is not the secret from 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. Your verification code runs for real, which is the point: a signature bug found now is one you do not find in production.

FlagEffect
--forward-toLocal URL to POST each event to. Without it, events are only listed.
--eventsComma-separated event types. Filtered server-side, so you save the round trips as well as the noise.
--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

Output is one line per event, then the status your app returned:

14:03:22  customer.created  evt_Ck7Rn2vZ9xQ4mTpLbw
  customer.created  evt_Ck7Rn2vZ9xQ4mTpLbw  -> 200

The stream reconnects on its own. Each failed attempt waits longer than the last, with jitter and a ceiling, so a server having a bad minute is not hammered by every CLI at once.

Events written while the stream was down still reach you. The server’s stream always starts from the newest event, so on reconnect the CLI reads the gap back out of the event log and forwards it before it resumes. Order is preserved and nothing is delivered twice. It tells you what it did:

> Reconnected. Replaying 2 event(s) that arrived while the stream was down.

Sometimes the gap cannot be replayed. The log may be unreachable, or the outage may have run longer than the replay cap of 500 events. When that happens the CLI says so and names the command that reconciles it:

! The gap was longer than 500 events, so only the most recent 500 are replayed.
!   Reconcile the rest with: billkit events list

It never skips events quietly. A missed event you can see costs you a minute. A missed event you cannot see costs you an afternoon of debugging an app that was working all along.

A connection that goes silent is dropped and re-dialled. The server sends a keep-alive every 15 seconds, so 45 seconds of nothing at all means the socket is dead rather than the account quiet. Without that check a laptop that sleeps, or a proxy that holds a socket open, leaves listen sitting there looking healthy while every event goes missing.

A rejected key ends listen rather than retrying it. A 401 or a 403 will not turn into a 200 on the next attempt, so the CLI prints what to change and exits non-zero. That makes it safe to wrap in a script or a CI step. Connection errors, 429 and 5xx are still treated as temporary and retried.

Triggering events

billkit trigger customer.created

trigger makes the real test-mode API call that produces the event, so what reaches your handler is a genuine payload from a genuine object rather than a fixture. Run it with no argument to list what is available.

EventWhat it actually does
customer.createdCreates a customer.
customer.updatedCreates a customer, then renames it. You will see customer.created first.
product.createdCreates a product.

That is the whole list. Payment and subscription events cannot be triggered, because they need a real Mollie payment to exist. To exercise those, run a test checkout and pick an outcome at Mollie’s status selector, or advance a test clock.

Every event here reaches a running listen. That is the bar for being on the list at all, because a trigger that reports success while your handler sees nothing sends you hunting for a bug in code that was working the whole time.

trigger refuses a live key outright.

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. It cannot express floats, arrays or nested objects, so reach for curl or an SDK for those.

billkit api keys anything that is not a GET, and takes --idempotency-key like the other write commands. The escape hatch is not an exception to the safety rules.

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.

Anything that moves money carries an Idempotency-Key, whether or not you pass one. The reason is the worst case: the request reaches the API, the refund is created, and the answer is lost. Rerun the command as typed and you have refunded twice. 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.

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

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 ask you to confirm. With no terminal, such as in CI or behind a pipe, they refuse rather than hang on a prompt nobody can answer, and tell you to pass --yes.

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

Test mode never asks. Being cheap to run is the point of it.

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 or 1.

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.