Changelog
All notable changes to Hardcount, called Velox before v0.9.0, are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Pre-1.0 versioning: we’re on 0.MINOR.PATCH. MINOR bumps for new
features and can break things. PATCH is for fixes, and a fix can still change
something you rely on, so read Before upgrading for every release. What
can break, and how it is announced: Versioning before 1.0.
This page shows the 0.9.x and 0.8.x releases. Every earlier release is in the full changelog on GitHub.
[0.9.1] - 2026-10-29
Section titled “[0.9.1] - 2026-10-29”Small fixes to what you see in your first hour: the dashboard shows the Hardcount logo, and the quickstart no longer guesses how long the demo takes.
Before upgrading: nothing to do. There are no migrations and no setting changes. On a Compose install, change the image tags in .env to v0.9.1, then run docker compose pull and docker compose up -d --no-build.
Changed
Section titled “Changed”- The quickstart and its docs no longer say how long the demo takes. The “about two minutes” guess was never measured (the demo runs in seconds).
docs/first-invoice.mdno longer says it “matches the newest code onmain” when read at a release. (#1417)
- The dashboard shows the Hardcount logo. The sidebar, sign-in pages and browser tab icon showed the old purple lightning bolt. They now show the tally mark used on gethardcount.com. Nothing else in the dashboard’s colours changes. (#1418)
[0.9.0] - 2026-10-28
Section titled “[0.9.0] - 2026-10-28”The first release named Hardcount. Usage prices belong to the plan, a usage event says when it will not be billed, prepaid credit carries its currency, and migrations run with goose. It installs fresh only: it refuses a database made by Velox or by any earlier build.
Velox is now Hardcount (#1409). The old names are not read. Rename what you run and call:
- Commands:
velox,velox-bootstrap,velox-doctorarehardcount,hardcount-bootstrap,hardcount-doctor. - Settings: every
VELOX_*variable isHARDCOUNT_*, such asHARDCOUNT_ENCRYPTION_KEY. - API keys start
hc_(hc_secret_live_…,hc_platform_…), and ids too (hc_cus_…). The default invoice prefix isHC. - Images:
ghcr.io/gethardcount/hardcountandghcr.io/gethardcount/hardcount-dashboard. - Compose: services
hardcount-apiandhardcount-dashboard; database and admin userhardcount. - Limited database user:
hardcount_app. - Metrics:
hardcount_*. - Headers:
Hardcount-Signature,Hardcount-Event-Type,Hardcount-Request-Id,Hardcount-Version; Postmark metadatahc-outbox-id. - Cookies:
hardcount_session,hardcount_device. - Stripe metadata keys and idempotency keys:
hardcount_*, such ashardcount_invoice_id. - Repository and Go module path:
github.com/gethardcount/hardcount. Go code that importedgithub.com/sagarsuperuser/veloximportsgithub.com/gethardcount/hardcountnow.
v0.9.0 needs a fresh database (#1354, #1409). A database made by Velox is refused, and nothing in it changes. From a build of main since the switch to goose (#1198), hardcount migrate and start-up stop with this database was made by Velox; from v0.8.x or an older build of main, with no migration record.
Pricing: prices belong to the plan (ADR-130).
- A plan lists its own usage prices.
POST /v1/planstakesprices, and/v1/plans/{id}/pricesadds, changes and removes one. Two plans can charge different rates for the same meter. - A price names its meter, the events it takes (
dimension_match, string values) and its rate:per_unit,graduated(the last tier hasup_to: null) orpackage(a part package bills as a whole one; there is no overage field). Rates are in the currency’s smallest unit, such as cents, with up to 12 decimal places. A price takes the plan’s currency. - The matching price with the most labels takes an event. Two prices with the same number of labels that could both take one event are refused when you save the second (409
price_conflict). There is nopriority. - A price change on a plan a subscription has used applies from each subscriber’s next billing period. A subscription that joins the plan after the change pays the new price from then, unless it was on the plan earlier in that billing period. On a plan no subscription has used, a change applies at once. Every price write answers
effective: "now"or"next_period". Test clocks follow the same rule in simulated time. - A meter only counts.
keyis nowevent_name. A meter has oneaggregation(sum,count,maxorlast), fixed once the meter has a usage event (409meter_has_usage).last_everis gone. - Price overrides name a
price_idand take a price’s fields. - A usage event’s 201 says what Hardcount found when the event arrived, in
billing_status:no_pricewhen the plan prices the meter but no price takes the event’s dimensions, ornot_on_plan,outside_subscriptionorafter_close. Nobilling_statusis not a promise that the event is billed. - Provider costs are in cents:
unit_cost_centson a provider cost,provider_cost_centson a usage event, andcost_centsin the margin report. - Removed:
/v1/rating-rules,/v1/meters/{id}/pricing-rules,/v1/meter-pricing-rules,Meter.rating_rule_version_id,Plan.meter_ids,rule_key, the meter’s default price andmeter_in_use. - The base fee stays locked while a subscription that is not canceled uses the plan, in test or live mode, and the billing timing once a subscription has used it. To change either, make a new plan. So a used plan that bills its base fee
in_advancekeeps a fee above 0. base_bill_timingis required when the base fee is above 0, and must bein_arrearswhen it is 0.- Yearly plans bill usage once a year, on the invoice at the end of the year. For monthly usage beside a yearly fee, use two subscriptions (how).
- Recipes create meters and plans only. They no longer change your dunning policy or add a webhook endpoint.
Before upgrading: v0.9.0 cannot upgrade a database. It needs a fresh one, and refuses a database from any earlier build. The notes below say what changed since a build of main from the switch to goose on (#1198), which they call “the previous release”. Their migration steps no longer apply; the API and settings changes do.
hardcount migrate statusprints a plan now. A script that reads its oldversion: N, dirty: …line must change.- API callers: publishable keys (
hc_pub_…) are removed (#1415). A request with one gets the usual 401 for an unknown key. Use a secret key, from your server only. hardcount migrate forceis removed, andhardcount migrate rollbackruns only withAPP_ENV=local. To go back, restore the backup taken before the upgrade. The restore loses everything written after the backup (Going back).- Hardcount’s three extensions (
pgcrypto,citext,btree_gist) must be in schemapublic. Migrations now run withsearch_path = public, so an extension kept in another schema is not found, and the migration stops without changing anything (Postgres requirements). - If you price LiteLLM traffic yourself, add
cache_write_5mandcache_write_1hprices for each Anthropic model (#1349). Cache writes billed asinput; they now bill under those token types, and with no price they bill $0 and show as unmatched usage. Theanthropic_stylerecipe already has them. - Don’t release held LiteLLM usage while copies of the previous release are serving: during the rolling deploy, or after rolling back (#1349). A release run by the previous release’s code does not know a call’s cache writes may already be billed inside its
inputrow, and can bill them twice. - API callers: the LiteLLM spend response now carries
cache_hits, and the margin report no longer carriescache_write_excluded(#1349). Nothing in Hardcount readcache_write_excluded; it was alwaystrue. - LiteLLM:
/v1/messages(Claude Code),/v1/responses, Gemini and pass-through usage starts billing with this release (#1352). It bills from the upgrade on, also on the invoice of the period already in progress. It bills only when the call names a known customer: Claude Code and Codex must sendx-litellm-customer-id. Atokensprice withoutmodelcharges models you have not priced. - Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1 and Mythos 5.1 arrive under their own names (#1351). Hardcount billed Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 at the older model’s prices, and did not price Haiku 5.5. A price for an older model does not cover them: give each its own price. The
anthropic_stylerecipe has them. - A Claude model name Hardcount does not know is no longer priced as an older model (#1351). For example,
claude-opus-4-9billed at Claude Opus 4 prices, andclaude-sonnet-4-5-thinkingat Sonnet 4.5 prices. Such a name now keeps its own name, shows as not billed and is logged, until you add a price for it (how). - A price on a raw Claude model name stops matching some names (#1351). Haiku 5.5, Bedrock cross-region names such as
us.anthropic.claude-sonnet-4-6,converse/andazure_ai/names, and some Google Cloud dated names now arrive as the model’s name, such asclaude-sonnet-4.6. To price a raw name, put it inmodel_raw, with one more label than the price it refines:{model, model_raw, token_type}beside{model, token_type}. - Provider costs for Claude models are looked up under the models’ new names (#1351). Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 now arrive as
claude-opus-5.5,claude-sonnet-5.5,claude-fable-5.1andclaude-mythos-5.1, not asclaude-opus-5and the like. Google Cloud@defaultand@datenames of Opus 4.1 to 4.8, Sonnet 4.5 and Sonnet 4.6 arrive as their own model, notclaude-opus-4orclaude-sonnet-4. A name Hardcount does not know, such asclaude-opus-4-9, keeps its own name. A cost saved on the older model’s name does not cover them, and a cost applies only to usage that arrives after you save it. While any of a customer’s usage has no cost, their margin shows no percentage, for every model (how). - Migration 181 adds two columns to
invoice_charge_attempts(#1225). It is instant (a constant default, no table rewrite), and copies of the previous release ignore the columns, so they keep serving while it runs. - Migration 182 adds one table,
login_throttle(#1254). It is new and empty, so it is instant, and copies of the previous release never read it: they keep serving while it runs. - Migrations 183 and 184 let a credit note’s refund be
unknown(#1267). 183 adds a column with no default and widens therefund_statuscheck for new rows; both are instant. 184 checks the existing rows without blocking writes. Copies of the previous release keep serving while they run. - API callers: a credit note’s
refund_statuscan now beunknown(#1267). Stripe gave no clear answer, so the customer may already be refunded. Code that switches onrefund_statusneeds the new value.POST /v1/credit-notes/{id}/retry-refundaccepts it and answers 502payment_outcome_unknownwhile the result stays unknown. - Migration 185 adds one table,
invoice_disputes(#1287). It is new and empty, so it is instant, and copies of the previous release never read it: they keep serving while it runs. - To record card disputes, turn on three Stripe events for your Stripe webhook endpoint (#1287). Add
charge.dispute.created,charge.dispute.updatedandcharge.dispute.closedto the endpoint in Stripe. Without them, Hardcount never hears of a dispute (Card disputes). - API callers: an invoice’s
attention.reasoncan now bedispute_lost, and a payment-timeline row’ssourcecan bedispute(#1287). Code that switches on either needs the new value. - Migrations 187 and 188 record why an email was not sent (#1310). 187 adds a column with no default to
email_outboxand a check for new rows; both are instant. 188 checks the existing rows without blocking writes. Copies of the previous release keep serving while they run. - Migrations 189 and 190 record who caused each email and charge attempt (#1314). 189 adds two columns with no default to
email_outboxandinvoice_charge_attempts, checks for new rows, and thecharge_nowtrigger; all are instant. 190 checks the existing rows without blocking writes. Copies of the previous release keep serving while they run. To go back to the previous release after this one has run, first runUPDATE invoice_charge_attempts SET trigger_source = 'auto_charge' WHERE trigger_source = 'charge_now'; the previous release does not know Charge now attempts and can misjudge a late card decline (ADR-102). - Migration 191 drops
subscriptions.usage_cap_unitsandsubscriptions.overage_action(#1327). Dropping a column is instant, but copies of the previous release read both columns, so stop every old copy before it runs (Upgrades). A subscription that had a block usage cap bills all its usage from its next period close (see Removed). To list them, run this before you upgrade:BEGIN; SET LOCAL app.bypass_rls = 'on'; SELECT tenant_id, id, code, usage_cap_units FROM subscriptions WHERE overage_action = 'block' AND usage_cap_units > 0; COMMIT; - API callers: payment-timeline rows are reworded, and a row’s
statusis nowneutral,succeeded,warningorfailed(#1297). Each row also carriesline, the same row in the activity line standard’s words, with its actor. Code that matches adescriptionor switches onstatusneeds the new values. - API callers: subscription timeline rows are reworded, and a row’s
statusis nowneutral,succeeded,warningorfailed(#1300). Each row also carriesline, the same row in the activity line standard’s words, with its actor, anderrorwhen it has a problem. Code that matches adescriptionor switches onstatusneeds the new values. - API callers:
GET /v1/subscriptionsanswers 400 to astatusit does not know (#1341). The statuses aredraft,trialing,active,canceledandarchived. Before, any other value returned an empty list.status=pausedmatched nothing, because paused collection is not a status: usecollection=paused. - Migration 192 clears the cancel schedule and the pending plan change from canceled and archived subscriptions (#1337). It changes no table; it updates only rows earlier cancels left. A collection pause stays. Copies of the previous release keep serving while it runs.
- API callers: an invoice a credit note leaves with nothing due is now
paid, soPOST /v1/invoices/{id}/voidrefuses it (#1306).invoice.paidis sent for it, withamount_paid_cents0. A note issued while a charge is running leaves the invoice open at $0; if that charge fails, the next dunning retry marks it paid. - A password on the built-in leaked-password list is refused when it is set (#1222). Passwords already in use keep working. If a script bootstraps or resets with a fixed password, check that it still works; a refused password gets a 422.
- In staging and production, sign in over HTTPS (#1226). The sign-in cookie is now always
Secure. Over plain HTTP only Chrome and Firefox athttp://localhostkeep it; Safari does not. A quickstartdeploy/compose/.envwritten before this has noAPP_ENV, so it runs in production mode: addAPP_ENV=localto it, or run./quickstart.sh destroyand start again. - A rate limit or
RUN_MIGRATIONS_ON_BOOTthat Hardcount cannot read now stops start-up (#1260). Before,HARDCOUNT_RATE_LIMIT_GENERAL_PER_MINorHARDCOUNT_RATE_LIMIT_INGEST_PER_SECset to0or to a non-number quietly used the default, and an unreadableRUN_MIGRATIONS_ON_BOOTmeantfalse. The log names the setting: fix it, or unset it. - Without Redis, sign-in is now rate-limited (#1255). Outside production with no Redis,
/v1/authnow answers 429 afterHARDCOUNT_RATE_LIMIT_GENERAL_PER_MINrequests a minute (default 100) from one address; before, it allowed every request. A test script that signs in more often from one address needs that setting raised. - API callers: an email address must be one bare address, such as
bob@example.com(#1259). A name, quotes, angle brackets or a second address get a 422, as does a domain with no dot. Team invites andmake bootstrapnow apply the dot rule too, sotenant-b@localis refused. The dashboard forms already refused these. - A stored address that fails this check is kept, and email to it now fails at once. For a customer, Sent emails shows
failedwithemail: recipient is not a single valid email address; fix the address and send again. A dev owner such astenant-b@localgets no password-reset email. A failing company email blocks saving Settings until fixed. - On a Compose install, set the idle-transaction timeout before you start this release (#1258). New data volumes get it; an existing one does not, because Postgres runs its setup script only on an empty volume. From
deploy/compose:docker compose exec postgres psql -U hardcount -d hardcount -c "ALTER ROLE hardcount_app SET idle_in_transaction_session_timeout = '30s'". - With
CORS_ALLOWED_ORIGINS=*, a dashboard at another address than the API cannot sign in: list its address instead, such ashttps://app.example.com(#1262).*now allows any site without the sign-in cookie. An entry with a*inside, such ashttps://*.example.com, or*beside other addresses, stops start-up: list each address in full. - An
OPTIONSrequest that is not a CORS preflight now gets the route’s own answer, such as 404 or 405, not 204. A load-balancer health check that sendsOPTIONS, as HAProxy’soption httpchkdoes by default, must useGET /health/ready. - After the upgrade Hardcount asks Stripe about every parked invoice, and charges again an open one with no payment at Stripe for 24 hours (#1307). Write off first any the customer paid another way:
SELECT tenant_id, id, invoice_number FROM invoices WHERE payment_status = 'unknown' AND COALESCE(stripe_payment_intent_id, '') = '';. Migration 186 matters only for an install that ranmainbetween 2026-10-19 and this change: it gives back the charge hold that code cleared from a parked invoice. Elsewhere it changes nothing. - API callers: read routes now refuse what they used to drop (#1335). An unknown customer or meter id on the credit balance, ledger and grants, usage summary, sent emails and card status reads is 404. A
fromortoonGET /v1/usage-eventsor/aggregatethat is not RFC 3339 or YYYY-MM-DD is 422, and so is a provider cost rate in a currency other than USD. Code that relied on the old answers must change. Cost and margin count every saved rate as dollars, so save again in USD any rate this finds:SELECT tenant_id, provider, model, token_type, currency FROM provider_cost_rates WHERE currency <> 'USD';. - Behind a proxy, Hardcount reads the client address from
X-Forwarded-Foronly (#1315).X-Real-IPis no longer read. If your proxy sends onlyX-Real-IP, make it sendX-Forwarded-Fortoo (nginx:proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;), or every request counts as the proxy’s address. The Compose nginx already sends both. - Migrations 193 and 194 record the invoice whose dunning paused a subscription’s collection (#1340). 193 adds an empty column to
subscriptionsand a check, both instant; every pause already set stays yours. 194 checks the existing rows without blocking writes. Once 193 has run, a copy of the previous release can no longer clear a pause that the new release’s dunning set: the check refuses its write with an error. A pause it sets over that one stays tied to the invoice. Upgrade every copy together. - API callers:
pause_collectionnow carriesset_by,operatorordunning, andsubscription.collection_resumedcan carrytriggered_by: payment(#1340). Code that switches ontriggered_byneeds the new value. - Migrations 195 to 198 add
invoices.below_minimum_at, the dunning resolutionamount_below_minimumand the charge attempt outcomebelow_minimum(#1355). 195 adds an empty column and widens a check for new rows, and 197 widens another, all instant. 196 and 198 check the existing rows without blocking writes. Copies of the previous release keep serving while they run. - API callers: a failed payment the customer’s bank wants them to confirm has the attention code
payment.authentication_required(it waspayment.declined), with only thesend_reminderaction (#1360). Code that matches onpayment.declinedneeds the new code. - While copies of the previous release still send email, a reminder to confirm a payment can go out as the ordinary retry warning, with a retry date (#1360). That retry charges nothing. Upgrade every copy together.
- API callers:
amount_below_minimumis a new value of the invoice attentionreason, the dunning runresolution(also indunning.resolved) and the hosted page’spay_unavailable_reason, Collect payment can answer 409 with that code, and a payment timeline row’sevent_typecan becharge_attempt.below_minimum(#1355). Code that switches on these needs the new value. - Writing off a spend-threshold invoice no longer has the period’s invoice bill its charges again (#1353). If you relied on that, void the threshold invoice instead, before the period’s invoice is built.
- Migrations 199 and 200 give every credit ledger row a currency (#1361). 199 fills it for existing rows with the currency each customer’s balance was shown in, then requires one on new rows; 200 checks the rows and makes the column NOT NULL. Once 199 has run, a copy of the previous release cannot write credit: stop the old copies before you migrate.
- API callers: credit is per currency (#1361).
POST /v1/credits/grantand/adjustrequirecurrency.GET /v1/credits/balance/{id}returns{data: [...]}, one balance per currency; the live balance returnsbalances; grants returnremaining_by_currency; the cost dashboard’screditsis an array; analytics returnscredit_balance_by_currencyinstead ofcredit_balance_total. Ledger rows andcredit.*webhooks carrycurrency. - API callers:
POST /v1/invoicesrefuses a currency that is not the customer’s, with 409customer_currency_conflict(#1361). The billing profile’s and every live subscription’s plan currency must agree with it. With no currency sent, the invoice takes the profile’s, then the plan’s, then the workspace default. - Migrations 201 and 202 remove values and a table that no Hardcount code writes (#1365). 201 makes draft plans active (a draft plan already billed like an active one). No Hardcount version wrote the other removed values; if a row holds one, 201 stops without changing anything and lists the rows (runbook). Rows an earlier version wrote stay as they are. 202 checks the existing rows without blocking writes. Once 201 has run, a copy of the previous release cannot delete a test clock (it deletes from
billed_entries): stop the old copies before you migrate. - API callers and webhook receivers: an invoice paid outside Hardcount has no
stripe_payment_intent_id, and haspaid_out_of_band: true(#1370). Before, Record payment and a recovery run resolved aspayment_recoveredwroteout_of_band:<time>there, which is not a Stripe id. Code that looked for that prefix should readpaid_out_of_band. Every invoice now has the field. The same goes forrecorded_payment_intent_idinpayment.duplicate_charge,payment.amount_mismatch,payment.currency_mismatchandpayment.received_on_voided_invoice: it is empty for such an invoice, and these events now carrypaid_out_of_band. - Migrations 203 and 204 move offline payments off the PaymentIntent column (#1370). 203 adds
invoices.paid_out_of_band, turns eachout_of_band:value intopaid_out_of_bandwith no PaymentIntent, deletes the charge-attempt rows stored under that value, and refuses the value from then on. Amounts, status and dates do not change. While 203 reads every invoice and charge attempt, invoice reads and writes wait. 204 checks the existing rows without blocking writes. Stop the old copies before you migrate: on one, Record payment and resolving a recovery run aspayment_recoveredfail. - API callers:
reset_billing_cycleis removed from spend thresholds (#1371).PUT /v1/subscriptions/{id}/billing-thresholdsrefuses it,trueorfalse, with 422: drop it from the body.billing_thresholdsno longer carries it, andsubscription.threshold_crossedno longer carriesreset_cycle. - Migration 205 drops
subscriptions.billing_threshold_reset_cycle(#1371). Dropping a column is instant, but copies of the previous release read it, so stop every old copy before it runs. A subscription that had it true keeps its thresholds and keeps its period from its next threshold invoice on. To list them first:BEGIN; SET LOCAL app.bypass_rls = 'on'; SELECT s.tenant_id, s.id FROM subscriptions s WHERE s.billing_threshold_reset_cycle AND (s.billing_threshold_amount_gte IS NOT NULL OR EXISTS (SELECT 1 FROM subscription_item_thresholds t WHERE t.subscription_id = s.id)); COMMIT;On one billed in advance on anniversary dates whose current period a threshold invoice started, the base from the old period’s end to the new one’s (May 1 to 20 after an April 20 threshold in April) is on no invoice: bill it with a one-off invoice if wanted. - A spend threshold now counts and bills usage only (#1371). Its invoice no longer carries the base fee; the period’s own invoice bills it, prorated on an immediate cancel. An
amount_gtethat counted the base is reached later, by the base fee: lower it by the base to keep the same point.running_subtotalin thesubscription.threshold_crossedwebhook and thesubscription.threshold_deferredaudit entry no longer counts the base either. A threshold invoice issued before the upgrade keeps its base, and it is not billed again. - API callers: before you upgrade, add
"status": "draft"to everyPOST /v1/subscriptionsthat should wait for/activate(#1372). A create with nostart_nowand no trial made a draft. The same request now makes anactivesubscription, invoices a non-zero base fee billed in advance on day 1, and charges it to the customer’s saved card if they have one. A later/activatethen answers 409. Removestart_now: it is now a 422. - API callers: send
quantityon every usage event, with at most 12 decimal places, and senddimensions, notproperties(#1374). A missing quantity was stored as 0, extra decimal places were rounded off, andpropertieswas used asdimensions; each is now a 422 (apropertiesthat isnullor{}is ignored). A quantity that is not a number or a numeric string was a 400 for the whole body; it is now a 422 that names the event. - API callers: an invoice line’s
quantityis now the exact decimal string, andquantity_decimalandunit_amount_centsare gone (#1374). Readquantity(it was the integer, truncated) andunit_amount_decimal. The hosted invoice data atGET /v1/public/invoices/{token}changes the same way. - API callers: send only the fields the API reference lists (#1399). Any other field in a request body, including one a GET returns but the server sets (
id,tenant_id,created_at,updated_at), is now a 422 naming it. So is a name in another case (Emailforemail), and any field sent to a POST, PUT, PATCH or DELETE operation that takes no body, such as/cancel. Remove it from the body. In a usage batch,errors[]names each event that has one. A body that is not valid JSON is now a 400 on dunning policy create andPATCHand onPOST /v1/invoices/create_preview(it was 422). Hardcount stores a 400 against itsIdempotency-Key, so send the corrected body with a new key. - API callers: page every list with
limitandoffset, and readtotal(#1402). Theaftercursor andhas_more/next_cursorare gone from the audit log, invoice and usage-event lists; a request that still sendsaftergets a 422.limitis 1 to 100 (default 50) on every list; a larger, negative or non-numberlimitoroffsetis a 422 naming it (it was clamped, or a 500 for a negative offset). The usage-event list took up to 1000. Lists that answered onlydatanow page too, exceptGET /v1/credits/balance/{customer_id}, andGET /v1/customers/{id}/sent-emailsanswersdata(wassent_emails).GET /v1/webhook-endpoints/endpoints/{id}/deliveriesno longer carriesendpoint: read it from the newGET /v1/webhook-endpoints/endpoints/{id}. Held usage, the two timelines and four other sets answer in one response and do not page; Lists names them. - API callers: read every error as
{"error": {"type", "code", "message", "request_id", "param"}}(#1402).Idempotency-Keyerrors moved their reason fromtypetocode(typeisinvalid_request_error;bad_requestis nowinvalid_request).GET /v1/auth/password-reset/checkanswers a 422 error with codeinvalid_or_expired, not{"valid": false}. An unknown path, a wrong method, a panic, a timeout and/metrics401 answer the envelope, not plain text or an empty body. - API callers: send a dunning policy’s schedule as
retry_days(#1375). It lists the days to wait before each retry:grace_period_days: 3, retry_schedule: ["96h", "168h"], max_retry_attempts: 3isretry_days: [3, 4, 7]. Create andPATCHrefuse the three old fields with 422, and responses no longer carry them. - Migrations 206 and 207 rewrite each dunning policy’s schedule as
retry_days(#1375). Runs retry on the same days. A policy whose retries use a wait that is not whole days from 1 to 30, such as"36h", stops 206 without changing anything (runbook). Copies of the previous release read the dropped columns: stop every old copy before you migrate. - Webhook receivers: every event’s
datachanged. It now holds the resource the event is about, as the API returns it, under the resource’s name (#1377, ADR-131). For example,invoice.paidsends the whole invoice asdata.invoice. Change your receiver to read the resource; the catalog lists the few keys an event adds beside it. An event queued before you upgrade is always sent with its olddata: on its retries (up to about 26.5 hours) and on any replay. Accept both shapes for that time, and on a replay of an older event. Old key → new key:invoice.finalized,invoice.voided,invoice.marked_uncollectible,invoice.paid,payment.succeeded,payment.failed: each key moves underdata.invoicewith its name, exceptinvoice_id→invoice.id,amount_cents(the invoice total) →invoice.total_amount_cents,payment_intent_id→invoice.stripe_payment_intent_idandfailure_message→invoice.last_payment_error.invoice.payment_recorded:invoice_id→invoice.id,amount_cents→invoice.amount_paid_cents,recorded_at→invoice.paid_at;invoice_number,customer_idandcurrency→ underinvoice;recovered_fromstays.payment.duplicate_charge,payment.amount_mismatch,payment.currency_mismatch,payment.received_on_voided_invoice:invoice_id→invoice.id,recorded_payment_intent_id→invoice.stripe_payment_intent_id;invoice_number,customer_id,currency,paid_out_of_bandandamount_paid_cents→ underinvoice;incoming_payment_intent_idandcaptured_centsstay.payment.dispute_opened,payment.dispute_closed: the dispute’s keys → underdata.dispute, withdispute_id→dispute.idandpayment_intent_id→dispute.stripe_payment_intent_id;invoice_id→invoice.id;invoice_numberandcustomer_id→ underdata.invoice.payment_method.attached:customer_id→payment_method.customer_id;stripe_payment_method_idstays.payment_method.updated:customer_id,card_brandandcard_last4→ underpayment_method;stripe_customer_idstays.- All
subscription.*events:subscription_id→subscription.id;customer_idandstatus→ undersubscription;current_period_startandcurrent_period_end→subscription.current_billing_period_startand_end;item_count→ the length ofsubscription.items.canceled_at,cancel_at,cancel_at_period_endandcancel_effective_at→ undersubscription;trial_end→subscription.trial_end_at;behaviorandresumes_at→subscription.pause_collection. subscription.item.*andsubscription.pending_change.*:item→item_id(an event that haditem_idkeeps it), with the item insubscription.items;new_plan_idandeffective_at→ the item’spending_plan_idandpending_plan_effective_at, or itsplan_idonce applied.canceled_by,reason,ended_at,triggered_by,resumed_at,old_plan_id,applied_atand thesubscription.threshold_crossedkeys stay.subscription.collection_resumedfrom the API now also carriestriggered_by: operator.customer.email_bounced,customer.email_complained:customer_id→customer.id;reasonstays.dunning.started,dunning.escalated,dunning.resolved:run_id→dunning_run.id;invoice_id,customer_idandresolution→ underdunning_run;attempts→dunning_run.attempt_count;final_subscription_action,final_invoice_actionandappliedstay.credit.balance_low,credit.balance_depleted,credit.balance_recovered,credit.commit_retired:customer_id,currencyandbalance_cents→ undercredit_balance;threshold_centsand the othercredit.commit_retiredkeys stay.
- Migrations 208 and 209 give each meter one aggregation (#1376). 208 drops
meter_pricing_rules.aggregation_mode, renamesmeters.keyandusage_held_events.meter_keytoevent_name, and checksmeters.aggregationfor new rows; 209 checks the existing rows. Each is instant, but copies of the previous release read the old columns, so stop every old copy first. If a pricing rule’s mode is not its meter’s aggregation, or a meter’s aggregation is notsum,count,maxorlast, 208 stops before changing anything: install fresh. - API callers: a meter’s
keyis nowevent_name(#1376). Sendevent_nametoPOST /v1/meters; a meter, a held-usage group, customer usage and the public cost dashboard carryevent_name. The held-usage discard body’smeter_key, analytics’ top-meterkeyand the recipe preview’smeters[].key,pricing_rules[].meter_keyandplans[].meter_keys(nowevent_names) are renamed too. The discard answers an unknown field,meter_keyincluded, with a 422 and discards nothing. - API callers: a
PATCH /v1/meters/{id}that changes the aggregation of a meter with usage events is a 409meter_has_usage(#1376). A price bills under its meter’saggregation. - Migrations 210 and 211 give the price shapes their new names (#1381). 210 renames the rate columns of rating rules, price overrides and provider cost rates, and drops
overage_unit_amount_cents; 211 checks the existing rows. If any of those tables has a row, or an invoice line is pricedflat, 210 stops before changing anything: install fresh. Copies of the previous release read the old columns: stop every old copy first. - API callers: send prices and provider costs in the new words (#1381). Old → new:
mode: "flat"→per_unit;flat_amount_cents→unit_amount_cents;graduated_tiers→tiers, last tierup_to: null(not 0);package_amount_centsis a decimal string;cost_per_token(dollars) →unit_cost_cents(cents). A request with an old word is a 422 naming it. Responses:provider_cost_microsandcost_micros→provider_cost_centsandcost_cents. - Migration 212 moves usage prices from meters to plans (#1386). It adds
plan_pricesand their versions, points price overrides and invoice lines at a price, and drops rating rules, meter pricing rules and the plan’s meter list. If any of those has a row, 212 stops before changing anything: install fresh. Stop every old copy first. - API callers: usage prices are set on the plan (#1386).
/v1/rating-rules,/v1/meters/{id}/pricing-rulesand/v1/meter-pricing-rulesare gone: sendpricestoPOST /v1/plans, or use/v1/plans/{id}/prices. A plan hasprices, notmeter_ids. A price override names aprice_id.base_bill_timingis required with a base fee. - API callers: a recipe’s plans carry their
prices(#1389). A recipe, its detail and its preview listmetersandplans, and each plan lists itsprices(meter,dimension_matchand the rate). Gone:rating_rules,pricing_rules,dunning_policyandwebhookon a recipe,event_nameson its plans, andprices,dunning_policiesandwebhook_endpointsin the preview.createscounts meters, plans and prices. - Operators:
hardcount_usage_after_close_total{origin}is nowhardcount_usage_unbilled_total{reason="after_close", origin}(#1390). Change the alerts and dashboards that read it. - API callers:
billing_statuson a usage event’s 201 has three more values,no_price,not_on_planandoutside_subscription(#1390). Code that switches on it needs them. A trial event, and an event of a customer with no active or trialing subscription, now carryoutside_subscription. An event in an invoiced period of an active or trialing subscription isafter_closewhatever the plan prices now, unless another subscription’s period not invoiced yet holds its time.
- API: an item change says where its credit for unused time went (2026-10-28, #1406).
prorationhascredited_to_balance_cents,reduced_open_invoices_centsandheld_on_draft_invoices_cents. A change now to a plan with another billing interval, billed in advance, returnsnew_period_invoice: the invoice it created for the new period, with its number, status and total. Billed in arrears, it creates no invoice then and the field is absent. - A usage event’s answer says when Hardcount found a reason it is not billed (2026-10-27, #1390). Its 201 carries
billing_status:after_close,not_on_plan,outside_subscriptionorno_price(no price of the plan takes its dimensions). It is what Hardcount found when the event arrived; no status is not a promise that the event will be billed. The batch answer gives each event’s result at its index inresults, the batch and LiteLLM answers count each status, the held-usage release countsno_price, andhardcount_usage_unbilled_total{reason, origin}counts them. - Webhook receivers:
payment.currency_mismatchcarriescharge_currency, the charge’s currency, beside the invoice (2026-10-25, #1384). Compare it with the invoice’scurrency. - Webhook receivers: dunning’s pause now sends
subscription.collection_pausedwithtriggered_by: dunning, and an operator’s pause carriestriggered_by: operator(2026-10-25, #1384). Before, a dunning pause sent no event. Dunning never resumes collection itself; paying the invoice does (triggered_by: payment), unless the subscription was canceled. - The
anthropic_stylerecipe prices Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1 and Mythos 5.1 (2026-10-22). It is version 6.0.0, with Sonnet 5 at Anthropic’s $2 / $10. Haiku 5.5 has two prices: a prompt over 100,000 tokens (input, cache reads and cache writes) pays more, and its LiteLLM events carrycontext_tier. Provider costs have one cost per model and kind of token, so one Haiku 5.5 cost covers both prices, and the margin on prompts over 100,000 tokens is wrong (more). New installs only (#1351). - Usage that arrives after its period was invoiced is now flagged, on active and trialing subscriptions (2026-10-22). It is still stored and billed on no invoice. The event’s 201 carries
billing_status: "after_close", batch and LiteLLM answers countafter_close, andhardcount_usage_after_close_totalcounts them. Not flagged: usage on a canceled subscription, and usage before a spend-threshold invoice that is not paid yet (if that invoice is voided before the period closes, the period’s invoice bills it; a write-off does not do that). If the billing-period read fails, ingest answers 503ingest_unavailable(late usage) (#1350). - API: the held-usage release answer has
pending_threshold_invoice(2026-10-22). It counts released rows before the end of an unpaid spend-threshold invoice. Rows before a paid one’s end now count asoutside_open_period; both were counted asin_open_period(#1350). - Credit note numbers can now start with your own prefix (2026-10-22). Set it under Settings → Invoicing, or send
credit_note_prefixtoPUT /v1/settings. The default staysCN, and it must differ from the invoice prefix, or an invoice and a note would share a number. New notes get the prefix; issued notes keep their numbers, and the count carries on (#1339). - API:
GET /v1/credit-notes/statscounts every credit note by status and sums the issued ones per currency (2026-10-22). (#1339) - API:
GETandPUT /v1/settingsreturnnext_invoice_sequence(2026-10-22). It is the number the next invoice in the request’s mode gets. Settings → Invoicing shows it in the prefix preview, which always said-000001before. (#1323) - Each email and charge attempt now records who caused it, and Charge now is told apart from automatic charges (2026-10-20). The person, API key, customer or Hardcount itself, as in the audit log. Rows from before the upgrade record no one. Nothing on screen changes yet; the Activity cards name them in a later release. (#1314)
- API: a dunning run’s events and a customer’s sent emails now carry
line(2026-10-20). It is the row in the activity line standard’s words, the same as on the invoice’s payment timeline.GET /v1/dunning/runs/{id}also returnsdegraded, naming any source it could not read. The dashboard pages show the lines in a later release. (#1310) - New metrics show when emails or webhooks stop going out (2026-10-19). For operators: page on
hardcount_email_outbox_failing_emails(an SMTP relay that keeps refusing mail),hardcount_email_outbox_oldest_due_age_secondsandhardcount_webhook_outbox_oldest_pending_age_secondsin live mode. Before, a refused sender was retried for 72 hours, then dropped with one log line (alerts). (#1304) - A webhook event that could not be queued is now counted (2026-10-27).
hardcount_webhook_outbox_enqueue_failures_total, byevent_type, counts each event the outbox could not store; no endpoint gets it. Every series starts at 0, so the first one alerts. (#1316) - Emails and webhook events given up on are now counted (2026-10-19).
hardcount_email_outbox_dead_lettered_totalandhardcount_webhook_outbox_dead_lettered_total, bycauseandmode, count each row once, when it is marked failed. Every series starts at 0, so the first one alerts. (#1304) - CI now uploads screenshots of the activity cards on every run (2026-10-19). For contributors: the invoice and subscription Activity cards, a Dunning run and Sent emails, on the real clock and on a test clock, as the
activity-shotsartifact. The browser tests now send email to Mailpit (how to compare). (#1296) - Card disputes from Stripe are now recorded against the invoice (2026-10-19). For anyone collecting by card: the
charge.dispute.created,.updatedand.closedevents update the record in any order, andpayment.dispute_openedandpayment.dispute_closedare sent. A lost dispute is flagged on the invoice, and names commit relief when the invoice bought prepaid credit. The invoice stays paid and no money moves (runbook). (#1287, fixes #979) - Repeated wrong passwords now slow down dashboard sign-in, instead of locking the account (2026-10-18). After 5 wrong tries from one address, or 10 for one email from many, each try waits 1 to 15 minutes, and the page says how long. A browser that signed in before is not slowed by other people’s guesses. A new browser waits while the guessing goes on: a password reset from it lets it in, which needs email set up; without email,
DELETE FROM login_throttle;clears every wait. No setup and no Redis. (#1254) make migrationwrites the next migration file, and-shorttests check every file after 180 (2026-10-18). For contributors: the name and number, an Up and a Down,SET LOCALonly, the one index-build shape, andVALIDATE CONSTRAINTin a file of its own. Steps: Writing a database migration. (#1212)- The migration checks now refuse two
ADD COLUMNforms that break an upgrade with data (2026-10-18). For contributors:NOT NULLwith noDEFAULT, which fails on a table with rows, and a value computed for each row, such asDEFAULT gen_random_uuid(), which rewrites the table under a lock. Safe forms: Common changes. (#1257, fixes #1231)
Changed
Section titled “Changed”- API: an item change’s
proration.typeiscreditwhenever part of the credit goes to the customer’s balance (2026-10-28, #1406). That includes customer credits an open invoice hands back when its amount due drops below them. It wasadjustmentthere. - API clients generated from the spec: fields the server always sends are now required (2026-10-27, #1405). The JSON is unchanged. Generated types make them non-optional:
paid_out_of_bandandis_simulatedon invoices, and fields of customers, meters, plans, prices, price overrides, provider costs and the public cost dashboard, among others (the PR lists them).DELETE /v1/price-overrides/{id}is documented as returning the whole override.POST /v1/billing/runis documented as answering 206 whenerrorsis not empty, with what each entry means.POST /v1/auth/password-reset/confirmis documented as the 200 withmessageit has always sent; the spec said 204. The public cost dashboard’s text no longer says every array is empty without a subscription:creditsstill lists the balance. - Breaking for API callers: every list pages one way,
limitandoffsetin and{data, total}out (2026-10-27, #1402). Meters, plans, price overrides and test clocks stopped at 500 rows with no sign; a test clock’s customers and subscriptions at 1,000; a customer’s payment methods at 100; API keys, tenants and webhook events at 50; credit notes had nototal. Every list route now reaches its last row, except the customer list withsearchor sorted by name or email, which looks at 5,000 customers at most. Held usage, the two timelines and four other sets do not page; Lists names them and their limits. The dashboard reads every page where it needs the whole list, and the audit log and an endpoint’s deliveries page with Previous and Next. - Breaking for API callers: every error answers the one JSON envelope (2026-10-27, #1402). An unknown path is a 404
route_not_found, a wrong method a 405method_not_allowedwithAllow, a panic a 500internal_errorand a time-out a 504timeout; each was plain text or empty. Idempotency, usage batch, password-check, Postmark webhook and live-event stream errors carrycodeandrequest_idtoo. - Breaking for API callers: every request body refuses a field its operation does not list (2026-10-27, #1399). A misspelled or unknown field, a name in another case, or one the server sets such as
id, is a 422 naming it, and nothing changes. A POST, PUT, PATCH or DELETE operation that takes no body refuses one with any field. Before, most routes dropped it: aPATCHwith a wrong field name answered 200, and{"at_period_end": true}to/cancelcanceled at once. The Stripe and Postmark webhooks and the LiteLLM callback take any field. - The audit row
subscription.net_negative_usagenames each meter byevent_name(2026-10-27). Itsmetersentries hadmeter_key, the old name of the meter’sevent_name. Rows written before the upgrade keepmeter_key, and the activity line still names their meters. - API callers: customer usage over a window you choose (
fromandto) no longer adds an “Estimate” line towarnings(2026-10-27).period.source: "explicit"says the amounts are an estimate at each subscription’s current prices, and count only active and trialing subscriptions;warningsholds only problems. On the customer page, the Activity card’s 7, 30 and 90-day views and the Margin card say they are an estimate that leaves out canceled subscriptions, and the Activity card no longer shows “Usage may be incomplete” for them. - The price override form lists the prices of the customer’s own plans first (2026-10-27).
- Breaking for webhook receivers: each event carries the resource it is about (2026-10-24, #1377).
dataholds the invoice, subscription, customer, payment method, dunning run or credit balance as the API returns it, plus the few facts the resource lacks. Every event of a family carries the same resource. The API’s payment-method rows now carrycustomer_id. A Stripe event that arrives after a card was removed in Hardcount no longer sendspayment_method.attachedorpayment_method.updated. Resuming collection that is not paused, or clearing a cancel that is not scheduled, changes nothing and now sends no event; the clear also writes no audit log entry. See Before upgrading for each moved key. - Held usage no longer fixes a meter’s aggregation; only usage events do (2026-10-24, #1379). A meter made with the wrong aggregation while its usage was held can now be changed.
has_usageis true only once the meter has usage events. - Breaking for API callers: request decimals are validated with one helper, and each field’s limits are documented (2026-10-24, #1378). Price and price-override rates: 0 or more, under 10^18 cents. A provider cost’s
unit_cost_cents: required, 0 or more, under 10^10.usage_gte: more than 0, under 10^26. Each, likequantity, has at most 12 decimal places and 100 characters. A value outside these is a 422 naming the field. Valid values bill as before. - Breaking for API callers: a usage event’s
quantityis checked as the API reference says (2026-10-23, #1374). It is required on all three ingest routes (backfill used 0 for a missing one): a JSON number, or a string holding one, with at most 12 decimal places and a size under 10^26. A negative value is still a correction. Anything else is a 422 namingquantity, and a batch names each failing event. Valid events bill as before. - Breaking for API callers: a dunning policy’s schedule is one list of days,
retry_days(2026-10-23, #1375). The number of retries is its length, so the count, the first wait and the other waits can no longer disagree. Each wait is whole days from 1 to 30, at most 15 retries. The dashboard’s policy editor has one field, and the bundled recipes useretry_days(new major versions). A new policy in the dashboard now starts at day 3, 7 and 14, the seeded default’s days (it was day 3, 6 and 11). - Breaking: a meter has one aggregation, fixed once the meter has a usage event (2026-10-23, #1376). Every price of the meter bills under its
sum,count,maxorlast. Before its first usage event it can change, even while subscriptions use the meter. Alastmeter’s preview and invoice now pick the same event when two share a timestamp (the higher id). The meter page shows the lock (has_usage). - The built-in recipes are now at 8.0.0 (
anthropic_style), 7.0.0 (openai_style) and 5.0.0 (replicate_style) (2026-10-24, #1376). A recipe names its meter byevent_name, and its prices have noaggregation_mode. Prices are unchanged. - Breaking: a package price bills a part package as a whole one (2026-10-25, #1381). It bills ceil(quantity ÷
package_size) packages, and its amount can be a fraction of a cent: $0.002 per 1,000 tokens ispackage_amount_cents: "0.2". Before, units past the last whole package billed atoverage_unit_amount_cents, which is gone. Other prices bill as before. - Breaking for API callers: one unit rule for prices and provider costs (2026-10-25, #1381). Every
*_centsfield is in the currency’s smallest unit, and a price isper_unit,graduatedorpackage(ADR-130). A graduated price’s last tier isup_to: null, and only the last. A price takes only its mode’s fields:tierson aper_unitprice, for example, is a 422 naming it. A provider cost rate is US cents per token. Invoice lines saypricing_mode: "per_unit". - The built-in recipes are now at 9.0.0 (
anthropic_style), 8.0.0 (openai_style) and 6.0.0 (replicate_style) (2026-10-25, #1381). Their rates aremode: per_unitwithunit_amount_cents. Prices are unchanged. - Breaking: a usage price belongs to a plan, so two plans can charge different rates for one meter (2026-10-25, #1386). The price with the most labels takes an event; two that could tie are refused when saved. A price change applies at once on a plan no one has used, else from the next period of each subscriber already on the plan, on a test clock too (ADR-130).
- The built-in recipes are now at 10.0.0 (
anthropic_style), 9.0.0 (openai_style) and 7.0.0 (replicate_style) (2026-10-26, #1389). A recipe file holds only meters and plans with their prices. Prices are unchanged. - A subscriber pays a plan as it stands when they join it (2026-10-26, #1388). A subscription that starts after a price change, an immediate move onto the plan and an item added on it are priced at the plan’s prices of that moment, from the first period. After a trial, the moment is the trial’s end. Before, they paid the prices of their period’s start until the next period. A subscription that leaves the plan and joins it again in the same period keeps the prices of its first join until its next period. An override created before the join, even that day, applies from the first period. On a test clock, a change or override takes effect just after the clock’s time, so a subscriber who joins at that same clock time gets the earlier prices. A subscriber already billing on the plan gets a change from its next period, on a test clock too (ADR-130 §4). An item added after its period ended, before that period was billed, no longer bills that period’s usage.
- A price override names one plan price and follows the customer’s clock (2026-10-25, #1386). It keeps applying when the price’s rate changes. Removing the price ends it, and the answer lists the overrides it ended. On a test clock, a new or ended override follows the clock’s time.
- A plan’s base fee can be set to 0 (2026-10-25, #1386). A plan with no base fee bills at period end, so
in_advancewith a base of 0 is refused.base_bill_timingis fixed once a subscription has used the plan. - Customer usage and the cost dashboard price usage the way the invoice does (2026-10-25, #1386). Customer usage lists
pricesper plan price, with ano_pricerow for usage no price of the plan takes. Each subscription’s whole current period is priced as its invoices price it, threshold invoices included, and the period shown spans all of them; a window you pick is an estimate at today’s prices. - The dashboard edits usage prices on the plan’s page (2026-10-25, #1386). The page says before you save whether a change applies at once or from each subscriber’s next period. A meter’s page lists the plans that price it. The Prices tab is gone.
- The Provider costs page takes and shows dollars per 1M tokens (2026-10-25, #1381). It was dollars per token. The price forms show a graduated price’s last tier as “No limit”.
- Breaking for API callers: a new subscription starts now unless you create it as a draft (2026-10-23, #1372). Before,
POST /v1/subscriptionswithoutstart_now: trueand without a trial made a draft that billed nothing until activated. Now it is active (or trialing) at once and a non-zero base fee billed in advance is invoiced on day 1. Old → new:start_now: true→ leave it out; no flag →status: "draft".start_now, an empty or nullstatus, andstatus: "draft"with a trial are a 422. The dashboard’s “Start immediately” box is now “Create as draft”, off by default. - A credit pays only invoices in its own currency (2026-10-23). Each grant, adjustment and credit-note credit is in one currency, and a balance in two currencies shows as two balances, never added together or converted. Before, USD credit paid a EUR invoice cent for cent. Grant and Deduct ask for the currency, the low-balance alert amount applies in each currency, and a credit note’s PDF names the currency its credit pays (ADR-129, #1361).
- A manual invoice uses the customer’s currency (2026-10-23). One in another currency is refused, and an invoice with no currency takes the live subscription’s plan currency when the customer has no billing-profile currency; before, it took the workspace default. In the dashboard, New invoice then shows the currency fixed (#1361).
- New page: what can break before 1.0, and where each break is announced (2026-10-23). See Versioning before 1.0.
SECURITY.mdnow says only the latest release gets security fixes. (#1363) - The roadmap is now Now, Next, Later and Not building (2026-10-23). Usage prices set per plan are planned for v0.9.0; spend alerts and generated Go and TypeScript SDKs come next, in v0.10.0 (roadmap). (#1363)
- Twenty claims about Lago, OpenMeter, Flexprice and Kill Bill are corrected against their code (2026-10-23). Each correction names the commit it was checked in, on How Hardcount compares or in the ADR. ADRs keep their text and gain a dated correction. (#1363)
- Docs no longer overstate Hardcount (2026-10-23). Row-level security covers most API requests, not all (list); for contributors, a new
TxBypassuse fails a test until it is on that list. Grant entries in the credit ledger are updated in place. There is novolumepricing mode. The docs no longer suggest archiving old usage events, which would break dedup, and Scale says how dedup would survive a split by month. (#1363) - Docs: internal records, audits and design notes moved to docs/dev/; overlapping pages merged; two runbook steps corrected.
- Docs: the self-host, ops, benchmark and runbook pages lead with the short path, with repeats and analogies cut. Claims corrected include the email rate, the shutdown worst case and the parked-invoice gauge.
- Docs: the front-door pages and guides lead with the short path, with repeats, analogies and padding cut. Claims corrected include tenant isolation, which webhooks are never lost, and what the LiteLLM integration stores.
- Breaking for API callers: the create routes for subscriptions, invoices and invoice lines, credit grants and adjustments, credit notes, customers, plans, prices and price overrides refuse a field they do not know, and so do invoice refunds, the customer billing profile, and the subscription routes that schedule a cancel, pause collection, set spend thresholds, and add or change an item (2026-10-22, #1327). The answer is a 422 with
paramset to the field, such astrial_period_daysinstead oftrial_days, and nothing is written. Before, the field was dropped: a trial asked for that way became a subscription charged on day 1, andexpiryinstead ofexpires_atmade a grant that never expires. A refund withamountinstead ofamount_centsrefunded the whole invoice,flat_amountinstead offlat_amount_centsmade a free price, andtax_exempton a billing profile left an exempt customer taxed. A 422 is not kept under theIdempotency-Key, so the corrected request can reuse the key. An item change that sentimmediatelyinstead ofimmediatewas a 200 that moved the plan at period end. Also refused with a 422:trial_daysbelow 0 or above 730, an extend-trial past 730 days from the trial’s start, a commit relief withrefund_amount_cents,out_of_band_amount_centsorrefund_typeat the top level instead of insidecommit_relief(it was ignored, and the relief refunded the card in full), a plan created with astatusother thanactive, and a plan or customerPATCHwith a status the database does not allow (that was a 500).dunning_policy_idis not a customer create field: assign the policy withPATCH /v1/customers/{id}. The dashboard sends only known fields. Other PATCH routes and the dunning policy routes are unchanged (ADR-112). - An operator’s Add payment method link now replaces a working default card (2026-10-22, #1343). Before, the card the customer saved through it was added beside the default. A card paid on the hosted page is still added beside a working default (ADR-021).
- API callers: the
finalizeaudit row of an invoice Hardcount created finalized carriesborn_finalized: true(2026-10-21, #1319). Renewals, first invoices, plan changes, spend thresholds and final invoices get it. A draft finalized by a person or by a tax retry does not. - The invoice’s Activity card now groups rows by day and names the timezone once (2026-10-21). Days and times follow the account’s timezone. Each row shows its time, the amount after the title, who did it, and on a test clock when it was recorded. Invoices, credit notes and subscriptions in a row are links. The dot is grey, green, amber or red. (#1318)
- A failed-payment notice or payment setup email that Hardcount did not send now says why on the invoice’s Activity card (2026-10-20). It reads “Failed payment notice not sent · The invoice was already paid”, or voided, or marked uncollectible, not “already closed”. Emails skipped before this release keep the old words. (#1310)
- Subscription activity rows now follow the same wording rules as invoice rows (2026-10-19). Spend thresholds show money, not cents. A failed proration says what was not billed, not a server error. A plan change for renewal reads “Plan change scheduled”. The dot is grey, amber or red, with one meaning each. (#1300)
- Invoice activity rows now use one wording and name who did it (2026-10-19). Emails read “Invoice email delivered”, a credit note has one title with its total and where it went, a void or write-off shows its amount, and rows name the person or API key. The dot is grey, green, amber or red, with one meaning each. (#1297)
- New design record for activity lines (2026-10-19). For contributors: ADR-128 sets one wording per event, a named actor on every line and one clock rule for the Activity cards, the Dunning page and Sent emails. The rules are in the writing-style guide. The screens change in later PRs. (#1294)
- The upgrade guides now say to stop the API while a failed upgrade is fixed, and what going back loses (2026-10-19). With Compose,
docker compose stop hardcount-apiends the restarts that retry it. A restore loses everything written after the backup, and Hardcount can charge some invoices again: Going back lists what to check. A stack built from source now upgrades to a release, after a backup. (#1292, fixes #1230) - The OpenAPI file now lists every route the server answers (2026-10-18). Thirteen were missing, among them the data behind the hosted invoice and payment-update pages, the Stripe and Postmark webhooks,
/health/readyand/metrics. A Go test now fails when the router andapi/openapi.yamldisagree (OpenAPI workflow). - New section: who can sign in to the dashboard (2026-10-18). It lists what protects sign-in with no setup and says Hardcount has no two-factor sign-in yet. To add one, put sign-in behind your own SSO or VPN; the section names the paths to keep open (Run Hardcount in production on one VM). Two-factor sign-in is built when users or a security review ask for it (ADR-095).
- The OpenAPI file now describes every success response and the
Idempotency-Keyheader (2026-10-18). For API users and generated clients: 37 routes had a missing or empty response schema, among them the list routes,POST /v1/credits/grantand the test clocks. Usage eventquantityis a decimal string, as the server always sent it. Invoices, usage events and credit ledger entries gain fields the server already sent (API endpoints). - New page: card payment failure handling (2026-10-18). How Hardcount handles declines, ambiguous charge results, crashes mid-charge, duplicate or late Stripe webhooks and refunds, and the four gaps still open (Card payment failure handling).
- Browser tests now set up pricing, recipes, price overrides and dunning policies through the dashboard (2026-10-18). Contributors:
web-v2/e2e/account-setup.spec.tshas four journeys, each checked against the API, and the smoke test now opens the six public pages signed out, including links with no or a dead code. - Browser tests now change subscriptions through the dashboard (2026-10-18). Contributors:
web-v2/e2e/subscriptions.spec.tshas four journeys: change plan now (proration invoice) and at renewal, cancel at period end, undo and cancel now, quantity and item changes against the upcoming invoice, and trial and collection controls. Each checks the page against the API. - A misspelled key in a bundled recipe now fails CI and stops Hardcount at startup (2026-10-18). For contributors: before, a key such as
base_bill_timmingwas skipped without a word, so the plan quietly billed in arrears. The three bundled recipes load unchanged. - Browser tests now issue, settle, correct and close invoices through the dashboard (2026-10-18). Contributors:
web-v2/e2e/invoices.spec.tshas four journeys: a one-off invoice from draft to finalized and Save & send, credit notes before and after an offline payment, void and write-off, and the credit notes page. Each checks the screen and the payment page against the API. - Migrations run with goose, from one v0.8.1 baseline (2026-10-18). A new database runs one generated file instead of 178. A failed migration is undone, so nothing needs repair: fix the cause and run again. Copies that start together take turns. ADR-125 supersedes ADR-124. (#1198)
hardcount migrate statusshows the upgrade plan (2026-10-18). It prints the database’s version, the migrations to apply, index builds marked, and whether a migration is running now. It only reads, so run it with the new release before upgrading. (#1198)
Removed
Section titled “Removed”- Breaking for API callers: publishable API keys (2026-10-28, #1415). Hardcount keeps two key types, secret and platform. A request with an
hc_pub_key gets 401, andPOST /v1/api-keyswithkey_type: publishablegets 422. The bootstrap response has nopublishable_key_test. Stripe’s publishable key (pk_…) under Settings → Payments is not affected. - Breaking for API callers: values and tables kept only for data an earlier version wrote (2026-10-28, #1415). v0.9.0 installs fresh, so nothing holds them:
- dunning run
resolutionmanually_resolved; - invoice line
line_typediscountandtax; - an invoice’s
stripe_invoice_id; - the
coupons,coupon_redemptionsandcustomer_discountstables.
- dunning run
- Breaking for API callers: duplicate fields and a second dedup key (2026-10-23, #1374). Old → new:
- an invoice line’s
quantity(the integer, truncated) andquantity_decimal→quantity, the exact decimal string;unit_amount_cents→unit_amount_decimal. The same on the hosted invoice data. Amounts, PDFs and the dashboard do not change. The hosted page now writes a whole quantity of 1,000 or more on a line that is not usage as1,500, not1500, as the dashboard does; - a usage event’s
properties→dimensions; an event whosepropertieshas a key is a 422, andnullor{}is ignored; - the
Idempotency-Keyheader on the usage routes (/v1/usage-events…,/v1/integrations/litellm/spend) → each event’sidempotency_key, already required. The header is ignored there, so nothing is replayed from it, and a batch over 1 MB is always a 413batch_too_large.
- an invoice line’s
- The billing-interval rebuild at start-up (2026-10-23, #1374). It rebuilt intervals for items written before 2026-07-27. A 0.8.x database cannot be upgraded, so no database needs it. Two copies starting together no longer race on it, which deadlocked behind PgBouncer (#1364).
- The
hardcount migrate forcestub, theHARDCOUNT_BILLING_INTERVALS_READERstart-up warning, andHARDCOUNT_TEST_CLOCK_CATCHUP_DELAY_MS(2026-10-23, #1374).hardcount migrate forceis now an unknown subcommand. The setting did nothing but log a warning, and the delay only slowed test-clock catch-up for a retired manual test. - Breaking: a recipe no longer creates a dunning policy or a webhook endpoint (2026-10-26, #1389). Installing one leaves your default dunning policy as it was. Set retries under Dunning policies and add an endpoint under Webhooks.
- Breaking for API callers: rating rules, meter pricing rules and a meter’s default price (2026-10-25, #1386). Use plan prices. The 409
meter_in_usegoes with them: a price change never reprices the period already open. A recipe install puts its prices on its new plan and never changes another plan’s prices. - Breaking for API callers: a pricing rule’s own aggregation, and
last_ever(2026-10-23, #1376). A meter has one aggregation for all its prices (ADR-130), soaggregation_modeand its valueslast_during_periodandlast_everare gone. Use alastmeter for a latest-value price; nothing reads a value from before the period any more. - Breaking for API callers: 8 operations that another operation already does (2026-10-23, #1368). They now answer 404. Use the replacement; some response shapes differ, so check the API reference. The kept invoice, subscription and usage reads answer 200 with no rows for an unknown customer id, where the removed routes answered 404. Other differences are on each line. The replacements:
POST /v1/checkout/setup→POST /v1/customers/{id}/payment-methods/setup-session, which takes the name, email and address from the customer’s record;GET /v1/checkout/status/{customerID}→GET /v1/customers/{id}/payment-methods;GET /v1/webhook-endpoints/events/{id}/deliveries→GET /v1/webhook_events/{id}/deliveries, which answers 404 for an unknown event id, not 200 with no rows, and for a replay’s id lists the deliveries of the original event and of every replay;POST /v1/webhook-endpoints/events/{id}/replay→POST /v1/webhook_events/{id}/replay, whosestatusisqueuedorno_matching_endpoints, notreplayed;GET /v1/customers/{id}/invoices→GET /v1/invoices?customer_id=;GET /v1/customers/{id}/subscriptions→GET /v1/subscriptions?customer_id=;GET /v1/usage-summary/{customer_id}→GET /v1/usage-events/aggregate?customer_id=, withfromandto(there is no default window);GET /v1/billing/preview/{subscription_id}→POST /v1/invoices/create_previewwithcustomer_idandsubscription_id.
- Breaking for API callers:
reset_billing_cycleon spend thresholds (2026-10-23, #1371). A spend threshold invoice always keeps the billing period, Stripe’s default; no open-source engine we compared has the option. On an anniversary subscription with an in_advance base, it left part of a period’s base on no invoice after each fire. The dashboard’s “Start a new billing period” box is gone. - Breaking for API callers: values no Hardcount code writes any more (2026-10-23, #1365). Code that lists these values can drop them:
- subscription
statusarchived;GET /v1/subscriptions?status=archivedis a 400; - plan
statusdraft; aPATCHthat sends it is a 422, and a draft plan is nowactive(it already billed like one); - invoice
tax_statusfailed(a failed calculation stayspending); - dunning run
statepaused(thepausedflag stays);GET /v1/dunning/runs?state=paused, or any state butactive,escalatedandresolved, is a 400; - dunning event types
retry_scheduled,retry_succeeded,retry_failed,pausedandresumed.
- subscription
- The
billed_entriestable (2026-10-23, #1365). Nothing ever wrote it. recovery_supersededfrom an invoice’srecovery_warning.code(2026-10-23). It warned that paying a written-off spend-threshold invoice double-charged; that no longer happens (#1353).- The v0.8.x upgrade path (2026-10-23, #1354). Hardcount no longer takes over a v0.8.0, v0.8.1 or v0.8.2 database, and
hardcount migrate statusno longer saysready to moveorrefused. The baseline is now ordinary source, and the migrations CI job checks a fresh install under each role setup. - The block usage cap:
usage_cap_unitsandoverage_actionon subscriptions (2026-10-22, #1327; the usage-cap half of #973). Withoverage_action: block, the cap added up the units of every meter on an invoice and scaled each meter’s billed quantity down to fit; usage was still recorded. No billing engine we compared does this, the threshold invoice ignored the cap, and a capped close after a threshold invoice could bill more than the usage cost. Subscriptions that had a cap now bill all usage. API responses and webhooks no longer carryusage_cap_unitsoroverage_action, and a create that sends either gets a 422. The dashboard’s Usage limits fields are gone. To invoice before a bill grows too large, use a spend threshold. - The Stripe Search sweep for parked invoices, and its metric
hardcount_parked_search_errors_total(#1307). Parked invoices are checked through Stripe’s List API instead (see Fixed). Remove alerts on the metric. hardcount migrate forceandhardcount-migrate-safety(2026-10-18). goose leaves no half-applied state, so there is nothing to force. The safety tool replayed the old migration files, which no longer ship.
- The Dunning page’s Resolve dialog starts with no choice made (2026-10-28, #1406). It opened with “Payment recovered” chosen, so Resolve with no other click recorded a payment and marked the invoice paid. Resolve now waits for a choice.
- Finalize on a draft invoice asks first (2026-10-28, #1406). The dialog says the invoice can’t be edited afterwards, that a manual invoice’s tax is worked out then, and whether the customer’s card is charged or they are emailed a link to add one. It promises the email only when the server can send it (SMTP and
PAYMENT_UPDATE_URLset). With Stripe not connected in the mode, it says no card can be charged or added online. While the subscription’s collection is paused, it says nothing is charged until collection resumes. - After a plan or quantity change, the toast names each part of a credit for unused time (2026-10-28, #1406). A credit that went partly to the balance and partly off an open invoice read as a balance credit for the whole amount. A credit on a draft invoice says it waits for the draft to be finalized.
- Changing now to a plan with another billing interval says what happens (2026-10-28, #1406). The dialog said the price difference is charged or credited. It now says a new period starts. On a plan billed in advance, the new period is invoiced at once and the toast names that invoice, with its total unless it is a draft. On a plan billed in arrears, the new period is billed at its end.
- Deleting a dunning policy that has run payment retries answers 409 with the reason, not 500 (2026-10-28, #1406). The runs keep pointing at it, so it stays. The dashboard shows the message.
- The invoice PDF lists every issued credit note (2026-10-28, #1406). It read the newest 100 notes of any status, so past 100, or with drafts among them, rows were missing and stopped adding up to Amount Due. This covers the downloaded, emailed and hosted-page PDFs.
- The Advance clock dialog says how to test payment retries that pause or cancel (2026-10-28, #1406). Move to the last retry first, then to the next billing date. One longer move bills a period a real clock would not (#1276).
- A manual invoice for a subscription’s exact billing period no longer stops that period from being billed (2026-10-28, #1407). API: an invoice created with
subscription_idand the next cycle’s period took the cycle invoice’s place, and the billing run skipped that cycle invoice with its base fee and usage. A period skipped this way before the fix is not billed again. - An in-arrears base fee with a change in the period bills exactly the period’s days (2026-10-28, #1407). Each segment was rounded to whole days on its own, so the segments could bill one day more or less than the period. This could happen with two mid-day changes, with one change at an exact half day from the period start, and with one change in a period that crosses a daylight-saving change in the tenant’s timezone. Each change time is now rounded to whole days from the period start. This covers the cycle invoice, its preview and an immediate cancel’s final invoice.
- Voiding an invoice hands back all the customer credit applied to it (2026-10-28, #1407). It counted at most 100 credit applications, so an invoice paid by more returned too little.
- An invoice with more than 50 credit notes: every note counts toward what is left to credit, refund and reverse (2026-10-27, #1404). Hardcount added up only the newest 50. A refund with no amount could be refused, a cancel or plan change crediting unused time could fail, with Stripe Tax voiding an unpaid invoice could reverse the older notes’ tax again, and a written-off invoice could miss its unapplied-credit warning.
- A refund with no amount works on a paid invoice that already has a credit note that is not a refund (2026-10-27, #1404). It refunds what was paid by card less the refunds on the invoice’s credit notes that are not voided, up to what is left of the invoice total. It was refused with 422 when those other notes came to more than the part of the invoice not paid by card.
- A card-setup link asked for with Stripe not connected answers 409
stripe_not_connectedfor every customer (2026-10-27, #1399). A customer not yet sent to Stripe got a 500. The dashboard shows the message. The customer’s update-payment page answers 409, not 502, and says adding a card online isn’t available, also for a customer Hardcount already sent to Stripe. - The first-invoice tutorial is checked in CI: every pull request runs its commands on its own build and compares each output with the page (2026-10-27, #1400). Its wording now matches Hardcount: the meter is named Tokens and counts
tokensevents, and the invoice’s ⋯ menu is next to Audit log. The quick start runs v0.8.2, so the page links its v0.8.2 version. - An installed recipe’s card says its settings were set at install (2026-10-27). It said “you can adjust”, while they are fixed once installed.
- The Recipes page now says to edit an installed recipe’s plan and prices on the plan’s page (2026-10-27). It sent you to the Pricing page, which no longer lists prices.
- The customer’s Daily usage chart shows each axis label whole, and large numbers in short form, such as 180K (2026-10-27). Long labels were cut off: 180,000 read “80000” and 0.00008 read “00008”.
- The Edit plan dialog shows a used plan’s own billing timing, and says before you save that a used plan billed in advance needs a base fee above 0 (2026-10-27). It showed “At end of period” and “0 for no base fee”, and Save failed. The API’s refusal no longer tells you to change the timing, which is fixed.
- Two installs of the same recipe at the same time: the second now answers 409, not 500 (2026-10-26, #1389). It installs nothing, and a retry returns the first install.
- When an item leaves a meter on the first day of a period and an item on another plan that prices the meter is added later that day, each event is billed once (2026-10-26, #1388). The added item bills from the period start, and the events before the first item left were billed by both items. This covers the invoices, the previews, customer usage and the live balance.
- A row that does not say test or live is refused, instead of being stored as live (2026-10-25, #1385). The database stamps each new row’s mode from the transaction’s
app.livemodesetting, and read an unset setting as live. An insert with it unset now fails withneeds app.livemode set to on or off(migration00213_livemode_trigger_requires_mode.sql). Hardcount sets it on every write. SQL you run by hand must set it first: Manual fixes shows how. - Work that does not say test or live mode is refused, instead of being treated as live (2026-10-25, #1385). Hardcount reads the mode on every request and job, and a missing one now fails with
livemode was never set on ctx(an API answers 500) rather than using live data or live Stripe keys. In the database, an insert into a table with alivemodecolumn fails withoutapp.livemodeset toonoroff, and a read shows no rows (migrations00213_livemode_trigger_requires_mode.sqland00214_livemode_policies_require_mode.sql). SQL you run by hand must set it first: Manual fixes shows how. - Stripe keys that cannot be read are reported as an error, not as “Stripe not connected” (2026-10-25, #1385). This covers refunds, the hosted invoice page, payment-method setup and Stripe Tax. A tax calculation that cannot read the keys waits with the new reason
credentials_unreadableonhardcount_tax_outcome_total(Tax calculation). - Every collection pause that ends sends one
subscription.collection_resumed, with its own trigger (2026-10-25, #1384). A pause can end before Hardcount clears it: its date comes, or its invoice is paid. A new pause or a resume call that reached it first sent no resume, or senttriggered_by: operator. And the scheduled resume could clear a pause dunning set a moment earlier, sendingtriggered_by: paymentfor an invoice nobody paid. The scheduled resume’s event and audit row are now saved with the change. - On a test clock, a cancel by dunning says
canceled_by: dunninginsubscription.canceledand in its audit row (2026-10-25, #1384). It saidoperator, taken from the API key of the clock advance that ran dunning. - An event’s
after_closeflag, its counter and its WARN follow the aggregation the event is counted under, even when the meter’s aggregation changes during the ingest (2026-10-25, #1382). Before, they could follow the old one. Billed amounts were not affected. - An amount too large for 64-bit cents refuses the invoice or fails the read, instead of billing or showing a wrapped number (2026-10-25, #1383). This covers base fees, prorations, tax splits, previews, the live balance, threshold checks, credit notes, the credit ledger and dashboards. Amounts that fit (up to about $92 quadrillion) are unchanged, except a rounding that was one cent off when it divided by more than about 4.6 × 10^18 cents. A spend threshold counts usage only, so a base fee this large does not stop its fire.
- Manual tax is exact on any invoice amount, and an invoice too large for 64-bit cents fails instead of carrying wrong amounts (2026-10-24, #1378). At 20%, a 1e14-cent invoice was taxed 1553255926290 cents; it is now taxed 2e13. An invoice whose sum or total with tax does not fit fails to bill with an overflow error.
- A usage event whose provider cost is too large to record is stored and billed with no cost (2026-10-24, #1378). Before, the ingest, a LiteLLM call or a held-usage release failed with a database error. The margin report counts the event in
unresolved_events. - A spend threshold followed by an immediate cancel no longer bills the whole period’s base fee (#1371). A 3000c monthly base canceled after 24 days billed 3000c; it now bills 2400c, as without a threshold.
- An invoice paid outside Hardcount no longer shows a Stripe payment ID (2026-10-23). Its Payment details showed
out_of_band:<time>as the Stripe payment ID. A second payment by card on such an invoice now says the first was recorded as paid outside Hardcount (#1370). - A plan change, a new subscription or an activation no longer fails with a deadlock error when a meter’s prices or aggregation are edited at the same time (2026-10-23). When two plans that bill an edited meter were involved, Postgres could stop one of the requests (
40P01). Subscription writes now lock plans in the same order as the meter edit (#1367). - A webhook endpoint can subscribe to
payment.currency_mismatch(2026-10-23). Hardcount sent it, but an endpoint that named it was refused as an unknown event (only*andpayment.*got it), and the dashboard’s event picker did not offer it, norpayment.dispute_openedandpayment.dispute_closed. A test now fails when an event Hardcount can send is missing from the list endpoints accept, the picker or the webhooks page (#1365). - The OpenAPI file lists the values the database accepts (2026-10-23).
tax_error_codeis an enum and includesprovider_not_configured, and the removed values above are gone. A test now fails when a stored enum’s database check and its OpenAPI enum differ (#1365). - Editing a price no longer reprices the billing period already open (2026-10-23, #1362; on the plan since 2026-10-25, #1386). A price change on a plan a subscription has used applies from each subscriber’s next billing period, on a test clock too, and a meter’s aggregation is fixed once the meter has a usage event. Before, a price edit, a new price or an aggregation change repriced usage already recorded.
- A card payment the customer’s bank wants them to confirm (3-D Secure) is no longer shown or emailed as a declined card (2026-10-23). The invoice notice says the customer must confirm and offers Email payment link, not Retry payment. The customer is asked to confirm on the hosted invoice page. Payment retries count but don’t charge that card, as in Stripe Billing (runbook) (#1360).
- The payment-failed email now gives Stripe’s reason for a card decline, such as “Your card has insufficient funds.” (2026-10-23). It gave no reason: the reason was saved under a field the email sender didn’t read. For a card refused for good it asks for a different card. A failure Stripe gives no card-decline message for still has no reason line (#1360).
- A written-off invoice that billed a subscription’s current period in advance now counts as unpaid when the subscription changes (2026-10-23). While it has an amount due, a plan change, added item or seat increase that would charge for the rest of the period is refused with
unpaid_invoice_blocks_change; it used to charge nothing for the rest of the period (#1359). - A cancel, downgrade, removed item or plan swap now credits the unused time on that written-off invoice, as it does on an unpaid one (2026-10-23). It lowers what the invoice asks for, so a customer who pays it later pays only for the time they had; it used to give nothing. A cancel on the period’s first day voids it instead, if nothing is paid on it, no charge on it is being confirmed with Stripe and it bills nothing else (#1359).
- A written-off invoice still waiting on an unclear charge is checked again once Stripe can be reached (2026-10-23). When Stripe refused the check (not connected, key rejected, customer missing), it used to stay waiting for good (#1359).
- Writing off an invoice now asks Stripe to close a Checkout page still open for it (2026-10-23). The customer could pay through it for up to an hour after it opened, and still can if Stripe refuses the request (#1359).
- An invoice below Stripe’s minimum charge, such as $0.30 USD, is no longer treated as a declined card (2026-10-23). It was marked failed, started payment recovery, emailed the customer that it was declined and, when recovery ran out, ran the final action (by default, pausing the subscription). Now, once Stripe refuses a charge or a hosted-page Pay click for it, nothing is marked failed or emailed, any recovery ends, and the invoice stays open with a notice to void it, record a payment or pay it from credit with Collect payment, which also asks Stripe again (runbook) (#1355).
- Payment retry emails no longer call every failed attempt a decline, or show “payment failed: “ before Stripe’s reason (2026-10-23). A retry with no card on file, or one Stripe refused for another reason, read “was declined” (#1355).
- A Pay click Stripe refuses on the hosted invoice page no longer holds the invoice’s charges for an hour (2026-10-23). The refused page counted as open for 65 minutes, so no charge or payment retry ran (#1355).
- The finalize-time collect log no longer says the auto-charge sweep will retry an invoice Stripe refused or answered unclearly (2026-10-23). The sweep charges only queued invoices; the line now says what happens next (#1355).
- A written-off spend-threshold invoice is no longer billed again at period end (2026-10-23). The period’s invoice billed again what the threshold invoice already billed. Paid late, or partly paid by credit, the customer paid twice. Now only a void made before the period’s invoice is built gives that window back. The spend-threshold scan also stops recomputing tax every tick after a void (#1353).
- A void of a spend-threshold invoice that finishes before the period’s invoice is saved is now always honored (2026-10-23). A void that finished while the period’s invoice, an immediate cancel’s final invoice or a plan change’s invoice was being saved could leave that invoice billing only what came after the threshold invoice, so the voided charges were billed nowhere. Now that invoice sees the void and bills them, or the void waits and lands after it (#1353).
- Anthropic cache writes sent by LiteLLM now bill at the cache-write price (2026-10-22). They billed as input, 1.0x instead of 1.25x (5-minute) or 2x (1-hour): a call Anthropic charges $0.52 billed $0.31. A call first saved before this keeps its old billing when LiteLLM resends it or a held row of it is released, so its writes do not bill twice (#1349).
- Claude on Google Cloud with an
@defaultor@datename bills at its own model’s price, for usage sent after you upgrade (2026-10-22). Opus 4.5 to 4.8 billed at Claude Opus 4 prices, 3 times too much. Usage stored before the upgrade keeps the old price (#1351). - LiteLLM’s Bedrock cross-region names,
converse/andinvoke/routes, Foundryazure_ai/names, and Google Cloud@datenames of older Claude models now find their recipe prices, for usage sent after you upgrade (2026-10-22). Before, a name such asus.anthropic.claude-sonnet-4-6orvertex_ai/claude-haiku-4-5@20251001kept its raw name, matched no recipe price and was not billed. Now it is billed. OpenAI names on Foundry, such asazure_ai/gpt-4o, find theirs too. Usage stored before the upgrade stays unbilled until you price it (#1351). - LiteLLM response-cache hits and failed calls are no longer billed (2026-10-22). A hit billed as a fresh call, with its would-have-been cost as COGS; the provider charged nothing. The response counts hits in
cache_hits. Hits saved before this stay billed (#1349). - LiteLLM
/v1/messages(Claude Code),/v1/responses, Gemini and pass-through usage is now billed (2026-10-22). Hardcount billed only chat, text completion and embedding calls and answered 200 to the rest, so this usage was lost with no log./v1/messagesis affected on Anthropic, Bedrock, Vertex AI, Azure AI and MiniMax models. Usage sent before this release is not recovered (#1352). - A LiteLLM call Hardcount does not bill but that carries tokens is now logged (2026-10-22). Image, audio, realtime, batch and A2A calls log a WARN with the call type and count in
hardcount_litellm_skipped_with_tokens_total(#1352). - Dunning’s final pause or cancel now waits for the invoice grace (2026-10-22). When retries ran out just after a period ended, the pause or cancel built that period’s invoice at once, and usage arriving inside
INVOICE_GRACE_PERIODwas billed on no invoice. The dunning run now waits until the grace ends. Your own subscription changes still close the period at once (#1348). - A dunning run with every retry used no longer reads as retrying (2026-10-22). The invoice page said “Next retry” and the invoice notices said retries continue while the final pause, cancel or write-off was still to run, so no charge was coming. The page now says “Final step”, the Dunning list says “Final step in …”, and the notices promise no retry.
GET /v1/dunning/runsreturns the policy’smax_retry_attempts(#1348). - A held-usage release inside the invoice grace counts rows stamped after the period end as
in_open_period(2026-10-22). The next period bills them; they were counted as outside. A subscription that ends at that period end still counts them as outside (#1350). - The margin docs say what margin is (2026-10-22): usage rated in the window, invoiced or not, not “what you billed” (#1350).
- Issue credit note on the Credit Notes page now opens the invoice page’s dialog (2026-10-22). Its own form offered only the newest 50 open and 50 paid invoices, showed amounts in the default currency, and allowed more than was left to credit. Its refund option always failed. Now you pick the customer, then the invoice. Written-off invoices no longer offer Issue credit note, which the server refused (#1339).
- A lowercase invoice prefix set through the API no longer blocks saving Settings (2026-10-22). The form refused the value it loaded. Both prefixes are now stored in capitals, as numbers always printed them;
PUT /v1/settingsanswersACMEforacme(#1339). - The Credit Notes page’s totals and tab counts now cover every note (2026-10-22). They counted only the 100 notes on screen, and a tab hid the other tabs’ counts. They now come from the server, and amounts in two currencies are shown side by side, not added (#1339).
- The Subscriptions page’s Paused tab now lists subscriptions whose collection is paused (2026-10-22). It sent a status no subscription has, so it was always empty. The tab is now called Collection paused, and lists show a “Collection paused” chip. A timed pause leaves the tab at its resume date, when collection starts again. API:
GET /v1/subscriptions?collection=paused(#1341). - API: invoices carry
test_clock_id, the test clock their dates follow (2026-10-22). It is the customer’s clock, on the list and get routes. The Invoices page reads “Due in N days” from it and no longer loads subscriptions to work it out (#1341). - A voided or written-off invoice no longer reads as owed on the dashboard (2026-10-22). The subscription page, Home, the command palette, the Invoices and Dunning lists and the customer’s “Last invoice” showed “pending” and “$29.00 due” on a void. Home, and the API’s
failed_payments, no longer count it as a failed payment. The Invoices and Dunning lists’ “Amount due” column is now “Amount”: a void shows the amount it was voided at, a paid invoice what paid it (it read $0.00), and the Invoices sort orders rows by the figure shown. On a phone, Home’s recent invoices no longer draw the amount over the badges. (#1328) - Dunning “Recovered” counts only runs a payment closed, and “At risk” only open invoices (2026-10-22). A void, a write-off, or a credit note or customer credit that left nothing due counted as recovered, and a void’s or write-off’s amount stayed at risk. The “Recovered” tab is now “Resolved”. API:
GET /v1/dunning/statsaddsrecovered_count;dunning_recovery_ratecounts payments only. (#1328) - The Home revenue chart’s axis reads $1.2K for $1,200, in the workspace currency (2026-10-22). It read “$12.0K” and always printed “$”. (#1328)
- A subscription whose trial ended no longer shows a red “Expired” badge in the list (2026-10-22). The badge shows only during a trial, as on the subscription page. (#1328)
- A payment, void or credit made on the dashboard now refreshes the sidebar counts, the Dunning page and “Last invoice” at once (2026-10-22). They kept the old state until the page changed. “Last invoice” skips drafts and shows what was paid, customer credit included. (#1328)
- A cancel or plan swap that voids an unpaid prebill now closes its dunning run and stops its payment page (2026-10-22). The void reversed the tax and sent
invoice.voided, but the run stayed active until its next retry, days later, and a hosted-page Checkout session stayed payable for up to an hour. Every void now does all of it. When dunning’s final cancel voids the run’s own invoice, the run ends resolved, with no escalation email (ADR-118). (#1337) - A canceled subscription no longer shows a scheduled cancel or a pending plan change (2026-10-22). A cancel left both on the subscription, so the dashboard offered Undo on it. A collection pause stays after a cancel: it still holds the subscription’s unpaid invoices, its final invoice among them, until you resume collection. (#1337)
- Credits, credit notes, Home and the audit log show each amount in its own currency (2026-10-22). A EUR customer’s credit read “$20.00”, and a EUR credit note was added into the dollar totals. Credit balances from
GET /v1/credits/balancesand/balance/{customer_id}now carry the customer’scurrency. Home’s MRR and Revenue say “in USD only”, and the chart axis uses the same currency. The credit-note and credit-ledger CSV exports have a Currency column (ADR-100). (#1336) - The invoice footer and the support URL now print where Settings says (2026-10-22, #1333). The footer was printed nowhere. It now prints on invoice PDFs and the payment page, and each invoice keeps the footer it was issued with. Invoices issued before this release show no footer. A footer longer than the page breaks onto the next page. The support URL now prints on invoice and credit-note PDFs too.
- Emails no longer name “Velox Billing” as the seller (2026-10-22, #1333). With no business name set, or when the settings read failed, emails came from “Velox Billing”. They now show no seller name. Set a business name in Settings → General.
- A credit expiry date picked in the dashboard is valid through the end of that day (2026-10-22). A grant “through Nov 30” was saved as 23:59:59.999, 1 ms before the November invoice is issued, so it paid nothing and the card was charged in full. A picked day is now saved as the next midnight, and reads “Valid through Nov 30”. A grant that ends at any other time, such as an API grant at 17:00, reads “Expires” with its time. Grants created in the dashboard before this release keep the gap and read “Expires Nov 30, 11:59 PM”; grant them again if needed. The commit line in New invoice now accepts today as its last day (ADR-071). (#1332)
- An operator invoice line is an add-on, base fee or usage line (2026-10-22). A
discountortaxline was accepted and added to the total, and a tax line was then taxed again at finalize. Both now answer 422 on create and on add line, and the “Add line item” dialog no longer offers Discount. Both line pickers now name the type “Add-on”. A refused line no longer uses up an invoice number. (#1324) - An invoice’s subscription must belong to its customer (2026-10-22).
POST /v1/invoicesaccepted another customer’ssubscription_id, so dunning, collection pause and test clocks acted on the wrong customer. It now answers 422 withparam: subscription_id. (#1324) - The paused-collection invoice notices now say when payment retries continue (2026-10-22). Pausing collection keeps new invoices as drafts, but retries already under way keep charging, as in Stripe. The notices said adding a card would not charge the invoice, and the dunning email, the dunning policy outcome and the write-off dialog said a pause stops all charging. The pause dialog now says retries already running continue. Void the invoice or mark it uncollectible to stop the retries. (#1323)
- Settings say what they do (2026-10-22). The business email hint no longer claims a Reply-To; the address is printed on invoices and credit notes. In test mode, the General, Invoicing and Tax tabs say that test and live share these settings. (#1323)
- A card saved through a setup or update link becomes the default, so the next retry charges it (2026-10-22, #1343). After a decline it sat beside the old card, and every dunning retry, auto-charge and Collect payment charged the old card again, though the page said the new one would be used. Stripe’s default now follows Hardcount’s, and the audit log records the default change (ADR-021).
- A late Stripe webhook no longer brings back a card that was removed (2026-10-22, #1343). A redelivered
setup_intent.succeededre-added a card the operator had removed, or an older copy of a card saved again, and an older copy could take the default back. Stripe cannot charge a removed card, so the card now stays removed. - The hosted invoice page charges in the invoice’s currency (2026-10-22). On a Stripe account with Adaptive Pricing on, Checkout could offer the buyer a converted local-currency price with a conversion fee, so a USD 10.29 invoice was charged as INR 1,010.68. Hardcount now turns Adaptive Pricing off on the session (#1344).
- Credit for unused prepaid time goes to the invoice that charged it, newest first (2026-10-22). A seat decrease, downgrade, removal, cancel or swap split it by subtotal or put it on one invoice, so an unpaid upgrade invoice could stay due while the credit went to the balance. The timeline now shows credit that does not fit, and splits a credit that went partly to an open invoice (#1345).
- The upcoming-invoice preview of an in-advance subscription with a plan change scheduled for the period end shows the new plan’s price and period (2026-10-22). It showed the old plan, while the invoice that period end issued billed the new one. The preview’s
plan_namenow lists the old plan too, because the period that is ending still bills on it (#1342). - Paying the overdue invoice now resumes collection that dunning paused (2026-10-22). Before, a subscription that dunning paused stayed paused after the customer paid, by card, offline or through a credit note: new invoices stayed drafts and nothing charged them. Now paying the invoice whose dunning paused collection resumes it, even while another invoice is still owed, and the subscription’s activity shows “Collection resumed · The overdue invoice was paid”. Invoices from the paused periods stay drafts. A pause you set stays until you resume it, and dunning no longer replaces your pause or its resume date with its own. (#1340)
- The API spec lists every request field the server reads (2026-10-22). A generated client could not attach a customer to a test clock (
test_clock_id), send a credit note’srefund_type,lines[].invoice_line_item_idor reliefinvoice_id/reason, or sendpropertieson a batch usage event. A test now fails when a request body’s fields differ from the JSON fields of the Go type its handler decodes, or when one is an array or object and the other is not.balance_afteris documented as cents (#1338). - A wrong id or date on a read route is now an error, not an empty answer (2026-10-22). A typo’d customer id read as “no credit, no card”, and a plain date such as
from=2030-01-01returned all-time usage totals. Now the id gets 404 and the date filters. A rule with no tiers sendsgraduated_tiers: [], notnull; itemmetadatais an object, left out when empty, not"e30=". A test clock’s subscription list now carries names, billing time and items (#1335). - Attaching a meter to a plan, or any plan edit, no longer clears the plan’s tax code and description (2026-10-22).
PATCH /v1/plans/{id}wrote both on every call, so a tax code set through the API was lost. The base fee then fell back to the tenant’s default tax code, or to no code when the tenant has none. A field left out now keeps its value; to clear one, send an empty string (#1334). - Usage sent during a free trial is no longer billed, and usage a subscription canceled earlier that day already billed is not billed again (2026-10-22). A period starts at midnight. So the first paid invoice after a mid-day trial end billed that day’s trial usage. A subscription recreated the day its predecessor ended billed that day’s usage a second time. Usage now starts at the trial end, or where the other subscription’s ended. The base fee still counts the whole day (ADR-012). (#1329)
- An immediate monthly-to-yearly (or yearly-to-monthly) swap on an in_advance plan now bills the usage before the swap (2026-10-22). The swap’s invoice carried only the new period’s base, so the old period’s usage up to the swap was never billed. It now carries that usage, once, from any spend-threshold invoice’s end (ADR-056). (#1326)
- On a test clock, a trial that runs out is dated at its end, and a dunning run’s final actions at its escalation (2026-10-21, #1319). An advance past either dated “Trial ended”, the first invoice’s issue and due dates, the pause, cancel and write-off at the advance instead, so the first invoice sorted after the renewal. When the advance also crossed a renewal, the pause or cancel is dated at that renewal, so the cancel still voids or credits the renewal’s unused invoice.
- A payment on the hosted invoice page is dated on the invoice’s test clock (2026-10-21, #1319). It used to carry only its real time, so on a simulated invoice it sorted before the invoice’s own rows. On the real clock nothing changes.
- The invoice’s Activity card names every source it could not load (2026-10-21). A failed read of payment attempts or disputes printed its code; it now reads “payment attempts” or “disputes”. The never-shown “Payment attempts” count under Payment details is removed. (#1318)
- A cancel, plan swap or item change on a partly credited invoice now gives back what fits (2026-10-20). An operator credit note, or a partial waive (credits paid part, a note waived the rest), leaves an invoice less room. When the unused part was larger than that room, a cancel or swap gave nothing and an item change answered 500. Now it credits the room left and logs the rest. (#1312)
- A charge Stripe refuses for the API key no longer counts as a decline (2026-10-20, #1313). After a key is rolled, or a restricted key lacks a permission, charges pause instead: the invoice is not marked failed, no dunning retry is used, and the customer gets no email. Settings → Payments shows the refused key until a charge goes through again or you reconnect. Charges resume once the key works, including a restricted key you fix in Stripe by adding the permission.
- A restart during a tax retry no longer leaves the invoice an unbilled draft (2026-10-20). With Stripe Tax, a deploy or a lost leader lease between saving the tax and finalizing left a draft that nothing retried, so the period was never billed. The finalize now completes. If the finalize itself fails, Retry tax shows an error, and the invoice stays a draft with its tax saved: finalize it. (#1308)
- A parked invoice now ends without a person (2026-10-20, #1307). A charge whose unclear answer named no payment, or that outlasted the caller’s time limit, is parked. Hardcount now lists the customer’s payments in Stripe every tick: a payment that appears is recorded, and with none after 24 hours the invoice is charged again as usual. Before, it waited for a Stripe search or a write-off. The notice is now a Warning.
hardcount_parked_invoicesnow counts only parks Hardcount has not ended on schedule (still parked 26 hours after the charge’s hold, or with no hold); alert ondisposition="open"above 0. - Revenue and the revenue chart count what paid each invoice, not its total (2026-10-20). A credit note on an unpaid invoice lowers what is due but not the total, so the waived part counted as revenue; an invoice waived in full added its whole total.
revenueandrevenue_centsin the analytics API change the same way. (#1306) - A credit note that leaves an unpaid invoice with nothing due now marks it paid (2026-10-20). Before, the invoice stayed open at $0: dunning closed as recovered with no
invoice.paid, or its final action canceled the customer. Nowinvoice.paidis sent withamount_paid_cents0, dunning closes at once, and the invoice can no longer be voided, as in Stripe. (#1306) - The test-clock docs now say how to rehearse a dunning policy that cancels or pauses (2026-10-20). An advance bills every skipped period before it retries payments, so one long move bills a period a real clock would not. Move to the time of the last retry, then to the next billing date: the cancel or pause is dated at the time you move to (first invoice, #1276). The 1-year advance limit no longer claims Stripe parity. (#1311)
- On a test clock, a credit that was live at a payment retry now pays it (2026-10-20). Test mode only. An advance expired credit before it ran the payment retries it skipped, so a retry dated before the credit’s expiry charged the card in full. Credit now expires after the retries (ADR-029). (#1311)
- A late period close no longer loses credit that was live when the period ended (2026-10-19). The hourly expiry run retired a grant an hour after
expires_at, even while that period’s close was retrying or its invoice waited for Stripe Tax, so the card was charged in full. Now the run waits for them, and a grant live at the period end pays that period’s invoice, at a dunning retry too. An expired grant can stay in the balance longer (ADR-071). (#1305) - An SMTP relay that refuses the sender no longer marks customers bounced (2026-10-19). SendGrid’s
550 The from address does not match a verified Sender Identityread as a bounce: the email was given up on at once, later emails to that customer were dropped, and nothing paged. Only a refusal of the recipient address is a bounce now; other refusals are retried (bounces). (#1304) - The outbox and Stripe circuit-breaker runbooks now say what the code does (2026-10-19). A failing webhook endpoint has its own page: it never grows the outbox. Both outbox pages give SQL to send failed rows again, guarded to
failedrows. The breaker is one per process, not per tenant. Roles that run every few seconds page after 5 minutes. (#1304) - A database error during a dunning retry no longer uses up an attempt (2026-10-19). Before, it counted as a declined payment: the customer’s warning email gave the error as the reason, and on the last attempt dunning cancelled, paused or wrote off with no final charge. Now the retry is skipped, a
WARNline names the invoice and the error, and the next pass usually retries it within the hour. If the database is still down when Hardcount undoes the attempt, as in a restart or failover, the attempt still counts (Not handled yet). (#1302) - A panic on one row no longer stops billing or dunning for every tenant (2026-10-19). The rest of the tick runs, and the panic still pages. A panicking dunning retry no longer spends an attempt. A panicked tick reruns after one interval, not 60 s. A failed payment-method read is now a counted error. (#1303)
- A replayed webhook now carries the original event’s
idandcreated_at(2026-10-19). It carried a new id, so a receiver that skips ids it has seen, as the webhook guide says to, ran the event again: a replayedinvoice.paidcould be booked twice. Now a receiver that already processed the event skips the replay. Store an event’sidonly once you have handled it (rule 1): anidstored on arrival makes an event that failed skip its retries and replays. The dashboard still lists each replay as its own row. (#1301) - A webhook endpoint that answers with a redirect is no longer marked delivered (2026-10-19). Hardcount followed the redirect, and the
200from the page it led to counted as delivered, although the endpoint never took the event. A3xxis now a failure and is retried; register the URL that answers directly (Retries). (#1301) - A trial that runs out now reads “Trial ended” on the subscription’s Activity card, not “Trial ended early” (2026-10-19). Only an operator’s End trial reads “early”. A trial that ends with a cancellation due reads “Subscription canceled · At trial end”. (#1300, fixes #1278)
- A charge whose result was lost can no longer take a second payment (2026-10-19). After a crash, a shutdown or a lost answer, nothing charges, voids or records an offline payment on the invoice until Hardcount has asked Stripe, usually within about an hour (a time limit parks the invoice instead, see the parked-invoice entry above). A payment found is recorded; otherwise charging resumes. Write-off stays allowed (ADR-127, runbook). (#1299, fixes #976)
- A prepaid commit is now taxed once, on its usage (2026-10-19). For accounts with a tax provider on. The commit purchase was taxed, then the usage its credit paid was taxed again. A commit line now carries no tax, with the reason
prepaid_credit_purchase. Issued invoices are unchanged; a draft whose tax was worked out before the upgrade is taxed this way when it is finalized. If your country taxes prepayments when bought, read Tax on prepaid credit first. (#1288, fixes #1271) invoice.finalizednow carriescredits_applied_cents, and the docs say when credit is used (2026-10-19). Hardcount uses a customer’s credit when it collects an invoice, after this event, so the event’samount_due_centsis the amount before credit. To mirror what is owed, read the invoice (webhooks, ADR-088). (#1295, fixes #1281)- The invoice Activity card no longer states false things (2026-10-19). Retries that ran out say what they did, such as “Collection paused”, not “Escalated for manual review”. A declined retry is red and shows the decline. Credit-note and decline reasons are in plain words. (#1297)
- A mid-period cancel’s credit for unused prepaid time now reduces the final invoice (2026-10-19). For in-advance plans that also bill usage. With a card on file, the card paid the whole final invoice and the credit sat unused on the balance. With no card, the email asking for a card quoted the whole amount. (#1286, fixes #1272)
scripts/restore.shnow gives each restored table the access rights it has in the backup (2026-10-19). Restored into a database that gives new tables more, as a new Compose install does,audit_logcame back with more access forhardcount_appthan the backup gave.psqlnow runs the restore, so it must be at least as recent aspg_restore. A plain SQL dump restores only into a database without default privileges. (#1287)- Upgrade, seat-increase and add-item invoices now send
invoice.finalizedand get a finalize row in the audit log (2026-10-19). Before, an app that syncs revenue oninvoice.finalizednever saw them; the first event wasinvoice.paid. The event has the same fields as a period-end invoice’s. One that starts as a draft sends it when finalized. (#1293) - Clicking “Issue credit note” again after an error no longer makes a second note or refund (2026-10-19). If Stripe was slow, the dashboard could show an error although the note was made, and a second click made another. Each open of the dialog now sends one Idempotency-Key, so a retry gets the first note back. (#1290, fixes #1273)
- A refund with no clear answer from Stripe now shows
unknown, notfailed(2026-10-18). After a timeout, a Stripe 5xx or a rate limit the customer may already be refunded, sofailedinvited a second refund by hand in Stripe. A refund Stripe refused staysfailed. Retry refund looks in Stripe first, then resends the same request. The dashboard still showsunknownas “refund pending”: retry it through the API for now. (#1267, fixes #965) - Two changes to one subscription’s items at the same moment no longer bill twice (2026-10-19). For seat or plan pricing billed in advance: a seat sync, a second operator or a retry overlapping a change was priced from the old state too. Now it is priced from what the first change left, or answered 409
item_changed. Two adds or plan changes onto plans that bill one meter can no longer both go through and bill that meter twice every period, and two removals can no longer leave a subscription with no items. (#1283, fixes #1270) - A block usage cap no longer bills above the cap (2026-10-18). For subscriptions with
overage_action=block. With three or more meters, or a meter billed for part of the period, a cap could bill every unit, and an item removed mid-period could take the invoice past the cap. All usage on an invoice now scales by one factor and stays within the cap. The cap counts each meter only over the part of the period it is billed for, so usage from before an item was added, or after it was removed, no longer scales the invoice down too far. After a spend-threshold invoice, the next-invoice preview now keeps tiered prices on when a cap is set but not passed, as the invoice does. Issued invoices are unchanged. (#1261, fixes #1192) - The period close now bills the usage of an item removed after the period ended (2026-10-18). If the item was removed before a late close ran, some of its meters billed 0 for that period. (#1261)
- PDFs print long line descriptions whole, and large totals no longer overlap their labels (2026-10-18). On invoices and credit notes, a description over 50 characters was cut to 47 and
...; it now wraps inside its column. Totals now line up with the Amount column, so amounts of $10,000 or more and long tax names fit. (#1263, fixes #1228, #1178) - A Stripe 409 no longer counts as a declined charge (2026-10-18). After a dropped connection, Stripe can answer the resent charge with 409 while the first one still runs. Hardcount then started dunning, and its next retry could charge the card a second time. Now the invoice is parked as
unknown(runbook, part of #976, #1264). - Dunning no longer cancels or pauses a subscription while the invoice’s payment is
processingorunknown(2026-10-19). If the payment reaches that state just before dunning’s final action, dunning waits and checks again. A payment that then fails gets the final action. If you write off a parked invoice whose dunning used every retry, apply that action by hand (runbook, part of #976, #1284). - Usage below zero after corrections no longer stops the invoice (2026-10-18). For some meters, the period close and an immediate cancel failed and built no invoice, base fee included. Now any meter below zero bills 0, the rest is invoiced, and the subscription timeline names it. A block usage cap totals what the invoice bills, not a meter’s net total. Failed billing recovers on its next retry; a parked subscription needs
retry-billing(runbook, #1219). - The Compose stack now sets
idle_in_transaction_session_timeout = '30s'onhardcount_app(2026-10-18). The Postgres requirements call it required: without it, a Hardcount process frozen mid-transaction keeps a subscription locked until something ends its session. Existing volumes need the step under “Before upgrading”. (#1258, fixes #1229) - Dunning no longer charges a card the bank refused for good (2026-10-18). After a lost, stolen or similar decline, retries keep their schedule but charge only once another card is the customer’s default. The customer is asked for a different card, and the invoice says so. A card the customer then saves replaces the refused one as the default, and the next retry charges it. (#1225, fixes #966)
- The payment-failed email’s plain-text part labels the pay link “Pay invoice” (2026-10-18). It said “Update payment method” for the link to the hosted invoice page, which takes payment. (#1225)
- Adding an item to a subscription now refuses an archived plan (2026-10-18). Creating a subscription and changing an item’s plan already refused one;
POST /v1/subscriptions/{id}/itemsdid not, so new business could land on a retired plan. It now answers 409invalid_statewith the same message as create. Items already on an archived plan keep billing. (#1218, fixes #1194) - Writing off an invoice with “Also pause collection” ticked now shows the paused subscription (2026-10-18). The pause happened, but the invoice page still said “The subscription is still billing.” and the subscription page still offered Pause collection, until it was reloaded about 30 seconds later. Both pages now refresh the subscription. (#1208)
Security
Section titled “Security”- Setting a password now refuses common leaked passwords (2026-10-18). Bootstrap, password reset and invite accept refuse about 30,000 passwords of 12 or more characters, such as
Password1234, in any letter case. Passwords already set still work. (#1222) - Go 1.26.10 (2026-10-21). Clears GO-2026-6809 in
crypto/x509, whichgovulncheckreaches from Hardcount code; CI lint failed on every branch until this lands. No application code change. - The client address comes from chi’s trusted-proxy middleware (2026-10-20).
X-Forwarded-Forcounts only from aTRUST_PROXYaddress, every header line is read, and an entry that is not a bare IP stops at the proxy. Rate limits, audit and session rows, logs and traces all use it (ADR-095, #1315). - Traces no longer carry a link’s token (2026-10-20). A request span is renamed to
{method} {route}once routed, such asGET /v1/public/invoices/{token}(before,:idreplaced ID-like parts). Itsurl.pathholds the route or a redacted path, andclient.addressthe trusted client address. Redaction covers hosted invoice, payment-update, cost-dashboard and invite links, in traces and the access log (#1315). CORS_ALLOWED_ORIGINS=*now allows any site without the sign-in cookie (2026-10-18). The API answers*with no credentials, so another site’s page can call it with an API key only. Listed addresses still may send the cookie. CORS now runs ongithub.com/rs/cors(Self-host Hardcount, #1262).- Every email address Hardcount accepts or sends to gets the same check (2026-10-18). Customer emails and CC lists, the
emailof an invoice or credit-note send (before, only checked for being present), team invites, the company email and the bootstrap owner email must be one bare address. The sender checks To and Cc again before sending (ADR-082, #1259). - Sign-in stays rate-limited while Redis is down (2026-10-18). The per-address limit on
/v1/authused to allow every request when a Redis call failed, so an outage made password guessing unlimited. Each copy now counts in memory with the same limit until Redis answers again (ADR-095, #1255). - The sign-in cookie is always
Securein staging and production (2026-10-18). It droppedSecurewhen the browser was on plain http at localhost, for Safari on the quickstart. The quickstart now runs withAPP_ENV=localinstead. Anhttp://address setting, such asDASHBOARD_BASE_URL, logs a start-up warning (ADR-095 Phase 0(b), #1226).
[0.8.2] - 2026-10-18
Section titled “[0.8.2] - 2026-10-18”Fixes before the first partner installs. Long invoice PDFs go onto more pages, and every email has a Date and a Message-ID. Webhook URLs with a private IPv6 address are refused when saved, and sign-ins record the real client address. No database migrations.
Before upgrading:
OTEL_TRACES_SAMPLERnow takes effect. If you set it, Velox now keeps only the traces it selects; before, every trace was exported. Unset, nothing changes.- A webhook endpoint saved earlier with a private IPv6 URL still fails every delivery. Change its URL to a public address.
Changed
Section titled “Changed”make testno longer reads.env(2026-10-18). For contributors: onlymake run,dev,migrate,migrate-statusandbootstrapload it, so tests see the same settings as CI.make test-unitruns packages in parallel (about 30 s, was 60 s). The unusedtest-integrationandtest-verbosetargets are gone. (#1207)- ADRs 001–061 all open with a short Summary (2026-10-18). Forty-six records gained one, giving the current rule and status, including what later ADRs changed. The records below each Summary are unchanged. Fourteen status lines now name their dated amendment or partial supersession. See the ADR index.
- Go tests now walk five untested backend flows over HTTP on a real database (2026-10-18). Contributors:
internal/apihas new journeys for API keys (create, use, revoke, rotate), offline payments and signed Stripe webhooks, the update-payment link, dunning-policy delete, and whoami, the mode switch and the reset-link check. (#1204) - Decision recorded: migrations move to goose in v0.9.0 (2026-10-18). Nothing changes yet. v0.9.0 takes over a v0.8.0, v0.8.1 or v0.8.2 database without changing a table; older ones upgrade to v0.8.2 first. A failed migration leaves nothing to repair, so
velox migrate forcegoes. ADR-125 supersedes ADR-124; ADR-073’s deadlock correction stands. - Dashboard lint now fails on unused code (2026-10-18). For contributors:
npm run lintinweb-v2runs knip after eslint, so an unused file, export or dependency fails CI. Unused dashboard files, exports and three unused dependencies are deleted.web-v2/knip.jsoncsays what is ignored and why. (#1196) - The README shows before it tells (2026-10-17). The pictures come first, then a small diagram of how Velox works and one example usage event, then the quick start. The gaps table is shorter, and the volume line now uses the €6.49-a-month server test.
OTEL_TRACES_SAMPLERnow decides which traces Velox keeps (2026-10-18). Velox set its own sampler, which replaced the variable, so every trace was exported whatever you set. Unset, all traces are still kept. The Compose stack now passes the sampler settings, and its example endpoint uses the OTLP/HTTP port 4318 (self-host settings).- A failed release of an
Idempotency-Keyis now logged and counted (2026-10-18). When a 409, 422 or usage-ingest 5xx could not free its key, nothing was recorded, and retries with that key got 409. It now logs an error and counts invelox_idempotency_cache_errors_total{stage="release"}. The finalize log and metric help no longer claim a retry runs the request again (API errors). - Long invoice and credit-note PDFs now go onto more pages (2026-10-17). Before, rows after about the 30th and the totals were drawn below the page edge, out of sight. Later pages repeat the table header and are numbered “Page N of M”, and the totals stay together on the last page. Amounts are unchanged.
- Thai baht amounts on PDFs show their currency (2026-10-17). The PDF font has no ฿ sign, so THB amounts printed with no currency mark. They now print as
THB 1,234.00, like AED and SAR. - Every email now has a
Dateand aMessage-ID, and its text is encoded the standard way (2026-10-18). Mail servers and spam filters expect both headers. Accented names and long lines, such as a long note on a setup link, now travel as quoted-printable in short lines. The Message-ID uses yourSMTP_FROMdomain. The wording is unchanged (#1187). - Webhook endpoints with a private IPv6 address are refused when you save them (2026-10-18). Before, a URL such as
https://[fd00::1]/was saved, then every delivery to it failed. Saving now uses the same address check as sending. An endpoint saved earlier with such a URL still fails; change its URL (webhooks, #1199).
Security
Section titled “Security”- Dashboard sign-ins record the real client address (2026-10-18). A login or invite accept stored the first
X-Forwarded-Forentry, which any client can write, indashboard_sessions.ip. It now stores the addressTRUST_PROXYresolves, the same one the audit log and rate limits use. Nothing reads this column yet. (#1203)
[0.8.1] - 2026-10-17
Section titled “[0.8.1] - 2026-10-17”Safer upgrades: a repair command for a database upgrade that stopped midway, and a 5-second limit on how long a migration waits for a table lock. Also a faster invoice list, and the Compose stack passes the Postmark webhook login and VELOX_API_BASE_URL. No database migrations.
Before upgrading:
- A migration that cannot get a table lock within 5 seconds now stops (#987). Velox exits with an error that says how to retry or repair. Set
MIGRATION_LOCK_TIMEOUT(for example30s, or0for no limit) to change the wait. See the runbook. - A dirty
schema_migrationsnow has a named error and a repair command (#967). Velox still refuses to start, and the error names the migration and thevelox migrate force <version>commands to run. See the runbook. - The Compose stack now passes
POSTMARK_WEBHOOK_USER,POSTMARK_WEBHOOK_PASS,VELOX_API_BASE_URLandMIGRATION_LOCK_TIMEOUTfrom.env(#1150). If you added them with an override file or by editingdocker-compose.yml, move them to.env.
velox migrate force <version>repairs an upgrade that stopped midway (2026-10-17, #967). Velox still refuses to start whileschema_migrationsis dirty, but the error now names the migration and the exact commands. Force refuses while a migration runs. Steps: runbook.
Changed
Section titled “Changed”- Sizing for one small server, measured (2026-10-17). A €6.49/month Hetzner CX23 took about 1 million events a day with 0 errors, and its disk fills in about two months (scale). The one-VM guide gains a Docker install link, a waiting health check, a start-up warnings table and a private backup folder. (#1163)
- The README’s “What it looks like” is two labelled pictures (2026-10-17). The invoice your customer gets, then your margin per model, each with one line saying what it shows. The text copy of the same numbers is gone.
- Database upgrades no longer wait on a table lock without limit (2026-10-17, #987). A migration that cannot get its lock within 5 seconds stops, leaves nothing behind and says how to retry.
MIGRATION_LOCK_TIMEOUTchanges the limit. CI fails new migrations that build an index or constraint the write-blocking way (runbook). (#1167) - The invoice list costs the same few database reads whatever the page size (2026-10-17). Each row read its own banner facts, so 25 rows took about 110 ms. A page now reads each fact (card on file, Stripe connection, dunning, pause, payment page) once. Banners are unchanged (#1142). (#1166)
- The Compose stack passes the Postmark webhook login and
VELOX_API_BASE_URLto Velox (2026-10-17). SetPOSTMARK_WEBHOOK_USER,POSTMARK_WEBHOOK_PASSandVELOX_API_BASE_URLin.env; editingdocker-compose.ymlor adding an override file is no longer needed (#1150).
[0.8.0] - 2026-10-17
Section titled “[0.8.0] - 2026-10-17”LiteLLM usage for an unknown customer is held instead of lost, new accounts retry failed payments, Settings → Email with a test send, and dates that follow the billing timezone. Three database migrations (0178, 0179, 0180).
Before upgrading:
- New accounts start with a “Default retries” dunning policy; existing accounts do not get one (#1152). An account with no default policy still retries nothing after the upgrade. Create a policy if you want retries.
- LiteLLM usage for a customer or meter that does not exist yet is held, not dropped (#1044). The callback still answers 200, with a new
heldcount. Create the customer, then release the held usage from the Usage page. - Email actions answer 409
email_not_configuredwhenSMTP_HOSTis not set. This covers sending an invoice or credit note, sending a payment-method setup link and inviting a teammate. - A charge Stripe never answered returns
payment_outcome_unknown, notprovider_error. Add the new code if you branch on it. - Many messages were reworded; codes are unchanged. Match on
reasonandcode, not on the text. - Set your company name if you have not (Settings → Business details). With none set, PDFs no longer print “Velox” as the seller, and that includes already-issued invoices, because PDFs are rendered when opened (#1093). Totals and lines do not change.
- LiteLLM usage for an unknown customer is held, not lost (2026-10-17, #1044). Usage for an unknown
useror meter was answered 200 and dropped. Now it counts inheldand waits on the Usage page. After you create the customer, Release records each row once. Discarded rows stay unbilled. See LiteLLM. (#1151) - Settings → Email shows whether this server can send email (2026-10-17). It lists the SMTP settings and email-link variables, says what fails while one is missing, and sends you a test email with the mail server’s real answer. API:
GET /v1/settings/email,POST /v1/settings/email/test(dashboard sign-in only); whoami gainssmtp_configured. (#1121) - Browser tests for the dashboard (2026-10-16). Contributors:
npm run test:e2einweb-v2runs Playwright against a real API. It opens every page at desktop and phone width, fails on console errors, failed API calls or sideways scroll, and runs five key journeys. CI job:e2e. See Browser tests (Playwright). (#1101)
Changed
Section titled “Changed”- The demo bills two models, so the margin report shows margin per model (2026-10-17).
scripts/demo.shsends Claude Sonnet 4.5 and Claude Haiku 4.5 usage: five invoice lines ($5.73), margins 67.1% and 20.0%, and 51.9% overall. The Margin card and the margin API (by_model[].margin_bps) show each model’s margin. See Quick start. (#1159) - New accounts retry failed payments from day one (2026-10-17, #1152). Each new account gets a “Default retries” policy in both modes: retries on days 3, 7 and 14, customer emails if set up, then no action on the subscription or invoice. Your first enabled policy replaces it. Existing accounts are unchanged. ADR-123 (#1155)
- The README is shorter and says each thing once (2026-10-17). It runs: what Velox is, why (the gaps table), what it looks like, quick start, whether it fits you, and what’s inside. Quick-start detail moved to Quick start: run Hardcount and its demo. About 1,950 words became about 1,200, and no checked fact was dropped. (#1157)
- Contributor pages start with a working setup (2026-10-17). Contributors: CONTRIBUTING goes from clone to a billed test customer in five steps, and splits CI into seven required checks and three optional ones. Architecture adds the life of a usage event, the background jobs and every invoice state. (#1146)
- Features and “Why Velox” are tables (2026-10-17). What’s in the box lists each feature, what it does for you and where to read more. Why Velox exists opens with who it is for and a four-row table of needs. (#1147)
- The operations runbook is one page per problem (2026-10-17). Operators: docs/ops/runbook/ opens with a “what do you see?” table, and each page runs Symptom, Why, Check, Fix, Verify. The leader-lease runbook is now A background job is stuck. Alerts, health checks and log searches are on one page. (#1148)
- “How much traffic Velox handles” opens with the short answer (2026-10-17). How much traffic Hardcount handles starts with the figures to plan on (1,000 events/s on
db.m7g.2xlarge, 12,000 ondb.m7g.4xlarge), what runs out first and what to do next. Self-host’s load numbers moved there. Both benchmark records carry a dated status banner. (#1144) - Design docs and dated records move out of the reader’s path (2026-10-17). The nine
docs/design-*.mdfiles are now in docs/design/, each naming its current ADR. Audits, reviews and other dated records move fromdocs/dev/to docs/records/. The docs index opens with “New here?” and five reader paths. (#1141) - API keys, API and webhooks pages start with a working call (2026-10-17). Integrators: API keys covers getting, sending, rotating and revoking a key. The Velox API opens with one curl and links the served
/openapi.yaml; there is no SDK yet. Webhooks starts with five steps to a first verified event. (#1143) - A new account’s Home says what Velox does and what to do first (2026-10-17). With no subscriptions, Home shows a welcome card and opens the “Bill your first customer” checklist once. The checklist shows the mode and adds “Send a usage event”. Pricing, Dunning, Test clocks and MRR terms are explained. (#1138)
- Each dashboard page says what it is for, in plain words (2026-10-17). List pages state their purpose in one line; credit notes, credits and reverse charge are explained in place. Titles, dialogs and buttons use sentence case, matching the menu. The publishable key reads “safe to use in a browser”. (#1145)
- Operator pages are task-first (2026-10-17). Operators: email setup, backups (
backup.sh,restore.sh,restore-drill.sh), Postgres requirements and tax calculation are now steps. Tax internals moved to architecture. Both.env.examplefiles are grouped Required → Required in production → Email → URLs → Optional. (#1140) - A “Production on one VM” guide (2026-10-17). Operators: Run Hardcount in production on one VM installs a pinned release, then adds HTTPS with Caddy, web addresses, email, Stripe and its webhook, nightly backups with a restore test, a go-live checklist and upgrade steps. The Stripe end-to-end test and release checklist moved to
docs/dev/. (#1136) - A step-by-step page from your own API calls to a first invoice (2026-10-17). Send usage and get your first invoice creates a test-clock customer, sends token usage (curl or LiteLLM-style), advances a month and opens the invoice, showing real output.
ai-pricing.mdis now a reference page, andlitellm.mdsays to create the customer first. (#1137) - The roadmap lists the API reference as shipped (2026-10-17). It shipped in v0.7.0, but the roadmap still listed it under Next.
- Test clocks say they hold customers, and the customer pages name your company (2026-10-17). A customer goes on a clock when created; the pages said you pin subscriptions. The update-payment and hosted-invoice pages say “Contact <your company>”, linked to your support URL or email. API: the payment-update link gains
branding.company_email. (#1135) - A “How Velox works” page, and a glossary in plain words (2026-10-17). How Hardcount works follows a usage event to a paid invoice in one picture and six steps, each naming what the dashboard shows. Each glossary entry starts with a plain sentence; code and database words moved to “For contributors”. (#1134)
- A quick-start page walks through each demo step (2026-10-17). Quick start: run Hardcount and its demo covers each of the nine steps: what it does and prints, where to click, and why Home reads $0. The demo ends with links to open. If Velox is down, it names the quick-start command, not only
make dev. (#1125) deploy/README.mdis a three-row “which one do I want” table (2026-10-17). Operators: load balancing, health checks, shutdown and rolling upgrades for two or more copies moved to High availability, and upgrade steps to Self-host. Contributors: the from-source and Docker-image recipes moved to CONTRIBUTING. (#1132)- The README says why Velox exists and who it fits (2026-10-17). “Why it exists” is a sourced table of the gaps small AI teams hit, detailed in Why Velox exists. “Who it fits” gives the measured volume of one Postgres. comparison.md: Kill Bill’s Aviate adds raw-event metering, not metering as a whole. (#1133)
- Docs have a first-time reader bar (2026-10-17). Writers: Writing style for Hardcount docs adds five rules for reader-facing pages, “You see:” after each task step, a list of words to keep out of UI text, and a before/after example.
- The README says who Velox is for and what each quick-start step shows (2026-10-17). A “Who it is for” line and a short “Why it exists” list come first. Each quick-start step ends with “You see:” and real output. It covers WSL on Windows and why Home reads $0. (#1124)
- Operator docs match the code (2026-10-17). One VM suits a small team’s production; two or more copies is the high-availability setup. Docs cover the Postmark bounce webhook,
PAYMENT_UPDATE_URL(only the “add a payment method” email), the replay routePOST /v1/webhook_events/{id}/replay, and Postgres 16 tested, 14 oldest. The unreadSTRIPE_WEBHOOK_SECRETis gone. (#1122) - The README explains Velox and the quick start in plain words (2026-10-17). A short “In plain words” summary sits at the top. The quick start is now three steps, and it says what each command does and what you see after it, including each step of the demo.
- Activity and Margin open on the billing period (2026-10-16). On the customer page, Activity opens on Cycle and Margin on “Current period”, the window the invoice uses; Margin adds “Last invoiced period”. A rolling 30 days never matched a period. An empty window names the latest usage date (ADR-121). (#1089)
- Dashboard unit tests run on Vitest (2026-10-16). Contributors:
cd web-v2 && npm testnow runsvitest run, andtsc -b(part ofnpm run build) type-checksweb-v2/tests/, so a type error in a test fails CI. The customnode --testalias loader is gone. - Velox code rules run in golangci-lint and
go test(2026-10-16). Contributors:make lint-clock,lint-funding-setandlint-tzare gone. Clock and funding-set rules areforbidigorules in.golangci.yml; waive with//nolint:forbidigo // wall-clock: <reason>or//nolint:forbidigo // single-invoice-ok: <reason>. A bare//nolintor a reasonless waiver fails. The timezone rule is aninternal/archtest. (#1099) - One page on how we test; MANUAL_TEST.md is retired (2026-10-16). Contributors: write a test at the layer How we test names for your change. Dashboard changes get a Playwright spec. Stripe, email and PDF are checked by hand before each release with the release checklist.
- The sidebar is grouped by task and Setup moved into it (2026-10-16). Groups: Billing, Revenue, Catalog, Developers. Dashboard is now Home; Settings is last. Setup progress is a sidebar row, not a floating pill over buttons. Ctrl+K lists every sidebar page (it missed four) with invoice states and amounts due. (#1091)
- Dialogs keep their buttons in view, and sign-in follows dark mode (2026-10-16). A tall dialog scrolls inside with Save and Cancel pinned; New invoice, recipe and Change Plan cut them off before. Sign-in and reset use the saved dark theme. Invoice states read Open, Past due, Paid or Void everywhere. (#1090)
- Invoice notices are one short sentence (2026-10-15). API: every
attention.messagewas reworded to at most three newline-separated lines: the cause, then the setup-link email and the prepaid credit at risk when they apply. The explanation and payment ids moved toattention.detail. Match onreasonandcode, not the text. (#1064) - Notices say what will really happen (2026-10-15). The no-payment-method and recovery-ended notices say when collection is paused, so a new card charges nothing yet. They also say when no setup link can be emailed (no customer email, or no
SMTP_HOSTorPAYMENT_UPDATE_URL) and when a test clock applies.GET /v1/whoamigainscan_email_setup_links. (#1064) - One notice above the invoice (2026-10-15). The invoice page shows only the most urgent notice. “Diagnostic detail” is now a collapsed “Payment details” section below the invoice. The test-clock banner is one line: “Simulated time: … Dates on this page follow the test clock.” (ADR-099 amended).
- Subscription and customer pages show one notice box (2026-10-15). The most urgent notice (billing failure, then no payment method, then scheduled cancel, then paused collection) is a box; the rest are one-line rows. “No payment method” reads “Renewals can’t be charged until the customer adds a card.” (#1065)
- Subscription errors read plainly (2026-10-15). API: refusals for activate, item changes, trial end and extend, scheduled cancel, pause, spend thresholds and over-balance credit deductions no longer name fields. “Billing made no progress” now reads “Billing didn’t finish…”. Codes are unchanged; match on
code, not the text. (#1065) - Usage with no price shows a plain notice (2026-10-15). The customer Activity card showed the server’s raw sentence (“meter … has usage matching no pricing rule”). It now reads “Some usage has no price”, lists the unbilled quantity per meter and links to the meter.
- The plan page shows when its price is locked (2026-10-15). With live subscriptions on a plan, the base price, billing timing and meter controls are off, with “N subscriptions use this plan…”. API:
GET /v1/plans/{id}gainslive_subscription_count. - “Price” everywhere a price was called a rule (2026-10-15). The Pricing tab “Rules” is now “Prices”; the meter page says “Default price” and “Prices for specific values”. Its trash icon asks “Remove … from this meter?”: it unlinks the price, which stays on the Prices tab. (#1066)
- Pricing, recipe and archive refusals read plainly (2026-10-15). API: the plan-locked, meter-has-no-price, last-price, recipe-conflict and customer-archive messages were reworded; they name subscriptions and meters, not API paths, fields or ids. Error codes are unchanged; match on
code, not the text. - Dashboard and Usage notes match the data (2026-10-15). In test mode with test clocks, the Dashboard says its totals leave them out. Usage shows “Set up a meter” only when no meter exists, and quantities read “12,500 tokens”. The account menu can reopen a hidden setup checklist. The unused
/onboardingpage is gone. (#1066) - Stripe key errors say what happened (2026-10-15). The Payments card says “Stripe didn’t accept these keys” or “Couldn’t check keys with Stripe” (amber: saved but not tested), not Stripe’s raw JSON. API:
last_verified_errorholds Stripe’s sentence; newlast_verified_error_kind(rejected,unavailable). Migration 0178. (#1067) - Settings and sign-in messages match what Velox does (2026-10-15). The webhook signing secret shows “Saved”, not “Verified”. The rotate, replace-secret and disconnect dialogs no longer describe changes at Stripe that Velox doesn’t make. Sign-in pages say “Couldn’t reach the server…”, and Forgot password states the 3-per-hour limit.
- Team invites say when they can’t be sent (2026-10-15). With email or
DASHBOARD_BASE_URLunset, the Team tab shows “Invites are off” and disables the form. API:GET /v1/whoamigainscan_send_invites; the 409 message no longer names the setting. - Plainer refusal messages (2026-10-15). API: reworded without jargon: webhook URL checks (“must start with https://”), passwords (“is too long. Use at most 72 characters…”), and tax rate (“must be from 0 to 100…”). Codes and fields are unchanged. A paused webhook endpoint gets a Resume button.
- A parked invoice offers Mark uncollectible, and a payment anomaly offers Open in Stripe (2026-10-15). API: new action code
mark_uncollectible;reconcile_paymentis now offered onpayment_anomaly.
- Subscription dates agree, and the upcoming invoice says what it is (2026-10-17). A canceled subscription reads “Ended <date>”, not “Renews”, on its page and in “Next bill”. Every page says “access through Nov 15 · doesn’t renew Nov 16”. The Upcoming invoice card shows its period and “estimate as of”. (#1119)
- Usage Export CSV holds every filtered row (2026-10-17). The export stopped at 50,000 rows. It now streams from the server with the page’s filters, event ids and no row limit, up to 365 days. A longer window exports the last 365 and says so. API:
GET /v1/exports/usage-events.csvtakescustomer_id,meter_idanddimensions. (#1123) - Usage totals follow each meter’s aggregation (2026-10-16). Usage summed every reading: a Maximum meter at 140, 210 and 120 GB showed 470 GB. Each meter now shows its own value, unit and daily line; “Total Units” is gone. API:
GET /v1/usage-events/aggregaterows gainvalue,aggregation(ormixed),events,customers,peakanddaily; it takestz. (#1097) - The customer payment page no longer offers a Pay button that can’t work (2026-10-17). Without Stripe keys it showed Pay, then “failed to create checkout session”. It now says payment isn’t set up and whom to contact. Errors and bad links use customer wording. API: the public invoice adds
pay_unavailable_reason. (#1118) - Customer and plan pages show their own subscriptions (2026-10-16). Both filtered the tenant’s first 50 subscriptions, so with more than 50 a customer could show “Subscriptions (0)”. They now query by customer or plan, show the full count and list the first 100, saying when there are more. (#1101)
- Pages fit a phone screen (2026-10-16). On a 390px-wide phone, Customers, Subscriptions, Invoices, Settings and customer credits were wider than the screen, so buttons such as Add Subscription sat off to the side. Headers and filters now wrap, and Settings tabs drop their icons on a phone. (#1101)
- A date-only
tofilter now includes the last second of that day (2026-10-16).to=2026-10-31ended at 23:59:59.000 UTC, so CSV exports, the invoice list and the audit log left out rows stamped 23:59:59.000001–.999999. It now ends at 23:59:59.999999 UTC. - Customer usage accepts a full leap year (2026-10-16).
GET /v1/customers/{id}/usage?from=2028-01-01&to=2028-12-31answered 422 “window must be ≤ 1 year”, because the cap was 365 days. It is now one calendar year, so a 366-day yearly cycle also works;/v1/customers/{id}/margingets the same cap. - A margin request without dates follows the customer’s test clock (2026-10-16).
GET /v1/customers/{id}/marginwith nofrom/tocovered the wall-clock last 30 days, so a customer on an advanced test clock read revenue and cost 0. It now covers the 30 days ending at the clock’s time. Others are unchanged. (#1082) - A new trial no longer hides the running cycle’s usage (2026-10-16). Adding a trial beside an active subscription moved the default
GET /v1/customers/{id}/usageperiod to the trial’s first paid period. Usage and the public cost dashboard read $0 until the trial ended. The default now stays on the current cycle. (#1073) - Dashboard charts bucket by UTC day on any Postgres (2026-10-16). Revenue, usage and MRR movement charts grouped rows by the database server’s time zone, so on a server not set to UTC a 02:00 UTC payment could show on the previous day. Buckets are now always UTC, as documented. (#1070)
- “Net N” due dates no longer print a day early across a clock change (2026-10-16). Due dates added N×24 hours, so a Net 30 invoice issued Nov 1 00:00 in New York printed “Due November 30”. Due dates now count calendar days in the billing timezone: due December 1. (#1077)
- Trials end on the right day across a clock change (2026-10-16).
trial_daysadded N×24 hours, so a 14-day trial started Mar 1 23:30 in New York ended Mar 16 00:30, not Mar 15 23:30. Trials now count calendar days in the billing timezone. (#1087) - Manual invoice PDFs show dates in the tenant timezone (2026-10-16). One-off invoices had no billing timezone, so the PDF and hosted page printed dates in UTC: a 6 PM Los Angeles invoice read “Issued October 17”. New manual invoices store the tenant zone; older ones are unchanged. (#1081)
- The duplicate-period refusal names the period as invoices show it (2026-10-16). API:
invoice_billing_period_takenprinted the stored end, “2026-11-01..2026-12-01”, for a period shown elsewhere as “Nov 1, 2026 – Nov 30, 2026”. It now uses that inclusive form in the invoice’s billing timezone. The code is unchanged. (#1078) - Activity’s Cycle dates end on the period’s last day (2026-10-16). Cycle read “Oct 1, 2026 → Nov 1, 2026” while Upcoming read “Oct 1, 2026 – Oct 31, 2026” for the same period. Both now use the second form. The 7d, 30d and 90d dates are unchanged. (#1069)
- Chart labels no longer show the previous day west of UTC (2026-10-16). The revenue and “Daily usage” charts shifted each day into the tenant timezone: in New York, the Oct 15 bar read “Oct 14” and October read “Sep 30”. Each bar now shows its own day; months read “Oct 2026”. (#1071)
- A prepaid-credit expiry of tomorrow is accepted in the evening (2026-10-16). The new-invoice dialog took “today” as the UTC date, so west of UTC in the evening, picking tomorrow read “Credit expiry must be a future date”. Today is now the date in the workspace timezone. (#1072)
- Usage and invoice date filters use the tenant timezone on first load (2026-10-16). In a new tab, Usage events and dated Invoices links could keep UTC day bounds, drawn before settings loaded. A New York tenant’s “last 30 days” started four hours late. The window now recomputes when the timezone loads. (#1074)
- The invoice due badge agrees with the due date (2026-10-16). It counted 24-hour steps from the issue time, so on the morning of “Due Oct 16” it read “Due in 1d”, and “Due today” lasted into Oct 17. It now counts calendar days in the workspace timezone. (#1076)
- The invoice page shows the same Issued, Due, Paid and Voided dates as the PDF (2026-10-16). It used the current org timezone, not the invoice’s own, so after a timezone change a date could be a day off. Invoices without a stored zone still use the org timezone. (#1079)
- A cancel invoice’s “view usage” link includes the cancel day (2026-10-16). The link ended a day before the period end, but a cancel invoice ends at the cancel time, so that day’s usage was missing and a same-day cancel showed “No events match”. Cycle invoices are unchanged. (#1080)
- Pause collection no longer offers today as the auto-resume date (2026-10-16). Collection resumes at the start of the picked day, so the server refused today with “must be in the future”. The earliest date is now tomorrow in the workspace timezone, or by the test clock for a clock-pinned subscription. (#1083)
- The Dashboard revenue chart shows days without revenue (2026-10-16). It plotted only days with paid invoices, so payments on Sep 20 and Oct 15 were joined as if revenue continued between them. Every day (or month, for 12 months) now has a point, at zero when nothing was paid. (#1084)
- The test clock “+1mo” preset lands on the right day (2026-10-16). It used the browser’s timezone: for a UTC tenant viewed from Los Angeles, Jan 31 became Mar 1 and Oct 1 became Oct 31, missing the Nov 1 cycle close. It now uses the tenant timezone. (#1085)
- The demo can be rerun on any day (2026-10-16).
scripts/demo.sh(and./quickstart.sh demo) named its customer by time of day only, so a rerun at the same time on a later day stopped with “HTTP 409” onPOST /v1/customers. Ids now include the date and a random suffix. - Docs: the runbook’s late-usage check queries the invoice’s period (2026-10-16). Runbook §15 and §16 called
GET /v1/customers/{id}/usagewithoutfromandto, which returns the current cycle. They now pass the invoice’s period, or use/v1/usage-eventsfor a canceled subscription. The compose demo command prints dashboard links on port 80. (#1068) - Draft invoices no longer look issued, and no PDF invents a seller (2026-10-16). A draft PDF carries a DRAFT mark and says it is not issued, instead of an issue date. Without a company name, PDFs print no seller (they printed “Velox”); the invoice page links to Settings. The page now shows the memo. (#1093)
- Home trends compare like with like (2026-10-16). Failed payments compared against 0 and Active customers against a negative baseline. “Customers” now means customers with an active or trialing subscription; the MRR sparkline is gone. API: overview adds
live_customers,live_customers_prev,failed_payments_prev;trialing_subscriptionsnow counts (it was always 0). (#1094) - The next-invoice preview prorates like the invoice (2026-10-16). A calendar first period, a mid-period item or a quantity change shows prorated base lines; usage counts only while its plan was on. A trial previews its first paid invoice. This covers the subscription page and
create_previewwithoutperiod. An empty preview says why. (#1096) - Used-up credit stays visible on the customer page (2026-10-16). A fully applied grant left $0.00 and no row. Credits now show granted, used, expired, canceled and deducted amounts, and every grant. API:
GET /v1/credits/grants/{customer_id}rows gainexpired_cents,retired_centsanddeducted_cents;GET /v1/credits/balance/{customer_id}andGET /v1/credits/balancesgaintotal_retiredandtotal_deducted. (#1098) - Margin no longer reads 100% when no provider cost is known (2026-10-16). The Margin card shows a dash, says whether rates are missing or unmatched, and links to Provider costs. Partly costed usage shows no margin either. API:
margin_bpsis omitted in both cases; newcosted_eventsandcost_rates_configured. (#1095) - A customer’s figures use the customer’s currency (2026-10-16). Credit balance, usage total, margin and New invoice used the workspace currency, so a EUR customer read $0.00. They now use the next invoice’s currency (billing profile, live plan, then latest invoice). Until it is known, they show a dash and New invoice waits. (#1092)
- Usage event log values have thousands separators (2026-10-15). “+35000” now reads “+35,000”, matching the totals above it; the value is formatted as text, so no digit is lost.
- Setting a plan’s base price to 0 no longer reads “Plan updated” when nothing changed (2026-10-15). The server keeps the old price when sent 0, so Edit plan now says so and disables Save. The meter page lists each plan’s base price in its own currency. (#1066)
- The customer Activity card’s total no longer adds up different currencies or calls quantities “events” (2026-10-15). It summed every currency into the first one and counted billed quantity in mixed units. It now shows billed usage per currency, and each meter row gives its own quantity and unit. (#1065)
- A meter’s row in Activity no longer reads “0 tokens · 0 rules” when its usage has no price (2026-10-15). It now says both numbers, as in “0 tokens billed · 12,400 tokens not billed · no price matched”, matching the notice above it.
- Activity’s 7d, 30d and 90d windows include the latest usage (2026-10-15). The window ended exactly at “now” and left out events stamped at that instant: every event sent without a timestamp to a test-clock customer. 30d showed $0.00 while Cycle showed usage. The Margin card had the same gap. (#1065)
- Long emails and IDs no longer run over their labels (2026-10-15). On a phone, the customer Details card and the subscription Properties card put each value under its label and wrap long values.
- The customer page’s credit balance updates after Save & send (2026-10-15). Finalizing a new invoice spends credit, but the balance kept its old value until a reload.
- Customer and subscription stats fit a phone (2026-10-15). At 390px wide, Billing cycle, Trial period and Outstanding were clipped; the cells now stack.
- Cancel immediately says it bills a final invoice (2026-10-15). It said “Stops billing right now”. The archived-customer notice drops “All data is read-only”; unpaid invoices are still collected. Pause collection says drafts stay drafts after resuming. Plan, quantity and item changes say “charged or credited now” only on advance-billed plans. (#1065)
- An invoice link that matches nothing says “Invoice not found” (2026-10-15). It showed the server’s raw text with a Retry button that could never work.
- Charge now on an invoice paid entirely from credit now ends its dunning run (2026-10-15). The invoice was marked paid, but its run stayed active at $0.00 until the next retry date and counted on the Dashboard and sidebar badge. It now closes as payment recovered. (#1061)
- After logging in, the dashboard opens the page you were trying to reach (2026-10-14). Opening a link to an invoice or customer while logged out went to login, then to the home page. It now returns to the linked page. Only paths inside the dashboard are followed.
- The “No payment method” notice no longer promises an email that cannot be sent (2026-10-14). It said Velox emails the customer a setup link, but without email set up (as in the quickstart) nothing is sent. It now says so and offers the customer page instead of Resend. (#1059)
- Amounts show in the invoice’s own currency (2026-10-14). Record offline payment, the invoice page’s credit-note and line-item dialogs, and dunning runs showed a non-default-currency invoice with the workspace currency symbol. The credit grant confirm no longer hard-codes “$”.
- The Dashboard’s outstanding total names every currency (2026-10-14). It summed only the default currency next to a count of all open invoices. It now lists each currency, for example “$200.00 + €300.00 outstanding”. The API adds
outstanding_by_currencyto the analytics overview. - Charge now says what happened (2026-10-14). “Payment initiated” showed even when credit paid the invoice with no charge. The message now reads paid, payment started, or failed, from the returned invoice.
- A write-off whose pause fails no longer says collection was paused (2026-10-14). One warning, “Collection not paused”, now says the invoice was written off and the subscription is still billing.
- Save & send on a new invoice shows one message (2026-10-14). When the email could not be sent, it showed a warning and then “Draft saved” for an invoice that was finalized.
- Email actions no longer say “sent” without email set up (2026-10-15). With no
SMTP_HOST, the dashboard said “Invoice sent” though the email failed. Sending an invoice, credit note or setup link, and inviting a teammate, now answer 409email_not_configuredand create nothing. Save & send still finalizes the invoice; password reset is unchanged. (#1062) - The Dunning “At risk” card shows every currency (2026-10-15). It summed only the default currency, so after the default changed to CAD it read “CA$0.00” while USD runs were active. It now reads, for example, “$739.00 + €5.00”. The API adds
at_risk_by_currencyto dunning stats. - The customer and subscription pages show amounts in their own currency (2026-10-15). After the default currency changed, USD invoices read “CA$”. Outstanding now lists each currency; invoices, plan prices and proration messages use their own. Credits use the customer’s billing currency. API: the customer overview adds
outstanding_balance.by_currency. (#1062) - The payment-method link error names the action (2026-10-15). A failed “Email a payment-method link” was titled “Failed to resend setup link”; it now reads “Couldn’t email the payment-method link”.
- The Margin card no longer spins forever (2026-10-14). A failed load shows a retry, and a custom range asks for both dates.
- A session that ends while you work sends you to sign in (2026-10-14). Pages showed “missing credentials … Authorization: Bearer …”. The dashboard now says “Your session ended. Sign in to continue.” and returns you to the page after sign-in.
- Error messages name what failed (2026-10-14). An error toast keeps its title (“Failed to attach meter”) with one sentence below. Copy ID shows only for server errors.
- Charge now no longer says a payment failed when it may have gone through (2026-10-14). A refusal reads “Couldn’t charge”. With no answer from the server or Stripe, toasts read “Result unknown” or “Payment result unknown” and say to check before retrying. API: an unanswered charge returns
payment_outcome_unknown, notprovider_error(the decline code). (#1063) - A failed load no longer looks like an empty page (2026-10-14). Recipes, Provider costs and meter prices said “none yet” on a failed request. Every list and detail page now shows “Couldn’t load …” with Retry, not raw server text. A rate limit reads “Too many requests. Wait a moment, then try again.” (#1063)
- Usage events say when filters hide events (2026-10-14). An empty 30-day window with older events read “No usage events yet”; it now reads “No events match these filters” with Clear filters. An export over 50,000 events says the file holds the newest 50,000. (#1063)
- Settings Save explains an invalid field on another tab (2026-10-14). It did nothing. It now opens that tab and says what to fix.
- Unknown addresses show “Page not found” (2026-10-14). The screen was blank. The crash screen keeps the raw error inside Details.
- No raw codes on screen (2026-10-14). The invoice notice’s support code moved into Details. “payment_anomaly”, the invoice list hover, the “payment: unknown” filter and Dunning reasons now read as words, for example “Why recovery started: No payment method on file”.
- Dashboard layout fixes (2026-10-15). Toasts follow dark mode and sit above the Settings save bar, which the setup pill no longer covers on a phone. “Result unknown” is an amber warning, not a red error. Detail-page header buttons wrap on a phone without cutting a test clock’s name short. (#1063)
- Amber is kept for problems (2026-10-14). The test-mode strip, the Dashboard open-invoices row, an unlinked price, the tax example and a finished clock advance are no longer amber. The sidebar Dunning count is amber, as on the Dashboard. Notices share one shape, with dark-mode colours.
Earlier releases: the full changelog on GitHub.