Credit notes & credit balance
A customer’s credit balance is money you owe them, held against their account. PaymentKit applies it automatically when their next invoice is collected. Each adjustment to the balance is recorded as a credit note — an immutable ledger entry — so the balance is always the running sum of every note.
Balances are tracked per currency. A customer can hold credit in USD and EUR independently.
Credit balance vs. credit notes
You can work at either level:
- Raise a balance to an absolute target with
PATCH credit-balance— PaymentKit computes the difference against the current balance and posts it to the ledger. - Lower a balance with
POST credit-balance/reduce— you state how much to take off rather than what the balance should end up as. This is the endpoint to use for reductions. - Append a specific credit note with
POST credit-notes— useful when you want a reason and memo on record, or to link the credit to a specific invoice. This endpoint only ever adds credit; it cannot reduce a balance.
Set a customer’s credit balance
PATCH credit-balance sets the balance to an absolute target, not a delta. PaymentKit
compares your target against the current balance and issues credit (if higher) or voids credit (if
lower) to match.
Deprecated for reductions — use POST credit-balance/reduce instead. An absolute target is
worked out against a balance you read earlier, so anything that moves the ledger in between — an
invoice finalizing and applying credit, a grant landing from elsewhere — is absorbed into the
difference and written as a real change. PATCH is also not covered by the idempotency
middleware at all (it intercepts POST and PUT only), so a retry after a timeout re-derives
the difference against whatever the balance is by then.
This endpoint is not going away, and it still works for raising a balance.
Dashboard
API
- Open the Customer
- Open the actions menu and choose Change invoice balance
- Pick the currency, choose Credit or Debit, and enter the amount
- Click Apply balance adjustment
The amount comes pre-filled with the customer’s current balance. What you enter replaces it.
The response is the resulting balance. Currency codes come back lowercase:
The target is compared with the customer’s ledger balance — the same number returned by
the customer object. Expiring credit that has passed its expires_at but has not yet been
reclaimed still counts toward it until PaymentKit reclaims it; see
Expiring credit.
Lowering the balance draws from the customer’s unexpired credit first — soonest-expiring
first, credit with no expiry last — and only then from credit already past its expires_at that
has not been reclaimed. So a reduction takes expiring credit before permanent credit. Read
Expiring credit before reducing the balance of a customer who holds any.
POST credit-balance/reduce below is the better endpoint for taking credit away, and the same
draw order applies to it.
Reduce a customer’s credit balance
POST credit-balance/reduce removes a stated amount of credit and returns the ledger entry that
records it. You say how much to take off, not what the balance should end up as, so the request
means the same thing no matter what else moved the balance in the meantime.
API
The response is 201 with the credit note that was written — a voided entry with a negative
amount_atom — plus balance_after_atom, the balance the write left behind, read in the same
transaction so a sweep need not re-read:
Store that cn_ id to correlate the reduction later. No companion invoice is created — reclaiming
unused credit is a ledger correction, not a document the customer receives — so invoice_id is
null.
It refuses rather than clamps
A reduction larger than the balance is rejected with 409 credit_balance_insufficient, not trimmed
to zero. Balances here can
legitimately go negative — a negative balance is a debit added to the customer’s next invoice — so
reducing past zero would overcharge them.
Both that check and expected_balance_atom are evaluated against the balance as read while the
request is handled. Nothing here takes a lock and the balance is a sum over the ledger rather
than a stored column, so a renewal finalizing for the same customer at that instant can still
take the balance negative. Read these as true of the balance at the moment it was read, not as a
serialized guarantee.
Errors
The two business refusals and the validation 422 use the
RFC 7807 field set (served as application/json): the
human-readable text is detail, and the machine-readable fields — error_code and the balance
figures — sit at the top level of the body next to it, the same shape as
collection_in_progress on invoices. The one exception is the in-flight duplicate 409 below,
which is a bare {"detail": "..."}.
Three conditions share 409 and their remedies differ, so branch on error_code, not on the
status.
Idempotency
Send a stable Idempotency-Key header of at least 8 characters (shorter is rejected with
422). A duplicate that arrives while the first request is still in flight is refused with the
409 above, for up to five minutes. Once the first request has completed, its response is
replayed for the same (key, account, endpoint) — and on this endpoint the endpoint half carries the
customer id, see the note below — instead of running again. The replay record is written in the
same transaction as the ledger entry, so once the reduction is committed a retry gets the stored
result, never a second reduction. If the process dies before that commit, nothing was written and
a retry applies for the first time — which is what you want, though an early retry sees the
in-flight 409 until that five-minute claim lapses.
Replay is guaranteed for 24 hours; don’t rely on it beyond that, and never reuse a key for a
genuinely new reduction — note that a refusal (4xx) is replayed too. Reconcile against
GET credit-notes when in doubt.
Keys are scoped to the customer on this endpoint. The replay scope is (key, account,
customer), so the same key sent for two different customers is two reductions, one each — a
batch expiry sweep can key by run id. This is specific to credit-balance/reduce: on
POST credit-notes the customer id is not part of the scope, and a key reused across
customers replays the first customer’s note for the second, so key per customer there.
If a response is ambiguous and you sent no idempotency key, don’t blind-retry. Read
GET credit-notes — the ledger, not the customer’s cached balance — to see whether the entry
landed.
Issue a credit note
POST credit-notes appends a credit to the customer’s balance with a reason and optional memo.
This endpoint cannot reduce a balance — use POST credit-balance/reduce for that.
amount_atom is validated as greater than zero, so a negative amount is rejected with 422
rather than posting a debit. There is no flag or reason that changes this: issuing and reducing
are separate endpoints on purpose, so a reduction gets the idempotency, the balance precondition
and the overdraw refusal that this one does not have.
API
Send an Idempotency-Key header so a retried request doesn’t issue the credit twice.
Expiring credit has to be switched on for your environment. Two separate 422s can come back,
and the order matters when you’re reading the error: the timestamp is validated first, so a
missing timezone offset or a date in the past is refused for that reason whether or not the
feature is on. Only a well-formed future timestamp reaches the check that reports the feature
is not enabled yet.
The credit note object
Reasons split three ways: some you set when issuing credit, some you set when reducing a balance, and some PaymentKit writes for itself.
auto_apply, debit_settlement, void_reversal and credit_expired are written by
PaymentKit for its own ledger bookkeeping, and expiry_clawback belongs to the reduce
endpoint. All five are rejected with 422 if you send them when creating a credit note.
When an invoice is voided
If an invoice that had credit applied to it is voided because the amount was never genuinely
owed — a plan-change proration, or a merchant voiding an invoice raised in error — that credit
returns to the customer’s balance automatically as a void_reversal note. You don’t need to issue
a compensating credit note yourself in that case; doing so would double-credit the customer.
Credit is not returned when the write-off ends a collection that was attempted and failed. An
invoice that exhausted dunning keeps the credit consumed whether it ends up void or
uncollectible — the customer owed that money and did not pay it, so the merchant absorbs the net
rather than the gross. Which of the two labels dunning applies is your
invoice_status_on_failure setting, and it affects your books only, never the customer’s balance.
The same holds for uncollectible: writing a debt off never returns the credit it consumed. If you
then void that same invoice yourself, the credit does come back — once, from the void.
If the invoice instead settled a negative (debit) balance, voiding it re-instates that debit as a
void_reversal note with a negative amount, since the customer never paid it. So a
void_reversal can move the balance in either direction — read its amount_atom rather than
assuming credit was added.
The returned credit can arrive as more than one note:
- If the customer holds no expiring credit in that currency, it is a single
void_reversalnote with noexpires_at. - Otherwise there is one note per expiring credit note the invoice drew on, each carrying that
note’s own
expires_at, plus at most one further note with noexpires_atfor the remainder. Voiding never turns expiring credit into permanent credit, and if a date has already passed the restored credit is already expired — PaymentKit reclaims it on the next pass.
However they are split, the notes always sum to exactly what the ledger says the invoice consumed.
Don’t infer the balance change from the invoice’s status. Check whether a void_reversal note
exists for the invoice, or read the customer’s balance after the void.
List a customer’s credit notes
Pass an optional currency filter; results are paginated and sorted newest-first.
Two more filters help with expiring credit:
Combine them to see what is about to be reclaimed:
?unused_only=true&expires_before=2026-10-01T00:00:00Z.
debit_settlement entries are never returned by unused_only=true, and they report
remaining_atom: null. They are debt bookkeeping — a negative balance zeroed onto an invoice —
not credit the customer can spend. The two answers agree on purpose: no entry reports unused
value while being invisible under unused_only=true.
How credit is applied
When an invoice is finalized, PaymentKit automatically draws down any available credit in the
invoice’s currency. Each application writes an applied credit note with a negative amount, and
the balance falls accordingly. You don’t need to apply credit manually — issuing it is enough.
Credit issued after an invoice was finalized can still reach that invoice, but each invoice draws at most once. While nothing has been applied to it, a payment retry on an open or past-due invoice applies whatever balance is available at that point. Once credit has been applied or a debit settled, later retries leave that invoice alone and the credit stays on the customer’s balance for the next one.
An invoice only ever draws on credit that has not expired, and it takes the soonest-expiring
credit first — so expiring credit is spent ahead of credit that never expires, and ahead of the
reclaim. Credit whose expires_at has already passed is skipped even in the window before
PaymentKit reclaims it, so what an invoice can draw is sometimes less than the balance below. See
Expiring credit.
Only credit still on the balance can be drawn down, so a reduction posted before an invoice
collects lowers what that invoice can take. A reduction never rewrites an application that already
happened — it appends a voided note of its own, and invoices already collected are untouched.
The current balances are also exposed on the customer object:
credit_balances lists every non-zero balance by currency and is the field to read. The legacy
singular credit_balance is retained for backward compatibility and reflects only the largest
balance.
Both report the ledger balance, which still counts credit that has expired but has not been reclaimed yet. An invoice cannot draw on that part.
Expiring credit
Set expires_at when you issue a credit note and PaymentKit handles the rest:
- Invoice spending stops at the instant
expires_atpasses. An invoice finalized one second later will not draw on that credit, even before it has been reclaimed. Credit issued without anexpires_atis unaffected. - The unused remainder is reclaimed shortly after, by a sweep that runs every 15 minutes by
default. Treat the reclaim as prompt, not as a deadline — what is guaranteed is that the credit
stops being spendable at
expires_at. The sweep setsexpired_aton the original note, writes avoidedcredit note with reasoncredit_expiredfor whatever it reclaims, and emits acredit_note.expiredevent carryingissued_atom,remaining_before_atom(what the note still held),clawed_back_atom(what was actually taken),expires_at,expired_atandcurrency. - The reclaim never takes more than the customer’s balance, and never takes credit that has
not expired. So
clawed_back_atomcan be less thanremaining_before_atom— including0, for a customer whose balance is at or below zero, or whose balance is only positive because of other credit that is still valid. The event still fires in those cases; reconcile onclawed_back_atom, not onremaining_before_atom. When the reclaim takes0nocredit_expirednote is written at all — onlyexpired_atis set. - A note that was fully used before its date is closed out silently:
expired_atis set, nothing is reclaimed, and nocredit_note.expiredevent is emitted. remaining_atomtells you what is still unused on each expiring credit note. It is not the same as spendable: betweenexpires_atpassing and the reclaim it still reports a value the customer can no longer apply, and it reads0once the note is closed out, whatever the reclaim was able to take.
Everything above is per currency. A customer’s balance, their expiring credit and each reclaim are tracked separately for each currency they hold credit in.
credit_note.expired is subscribable directly, as is the credit_note.* prefix. An endpoint
subscribed to all events (*) receives it too. See Webhook events.
When an invoice that drew on expiring credit is voided, the credit comes back with the same
expires_at it had. Voiding an invoice never turns expiring credit into permanent credit — and
if that date has already passed, the restored credit is already expired: it cannot be applied,
and PaymentKit closes it out on a following sweep, reclaiming whatever point 3 allows.
expires_at applies to credit issued with it. It cannot be added to a credit note after
the fact, and credit issued before this feature carries no expires_at and reports
remaining_atom: null.
To put an expiry on credit a customer already holds, reduce the balance by that amount with
POST credit-balance/reduce and re-issue it with POST credit-notes and an expires_at.
Reduce with reason: "expiry_clawback", which is what that reason is for, and send an
Idempotency-Key so a retry cannot take the credit twice. Use
POST credit-balance/reduce rather than PATCH credit-balance here: it takes the amount to
remove rather than the balance to end at, so the request means the same thing even if the
balance moved in between.
Do this only while the customer holds no unused expiring credit in that currency. A
reduction is drawn from the customer’s unexpired credit first — soonest-expiring first, credit
with no expiry last — and only then from credit already past its expires_at that has not been
reclaimed. So if they already hold expiring credit it is that grant the reduction consumes, not
the permanent credit you meant to convert, and you will have silently moved one expiry date
rather than added a new one.
Check first with a list call that returns only unused credit carrying an expiry:
?unused_only=true¤cy=USD&expires_before=2100-01-01T00:00:00Z. expires_before matches
only entries that carry an expires_at, so a timestamp later than any date you issue returns all
of them, including credit already past its date that has not been reclaimed yet. If that comes
back empty, no expiry date is at risk and the recipe is safe.
To try it on a sandbox account: pick a customer holding no other credit in that currency, issue a
credit with an expires_at a couple of minutes ahead, finalize an invoice after that time and
see it not apply, then check the customer’s credit notes a little later for the credit_expired
entry. If expires_at is refused with a 422 reading “expires_at is not enabled on this
environment yet”, it has not been switched on for your environment.
Webhook events
The subscribable webhook for credit activity is customer.updated. It fires whenever a
customer’s cached credit balance is refreshed: after credit is issued, applied, voided, reduced,
or reclaimed at expiry.
To track credit changes, subscribe to customer.updated and read credit_balances from the
customer object on the API. The webhook payload is the stored customer row, so data.object
carries the raw credit_balance cache ({currency: amount_atom}), not the credit_balances
array.
A customer.updated on its own does not mean the balance moved: the reclaim pass refreshes the
cache for every credit note it closes out, including one that was already fully used. Compare the
balance against what you last stored.
Credit-note ledger writes also emit credit_note.created. A credit note that reaches its
expires_at still holding unspent value emits credit_note.expired, whether or not there was
a balance left to reclaim; one that was already fully used is closed out silently (see
Expiring credit). Both events are individually subscribable, as is the
prefix credit_note.*. An endpoint subscribed to all events (*) receives them too.
credit_note.created fires in both directions
Every ledger write emits credit_note.created — including the writes that take credit away.
Subscribe to it and you receive both directions on the same event name. It is also visible in the
account’s event log (GET /api/{account_id}/events/), and the same rule applies to what you read
back from
GET credit-notes: reducing a balance appends an entry just as issuing one does, so the event name
alone never tells you which happened. Read the entry before assuming credit was granted.
The sign of amount_atom is the reliable signal. reason narrows it but does not settle it:
manual_adjustment is written in both directions, and a void_reversal can go either way. Only
expiry_clawback is exclusive to a reduction — the reduce endpoint is the only place that accepts
it, and issuing a credit note with that reason is rejected.
A reduction writes only the ledger entry. Unlike issuing a credit note with no invoice_id, it
creates no companion invoice, so no invoice.created accompanies it.