Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

Write off an invoice as uncollectible

POST
/v1/invoices/{id}/mark-uncollectible
curl --request POST \
--url http://localhost:8080/v1/invoices/example/mark-uncollectible \
--header 'Authorization: Bearer <token>'

Moves a finalized invoice to uncollectible (a bad-debt write-off). The invoice can still be paid later, for example with POST /v1/invoices/{id}/record-payment. Tax is not reversed.

Resolves any active dunning run with invoice_not_collectible, so payment retries stop. Fires the invoice.marked_uncollectible webhook event.

A written-off spend-threshold invoice keeps its charges billed: no later invoice bills them again unless the invoice is then voided. POST /v1/invoices/{id}/void says when a void gives them back.

Asks Stripe to expire any Checkout session still open for the invoice. If Stripe refuses or cannot be reached, the session stays payable until it expires, at most an hour after it opened.

If the invoice billed its subscription’s current period in advance, it still counts as unpaid for that subscription. 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, unless a charge on the invoice is still being confirmed with Stripe. A cancel, downgrade, removed item or plan swap lowers what it asks for by the unused time; a cancel or a change of billing interval 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. A written-off invoice from an earlier period, or one not tied to a subscription’s period, does not affect subscription changes.

id
required
string
Idempotency-Key
string

Makes the call safe to retry. Send a unique value (a UUID works) and the same value on every retry. For 24 hours, a repeat with the same key, method, path and body gets the first response again, with an Idempotent-Replayed: true header, instead of running again. The error cases are in docs/api-surface.md. The usage routes do not take it: each usage event carries its own idempotency_key.

Invoice written off.

Media typeapplication/json

A Hardcount invoice. Returned from create, finalize, void, list, get, rotate-public-token, and collect-payment endpoints.

object
id
required
string
tenant_id

Owning tenant. Returned for diagnostic clarity but redundant with the API key (which already determines the tenant). Omitted when empty.

string
customer_id
required
string
subscription_id
required

Empty for one-off invoices not tied to a subscription.

string
invoice_number
required

Set when the invoice is created, including drafts.

string
status
required

Lifecycle state of the invoice. uncollectible marks an invoice a dunning policy has given up collecting (no further retries) while keeping it on the books — distinct from voided (cancelled, owes nothing).

string
Allowed values: draft finalized paid voided uncollectible
payment_status
required

Payment-attempt status. unknown marks a Stripe charge attempt that returned an ambiguous error (5xx, timeout, connection reset) where we cannot tell whether Stripe processed the charge. With a payment intent recorded, Hardcount asks Stripe for it after a short wait. With none (a parked invoice), Hardcount lists the customer’s payments at Stripe every billing tick: it records a payment for the invoice that appears, and if none has appeared 24 hours after the attempt the invoice goes back to pending (failed after a dunning retry) and is charged again as usual.

string
Allowed values: pending processing succeeded failed unknown
currency
required
string
subtotal_cents
required
integer format: int64
discount_cents
required
integer format: int64
tax_amount_cents
required
integer format: int64
tax_rate
required

Tax rate as a percent (18.50 = 18.50%), up to 4-decimal precision. ADR-042/043.

number format: double
tax_name
string
tax_country
string
tax_id
string
tax_provider

Durable audit snapshot of the upstream tax engine that ran the calculation (e.g. stripe, none, manual).

string
tax_calculation_id

Provider-side calculation reference captured at build time. For Stripe Tax this expires after 24 h; persisted so finalized invoices remain reconstructable past expiry.

string
tax_transaction_id

Provider-side committed transaction reference, captured at finalize. For Stripe Tax this is tx_xxx and is the handle the provider needs for a later reversal when a credit note is issued.

string
tax_reverse_charge
boolean
tax_exempt_reason
string
tax_status

Whether tax has been calculated. ok: it has. pending: the calculation failed, and tax_error_code says why and whether Hardcount retries it. A pending invoice cannot be finalized, so a wrong tax amount is never sent.

string
Allowed values: ok pending
tax_deferred_at
string format: date-time
nullable
tax_retry_count
integer
tax_pending_reason
string
tax_error_code

Why tax could not be calculated, as a code. Set while tax_status is pending; tax_pending_reason has the detail.

string
Allowed values: customer_data_invalid jurisdiction_unsupported provider_outage provider_auth provider_not_configured unknown
tax_next_retry_at

The earliest time the background retry may calculate tax again, after a failed attempt. Absent when a retry may run now.

string format: date-time
tax_reversed_at

When the provider’s tax transaction was reversed after a void or write-off, or found to need no reversal. Absent while the reversal is not confirmed.

string format: date-time
payment_anomaly_kind

