Develop locally with the CLI
Install billkit, log in, stream events to localhost, and trigger test events without waiting for a real payment.
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.
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.
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.
Log in
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.
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.
Shows the profiles, a masked prefix of each key, and the API host. The * marks the default.
billkit config path prints the file location.
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
> 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.
| Flag | Effect |
|---|---|
| --forward-to | Local URL to POST each event to. Without it, events are only listed. |
| --events | Comma-separated event types. Filtered server-side, so you save the round trips as well as the noise. |
| --print-json | Print each event body to stdout as well as forwarding it. The one-line summaries move to stderr, so stdout stays pipeable. |
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
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.
| Event | What it actually does |
|---|---|
| customer.created | Creates a customer. |
| customer.updated | Creates a customer, then renames it. You will see customer.created first. |
| product.created | Creates 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.
--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.
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.
Test mode never asks. Being cheap to run is the point of it.
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
# 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.
BillKit