Recording payments
Register partial payments, read the running balance from the ledger, and revert a payment that came back — without ever issuing a corrective invoice you did not mean to.
Invoices and purchase invoices keep a payment ledger: a list of
individual payments, each with its own amount, date and method. Register
payments one at a time as the money comes in — the API recomputes the
paid and pending amounts after every entry, and the invoice reports
itself as paid once the balance reaches zero.
The ledger is append-only. Money that came in and then went back out (a returned direct debit, a card chargeback, a payment booked against the wrong invoice) is not erased: the entry is reverted, keeping its amount, date, method and reference, and gaining a reason, an instant and an author. A reverted payment stops counting, so the invoice goes back into the collection circuit.
The invoice status you read is derived from the ledger, not stored:
paid once the payments in force cover the amount due, partially_paid
while they cover part of it, and otherwise the document's own status
(sent, or overdue once the due date passes). Registering or reverting a
payment changes it on the very next read — there is no synchronisation step
that can fall behind.
The two amounts behind it are paid_amount (the sum of the payments in
force) and pending_amount (amount due − paid_amount).
The full cycle of one sale payment is therefore:
POST /v1/invoices/{id}/payments.paid_amount / pending_amount, or the ledger sub-resource.POST /v1/invoices/{id}/payments/{payment_id}/reversal. The invoice returns to sent or overdue and accepts a new payment.Register a sale payment
POST /v1/invoices/{id}/payments adds one payment to a sales invoice.
The body is small:
| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | Yes | Greater than 0. Cannot exceed pending_amount. |
paid_on | string (YYYY-MM-DD) | Yes | The date the money was received. |
payment_method | string (enum) | Yes | One of the catalog values (see below). |
reference | string | No | Your own reference (e.g. a transfer number). |
notes | string | No | Free internal note. |
payment_method is a closed enum of seven values: bank_transfer,
direct_debit, cash, credit_card, check, paypal, other. Fetch
the labelled catalog from GET /v1/payment-methods
instead of hardcoding them.
The response is 201 Created with the freshly created payment under
data:
{
"data": {
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b",
"object": "payment",
"invoice_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"amount": 500.00,
"payment_date": "2026-05-20",
"payment_method": "bank_transfer",
"payment_method_text": "Transferencia bancaria",
"reference": "TRF-2026-0042",
"notes": null,
"is_reversed": false,
"reversed_at": null,
"reversal_reason": null,
"reversal_reason_text": null,
"reversal_note": null,
"created_at": "2026-05-20T10:30:00Z",
"updated_at": "2026-05-20T10:30:00Z"
}
}The five revers* fields describe the state of that entry in the ledger.
A payment in force reports is_reversed: false and null in the other
four; see Reverting a payment.
import os, requests
resp = requests.post(
'https://api.factuarea.com/v1/invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01/payments',
json={
'amount': 500.00,
'paid_on': '2026-05-20',
'payment_method': 'bank_transfer',
'reference': 'TRF-2026-0042',
},
headers={'Authorization': f"Bearer {os.environ['FACTUAREA_API_KEY']}"},
)
resp.raise_for_status()
payment = resp.json()['data']
print(payment['id'], payment['amount'])const res = await fetch(
'https://api.factuarea.com/v1/invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01/payments',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FACTUAREA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 500.0,
paid_on: '2026-05-20',
payment_method: 'bank_transfer',
reference: 'TRF-2026-0042',
}),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json();
console.log(data.id, data.amount);curl -s -X POST \
https://api.factuarea.com/v1/invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01/payments \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 500.00,
"paid_on": "2026-05-20",
"payment_method": "bank_transfer",
"reference": "TRF-2026-0042"
}' | jq '.data'Partial payments & balance
The running balance does not live on the payment object — it lives on
the invoice. After registering one or more payments, read the invoice
(GET /v1/invoices/{id}) to see where it stands:
{
"data": {
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"object": "invoice",
"status": "sent",
"total": 1210.00,
"paid_amount": 500.00,
"pending_amount": 710.00,
"payments": {
"detail": [
{
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b",
"object": "payment",
"invoice_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"amount": 500.00,
"payment_date": "2026-05-20",
"payment_method": "bank_transfer",
"payment_method_text": "Transferencia bancaria",
"reference": "TRF-2026-0042",
"notes": null,
"is_reversed": false,
"reversed_at": null,
"reversal_reason": null,
"reversal_reason_text": null,
"reversal_note": null,
"created_at": "2026-05-20T10:30:00Z",
"updated_at": "2026-05-20T10:30:00Z"
}
],
"total": 500.00,
"pending": 710.00
}
}
}paid_amount/pending_amount— the collected and outstanding totals. Always present, computed from the payments in force: a reverted entry is still inpayments.detail, but it no longer adds topaid_amount.payments.total/payments.pending— the same two figures, mirrored inside thepaymentsobject. Always present.payments.detail— the array of individual payments. Materialised only on the show endpoint (GET /v1/invoices/{id}); in list endpoints it comes back as[](whiletotalandpendingstay populated) to keep listings cheap. Use the sub-resource for the detail on its own.
Once the last payment closes the balance (pending_amount reaches 0),
the invoice reports status: "paid".
A payment whose amount is greater than pending_amount is rejected
with 422 and subcode: "payment_exceeds_pending_amount"
(param: "amount"). A payment exactly equal to the pending amount is
valid and settles the invoice. See Errors.
List payments
GET /v1/invoices/{id}/payments returns the full ledger of one invoice,
ordered by payment date. An invoice with no payments returns
{ "data": [] } — never a 404.
{
"data": [
{
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b",
"object": "payment",
"invoice_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"amount": 500.00,
"payment_date": "2026-05-20",
"payment_method": "bank_transfer",
"payment_method_text": "Transferencia bancaria",
"reference": "TRF-2026-0042",
"notes": null,
"is_reversed": false,
"reversed_at": null,
"reversal_reason": null,
"reversal_reason_text": null,
"reversal_note": null,
"created_at": "2026-05-20T10:30:00Z",
"updated_at": "2026-05-20T10:30:00Z"
}
]
}Reverted payments stay in this list. Their absence is never the
signal — the signal is is_reversed: true. Code that detects a
reversal by an entry vanishing from the ledger will never fire, because
nothing is ever deleted. Filter on the flag, and sum only the entries
with is_reversed: false if you reconcile the balance yourself.
Reverting a payment
POST /v1/invoices/{id}/payments/{payment_id}/reversal voids one payment
of a sales invoice and states why. The payment is not deleted: a
payment that existed and stopped having effect is accounting information,
and its trace (reason, instant, author) is what explains why the invoice
stopped being collected.
| Field | Type | Required | Notes |
|---|---|---|---|
reason | string (enum) | Yes | One of the five values in the closed catalog below. |
note | string | No | Free-text remark, max 500 characters. |
The response is 200 OK with the already reverted payment under
data — the same InvoicePaymentDetail shape as the create and list
endpoints:
{
"data": {
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b",
"object": "payment",
"invoice_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"amount": 500.00,
"payment_date": "2026-05-20",
"payment_method": "direct_debit",
"payment_method_text": "Domiciliación bancaria",
"reference": "TRF-2026-0042",
"notes": null,
"is_reversed": true,
"reversed_at": "2026-06-02T08:15:00Z",
"reversal_reason": "direct_debit_return",
"reversal_reason_text": "Devolución de adeudo SEPA",
"reversal_note": "Devuelto por el banco con motivo MD01.",
"created_at": "2026-05-20T10:30:00Z",
"updated_at": "2026-06-02T08:15:00Z"
}
}The reason catalog is closed
reason is required and only these five values are accepted. There is no
refund value on purpose — a genuine refund is a corrective invoice,
not a reversal (see Reversal or corrective invoice?).
reason | When to use it |
|---|---|
direct_debit_return | The SEPA direct debit was returned by the customer's bank. |
card_dispute | The card payment was charged back or reversed after a dispute. |
misapplied_payment | The money arrived, but it was booked against the wrong invoice. |
bounced_effect | A bill or promissory note was dishonoured at maturity. |
recording_error | The entry was a mistake: it never corresponded to real money. |
Anything else returns 422 payment_reversal_reason_invalid with
param: "reason".
What happens to the invoice
The reverted amount stops counting towards paid_amount,
pending_amount and every treasury aggregate, so a paid invoice goes
back into the collection circuit on the next read:
overdueif its due date has already passed,sentotherwise,- and it accepts a new payment again.
There is no way to "un-pay" an invoice by hand: paid is a reading of the
ledger, not a status you can write. The generic status-change endpoint does
not accept it as a destination, precisely so the paid balance and the
ledger can never disagree. Reverting a payment is the only way back.
import os, requests
resp = requests.post(
'https://api.factuarea.com/v1/invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01'
'/payments/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b/reversal',
json={
'reason': 'direct_debit_return',
'note': 'Devuelto por el banco con motivo MD01.',
},
headers={'Authorization': f"Bearer {os.environ['FACTUAREA_API_KEY']}"},
)
resp.raise_for_status()
payment = resp.json()['data']
print(payment['is_reversed'], payment['reversal_reason'])const res = await fetch(
'https://api.factuarea.com/v1/invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01' +
'/payments/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b/reversal',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FACTUAREA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
reason: 'direct_debit_return',
note: 'Devuelto por el banco con motivo MD01.',
}),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json();
console.log(data.is_reversed, data.reversal_reason);curl -s -X POST \
https://api.factuarea.com/v1/invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01/payments/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b/reversal \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "direct_debit_return",
"note": "Devuelto por el banco con motivo MD01."
}' | jq '.data | {is_reversed, reversal_reason, reversal_reason_text}'Irreversible. There is no un-revert: a second reversal of the same
payment returns 422 payment_already_reversed, because overwriting
the first trace would erase the record of why the invoice stopped being
collected. If the money came back in, register a new payment.
The path is nested on purpose: over the v1 API a sale payment only exists
hanging from its invoice. Crossing an invoice and a payment that do not
belong together returns 404 — not 422 — and so does a payment of
another company: the three cases collapse into the same response without
revealing which one it was.
Reversal errors
| Status | Code | Meaning |
|---|---|---|
422 | payment_reversal_reason_invalid | reason is outside the closed catalog (param: "reason"). |
422 | payment_reversal_invalid | note is invalid — e.g. longer than 500 characters (param: "note"). |
422 | payment_already_reversed | The payment was already reverted. |
404 | resource_not_found | Unknown invoice or payment, mismatched pair, or another company's data. |
The reversal needs the invoices:write scope — the same one that
registers a payment. There is no payments:* scope family: over the
public API a sale payment is part of its invoice, and the credential that
can collect an invoice is the one that can undo that collection.
Reversal or corrective invoice?
This is the decision that matters, and getting it wrong has fiscal consequences. Reverting a payment is not issuing a corrective invoice.
- The money came back, the operation did not shrink — a returned
direct debit, a card chargeback, a dishonoured bill, a payment booked
against the wrong invoice. The customer has their money back, but you
still sold what you sold and you are still owed it. Revert the
payment: the debt survives, the invoice returns to
sentoroverdue, and it shows up in your outstanding balance again. Revenue does not change, so there is nothing to correct. - The operation itself shrank — a genuine refund, a discount granted
after the fact, a returned product, a wrong amount on the invoice. Now
revenue really does go down, and that is what a corrective invoice
records (
POST /v1/invoices/{id}/corrective). The original invoice keeps its collected payment; the corrective is the document that reduces the taxable base.
| What happened | Reverting a payment | Corrective invoice |
|---|---|---|
| Returned SEPA direct debit | direct_debit_return | — |
| Card chargeback / dispute | card_dispute | — |
| Dishonoured bill | bounced_effect | — |
| Payment booked on the wrong invoice | misapplied_payment | — |
| Entry that never matched real money | recording_error | — |
| Genuine refund to the customer | — | Yes |
| Post-sale discount, returned goods | — | Yes |
| Wrong amount, wrong tax, wrong customer | — | Yes |
A reversal never issues a corrective invoice, and it never touches the VeriFactu register: the invoice you sent is still the invoice you sent. Only its collection changed.
A returned receipt is not a bad debt. Reverting a payment does not reduce your output VAT and is not the bad-debt route of art. 80.Cuatro LIVA, which has its own formal requirements — a court claim or a notarial demand, its own deadlines, and a filing with the tax authority. An integration that reacts to a return by issuing a corrective will declare a reduction in revenue that never happened.
The payment.reversed event
Every reversal emits payment.reversed, so an integration finds out
that a collection came undone without polling. The payload carries the
reverted payment under data.object plus a data.reversal block with the
reason and the origin — gateway when the payment provider reported
the return, dispute or chargeback (real money already moved at the bank),
manual when a person recorded it.
Subscribe to it wherever you already react to payment.received: an
invoice you marked as collected can stop being collected, and until this
event existed there was no way to hear about it. See
Events for the payload and
Webhooks for delivery and signature.
Purchase invoice payments
Purchase invoices keep their own ledger (total_retention for IRPF
withholding lives on the purchase invoice resource). The contract is
asymmetric to the sales side — read it carefully before reusing code:
POST /v1/purchase_invoices/{id}/paymentsreturns201with the created payment underdata(objectpurchase_invoice_payment), not the full invoice.GET /v1/purchase_invoices/{id}/paymentsreturns{ "data": [...] }, newest first.- There is no reversal endpoint on the purchase side: reverting is a sales-invoice operation, because what it restores is a debt owed to you.
- The body adds an optional
bank_account_id(integer), andpayment_methodhere is a free string (max 30 chars), not the closed enum used on the sales side.
| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | Yes | Greater than 0. Cannot exceed the pending amount. |
paid_on | string (YYYY-MM-DD) | Yes | Between the issue date and today. |
payment_method | string | Yes | Free text, max 30 chars. |
bank_account_id | integer | No | Bank account the payment was made from. |
reference | string | No | Your own reference. |
notes | string | No | Free internal note. |
{
"data": {
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0c",
"object": "purchase_invoice_payment",
"amount": 423.50,
"paid_on": "2026-05-21",
"payment_method": "transferencia",
"bank_account_id": 12,
"reference": "TRF-2026-0099",
"notes": null,
"created_at": "2026-05-21T09:00:00Z"
}
}import os, requests
resp = requests.post(
'https://api.factuarea.com/v1/purchase_invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a05/payments',
json={
'amount': 423.50,
'paid_on': '2026-05-21',
'payment_method': 'transferencia',
'bank_account_id': 12,
},
headers={'Authorization': f"Bearer {os.environ['FACTUAREA_API_KEY']}"},
)
resp.raise_for_status()
print(resp.json()['data']['id'])const res = await fetch(
'https://api.factuarea.com/v1/purchase_invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a05/payments',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FACTUAREA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 423.5,
paid_on: '2026-05-21',
payment_method: 'transferencia',
bank_account_id: 12,
}),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json();
console.log(data.id);curl -s -X POST \
https://api.factuarea.com/v1/purchase_invoices/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a05/payments \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 423.50,
"paid_on": "2026-05-21",
"payment_method": "transferencia",
"bank_account_id": 12
}' | jq '.data'Purchase-invoice payment rules (BR-PUR-019) are enforced as 422: an
amount above the pending balance
(subcode: "payment_exceeds_pending_amount"), a date outside
issue_date … today (subcode: "invalid_payment_date"), or a payment on
a cancelled invoice (subcode: "purchase_invoice_not_payable").
Payment methods
GET /v1/payment-methods returns the closed catalog backing the sales
payment_method field, each with a value and a human label (Spanish).
It is a global enum catalog — not tenant-specific.
{
"data": [
{ "value": "bank_transfer", "label": "Transferencia bancaria" },
{ "value": "direct_debit", "label": "Domiciliación bancaria" },
{ "value": "cash", "label": "Efectivo" },
{ "value": "credit_card", "label": "Tarjeta de crédito" },
{ "value": "check", "label": "Cheque" },
{ "value": "paypal", "label": "PayPal" },
{ "value": "other", "label": "Otro" }
]
}Read it once at startup and present the labels in your UI; send the
value back in payment_method.
Errors
422payment_exceeds_pending_amount— the amount is larger than the outstanding balance (param: "amount"). This is a business-rule violation, so it is422, never409.422payment_reversal_reason_invalid— the reversalreasonis outside the closed catalog (param: "reason").422payment_reversal_invalid— the reversalnoteis invalid, e.g. over 500 characters (param: "note").422payment_already_reversed— that payment was already reverted; register a new one instead of undoing the reversal.409on a paymentPOSTis reserved for the standard idempotency / conflict envelope (a reusedIdempotency-Keywith a different body, or a concurrency conflict) — not for the payment data itself.
See Errors for the full envelope and code catalog.