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.
- Open Settings and find the API tokens section.
- Give the token a name that reminds you where it is used ("n8n spending sync") and press Create token.
- 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
429with aRetry-Afterheader — 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
422with"needsStatementMonth": true— retry with thestatementMonthfield. - A wrong-bank PDF returns
422with the parser's explanation. 429means 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.importedwebhook, 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
- 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
httpsaddress. - Copy the signing secret (
whsec_…) — like a token, it is shown only once. - Press Send test to fire a
pingdelivery 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
2xxstatus 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.