Set when a payment arrived that the invoice did not expect. The webhook event name of the cause: payment.duplicate_charge, payment.amount_mismatch, payment.currency_mismatch or payment.received_on_voided_invoice. Only the latest one is kept.

string
payment_anomaly_payment_intent_id

The Stripe PaymentIntent of that unexpected payment.

string
payment_anomaly_captured_cents

The amount Stripe took in that unexpected payment.

integer format: int64
payment_card_brand

Brand of the saved card that paid the invoice. Absent when the card is unknown.

string
payment_card_last4

Last four digits of the saved card that paid the invoice.

string
attention

Unified “this invoice needs operator attention” surface. Computed server-side from durable invoice fields (tax_status, tax_error_code, payment_status, last_payment_error). Never persisted — always derived. Omitted entirely when the invoice is healthy (matches Stripe’s last_finalization_error: null ergonomic). See ADR-009.

object
severity
required

Urgency of an Attention surface. Operators sort/filter on this and the dashboard renders one colour per level. Priority descending: Critical > Warning > Info.

string
Allowed values: info warning critical
reason
required

Closed, typed code naming why an invoice needs operator attention. Stable public-API contract — codes are never repurposed; deprecations keep the code reserved and add a successor. See Attention.code for the open, provider-specific sub-code. payment_anomaly (ADR-068) reports money that does not reconcile — double charge, captured-vs-booked amount mismatch, or a payment on a voided invoice — and is surfaced even on paid/voided invoices. dunning_exhausted reports a no-payment-method invoice whose automatic recovery has ended without collecting (2026-07-22). collection_paused reports an invoice that is queued for auto-charge but whose subscription has pause_collection set, so the charge sweeps deliberately skip it (2026-07-28). A dunning run already open on the invoice keeps retrying it through the pause, and message then says so; with no open run it is not charged automatically until collection resumes. commit_exposure reports an unpaid invoice that funded a prepaid commit whose credit is already spendable (ADR-078 funds commits at issue, not at payment) — note this reason is only returned when no other reason applies; when one does, the exposure is folded into that reason’s message and its actions instead, so clients must not treat the reason code as the presence test for exposure (2026-08-04). dispute_lost reports a payment on the invoice that the customer disputed with their bank and that the dispute took back (the dispute closed as lost). Hardcount leaves the invoice paid and moves no money; the operator records the loss. Surfaced on paid invoices. On an invoice that bought prepaid credit, the message also says how much of the credit is still spendable and names commit relief, because an ordinary credit note is refused there. When the invoice also has payment_anomaly, that reason stays and its message asks the operator to check Stripe before any refund. The disputes themselves are at GET /v1/invoices/{id}/disputes (2026-10-19). amount_below_minimum reports an invoice Stripe refused to charge, or refused a payment page for, because the amount is below Stripe’s minimum charge. The minimum depends on the currency the Stripe account settles in; Stripe’s reply, in provider_response, names it. Nothing was charged, the invoice stays finalized with payment_status pending, it is not charged again automatically, dunning does not start (a run already open ends), and the customer is not emailed. The operator voids it or records a payment; Collect payment applies the customer’s credit balance and pays it if that covers the amount, and otherwise asks Stripe again. Stripe’s answer can change (for a converted charge the minimum is the settlement currency’s), and a different answer ends this reason (2026-10-23).

string
Allowed values: tax_calculation_failed tax_location_required payment_failed payment_unconfirmed payment_anomaly payment_processing payment_scheduled awaiting_payment no_payment_method dunning_exhausted collection_paused commit_exposure dispute_lost amount_below_minimum
message
required

Human-readable copy, safe to render verbatim: one plain sentence saying what happens and what to do. Facts that must show up front follow on more lines, separated by a newline (\n), one sentence each and up to three lines in all: the setup-link email on no_payment_method (and on dunning_exhausted when no link can be emailed), and the prepaid credit at risk on an unpaid commit invoice. A no-card commit invoice carries all three. Wording may change between releases; switch on reason and code, not on this text.

string
actions

Prescribed next steps. First item is the primary CTA; the rest are secondary.

Array<object>
object
code
required

Operator’s recommended next step. Closed enum because every code maps to a concrete server endpoint or frontend route, and audit logs key off the code. reconcile_payment opens the payment in Stripe; it is offered on payment_anomaly, where the refund or correction is made there. mark_uncollectible opens the write-off and is offered only on a parked invoice (payment status unknown with no payment intent recorded), for a customer who paid another way: Hardcount records a payment that Stripe shows, and charges the invoice again if none appears within 24 hours. Dashboards render it as a secondary action. void_invoice is offered on prepaid-commit exposure, only while some of the granted credit is still unspent: voiding retires the unspent balance (ADR-078) and is the sole recovery for an unpaid invoice whose credit is already live. Credit the customer has already spent is never clawed back (2026-08-04). It is also offered on amount_below_minimum, an amount Stripe will not charge (2026-10-23).

