Charges and payments
The workspace ledger: what is owed, what arrived and how the second is allocated to the first.
Download the full API pública projection: openapi.json.
Charge and reconcile payments explains this resource in prose, with runnable examples.
| Method | Path | What it does |
|---|---|---|
GET | /contacts/{id}/ledger | Everything one person owes, has paid, and has in their favour |
GET | /obligations | What is owed, by state, contact, kind or group |
POST | /obligations | Record what somebody owes |
GET | /obligations/{id} | One cobro, its derived state and its allocation trail |
PATCH | /obligations/{id} | Adjust what is owed, its deadline or its label — with a reason |
POST | /obligations/{id}/cancel | Call off a cobro, and say what became of the money |
POST | /obligations/{id}/waive | Forgive what is owed, with a reason |
GET | /obligations/groups/{groupId} | A presupuesto, pack or plan and every line in it |
GET | /payments | Money that arrived — the Transferencias queue, or one contact |
POST | /payments | Record money by hand: cash, a card, a bono, a cheque |
GET | /payments/{id} | One payment with its full allocation trail |
POST | /payments/{id}/allocations | Allocate — or re-allocate — a payment across obligations |
DELETE | /payments/{id}/allocations/{allocationId} | Release one allocation, with a reason |
POST | /payments/{id}/assign-payer | Say whose money a payment is (or take the name off it) |
POST | /payments/{id}/reverse | Reverse a payment — the trio, and every allocation released |
GET /contacts/{id}/ledger
Everything one person owes, has paid, and has in their favour
payments:read — NOT contacts:read: what a person owes and has paid is strictly more than the ficha's identity surface.
THE ONE SHARED READ (§6.1): the ficha's Pagos tab, the agenda drawer and the agent's bounded read tool all consume this shape. credit_clp is the unallocated surplus (Σ live payments − Σ live allocations) and is NEVER a column; outstanding_clp sums the open obligations. It reads the WHOLE set and does not page, deliberately — a paged read would compute a credit that is too small, and do it silently.
pending_verification is EVIDENCE, NOT MONEY: payments whose payer is only a SUGGESTION for this contact. The platform has seen something arrive and has not attributed it, and stating it as received would be the platform confirming a transfer nobody has matched.
EVERY payments[] ROW CARRIES ITS CARD RECARGO — card_surcharge_clp (what the customer paid ON TOP, so the screen can draw «Recargo tarjeta $X»), card_fee_bps (the rate it was frozen at) and card_surcharge_source (calculado = that rate applied, ajustado = a figure an operator typed). ⚠ IT IS NOT PART OF amount_clp AND NOT PART OF ANY TOTAL HERE: credit_clp, allocated_clp and outstanding_clp are all net of it, because the recargo covers the card machine and settles nothing. A screen that wants what the terminal charged adds the two itself; a screen that wants what was paid towards the cobro uses amount_clp alone.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
today | query | string | no | patrón ^\d{4}-\d{2}-\d{2}$ |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/ledger \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"credit_clp": 0,
"outstanding_clp": 296000,
"obligations": [
{
"id": "b44936d0-0000-4000-8000-000000000004",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Ortodoncia — primera cuota",
"expected_clp": 108000,
"allocated_clp": 50000,
"outstanding_clp": 58000,
"overpaid_clp": 0,
"state": "partial",
"provenance": "manual",
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": "2026-10-10",
"closed_at": null,
"closed_reason": null,
"group_id": null,
"source": "staff",
"document": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000001",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000001",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 50000,
"method": "staff",
"reasons": [
"allocation_staff_manual"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:16.990Z",
"released_at": null,
"release_reason": null,
"live": true
}
]
},
{
"id": "b44936d0-0000-4000-8000-000000000003",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Limpieza dental",
"expected_clp": 30000,
"allocated_clp": 0,
"outstanding_clp": 30000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": null,
"closed_reason": null,
"group_id": "a067dce6-0000-4000-8000-000000000002",
"source": "staff",
"document": null,
"allocations": []
},
{
"id": "b44936d0-0000-4000-8000-000000000002",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Control de ortodoncia",
"expected_clp": 112000,
"allocated_clp": 0,
"outstanding_clp": 112000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": null,
"closed_reason": null,
"group_id": "a067dce6-0000-4000-8000-000000000002",
"source": "staff",
"document": null,
"allocations": []
}
],
"payments": [
{
"id": "e86256b2-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_recorded_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "transferencia",
"amount_clp": 50000,
"paid_on": "2026-09-24",
"allocated_clp": 50000,
"unallocated_clp": 0,
"bank": "Banco de Chile",
"account_number": null,
"document_number": "884213",
"operation_id": null,
"note": "Abono de la primera cuota",
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": null,
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:16.954Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000001",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000001",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 50000,
"method": "staff",
"reasons": [
"allocation_staff_manual"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:16.990Z",
"released_at": null,
"release_reason": null,
"live": true
}
]
}
],
"pending_verification": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /obligations
What is owed, by state, contact, kind or group
payments:read. ⚠ state MIXES A COLUMN AND A DERIVATION. cancelled and waived are stored; open is the stored status; pending, partial, paid, overpaid, overdue and overdue_partial are COMPUTED from allocations and exist nowhere in the schema — nothing sweeps them, so a late payment always finds a live obligation to settle. The server pushes down what SQL can answer and applies the derived half over a bounded window, reported as meta.derived_filter_window.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
contact_id | query | uuid | no | |
state | query | pending \ | partial \ | paid \ |
kind | query | appointment_deposit \ | service \ | pack \ |
group_id | query | uuid | no | |
limit | query | integer | no | ≥ 1, ≤ 200, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/obligations \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "b44936d0-0000-4000-8000-000000000004",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Ortodoncia — primera cuota",
"expected_clp": 112000,
"allocated_clp": 0,
"outstanding_clp": 112000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": "2026-10-10",
"closed_at": null,
"closed_reason": null,
"group_id": null,
"source": "staff",
"document": null,
"allocations": []
},
{
"id": "b44936d0-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "pack",
"direction": "receivable",
"label": "Pack 4 controles de ortodoncia",
"expected_clp": 96000,
"allocated_clp": 0,
"outstanding_clp": 96000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": null,
"closed_reason": null,
"group_id": "a067dce6-0000-4000-8000-000000000001",
"source": "staff",
"document": null,
"allocations": []
},
{
"id": "b44936d0-0000-4000-8000-000000000002",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Control de ortodoncia",
"expected_clp": 112000,
"allocated_clp": 0,
"outstanding_clp": 112000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": null,
"closed_reason": null,
"group_id": "a067dce6-0000-4000-8000-000000000002",
"source": "staff",
"document": null,
"allocations": []
}
],
"meta": {
"pagination": {
"limit": 50,
"offset": 0,
"total": 4
},
"derived_filter_window": 500
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /obligations
Record what somebody owes
payments:write. The variable-amount case staff type by hand — "Tratamiento conducto, $80.000, vence en 7 días". label is FROZEN at creation (ADR 0057): renaming a servicio next month must not rewrite what this patient was quoted. The three document-anchored kinds require their document, and writing one twice for the same document yields ONE expectation — a partial unique index, not a convention.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
contact_id | uuid | yes | |
kind | appointment_deposit \ | service \ | pack \ |
expected_clp | integer | yes | ≥ 1, ≤ 999999999999 |
label | string | yes | mín. 1, máx. 280 |
opened_on | string | — | patrón ^\d{4}-\d{2}-\d{2}$ |
due_on | string | null | — | patrón ^\d{4}-\d{2}-\d{2}$ |
group_id | string | null | — | |
appointment_id | string | null | — | |
reservation_id | string | null | — | |
sale_note_id | string | null | — |
curl -X POST https://api.vitrinadev.com/api/v1/obligations \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"expected_clp": 112000,
"label": "Ortodoncia — primera cuota",
"due_on": "2026-10-10"
}'Example response (201)
{
"data": {
"id": "b44936d0-0000-4000-8000-000000000004",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Ortodoncia — primera cuota",
"expected_clp": 112000,
"allocated_clp": 0,
"outstanding_clp": 112000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": "2026-10-10",
"closed_at": null,
"closed_reason": null,
"group_id": null,
"source": "staff",
"document": null,
"allocations": []
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
GET /obligations/{id}
One cobro, its derived state and its allocation trail
payments:read. state and outstanding_clp are computed on read; provenance rides alongside the state so the badge keeps ADR 0086's split after the rename of verified to paid — green means a bank said so, violet means a human did, and one hand-asserted peso in the total grades the whole figure down to manual.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
today | query | string | no | patrón ^\d{4}-\d{2}-\d{2}$ |
curl https://api.vitrinadev.com/api/v1/obligations/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "b44936d0-0000-4000-8000-000000000004",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Ortodoncia — primera cuota",
"expected_clp": 112000,
"allocated_clp": 0,
"outstanding_clp": 112000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": "2026-10-10",
"closed_at": null,
"closed_reason": null,
"group_id": null,
"source": "staff",
"document": null,
"allocations": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /obligations/{id}
Adjust what is owed, its deadline or its label — with a reason
payments:write. ⚠ expected_clp IS MUTABLE AND THAT IS CORRECT: the clinic quoted $15.000 for the evaluación, the patient chose two sessions, the expectation becomes $80.000 — and the allocations already made are untouched and still valid, because only the DERIVED outstanding changes. What must not happen is silently: reason is required and the change writes audit_log (obligations.adjust) in the SAME transaction, so a crash cannot separate the amount from why it moved. A closed cobro is a 409: its amount and its plazo are history.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
expected_clp | integer | — | ≥ 1, ≤ 999999999999 |
due_on | string | null | — | patrón ^\d{4}-\d{2}-\d{2}$ |
label | string | — | mín. 1, máx. 280 |
reason | string | yes | mín. 1, máx. 280 |
curl -X PATCH https://api.vitrinadev.com/api/v1/obligations/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"expected_clp": 108000,
"reason": "Se aplicó el descuento del convenio"
}'Example response (200)
{
"data": {
"id": "b44936d0-0000-4000-8000-000000000004",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Ortodoncia — primera cuota",
"expected_clp": 108000,
"allocated_clp": 0,
"outstanding_clp": 108000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": "2026-10-10",
"closed_at": null,
"closed_reason": null,
"group_id": null,
"source": "staff",
"document": null,
"allocations": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /obligations/{id}/cancel
Call off a cobro, and say what became of the money
payments:write. disposition is BR-266/267's vocabulary verbatim (aplicado | devuelto | perdido) and is ORTHOGONAL to the status: a cancelled reserva whose seña was KEPT is status: cancelled + disposition: perdido. Collapsing the two is how a dealership loses track of money it decided to keep. 409 when the cobro is already closed.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | yes | mín. 1, máx. 280 |
disposition | aplicado \ | devuelto \ | perdido |
curl -X POST https://api.vitrinadev.com/api/v1/obligations/<id>/cancel \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "La prestación no se realizó",
"disposition": "devuelto"
}'Example response (200)
{
"data": {
"id": "4fd0653c-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Control anulado",
"expected_clp": 9000,
"allocated_clp": 0,
"outstanding_clp": 0,
"overpaid_clp": 0,
"state": "cancelled",
"provenance": null,
"is_overdue": false,
"status": "cancelled",
"disposition": "devuelto",
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": "2026-09-22T22:21:17.176Z",
"closed_reason": "La prestación no se realizó",
"group_id": null,
"source": "staff",
"document": null,
"allocations": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /obligations/{id}/waive
Forgive what is owed, with a reason
payments:write. One of the three facts allocations cannot express, which is why status is a column at all. It does NOT release the pesos already applied: money that was applied was applied, the derivation stops counting the line as outstanding, and moving those pesos elsewhere is a release — its own verb, its own reason. 409 when the cobro is already closed.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | yes | mín. 1, máx. 280 |
curl -X POST https://api.vitrinadev.com/api/v1/obligations/<id>/waive \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Cortesía de la clínica por la espera"
}'Example response (200)
{
"data": {
"id": "03e03646-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "service",
"direction": "receivable",
"label": "Control extra",
"expected_clp": 12000,
"allocated_clp": 0,
"outstanding_clp": 0,
"overpaid_clp": 0,
"state": "waived",
"provenance": null,
"is_overdue": false,
"status": "waived",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": "2026-09-22T22:21:17.162Z",
"closed_reason": "Cortesía de la clínica por la espera",
"group_id": null,
"source": "staff",
"document": null,
"allocations": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /obligations/groups/{groupId}
A presupuesto, pack or plan and every line in it
payments:read. The group carries NO total: expected_clp, allocated_clp, outstanding_clp and the headline state are derived from its lines every time they are asked for, because a stored total is BR-249's defect with a friendlier name. Lines come back in the order money fills them (due_on ascending, deadline-less last).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
groupId | path | uuid | yes | |
today | query | string | no | patrón ^\d{4}-\d{2}-\d{2}$ |
curl https://api.vitrinadev.com/api/v1/obligations/groups/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a067dce6-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "pack",
"label": "Pack 4 controles de ortodoncia",
"status": "open",
"obligation_count": 1,
"expected_clp": 96000,
"allocated_clp": 0,
"outstanding_clp": 96000,
"state": "pending",
"obligations": [
{
"id": "b44936d0-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"kind": "pack",
"direction": "receivable",
"label": "Pack 4 controles de ortodoncia",
"expected_clp": 96000,
"allocated_clp": 0,
"outstanding_clp": 96000,
"overpaid_clp": 0,
"state": "pending",
"provenance": null,
"is_overdue": false,
"status": "open",
"disposition": null,
"opened_on": "2026-09-22",
"due_on": null,
"closed_at": null,
"closed_reason": null,
"group_id": "a067dce6-0000-4000-8000-000000000001",
"source": "staff",
"document": null,
"allocations": []
}
],
"created_at": "2026-09-22T22:21:14.760Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /payments
Money that arrived — the Transferencias queue, or one contact
payments:read. Without filters, the tenant's recent payments newest first. unapplied=true is the Transferencias queue: Σ live allocations < amount_clp, i.e. money we hold that is not fully spoken for — it covers all three shapes staff sees (no payer, a payer and no allocation, partly applied with a remainder), and unidentified=true narrows it to the first. Every row carries unallocated_clp, suggested_contact_id and payer_reasons (the matcher's stable codes, rendered as chips) plus its full allocation trail, released rows included — the Historial tab draws payment → allocations → obligations with no request per row. Reversed money is EXCLUDED unless include_reversed=true: reversed money is not money we hold.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
contact_id | query | uuid | no | |
unapplied | query | any | no | |
unidentified | query | any | no | |
include_reversed | query | any | no | |
from | query | string | no | patrón ^\d{4}-\d{2}-\d{2}$ |
to | query | string | no | patrón ^\d{4}-\d{2}-\d{2}$ |
limit | query | integer | no | ≥ 1, ≤ 200, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/payments \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "e86256b2-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_recorded_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "transferencia",
"amount_clp": 50000,
"paid_on": "2026-09-24",
"allocated_clp": 50000,
"unallocated_clp": 0,
"bank": "Banco de Chile",
"account_number": null,
"document_number": "884213",
"operation_id": null,
"note": "Abono de la primera cuota",
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": null,
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:16.954Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000001",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000001",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 50000,
"method": "staff",
"reasons": [
"allocation_staff_manual"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:16.990Z",
"released_at": null,
"release_reason": null,
"live": true
}
]
}
],
"meta": {
"pagination": {
"limit": 50,
"offset": 0,
"total": 1
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /payments
Record money by hand: cash, a card, a bono, a cheque
payments:write. Credits the ledger with provenance: "manual" — ADR 0086's distinction as a value: a human said so. verified means A BANK SAID SO and is unreachable through this door by construction (the database binds it to a comprobante or a gateway transaction).
WHAT IT IS FOR, in precedence order: an explicit allocations split is applied VERBATIM; obligation_id fills that one cobro up to its outstanding; otherwise the planner proposes the same split the matcher would have applied, and auto_allocate: false credits the money and applies nothing. Anything left over stays the contact's CREDIT — a first-class outcome, never an error. contact_id may be null: money whose payer is not yet known is recorded, not refused.
THE CARD RECARGO IS RESOLVED SERVER-SIDE. On a tarjeta_debito / tarjeta_credito payment the workspace’s configured rate for THAT card type (tenant.settings.card_fees — débito ≠ crédito) is applied and FROZEN on the row as card_fee_bps + card_surcharge_clp + card_surcharge_source: "calculado", and the response carries all three. Send neither field and that is what you get; send card_fee_bps to override the RATE for one swipe (0 is a rate and means a $0 recargo, never "unset"); send card_surcharge_clp to override the FIGURE, which freezes "ajustado". A workspace with no rate configured for that card type — and the legacy tarjeta member, whose card type nobody recorded — records NO recargo at all, never a $0 one. ⚠ THE RECARGO IS WHAT THE CUSTOMER PAYS ON TOP: it is NOT part of amount_clp and is allocated to nothing.
⚠ Idempotency-Key IS STORED ON THE ROW, not merely cached: a repeat returns 200 with the existing payment and meta.replayed: true instead of 409ing, because a receptionist who clicked twice asked one question. A first write answers 201. 409 when the split over-allocates the payment (error.details.unallocated_clp says what was left), when the cobro is closed, or when it belongs to somebody else.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
contact_id | string | null | — | |
source | manual \ | card_terminal \ | credit_note |
instrument | efectivo \ | transferencia \ | cheque \ |
amount_clp | integer | yes | ≥ 1, ≤ 999999999999 |
paid_on | string | yes | patrón ^\d{4}-\d{2}-\d{2}$ |
bank | string | null | — | máx. 120 |
account_number | string | null | — | máx. 120 |
document_number | string | null | — | máx. 120 |
note | string | null | — | máx. 2000 |
card_fee_bps | integer | null | — | ≥ 0, ≤ 10000 |
card_surcharge_clp | integer | null | — | ≥ 0, ≤ 999999999999 |
location_id | string | null | — | |
allocations | object[] | — | |
obligation_id | string | null | — | |
auto_allocate | boolean | — |
curl -X POST https://api.vitrinadev.com/api/v1/payments \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"instrument": "transferencia",
"amount_clp": 50000,
"paid_on": "2026-09-24",
"bank": "Banco de Chile",
"document_number": "884213",
"note": "Abono de la primera cuota",
"allocations": [
{
"obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 50000
}
]
}'Example response (201)
{
"data": {
"payment": {
"id": "e86256b2-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_recorded_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "transferencia",
"amount_clp": 50000,
"paid_on": "2026-09-24",
"allocated_clp": 50000,
"unallocated_clp": 0,
"bank": "Banco de Chile",
"account_number": null,
"document_number": "884213",
"operation_id": null,
"note": "Abono de la primera cuota",
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": null,
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:16.954Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000001",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000001",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 50000,
"method": "staff",
"reasons": [
"allocation_staff_manual"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:16.990Z",
"released_at": null,
"release_reason": null,
"live": true
}
]
},
"applied": [
{
"obligationId": "b44936d0-0000-4000-8000-000000000004",
"amountClp": 50000,
"allocatedClp": 50000,
"outstandingClp": 58000,
"expectedClp": 108000,
"state": "partial",
"label": "Ortodoncia — primera cuota"
}
],
"surplus_clp": 0,
"reasons": [
"payer_recorded_by_staff",
"allocation_staff_manual"
]
}
}Answers: 200 · 201 · 400 · 401 · 403 · 404 · 409 · 429
GET /payments/{id}
One payment with its full allocation trail
payments:read. Released allocations are INCLUDED and flagged live: false: the trail is the readable history of where a peso WAS applied, which is the question a dispute months later actually asks.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/payments/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "e86256b2-0000-4000-8000-000000000001",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_recorded_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "transferencia",
"amount_clp": 50000,
"paid_on": "2026-09-24",
"allocated_clp": 50000,
"unallocated_clp": 0,
"bank": "Banco de Chile",
"account_number": null,
"document_number": "884213",
"operation_id": null,
"note": "Abono de la primera cuota",
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": null,
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:16.954Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000001",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000001",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 50000,
"method": "staff",
"reasons": [
"allocation_staff_manual"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:16.990Z",
"released_at": null,
"release_reason": null,
"live": true
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /payments/{id}/allocations
Allocate — or re-allocate — a payment across obligations
payments:write. Writes one payment_allocation row per line, with method: "staff". Passing release makes it a RE-allocation: the named rows are released FIRST, in the same transaction, because nothing is ever edited in place — a split is changed by releasing and writing anew, so the history stays readable.
409 over_allocation when the lines exceed what is left on the payment (error.details.unallocated_clp carries the figure, so the screen can say «solo quedan $5.000 sin asignar»), obligation_not_open, contact_mismatch (one person's money never settles another's debt — a row Postgres refuses, not a rule a service remembers), payment_reversed, payer_unknown. Re-sending an identical split is a no-op, not a double-allocation.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
allocations | object[] | yes | |
release | object | — |
curl -X POST https://api.vitrinadev.com/api/v1/payments/<id>/allocations \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"allocations": [
{
"obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 20000
}
]
}'Example response (200)
{
"data": {
"payment": {
"id": "e86256b2-0000-4000-8000-000000000002",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_recorded_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "efectivo",
"amount_clp": 20000,
"paid_on": "2026-09-24",
"allocated_clp": 20000,
"unallocated_clp": 0,
"bank": null,
"account_number": null,
"document_number": null,
"operation_id": null,
"note": null,
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": "b1b1b1b1-0000-4000-8000-000000000002",
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:17.052Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000002",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000002",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 20000,
"method": "staff",
"reasons": [
"allocation_staff_accepted_plan",
"allocation_partial"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:17.057Z",
"released_at": null,
"release_reason": null,
"live": true
}
]
},
"applied": [
{
"obligationId": "b44936d0-0000-4000-8000-000000000004",
"amountClp": 20000,
"allocatedClp": 90000,
"outstandingClp": 18000,
"expectedClp": 108000,
"state": "partial",
"label": "Ortodoncia — primera cuota"
}
],
"surplus_clp": 0,
"reasons": [
"allocation_staff_manual"
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /payments/{id}/allocations/{allocationId}
Release one allocation, with a reason
payments:write. Stamps released_at / released_by / release_reason; the row SURVIVES. reason is required (the table requires it) — money un-applied with no stated reason hands the next person the same question with no more information than the first person had. 409 already_released, 404 for an allocation that belongs to another payment (never a 403, which would confirm it exists).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
allocationId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | yes | mín. 1, máx. 280 |
curl -X DELETE https://api.vitrinadev.com/api/v1/payments/<id>/allocations/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Se imputó a la obligación equivocada"
}'Example response (200)
{
"data": {
"payment": {
"id": "e86256b2-0000-4000-8000-000000000002",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_recorded_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "efectivo",
"amount_clp": 20000,
"paid_on": "2026-09-24",
"allocated_clp": 0,
"unallocated_clp": 20000,
"bank": null,
"account_number": null,
"document_number": null,
"operation_id": null,
"note": null,
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": "b1b1b1b1-0000-4000-8000-000000000002",
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:17.052Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000002",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000002",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 20000,
"method": "staff",
"reasons": [
"allocation_staff_accepted_plan",
"allocation_partial"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:17.057Z",
"released_at": "2026-09-22T22:21:17.094Z",
"release_reason": "Se imputó a la obligación equivocada",
"live": false
}
]
},
"released": {
"id": "e8df2087-0000-4000-8000-000000000002",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000002",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 20000,
"method": "staff",
"reasons": [
"allocation_staff_accepted_plan",
"allocation_partial"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:17.057Z",
"released_at": "2026-09-22T22:21:17.094Z",
"release_reason": "Se imputó a la obligación equivocada",
"live": false
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /payments/{id}/assign-payer
Say whose money a payment is (or take the name off it)
payments:write. The one field of a payment that is ever edited (§2.2): everything else about a received amount is immutable, and a wrong amount is a reversal plus a new row.
⚠ RE-IDENTIFYING A PAYER THAT ALREADY HAS LIVE ALLOCATIONS RELEASES THEM FIRST, and the response says which. The identity FK cascades contact_id onto the allocations, and dragging them onto a new person while their obligations still belong to the old one is a row Postgres refuses — releasing states the truth instead. contact_id: null puts the money back in the unidentified queue (the reject half of a suggestion).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
contact_id | string | null | yes |
curl -X POST https://api.vitrinadev.com/api/v1/payments/<id>/assign-payer \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "1a73af9e-0000-4000-8000-000000000001"
}'Example response (200)
{
"data": {
"payment": {
"id": "e86256b2-0000-4000-8000-000000000002",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_assigned_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "efectivo",
"amount_clp": 20000,
"paid_on": "2026-09-24",
"allocated_clp": 0,
"unallocated_clp": 20000,
"bank": null,
"account_number": null,
"document_number": null,
"operation_id": null,
"note": null,
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": "b1b1b1b1-0000-4000-8000-000000000002",
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:17.052Z",
"reversed": false,
"reversed_at": null,
"reversal_reason": null,
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000002",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000002",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 20000,
"method": "staff",
"reasons": [
"allocation_staff_accepted_plan",
"allocation_partial"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:17.057Z",
"released_at": "2026-09-22T22:21:17.094Z",
"release_reason": "Se imputó a la obligación equivocada",
"live": false
}
]
},
"released": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /payments/{id}/reverse
Reverse a payment — the trio, and every allocation released
⚠ payments:reverse, THE ONLY ROUTE ON THAT SCOPE. A reversal is the one act here that makes money the platform already told a customer about stop existing, and a workspace must be able to let somebody record money without letting them erase it.
Writes reversed_at / reversed_by / reversal_reason AND releases every live allocation, in ONE transaction: two statements that could commit separately would leave a reversed payment still settling obligations. ⚠ IT DOES NOT RE-IMPOSE THE awaiting_payment BLOCK (§5.1, open question 9.3): the customer was already told the money arrived, and the platform has no honest sentence for "we un-saw your transfer". 409 already_reversed — reversing twice never rewrites the first reason.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | yes | mín. 1, máx. 280 |
curl -X POST https://api.vitrinadev.com/api/v1/payments/<id>/reverse \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "El depósito fue rechazado por el banco"
}'Example response (200)
{
"data": {
"payment": {
"id": "e86256b2-0000-4000-8000-000000000002",
"contact_id": "1a73af9e-0000-4000-8000-000000000001",
"suggested_contact_id": null,
"payer_reasons": [
"payer_assigned_by_staff"
],
"source": "manual",
"provenance": "manual",
"instrument": "efectivo",
"amount_clp": 20000,
"paid_on": "2026-09-24",
"allocated_clp": 0,
"unallocated_clp": 20000,
"bank": null,
"account_number": null,
"document_number": null,
"operation_id": null,
"note": null,
"card_fee_bps": null,
"card_surcharge_clp": null,
"card_surcharge_source": null,
"location_id": "b1b1b1b1-0000-4000-8000-000000000002",
"bank_movement_id": null,
"gateway_transaction_id": null,
"recorded_by": null,
"recorded_at": "2026-09-22T22:21:17.052Z",
"reversed": true,
"reversed_at": "2026-09-22T22:21:17.114Z",
"reversal_reason": "El depósito fue rechazado por el banco",
"allocations": [
{
"id": "e8df2087-0000-4000-8000-000000000002",
"customer_payment_id": "e86256b2-0000-4000-8000-000000000002",
"customer_obligation_id": "b44936d0-0000-4000-8000-000000000004",
"amount_clp": 20000,
"method": "staff",
"reasons": [
"allocation_staff_accepted_plan",
"allocation_partial"
],
"allocated_by": null,
"allocated_at": "2026-09-22T22:21:17.057Z",
"released_at": "2026-09-22T22:21:17.094Z",
"release_reason": "Se imputó a la obligación equivocada",
"live": false
}
]
},
"released": []
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
Team
The workspace's members and their invitations: add one, change a role or branch, and remove one — removal always hands off the person's open work instead of stranding it.
Personal tokens
The credential a member mints for themselves: it sees exactly what that person sees, and stops working when that person loses access.