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 →

Refund a paid invoice

POST
/v1/invoices/{id}/refund
curl --request POST \
--url http://localhost:8080/v1/invoices/example/refund \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "amount_cents": 1, "reason": "duplicate", "description": "example" }'

Refunds a paid invoice to the customer’s card. Hardcount creates and issues a credit note (a document that reduces what the customer owes) for the refund, and sends the refund to Stripe.

Leave out amount_cents, or send 0, to refund what was paid by card less the refunds on the invoice’s credit notes that are not voided. A refund that failed or is not finished counts too: retry it with POST /v1/credit-notes/{id}/retry-refund. If other credit notes already cover part of the invoice, the refund stops at what is left of the invoice total. The response is the credit note. Check its refund_status: if Stripe refused the refund, it is failed. If Stripe gave no clear answer, it is unknown: the customer may already be refunded, so don’t refund again in the Stripe Dashboard. Retry either with POST /v1/credit-notes/{id}/retry-refund.

Only invoices paid by card through Stripe can be refunded here. For an invoice paid outside Stripe, create a credit note with out_of_band_amount_cents.

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.

Media typeapplication/json
object
amount_cents

Amount to refund. 0 or absent 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.

integer format: int64
reason
required
string
Allowed values: duplicate fraudulent requested_by_customer other
description

Text for the credit note line. Defaults to “Refund: ”.

string

The issued refund credit note.

Media typeapplication/json

A credit note reduces what a customer owes on an invoice, or gives money back after it was paid. Its total is split three ways: refund to the card (refund_amount_cents), credit to the customer’s balance (credit_amount_cents), and money returned outside Hardcount (out_of_band_amount_cents).

object
id
required
string
tenant_id
string
invoice_id
required
string
customer_id
required
string
credit_note_number
required
string
status
required
string
Allowed values: draft issued voided
reason
required
string
subtotal_cents
required
integer format: int64
tax_amount_cents
required
integer format: int64
total_cents
required
integer format: int64
refund_amount_cents
required
integer format: int64
credit_amount_cents
required
integer format: int64
out_of_band_amount_cents
required
integer format: int64
commit_retired_cents

Prepaid commit credits this note retired. Absent on other notes.

integer format: int64
currency
required
string
issued_at
string format: date-time
voided_at
string format: date-time
refund_status
required

The refund part’s state at Stripe. unknown: Stripe gave no clear answer (a timeout, a network error, a Stripe 5xx, a 409 or a 429), so the customer may already be refunded. Don’t refund it again in the Stripe Dashboard. Retry it with POST /v1/credit-notes/{id}/retry-refund, which looks for the refund at Stripe first and then resends the same request with the same idempotency key.

string
Allowed values: none pending succeeded failed unknown
stripe_refund_id
string
bill_to_snapshot

What an issued invoice or credit note prints about its buyer, copied when the document was issued (ADR-109). The printed name is legal_name when set, otherwise display_name.

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

What an issued invoice or credit note prints about its seller (the tenant’s company), copied when the document was issued (ADR-109).

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
tax_transaction_id

Tax provider’s reversal transaction, set when the note is issued.

string
is_simulated
required

True when issued_at is test-clock time.

boolean
issue_pending
required

True on a draft that Hardcount will issue automatically.

boolean
tax_reversal_pending
required

True when the tax reversal failed and will be retried.

boolean
metadata
object
key
additional properties
any
created_at
required
string format: date-time
recorded_at

Real-world time the row was written, when created_at is test-clock time.

string format: date-time
updated_at
required
string format: date-time

Example

{
"status": "draft",
"refund_status": "none"
}

Body is not valid JSON.

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"
}
}

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"
}
}

Code invalid_state. The invoice is not paid, was paid outside Stripe, has no payment to refund, or is already fully refunded.

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"
}
}

Validation error: missing or unknown reason, a negative amount, an amount above what is left to refund, refunds are not configured, or an unknown field (param names it; the body must use only the fields listed here). A misspelled amount such as amount is refused rather than read as a full refund.

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"
}
}