string
Allowed values: edit_billing_profile retry_tax retry_payment wait_provider rotate_api_key reconcile_payment review_registration charge_now send_reminder add_payment_method update_payment_method connect_tax_provider void_invoice mark_uncollectible
label

Default rendering label. UIs are free to substitute localized copy.

string
code

Open, dotted, provider-specific code (e.g. tax.customer_data_invalid, payment.declined). New codes ship without a contract bump. Stripe parity.

string
doc_url

Deep link to operator-facing documentation for this reason. Stripe parity.

string
param

Dotted-path pointer at the entity field the operator must edit to resolve the issue (e.g. customer.address.postal_code). Stripe parity. Empty when the resolution isn’t field-scoped.

string
detail

Hardcount’s own explanation behind message: why the state holds, what Hardcount does on its own, and ids support may ask for (for example the payment intent ids of a payment anomaly). Plain text; may contain blank-line paragraph breaks. Never a provider payload — that is provider_response.

string
provider_response

The provider’s own response (Stripe Tax error envelope, Stripe’s last payment error), exactly as received. Present only when Hardcount called the provider.

string
since

When the attention condition started — operators triage by age. tax_deferred_at for tax reasons; due_at for overdue; updated_at as a proxy for payment failures.

string format: date-time
next_attempt_at

When Hardcount will next retry on its own. Today only a tax retry sets it. Absent when no automatic retry is scheduled.

string format: date-time
due_by

The invoice’s payment deadline (its due_at). Hardcount does not act on this date; it is shown so the operator knows it. Absent when the invoice has no due date.

string format: date-time
recovery_warning

What an operator must know BEFORE recording an OFFLINE payment on a written-off invoice — a consequence that will go unreconciled, NOT a refusal. Absent when there is nothing to say.

object
blocked
required

Always false — this shape only ever warns.

boolean
code
string
Allowed values: tax_reversed_unrecoverable relief_not_reissued
message

Operator-facing sentence naming the consequence that will go unreconciled if the payment is recorded.

string
total_amount_cents
required
integer format: int64
amount_due_cents
required
integer format: int64
amount_paid_cents
required
integer format: int64
credits_applied_cents
required
integer format: int64
billing_period_start
required
string format: date-time
billing_period_end
required
string format: date-time
billing_period_display

Human period string with the inclusive last covered day (“Jun 1, 2028 – Jun 30, 2028”), date-only in the invoice’s billing timezone (ADR-058 / ADR-077). Computed on read; the raw billing_period_start/end stay half-open. Empty/omitted for one-off invoices with no period.

string
billing_timezone

IANA timezone the period boundaries are anchored in — the org billing timezone resolved and denormalized onto the invoice at issue (ADR-077). billing_period_display is rendered in this zone, so an issued invoice’s dates stay fixed even if the org later changes its timezone. Empty only on legacy invoices created before the zone was stamped (cycle invoices before migration 0134, manual invoices before 2026-10-16).

string
bill_to_snapshot

The buyer details this invoice was issued with (ADR-109), copied when it left draft. The PDF, the hosted page and the dashboard render an issued invoice from this copy, so a later edit to the customer or billing profile does not change it. Absent on drafts and on invoices issued before this field existed; those render from the current customer details.

object
display_name
required
string
legal_name
string
email
string
address_line1
string
address_line2
string
city
string
state
string
postal_code
string
country
string
tax_id

On an invoice, the tax ID its tax was calculated with when set, otherwise the billing profile’s tax ID at issue.

string
tax_id_type
string
supplier_snapshot

The seller (company) details this invoice was issued with (ADR-109), copied when it left draft. Absent on drafts and on invoices issued before this field existed. Branding (logo, brand color) is not part of it.

object
name
string
email
string
phone
string
address_line1
string
address_line2
string
city
string
state
string
postal_code
string
country
string
tax_id
string
issued_at

Set when the invoice transitions out of draft.

string format: date-time
nullable
due_at
string format: date-time
nullable
paid_at
string format: date-time
nullable
voided_at
string format: date-time
nullable
uncollectible_at

When the invoice was marked uncollectible.

string format: date-time
stripe_payment_intent_id

The Stripe PaymentIntent of the card charge that paid the invoice, or of its last charge that is in progress or failed. It can be absent after a charge was sent: for example when the invoice was then paid without a card (by credits, with nothing due, or outside Hardcount: paid_out_of_band), or when Stripe refused the last charge as below its minimum.

