Skip to navigation

Credit notes & credit balance

Hold a credit balance for a customer and apply it automatically to future invoices.
View as Markdown

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

ConceptWhat it is
Credit noteA single ledger entry: credit issued, applied to an invoice, or voided.
Credit balanceThe sum of all credit notes for a customer in a given currency.

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.

  1. Open the Customer
  2. Open the actions menu and choose Change invoice balance
  3. Pick the currency, choose Credit or Debit, and enter the amount
  4. Click Apply balance adjustment

The amount comes pre-filled with the customer’s current balance. What you enter replaces it.

FieldTypeDescription
amount_atomintegerThe target balance in the smallest currency unit (e.g. cents). A positive value is credit owed to the customer. A negative value is a debit: PaymentKit adds it to the amount due when the customer’s next invoice is finalized.
currencystringCurrency code (e.g. USD), accepted in any case. Each currency’s balance is independent.

The response is the resulting balance. Currency codes come back lowercase:

{ "amount_atom": 5000, "currency": "usd" }

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.

curl -X POST https://app.paymentkit.com/api/{account_id}/customers/{customer_id}/credit-balance/reduce \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: expiry-sweep-2026-06" \
-d '{
"amount_atom": 2500,
"currency": "USD",
"reason": "expiry_clawback",
"memo": "Unused promo credit from the May campaign"
}'
FieldTypeDescription
amount_atomintegerHow much credit to remove, in the smallest currency unit. Must be greater than 0 — this endpoint only reduces.
currencystringCurrency of the balance to reduce (e.g. USD). Only the named currency is touched.
reasonstringOne of expiry_clawback, manual_adjustment.
memostringOptional human-readable note (max 500 chars).
expected_balance_atomintegerOptional precondition. The reduction is applied only if the balance in this currency still reads as exactly this amount; otherwise the request is refused with 409 and nothing is written.

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:

{
"id": "cn_live_1a2b3c4d5e6f7g8h",
"customer_id": "cus_live_...",
"invoice_id": null,
"amount_atom": -2500,
"currency": "usd",
"type": "voided",
"reason": "expiry_clawback",
"memo": "Unused promo credit from the May campaign",
"created_at": "2026-06-17T14:00:00Z",
"updated_at": "2026-06-17T14:00:00Z",
"balance_after_atom": 7500
}

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

Statuserror_codeWhat happened
409credit_balance_precondition_failedexpected_balance_atom no longer matches. The body also carries current_balance_atom, expected_balance_atom and currency. Nothing was written — decide again from the current figure and retry with a new Idempotency-Key.
409credit_balance_insufficientThe reduction would take the balance below zero. The body carries current_balance_atom, requested_amount_atom and currency; nothing was written.
409noneA request with the same Idempotency-Key is still in flight (detail is a plain string). The original may yet succeed, so do not retry with a new key; poll GET credit-notes instead.
404noneNo customer with this id in this account.
422noneThe request itself was malformed — including an Idempotency-Key shorter than 8 characters. detail is the string Validation failed and the field errors sit in a top-level errors array.
{
"type": "about:blank",
"title": "Credit Balance Insufficient",
"status": 409,
"detail": "Cannot reduce a credit balance below zero. The customer holds 1000 and you asked to remove 2500.",
"instance": "https://app.paymentkit.com/api/acc_live_.../customers/cus_live_.../credit-balance/reduce",
"request_id": "be_prod_...",
"error_code": "credit_balance_insufficient",
"current_balance_atom": 1000,
"requested_amount_atom": 2500,
"currency": "usd"
}

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.

curl -X POST https://app.paymentkit.com/api/{account_id}/customers/{customer_id}/credit-notes \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: credit-overcharge-918" \
-d '{
"amount_atom": 2500,
"currency": "USD",
"reason": "manual_adjustment",
"memo": "Goodwill credit for billing error"
}'
FieldTypeDescription
amount_atomintegerCredit amount in the smallest currency unit. Must be greater than 0.
currencystringCurrency code (e.g. USD).
reasonstringOne of proration_excess, manual_adjustment. Any other reason is rejected with 422.
memostringOptional human-readable note (max 500 chars).
invoice_idstringOptionally link the note to an existing invoice. The invoice must exist and belong to this customer, or the request is refused with a 404. If omitted, a fully-paid companion invoice is created to back the credit.
expires_atstringOptional. An ISO 8601 timestamp with a timezone offset, in the future. From that instant the credit can no longer be applied to invoices, and PaymentKit reclaims whatever is still unused. Omit for credit that never expires — the default. See Expiring credit.

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

