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 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.
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.
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, 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.
export BILLKIT_API_KEY=bk_test_...
billkit api GET /v1/customersThree 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.
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 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
> 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.
| Flag | Effect |
|---|---|
| --forward-to | Local URL to POST each event to. Without it, events are 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 as well as forwarding it. The one-line summaries move to stderr, so stdout stays pipeable. |
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
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.
| Events | What it does |
|---|---|
| customer.created | Creates a customer. |
| customer.updated | Creates a customer, then renames it. |
| customer.deleted | Creates a customer, then deletes it. |
| product.created, product.updated, product.archived | Creates a product, then updates its description or archives it. |
| price.created, price.updated, price.archived | Creates a product and a monthly EUR 10.00 price, then updates the price's metadata or archives it. |
| coupon.created, coupon.updated | Creates a 10% coupon, then sets its redemption limit. |
| webhook_endpoint.created, webhook_endpoint.updated, webhook_endpoint.deleted | Creates 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.
--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.
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.
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.
Test mode never asks.
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
# 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/pricingOpen the url from that last response, choose paid, and checkout.session.completed and
subscription.created land on your handler seconds later.
BillKit