Ledger
The ledger is Plugipay's chronological record of every money movement in your workspace — payments in, refunds out, processor fees, payouts, and adjustments. Every event that nudges your balance lands here as one or more double-entry rows, in posting order, with a reference back to its source object.
The ledger is read-only. Entries are written by Plugipay in response to other API operations (capturing a checkout session, issuing a refund, settling a payout) and provider webhooks. There is no
POST,PATCH, orDELETEon/v1/ledger. To produce a ledger entry, perform the operation that generates it — e.g.POST /v1/refundswrites the refund row pair.
Endpoints at a glance
| Method | Path | What |
|---|---|---|
GET |
/v1/ledger |
List entries (paginated, filterable). |
GET |
/v1/ledger/balances |
Aggregated debit/credit/balance per account code. |
GET |
/v1/reports/ledger.csv |
Stream the ledger as a CSV attachment over a date range. |
GET |
/v1/reports/pnl |
Period P&L (revenue, refunds, fees, net). |
GET |
/v1/reports/cash-flow |
Period cash-flow summary. |
All endpoints require plugipay:ledger:read (or plugipay:report:read for /reports/*) and follow the standard envelope.
List entries
GET /v1/ledger
Returns ledger entries for the workspace, newest first by default. Cursor-paginated — see Pagination.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 20 |
1–100. |
cursor |
string | — | Opaque cursor from a prior response's meta.page.nextCursor. |
order |
asc | desc |
desc |
Order by postedAt then id. |
txId |
string | — | Rows sharing a transaction group (both legs of a posting). |
code |
string | — | Exact match on the account code (e.g. payments, gateway:xendit). |
sourceType |
enum | — | checkout_session, invoice, refund, payout, adjustment, or dispute. |
sourceId |
string | — | Filter to entries referencing a specific source object. Pair with sourceType. |
Date range filtering. Use cursor +
order=ascfrom a known start, or pull a window via/v1/reports/ledger.csv?from=...&to=.... Plainfrom/toon/v1/ledgeris reserved for a future release.
Response
{
"data": [
{
"id": "le_01HY7K3Z9A4B5C6D7E8F9G0H1J",
"accountId": "acc_01HX5Y6Z7A8B9C0D1E2F3G4H5J",
"txId": "cs_01HX9P2Q3R4S5T6U7V8W9X0Y1Z",
"code": "payments",
"direction": "credit",
"amount": 250000,
"currency": "IDR",
"sourceType": "checkout_session",
"sourceId": "cs_01HX9P2Q3R4S5T6U7V8W9X0Y1Z",
"memo": "Checkout cs_01HX9P... captured",
"postedAt": "2026-05-12T07:14:22.108Z"
}
],
"error": null,
"meta": {
"requestId": "req_...",
"timestamp": "2026-05-12T07:14:22.200Z",
"page": { "limit": 20, "hasMore": true, "nextCursor": "cur_..." }
}
}
Errors
| Code | When |
|---|---|
401 invalid_signature |
Bad HMAC. See Authentication. |
403 insufficient_scope |
Key lacks plugipay:ledger:read. |
400 invalid_cursor |
Cursor malformed or from a different account. |
Samples
// Node
import { Plugipay } from '@forjio/plugipay';
const pp = new Plugipay({ keyId, secret });
const page = await pp.ledger.list({ sourceType: 'refund', limit: 50 });
# Python
from plugipay import Plugipay
pp = Plugipay(key_id=..., secret=...)
page = pp.ledger.list(source_type="refund", limit=50)
// Go
cli := plugipay.New(plugipay.Config{KeyID: id, Secret: secret})
src, limit := "refund", 50
page, err := cli.Ledger.List(ctx, plugipay.LedgerListParams{
SourceType: &src, Limit: &limit,
})
# curl — signed via the helper in /docs/api/authentication
plugipay_curl GET '/v1/ledger?sourceType=refund&limit=50'
Retrieve a transaction group
Every business event produces two or more balanced rows sharing a txId. There's no GET /v1/ledger/{id} for a single row; instead, fetch the whole posting by transaction group:
GET /v1/ledger?txId=cs_01HX9P2Q3R4S5T6U7V8W9X0Y1Z
const legs = await pp.ledger.list({ txId: 'cs_01HX9P...' });
// [
// { code: 'payments', direction: 'credit', amount: 250000, ... },
// { code: 'gateway:xendit', direction: 'debit', amount: 7250, ... },
// { code: 'revenue:pln_...', direction: 'debit', amount: 242750, ... },
// ]
The sum of debits and credits within a transaction group are always equal. If they aren't, file a ticket with meta.requestId.
Summary: balances
GET /v1/ledger/balances
Aggregates every entry in the workspace, grouped by code, into a { debits, credits, balance } row per code (balance = credits - debits).
{
"data": [
{ "code": "payments", "debits": 0, "credits": 12500000, "balance": 12500000 },
{ "code": "gateway:xendit", "debits": 362500, "credits": 0, "balance": -362500 },
{ "code": "revenue:pln_xxx", "debits": 12137500, "credits": 0, "balance": -12137500 }
],
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Not currency-scoped — sum manually per code if you transact in multiple currencies. For period-bounded summaries with revenue/fee/net pre-computed, prefer the report endpoints (same shape as the portal's Reports tab):
GET /v1/reports/pnl?from=...&to=...¤cy=IDRGET /v1/reports/cash-flow?from=...&to=...¤cy=IDR
Export to CSV
GET /v1/reports/ledger.csv?from=<iso>&to=<iso>¤cy=<code>
Streams the ledger as a text/csv; charset=utf-8 attachment. This is the only endpoint that doesn't return the JSON envelope — the response body is raw CSV.
| Param | Required | Notes |
|---|---|---|
from |
yes | ISO 8601, inclusive. |
to |
yes | ISO 8601, inclusive. Must be >= from. |
currency |
no | ISO 4217. Omit for all currencies. |
Columns (stable across releases — safe to script against):
postedAt, txId, code, direction, amount, currency, sourceType, sourceId, memo
Amount is the integer minor-unit value (see Money).
plugipay_curl GET \
'/v1/reports/ledger.csv?from=2026-04-01T00:00:00Z&to=2026-04-30T23:59:59Z¤cy=IDR' \
> ledger-2026-04-idr.csv
The suggested filename in Content-Disposition is ledger-<from>-to-<to>.csv. The CSV is not paginated — Plugipay streams the full window in one response. For workspaces with millions of monthly rows, batch by week.
This is the API behind the portal's "Export → CSV" button. The two read from the same generator, so a script-driven export matches what your accountant sees in the dashboard, byte for byte.
The ledger entry object
| Field | Type | Notes |
|---|---|---|
id |
string | le_<ulid>. Stable, immutable. |
accountId |
string | acc_<ulid>. The workspace this entry belongs to. |
txId |
string | Transaction group ID. Usually the source object ID (cs_..., inv_..., rf_...) or a synthetic prefix like inv:<id>. All rows of one posting share this. |
code |
string | Account code: payments, payout, revenue:pln_<id>, gateway:xendit, tax:ppn, ar:<accountId>, etc. Colon-namespaced codes denote sub-accounts. |
direction |
enum | debit or credit. |
amount |
integer | Always positive in the smallest currency unit. The sign is carried by direction, never by amount. |
currency |
string | ISO 4217. |
sourceType |
enum | checkout_session, invoice, refund, payout, adjustment, or dispute. |
sourceId |
string | ID of the source object — dereferenceable via the corresponding resource. |
memo |
string | null | Free-text label written by Plugipay (e.g. "Refund rf_01HX..."). |
postedAt |
string | ISO 8601 UTC. The instant Plugipay confirmed the financial event — not necessarily when the customer paid. |
Sign convention
Standard double-entry: a credit on payments is money in, a debit is money out; a debit on revenue:<plan> recognizes revenue; a credit on gateway:<adapter> records a processor fee owed. Within one txId, sum(credits) == sum(debits).
Pending vs settled
The ledger only writes rows for confirmed financial events. An "authorized but not captured" card payment is not in the ledger — it sits on the underlying Checkout session as pending. The ledger row appears when the provider confirms capture.
So ledger entries are already settled by definition from the API's perspective. The portal's "pending" badge in Reports → Ledger is a synthesized view of payouts that haven't posted yet — entries tagged to a Payout (po_<id>) in state pending or in_transit. To derive it today:
GET /v1/payouts?status=pending,in_transit— payout IDs.GET /v1/ledger?sourceType=payout&sourceId=<each>— rows attributed to each.
A first-class settled filter is on the roadmap.
Reconciliation patterns
Bank-statement reconciliation. For each deposit, find the matching payout, then drill into its ledger rows:
const payouts = await pp.payouts.list({ status: 'paid', limit: 100 });
for (const po of payouts.data) {
const legs = await pp.ledger.list({ sourceType: 'payout', sourceId: po.id });
// sum(credits on 'payout') === payout amount === bank deposit
}
Monthly close. Pull the CSV for the calendar month and group by code:
plugipay_curl GET \
'/v1/reports/ledger.csv?from=2026-04-01T00:00:00Z&to=2026-04-30T23:59:59Z' \
> 2026-04.csv
Revenue and processing fees come straight off the revenue:* and gateway:* rollups. Most teams keep a saved spreadsheet template that maps Plugipay codes to their chart of accounts and re-runs it monthly.
Spot-checking a refund. GET /v1/ledger?txId=rf_01HX... returns two rows: a debit on revenue:<plan> and a credit on gateway:<adapter>, both sourceType=refund. Anything else means an edge case (partial, FX, dispute reversal) — cross-reference the Refund object's metadata.
Events
The ledger does not emit its own webhooks. The state changes that cause ledger writes (checkout_session.captured, refund.issued, payout.paid) do. Subscribe to those for real-time visibility — see Webhooks.
Next
- Refunds — writes refund ledger rows.
- Payouts — writes payout ledger rows.
- Portal → Ledger — same data with running balances and accountant tooling.