Factuarea APIDevelopers

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:

Register it — POST /v1/invoices/{id}/payments.
Read the balance on the invoice — paid_amount / pending_amount, or the ledger sub-resource.
Revert it if the money came back — 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:

FieldTypeRequiredNotes
amountnumberYesGreater than 0. Cannot exceed pending_amount.
paid_onstring (YYYY-MM-DD)YesThe date the money was received.
payment_methodstring (enum)YesOne of the catalog values (see below).
referencestringNoYour own reference (e.g. a transfer number).
notesstringNoFree 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 in payments.detail, but it no longer adds to paid_amount.
  • payments.total / payments.pending — the same two figures, mirrored inside the payments object. 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 [] (while total and pending stay 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.

FieldTypeRequiredNotes
reasonstring (enum)YesOne of the five values in the closed catalog below.
notestringNoFree-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?).

reasonWhen to use it
direct_debit_returnThe SEPA direct debit was returned by the customer's bank.
card_disputeThe card payment was charged back or reversed after a dispute.
misapplied_paymentThe money arrived, but it was booked against the wrong invoice.
bounced_effectA bill or promissory note was dishonoured at maturity.
recording_errorThe 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:

  • overdue if its due date has already passed,
  • sent otherwise,
  • 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

StatusCodeMeaning
422payment_reversal_reason_invalidreason is outside the closed catalog (param: "reason").
422payment_reversal_invalidnote is invalid — e.g. longer than 500 characters (param: "note").
422payment_already_reversedThe payment was already reverted.
404resource_not_foundUnknown 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 sent or overdue, 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 happenedReverting a paymentCorrective invoice
Returned SEPA direct debitdirect_debit_return
Card chargeback / disputecard_dispute
Dishonoured billbounced_effect
Payment booked on the wrong invoicemisapplied_payment
Entry that never matched real moneyrecording_error
Genuine refund to the customerYes
Post-sale discount, returned goodsYes
Wrong amount, wrong tax, wrong customerYes

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 origingateway 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}/payments returns 201 with the created payment under data (object purchase_invoice_payment), not the full invoice.
  • GET /v1/purchase_invoices/{id}/payments returns { "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), and payment_method here is a free string (max 30 chars), not the closed enum used on the sales side.
FieldTypeRequiredNotes
amountnumberYesGreater than 0. Cannot exceed the pending amount.
paid_onstring (YYYY-MM-DD)YesBetween the issue date and today.
payment_methodstringYesFree text, max 30 chars.
bank_account_idintegerNoBank account the payment was made from.
referencestringNoYour own reference.
notesstringNoFree 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

  • 422 payment_exceeds_pending_amount — the amount is larger than the outstanding balance (param: "amount"). This is a business-rule violation, so it is 422, never 409.
  • 422 payment_reversal_reason_invalid — the reversal reason is outside the closed catalog (param: "reason").
  • 422 payment_reversal_invalid — the reversal note is invalid, e.g. over 500 characters (param: "note").
  • 422 payment_already_reversed — that payment was already reverted; register a new one instead of undoing the reversal.
  • 409 on a payment POST is reserved for the standard idempotency / conflict envelope (a reused Idempotency-Key with a different body, or a concurrency conflict) — not for the payment data itself.

See Errors for the full envelope and code catalog.

On this page

Need a hand?Contact support