Persfina

User guide · 9 of 12

API & webhooks

Available on request. The API is enabled per account, for selected users. If you would like access, write in via the Contact form with a line about what you plan to build; once an administrator enables it, the API tokens and Webhooks sections appear in your Settings. Until then, those sections are hidden and API requests are refused.

The Persfina API lets your own scripts and automation tools — n8n, Make, a spreadsheet, a cron job — read your data, upload statements, and react to events, without signing in through the browser. Reading can never change or delete anything; the only write the API can perform is importing a PDF statement, and only with a token you created with that permission.

Everything the API returns is your data and only yours. A token is tied to your account, and every query is scoped to it on the server — there is no request, however crafted, that returns another user's data.

Tokens

API requests authenticate with a personal access token instead of your password.

  1. Open Settings and find the API tokens section.
  2. Give the token a name that reminds you where it is used ("n8n spending sync") and press Create token.
  3. Copy the token — it starts with pfa_ and is shown only once. Persfina stores only a hash, so it cannot be recovered later; if you lose it, revoke it and create a new one.

Send the token in the Authorization header of every request:

curl -H "Authorization: Bearer pfa_your_token_here" \
  https://persfina.com/api/v1/summary

A few things to know:

  • Tokens are read-only by default. Tick Allow statement uploads when creating one to also permit POST /api/v1/statements — leave it off for integrations that only need to read. A token's permissions cannot be changed later; create a new one instead.
  • You can hold up to 10 tokens. Prefer one token per integration, so revoking one does not break the others.
  • Revoking a token in Settings cuts it off immediately.
  • Treat a token like a password: it reads your entire financial picture. Never commit it to a repository or paste it into a shared document. If a token may have leaked, revoke it first and investigate second.
  • Requests are rate limited to 120 per minute per token by default. Exceeding it returns 429 with a Retry-After header — wait and retry. If your use case genuinely needs more, mention it when you write in.

Endpoints

All endpoints live under /api/v1/ and return JSON; all are GET except the statement upload. Amounts are SGD and follow the app-wide sign convention: negative = charge or spend, positive = payment, refund, or inflow. Dates are YYYY-MM-DD.

GET /api/v1/accounts

Your accounts — the same list as the Accounts page.

{
  "accounts": [
    {
      "id": "…",
      "name": "DBS Live Fresh",
      "type": "CREDIT_CARD",
      "bank": "dbs",
      "cardNumberLast4": "1234",
      "currency": "SGD",
      "archived": false,
      "createdAt": "2026-07-01T08:00:00.000Z"
    }
  ]
}

GET /api/v1/transactions

The spending ledger, newest first.

Query parameter Meaning
from, to Date bounds, YYYY-MM-DD, inclusive
accountId Limit to one account (an id from /api/v1/accounts)
limit Rows per page — default 500, maximum 2000
offset Skip this many rows (for paging)
{
  "transactions": [
    {
      "id": "…",
      "accountId": "…",
      "date": "2026-07-01",
      "description": "COFFEE HOUSE",
      "amount": -5.5,
      "currency": "SGD",
      "foreignAmount": null,
      "foreignCurrency": null,
      "exchangeRate": null,
      "remarks": "",
      "tags": ["eating out"]
    }
  ],
  "total": 1841,
  "limit": 500,
  "offset": 0
}

Page through large ranges with limit and offset, and use total to know when you are done. The foreign-currency fields are reference display only — amount (SGD) is always the figure to compute with, exactly as in the app.

GET /api/v1/summary

The Spending dashboard as JSON: net flow, spend by category, top merchants, and the spend/inflow trend. Takes the same range parameters as the dashboard itself:

Query parameter Meaning
range this-month, last-month, 3m, 6m, ytd, 12m (default), all
from, to Custom range, YYYY-MM-DD — overrides range
{
  "range": { "key": "3m", "label": "last 3 months", "from": "2026-04-18", "to": "2026-07-18" },
  "netFlow": { "spend": 4210.55, "inflow": 4900.0, "net": 689.45 },
  "categories": [ { "category": "Groceries", "spend": 612.4, "count": 18 } ],
  "topMerchants": [ { "merchant": "NTUC FAIRPRICE", "spend": 402.1, "count": 11 } ],
  "trend": [ { "period": "2026-05", "spend": 1380.2, "inflow": 1633.3 } ]
}

