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, or DELETE on /v1/ledger. To produce a ledger entry, perform the operation that generates it — e.g. POST /v1/refunds writes 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=asc from a known start, or pull a window via /v1/reports/ledger.csv?from=...&to=.... Plain from/to on /v1/ledger is 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=...&currency=IDR
  • GET /v1/reports/cash-flow?from=...&to=...&currency=IDR

Export to CSV

GET /v1/reports/ledger.csv?from=<iso>&to=<iso>&currency=<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&currency=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:

  1. GET /v1/payouts?status=pending,in_transit — payout IDs.
  2. 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.
Plugipay — Payments that don't tax your success