string
paid_out_of_band
required

True when the payment was recorded as made outside Hardcount, such as a bank transfer or cheque (POST /v1/invoices/{id}/record-payment, or a payment recovery run resolved as payment_recovered). Such an invoice has no stripe_payment_intent_id, so there is no card to refund.

boolean
last_payment_error
string
auto_charge_pending
boolean
no_pm_notified_at

When the customer was emailed a link to add a payment method for this invoice. The email is sent once.

string format: date-time
provider_payment_status

Stripe’s PaymentIntent status the last time Hardcount checked it, word for word. For information only: payment_status is the invoice’s payment state. Absent until Hardcount has checked. An invoice checked by the Stripe Search sweep of releases before this one may still show search_not_found, search_terminal_only or search_error.

string
provider_synced_at

When Hardcount last asked Stripe about this invoice’s payment, whether or not Stripe answered: a read of provider_payment_status, or a check of a charge whose result was lost.

string format: date-time
public_token

Hosted-invoice-URL credential (Stripe-parity hosted_invoice_url). Generated at finalize; drafts have an empty token. Rotatable via POST /v1/invoices/{id}/rotate-public-token.

string
net_payment_term_days
required
integer
memo
string
footer

The tenant’s invoice_footer as it was when this invoice was issued. Printed on the PDF and the hosted invoice page. Empty on a draft.

string
metadata
object
key
additional properties
any
created_at
required
string format: date-time
updated_at
required
string format: date-time
source_plan_changed_at
string format: date-time
nullable
source_subscription_item_id
string
source_change_type

Classifies per-item proration artifacts so the dedup index can distinguish a plan-change and a quantity-change that happen to share the same (subscription, item, timestamp).

string
Allowed values: plan quantity add remove
billing_reason

Mirrors Stripe’s invoice.billing_reason. Classifies the trigger that produced the invoice. Stamped at create time and never mutated.

string
Allowed values: subscription_cycle subscription_create subscription_cancel subscription_update manual threshold
is_simulated
required

Whether this invoice’s domain timestamps (created/issued/due/paid) were stamped on a test clock’s simulated time rather than wall-clock. Captured at write time (engine: the subscription is clock-pinned; manual composer: the customer is clock-pinned). The dashboard renders a “simulated” badge from this authoritative flag instead of inferring it. Always false in live mode.

boolean
test_clock_id

The test clock this invoice’s dates follow: the clock its customer is pinned to, for cycle and one-off invoices alike. Read from the customer on GET /v1/invoices and GET /v1/invoices/{id}; other responses may leave it out. Left out for a customer on wall-clock time, and always in live mode.

string

Example

{
"id": "hc_inv_42014438d82ddf33",
"status": "draft",
"payment_status": "pending",
"currency": "USD",
"tax_status": "ok",
"tax_error_code": "customer_data_invalid",
"attention": {
"severity": "info",
"reason": "tax_calculation_failed",
"actions": [
{
"code": "edit_billing_profile"
}
]
},
"recovery_warning": {
"code": "tax_reversed_unrecoverable"
},
"source_change_type": "plan",
"billing_reason": "subscription_cycle"
}

Invoice not found.

Media typeapplication/json
object
error
object
type

One of invalid_request_error, authentication_error, rate_limit_error or api_error. Every error the API answers has this shape, including an unknown path (404, code route_not_found) and a method the path does not take (405, code method_not_allowed, with an Allow header). The few exceptions are listed in docs/api-surface.md.

string
code
string
message
string
request_id
string
param

The request field the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed: any other field, or one the server sets (such as id), is refused with 422 and param naming it. An operation with no request body lists no fields: send no body, {} or null. The Stripe and Postmark webhooks and the LiteLLM callback take their sender’s body as it comes.

string

Example

{
"error": {
"type": "invalid_request_error"
}
}

The invoice cannot be written off in its current state. Codes: invalid_state (draft, paid, voided, or already uncollectible) or payment_in_flight (a charge is still running).

Media typeapplication/json
object
error
object
type

One of invalid_request_error, authentication_error, rate_limit_error or api_error. Every error the API answers has this shape, including an unknown path (404, code route_not_found) and a method the path does not take (405, code method_not_allowed, with an Allow header). The few exceptions are listed in docs/api-surface.md.

string
code
string
message
string
request_id
string
param

The request field the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed: any other field, or one the server sets (such as id), is refused with 422 and param naming it. An operation with no request body lists no fields: send no body, {} or null. The Stripe and Postmark webhooks and the LiteLLM callback take their sender’s body as it comes.

string

Example

{
"error": {
"type": "invalid_request_error"
}
}