{
"id": "cn_live_1a2b3c4d5e6f7a8b",
"customer_id": "cus_live_...",
"invoice_id": "in_live_...",
"amount_atom": 2500,
"currency": "USD",
"type": "issued",
"reason": "manual_adjustment",
"memo": "Goodwill credit for billing error",
"created_at": "2026-06-17T14:00:00Z",
"updated_at": "2026-06-17T14:00:00Z",
"expires_at": null,
"expired_at": null,
"remaining_atom": null
}
FieldDescription
idCredit note identifier, prefixed cn_.
expires_atWhen this credit stops being spendable. null for credit that never expires.
expired_atWhen PaymentKit closed this credit out after its expires_at passed — whether or not there was anything left to reclaim. null until then, and always null for credit with no expires_at.
remaining_atomHow much of this credit is still unused. Not the same as spendable: between an expires_at passing and PaymentKit reclaiming the credit, this still reports a value the customer can no longer apply. It becomes 0 once PaymentKit closes the note out, whatever the reclaim was able to take. Reported only on entries that add credit and whose consumption PaymentKit has tracked since they were issued — every note with an expires_at, and every note issued after the customer first held expiring credit in that currency. It is null on everything else: applied and voided entries, debit_settlement entries (debt bookkeeping, not credit the customer can spend), and credit issued before PaymentKit began tracking for that customer and currency.
typeissued (credit added), applied (consumed by an invoice), or voided (the balance was reduced).
amount_atomPositive when credit is issued; negative when applied or voided.
invoice_idThe invoice the note is linked to, if any.
reasonWhy the note exists. Not every reason a read can return is one you can set — see the table below. Treat this field as an open set; new values may be added.

Reasons split three ways: some you set when issuing credit, some you set when reducing a balance, and some PaymentKit writes for itself.

ReasonSet onWhat it means
proration_excessPOST credit-notesCredit for a paid period the customer no longer gets. PaymentKit also writes it itself for the proration excess on a subscription downgrade.
manual_adjustmentPOST credit-notes and POST credit-balance/reduceA merchant-driven adjustment. The only reason you can set in either direction.
expiry_clawbackPOST credit-balance/reduce onlyCredit you granted, reclaimed because it went unused past its useful life — an expired referral bonus, a promotional credit pulled back. It describes credit being taken away, so it cannot be set when issuing a credit note.
auto_applyPaymentKit onlyAn invoice drew credit down from the balance.
debit_settlementPaymentKit onlyA negative (debit) balance was settled onto an invoice.
void_reversalPaymentKit onlyAn invoice that had consumed balance was voided, so the ledger was compensated.
credit_expiredPaymentKit onlyAn expiring grant reached its expires_at and PaymentKit reclaimed what was left. Distinct from expiry_clawback, which is you reclaiming credit by hand — so you can tell your own action apart from the platform’s.

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_reversal note with no expires_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 no expires_at for 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

curl "https://app.paymentkit.com/api/{account_id}/customers/{customer_id}/credit-notes?currency=USD" \
-H "Authorization: Bearer sk_live_..."

Pass an optional currency filter; results are paginated and sorted newest-first.

Two more filters help with expiring credit:

Query parameterEffect
unused_only=trueOnly entries that added credit and still have unused value: positive entries, whose consumption PaymentKit has tracked since they were issued (see remaining_atom in Expiring credit), that have not been closed out, and that have had less drawn from them than they were issued for. applied and voided entries never appear. Credit whose expires_at has passed but which PaymentKit has not yet reclaimed is still included.
expires_before=<timestamp>Only entries carrying an expires_at earlier than the timestamp. Entries with no expires_at are never returned, and credit that already expired and was reclaimed still is — pair with unused_only=true for what is still live. The timestamp must include a timezone offset, like 2026-10-01T00:00:00Z; without one the request is rejected with a 422.

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:

{
"id": "cus_live_...",
"credit_balances": [
{ "amount_atom": 5000, "currency": "USD" },
{ "amount_atom": 1200, "currency": "EUR" }
]
}

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:

  1. Invoice spending stops at the instant expires_at passes. An invoice finalized one second later will not draw on that credit, even before it has been reclaimed. Credit issued without an expires_at is unaffected.
  2. 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 sets expired_at on the original note, writes a voided credit note with reason credit_expired for whatever it reclaims, and emits a credit_note.expired event carrying issued_atom, remaining_before_atom (what the note still held), clawed_back_atom (what was actually taken), expires_at, expired_at and currency.
  3. The reclaim never takes more than the customer’s balance, and never takes credit that has not expired. So clawed_back_atom can be less than remaining_before_atom — including 0, 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 on clawed_back_atom, not on remaining_before_atom. When the reclaim takes 0 no credit_expired note is written at all — only expired_at is set.
  4. A note that was fully used before its date is closed out silently: expired_at is set, nothing is reclaimed, and no credit_note.expired event is emitted.
  5. remaining_atom tells you what is still unused on each expiring credit note. It is not the same as spendable: between expires_at passing and the reclaim it still reports a value the customer can no longer apply, and it reads 0 once 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&currency=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.

EventTrigger
customer.updatedCustomer details changed. A credit-balance refresh is one of the changes that fires it.

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.

SignalCredit addedCredit removed
amount_atomPositive.Negative.
typeissued.voided for a reduction or a reversal, applied when an invoice consumed the credit.
reasonproration_excess, manual_adjustment, debit_settlement, void_reversal.expiry_clawback, manual_adjustment, auto_apply, void_reversal.

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.