GET /api/v1/portfolio

Your investments rollup — holdings, cash, and P&L, the same numbers as the Investments page. Takes the same range/from/to parameters as /summary (they scope realized P&L and dividends). SGD conversion uses the stored daily FX rates the app itself uses; a position with no stored price yet appears with null values rather than a guess, and hasUnpriced tells you when that is the case.

{
  "totalValueSgd": 52140.8,
  "unrealizedSgd": 3210.5,
  "hasUnpriced": false,
  "pricesAsOf": null,
  "holdings": [
    {
      "symbol": "VWRA.L",
      "name": "Vanguard FTSE All-World",
      "currency": "USD",
      "quantity": 120,
      "avgCost": 105.2,
      "costBasis": 12624.0,
      "lastClose": 118.4,
      "lastCloseDate": "2026-07-17",
      "marketValue": 14208.0,
      "marketValueSgd": 18220.1,
      "unrealizedPnl": 1584.0,
      "realizedPnl": 0
    }
  ],
  "cash": [ { "accountId": "…", "accountName": "Webull", "currency": "USD", "balance": 512.33 } ],
  "realizedByCurrency": { "USD": 240.5 },
  "dividendsByCurrency": { "USD": 96.2 }
}

GET /api/v1/statements

Your imported-statement inventory — useful for automations that check whether a month's statement is missing and remind you.

{
  "statements": [
    {
      "id": "…",
      "accountId": "…",
      "accountName": "DBS Live Fresh",
      "bank": "dbs",
      "filename": "estatement-jun.pdf",
      "periodStart": "2026-06-01",
      "periodEnd": "2026-06-30",
      "parsedCount": 43,
      "status": "IMPORTED",
      "importedAt": "2026-07-02T13:40:00.000Z"
    }
  ]
}

GET /api/v1/miles

Your saved miles-calculator balances: programme miles plus each bank points balance with its transfer ratio and minimum block, so an automation can compute convertible totals with the same integer math the calculator uses.

{
  "programmes": [
    { "code": "krisflyer", "name": "KrisFlyer", "milesBalance": 42000, "updatedAt": "…" }
  ],
  "pointsBalances": [
    {
      "programmeCode": "krisflyer",
      "currency": "DBS Points",
      "bank": "DBS",
      "points": 12000,
      "ratioMiles": 2,
      "ratioPoints": 1,
      "minBlock": 5000,
      "updatedAt": "…"
    }
  ]
}

GET /api/v1/cpf

Your saved CPF plan, projected to age 71 with the same engine as the CPF planner — integer cents throughout, rates as published for the year in ratesAsOf. Returns 404 if you have not saved a plan on the CPF page. Year rows by default; add ?months=true for the full month-by-month detail. Balance objects use the keys oa, sa, ma, ra, in cents.

{
  "ratesAsOf": "2026",
  "input": { "birthMonth": "1990-06", "asOfMonth": "2026-07", "baseSalaryCents": 600000, "hasLoan": false },
  "finalMonth": "2061-06",
  "finalBalances": { "oa": 0, "sa": 0, "ma": 0, "ra": 0 },
  "years": [
    {
      "year": 2026,
      "ageInYear": 36,
      "inflows": { "oa": 0, "sa": 0, "ma": 0, "ra": 0 },
      "outflowCents": 0,
      "endBalances": { "oa": 0, "sa": 0, "ma": 0, "ra": 0 }
    }
  ]
}

POST /api/v1/statements — upload a statement

Imports a PDF statement in one call — parse, match, and import, with the same duplicate protections as the upload wizard. Requires a token created with Allow statement uploads.

Send multipart/form-data with:

Field Contents
file The PDF (5 MB max, one file per call)
bank citibank, dbs, maribank, trust, or uob
statementMonth Optional, YYYY-MM — only needed when the response asks for it
curl -H "Authorization: Bearer pfa_…" \
  -F "file=@estatement-jun.pdf" \
  -F "bank=dbs" \
  https://persfina.com/api/v1/statements

The response reports one entry per card section found in the PDF:

{
  "ok": true,
  "filename": "estatement-jun.pdf",
  "bank": "dbs",
  "statementDate": "2026-06-30",
  "sections": [
    {
      "cardName": "DBS Live Fresh Card",
      "cardNumberLast4": "1234",
      "accountId": "…",
      "accountName": "DBS Live Fresh",
      "status": "imported",
      "importedCount": 43,
      "parsedCount": 43
    }
  ]
}

Section statuses:

Status Meaning
imported Written to the matched account; importedCount excludes duplicates already in your ledger
already_imported This exact file was imported into this account before — nothing written
no_matching_account No account of yours matches the card's last 4 digits — create the account in the app first, then upload again. The API never creates accounts on its own
empty The section printed no transactions

Other things to know:

  • If the PDF prints no statement date, the call returns 422 with "needsStatementMonth": true — retry with the statementMonth field.
  • A wrong-bank PDF returns 422 with the parser's explanation.
  • 429 means the server is busy parsing other statements — retry after a minute.
  • The uploaded PDF is handled exactly as in the app: deleted as soon as every section resolved, and swept within 24 hours otherwise. Nothing you upload is retained.
  • Each imported section fires a statement.imported webhook, so an upload automation and a webhook automation compose naturally.

Webhooks

Webhooks turn the flow around: instead of your automation polling Persfina, Persfina calls your URL the moment something happens.

Setting one up

  1. In Settings, find the Webhooks section and enter the URL of your receiver — an n8n Webhook-node URL, for example. It must be a public https address.
  2. Copy the signing secret (whsec_…) — like a token, it is shown only once.
  3. Press Send test to fire a ping delivery and confirm your receiver sees it. The button shows the HTTP status your endpoint returned.

You can hold up to 5 webhook endpoints.

Events

Event Fires when
statement.imported A statement import completes — once per statement section you confirm
ping You press Send test

A statement.imported delivery looks like this:

{
  "event": "statement.imported",
  "timestamp": "2026-07-18T10:30:00.000Z",
  "data": {
    "statementId": "…",
    "accountId": "…",
    "accountName": "DBS Live Fresh",
    "bank": "dbs",
    "filename": "estatement-jun.pdf",
    "importedCount": 43
  }
}

A natural pairing: receive statement.imported, then call /api/v1/transactions with that month's dates to fetch the new rows into your automation.

Verifying deliveries

Every delivery is a POST with three headers:

Header Contents
X-Persfina-Event The event name
X-Persfina-Delivery A unique id for this delivery
X-Persfina-Signature sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your signing secret

Verify the signature before trusting a delivery — it proves the request came from Persfina and was not tampered with. In Node.js:

const crypto = require("crypto");

function verify(secret, rawBody, signatureHeader) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader),
    Buffer.from(expected),
  );
}

Compute the HMAC over the raw body bytes, before any JSON parsing — re-serialized JSON may differ by a byte and fail the check.

Delivery behaviour

  • Your endpoint should respond with any 2xx status quickly; do your processing after acknowledging.
  • A failed delivery is retried twice — about 30 seconds and then 5 minutes after the first attempt.
  • After 10 consecutive failures the endpoint is automatically disabled so a dead URL does not accumulate traffic. Settings shows it as disabled; fix your receiver and press Re-enable.
  • The Settings section always shows each endpoint's last delivery result, so you can see at a glance whether your receiver is healthy.

Errors

Status Meaning What to do
401 Missing, malformed, or revoked token Check the Authorization: Bearer pfa_… header; create a new token if revoked
403 "API access is not enabled" Your account has no API grant, or it was withdrawn Write in via Contact
403 "Token is missing the … scope" The token lacks the scope the endpoint needs Create a new token
429 Rate limit exceeded Wait for the Retry-After seconds and retry

Privacy notes

  • The API serves your data to you; deliveries go only to URLs you configured yourself. Revoking a token, deleting a webhook, or losing the API grant stops all of it immediately.
  • Deleting your account deletes your tokens and webhooks with it.