@neuctra/finance
SDK and API reference
Record where business money goes from any JavaScript app, and read it back. Written for the IT team that connects your business software.
New to Neuctra Finance? Start with the Connect your software guide, then come back here for every call and error code.
How it fits together
- The owner signs in to the dashboard, creates accounts and adds money. Investments and withdrawals can only be made there.
- The owner creates a key on the Integrations page and gives it to the IT team.
- Your software calls the Neuctra Finance API through the SDK. The API checks the key, then records the movement in the owner's books.
- Spending stops at zero. An account only pays out what it holds, until the owner adds more.
Quickstart
Install the SDK. It has no dependencies and runs on Node.js 18+, in browsers, React and Next.js.
npm install @neuctra/financeKeep the API address and key in environment variables, never in source control.
1NEUCTRA_FINANCE_URL=https://finance-api.yourcompany.com
2NEUCTRA_FINANCE_KEY=nf_sk_...1import { NeuctraFinance } from "@neuctra/finance";
2
3export const finance = new NeuctraFinance({
4 serverUrl: process.env.NEUCTRA_FINANCE_URL,
5 apiKey: process.env.NEUCTRA_FINANCE_KEY,
6});
7
8const { business, key } = await finance.me();
9console.log(`Connected to ${business.name} as ${key.name}`);Keys and permissions
nf_sk_ server keys are for back-end code and are refused from browsers. nf_pk_ public keys only work from the websites listed on the key. Give a public key the fewest permissions and a per-transaction limit.
| Permission | Allows |
|---|---|
| read | Read accounts, balances, transactions, debts and the summary. |
| expenses:write | Record expenses. Refused when the account would go below zero. |
| income:write | Record income into an account. |
| transfers:write | Move money between two accounts of the business. |
| debts:write | Create debts and record payments or collections. |
| holds:write | Reserve money, then capture or release it. Server keys only. |
| requests:write | Ask for approval to spend above an account's approval line. |
| reversals:write | Undo a transaction with an opposite entry. |
A key can also be limited to some accounts. Revoking a key in the dashboard stops it within 30 seconds.
Record an expense
1const { transaction, account } = await finance.expenses.record({
2 accountId: "acc_3f9a1c", // copy it from the account page
3 amount: 12000, // major units: 12000 means 12,000.00
4 category: "Rent",
5 description: "November office rent",
6 reference: "RENT-2026-11", // your own invoice or order id
7 counterparty: "Blue Tower Properties",
8});
9
10transaction.balanceAfter; // what is left in the account
11account.spent; // everything spent from it so far| Field | Required | What it is |
|---|---|---|
| accountId | Yes | The account to take the money from. |
| amount | Yes | Positive number in the business currency, with its decimals (2 for USD, 0 for JPY, 3 for KWD). |
| category | No | Groups spending, for example Rent, Ads or Salaries. |
| description | No | What the money was for. |
| reference | No | Your own id: invoice, order or payroll run. |
| counterparty | No | Who was paid. |
| occurredAt | No | When it happened, if not now. ISO date or date-time. |
| metadata | No | Any small JSON object, up to 2,000 characters. |
| idempotencyKey | No | Generated for you. Pass your own to make retries across restarts safe. |
When an account runs out
An expense larger than the balance is refused and nothing is deducted. The same happens while the owner has frozen the account. Catch the error and tell someone.
1import { NeuctraFinanceError } from "@neuctra/finance";
2
3try {
4 await finance.expenses.record({ accountId, amount: 6000, category: "Stock" });
5} catch (err) {
6 if (err instanceof NeuctraFinanceError && err.isInsufficientFunds) {
7 // Nothing was deducted.
8 const { balance, requested, shortBy } = err.details;
9 await alertOwner(`Expansion fund is short by ${shortBy}. Please add money.`);
10 } else if (err.code === "ACCOUNT_FROZEN") {
11 await alertOwner("Spending from this account is paused.");
12 } else {
13 throw err;
14 }
15}
16
17// Or check first, for a friendlier UI:
18if (!(await finance.accounts.canSpend(accountId, 6000))) {
19 showMessage("Not enough left in this budget.");
20}See where money went
Every transaction keeps its category, reference, the key that recorded it and the balance right after it.
1// One account, newest first. Follows every page for you.
2for await (const t of finance.transactions.iterate({ accountId, kind: "expense" })) {
3 console.log(t.occurredAt, t.category, t.amount, t.source.keyName ?? "Dashboard");
4}
5
6// One page at a time
7const page = await finance.transactions.list({ accountId, limit: 50 });
8const next = await finance.transactions.list({ accountId, cursor: page.nextCursor });
9
10// The whole business at a glance
11const summary = await finance.summary();
12summary.balance; // available across every account
13summary.invested; // everything the owner put in
14summary.spent; // everything recorded as expenses
15summary.lowAccounts; // ids of accounts that are empty or running lowIncome and transfers
1await finance.income.record({
2 accountId: "acc_sales01",
3 amount: 4200,
4 category: "Online sales",
5 reference: "ORDER-88213",
6});
7
8await finance.transfers.create({
9 fromAccountId: "acc_sales01",
10 toAccountId: "acc_payroll",
11 amount: 3000,
12 description: "Move sales to payroll",
13});Debts
payable is money the business owes. receivable is money owed to the business. Payments move real money, so paying a debt needs the account to cover it.
1// A bank loan paid into an account: the balance goes up, the debt is tracked.
2const { debt } = await finance.debts.create({
3 direction: "payable", // you owe
4 counterparty: "City Bank",
5 amount: 50000,
6 accountId: "acc_operations", // optional: where the cash landed
7 dueDate: "2027-06-30",
8});
9
10// Each installment leaves the account and lowers what is outstanding.
11await finance.debts.pay(debt.id, { amount: 5000, description: "Installment 1" });
12
13// Money a customer owes you, collected later into an account.
14const invoice = await finance.debts.create({
15 direction: "receivable",
16 counterparty: "Globex Ltd",
17 amount: 7500,
18});
19await finance.debts.pay(invoice.debt.id, { amount: 7500, accountId: "acc_sales01" });Reserve money first
Recording a spend after it happened cannot stop it. A hold reserves the money before you pay: the account's available amount drops at once, so nothing else can spend it. Capture what you really paid (the held amount or less), or void it. Holds you forget are released when they expire.
1// 1. Reserve the money before you pay (server keys only).
2const { hold } = await finance.holds.authorize({
3 accountId: "acc_8f2c...",
4 amount: 800,
5 description: "Coffee beans order",
6 expiresInMinutes: 60 * 24, // expires after a day
7});
8
9// 2. Pay your supplier, then record what it really cost (800 or less).
10const { transaction, account } = await finance.holds.capture(hold.id, { amount: 750 });
11
12// Or, if the order fell through, give the money back.
13await finance.holds.void(hold.id);Big spends need approval
An owner can set an approval line on an account. Above it, members and integrations get APPROVAL_REQUIRED for expenses, transfers, loans and holds alike. Send a spend request instead; when an approver accepts it, the money is held for you to capture. Nobody can approve their own request, including requests sent with a key they created.
1try {
2 await finance.expenses.record({ accountId, amount: 2500, category: "Equipment" });
3} catch (err) {
4 if (!err.isApprovalRequired) throw err;
5 // Above the account's approval line: ask instead.
6 const request = await finance.spendRequests.create({
7 accountId,
8 amount: 2500,
9 description: "Espresso machine",
10 receiptUrl: "https://supplier.example/quote/77",
11 });
12 // Later (or when the spend_request.approved webhook arrives):
13 const decided = await finance.spendRequests.get(request.id);
14 if (decided.status === "approved") await finance.holds.capture(decided.holdId);
15}Fixing mistakes
Transactions are never edited or deleted. A reversal adds an opposite entry dated today, undoes both sides of a transfer, and reopens a debt if it reverses a payment. Taking back money that was already spent is refused.
1// Never edit or delete: add an opposite entry, with a reason.
2await finance.transactions.reverse("txn_51ab...", { reason: "Charged twice by the POS" });Webhooks
Add an https address on the Integrations page and choose the events you want. Each POST carries Neuctra-Signature: t=…,v1=…, an HMAC-SHA256 of t.body with your nf_whsec_ secret. Check it with verifyWebhook(), answer any 2xx, and ignore event ids you have already handled. Each delivery is sent once; resend a failed one from the delivery log.
1import express from "express";
2import { verifyWebhook } from "@neuctra/finance";
3
4const app = express();
5// Keep the raw body: the signature is over the exact bytes sent.
6app.post("/neuctra", express.text({ type: "application/json" }), async (req, res) => {
7 try {
8 const event = await verifyWebhook(req.get("Neuctra-Signature"), req.body, process.env.NEUCTRA_WEBHOOK_SECRET);
9 if (await alreadyHandled(event.id)) return res.sendStatus(200);
10 if (event.type === "account.low_balance") alertTheTeam(event.data);
11 res.sendStatus(200);
12 } catch {
13 res.sendStatus(400);
14 }
15});Testing safely
Create a separate business, for example "Acme (test)", with its own accounts and keys, and point your development setup at it. Businesses are free, and nothing in one can touch another. Delete it when you are done. The full API description is at /v1/openapi.json for code generators and Postman.
Retries
Every write carries an idempotency key. The SDK makes one for you and reuses it when it retries a timeout or a 502, so an expense is never recorded twice. If your own job may run again after a crash, pass a key that stays the same for that piece of work. A repeat returns the original transaction with replayed: true.
1await finance.expenses.record({
2 accountId,
3 amount: 899,
4 reference: "PO-2041",
5 idempotencyKey: "po-2041", // stable for this purchase order
6});Errors
Errors are NeuctraFinanceError with status, code, a plain-language message and a hint on what to do next.
| Code | Status | Meaning |
|---|---|---|
| INSUFFICIENT_FUNDS | 402 | The account cannot cover the amount. details has balance, requested and shortBy. |
| ACCOUNT_FROZEN | 423 | The owner paused spending from this account. |
| ACCOUNT_CLOSED | 409 | The account no longer accepts money in or out. |
| OVERPAYMENT | 422 | More than the outstanding amount was paid on a debt. |
| VALIDATION_ERROR | 400 | A field is missing or has the wrong format. The message names it. |
| NOT_FOUND | 404 | The account, transaction or debt does not exist in this business. |
| INVALID_API_KEY / KEY_REVOKED | 401 | The key is wrong, or the owner revoked it. |
| MISSING_SCOPE | 403 | The key was not given this permission. |
| OWNER_NOT_VERIFIED | 403 | The business owner has not verified their email yet. Keys work once they do. |
| ACCOUNT_NOT_ALLOWED / AMOUNT_LIMIT | 403 | The key is limited to other accounts or a lower amount. |
| SERVER_KEY_IN_BROWSER / ORIGIN_NOT_ALLOWED | 403 | A server key was used in a browser, or a public key from an unlisted site. |
| APPROVAL_REQUIRED | 403 | Above the account's approval line. Send a spend request. |
| BUDGET_EXCEEDED / CATEGORY_REQUIRED | 422 / 400 | The category's monthly budget is used up, or the business needs a known category. |
| PERIOD_LOCKED | 409 | The owner closed the books for that date. Use a later date. |
| IDEMPOTENCY_MISMATCH | 422 | The idempotency key was already used for a different request. |
| HOLD_EXPIRED / HOLD_NOT_ACTIVE / CAPTURE_TOO_LARGE | 409 / 422 | The hold ended, or you tried to capture more than was held. |
| KEY_EXPIRED / IP_NOT_ALLOWED | 401 / 403 | The key's end date passed, or it was used from an address not on its list. |
| RATE_LIMITED | 429 | Too many requests. Wait for the Retry-After header. |
| PLAN_LIMIT | 429 | The business owner's monthly API allowance is used up. It resets on the 1st (UTC). err.isPlanLimit in the SDK. |
| BUSY / UPSTREAM_ERROR | 409 / 502 | Temporary. The SDK retries these with the same idempotency key. |
REST API
Not on JavaScript? Call the API directly with Authorization: Bearer nf_.... Amounts are in major units. Errors come back as { error: { code, message, hint, details } }.
1curl -X POST https://finance-api.yourcompany.com/v1/expenses \
2 -H "Authorization: Bearer $NEUCTRA_FINANCE_KEY" \
3 -H "Content-Type: application/json" \
4 -H "Idempotency-Key: po-2041" \
5 -d '{"accountId":"acc_3f9a1c","amount":899,"category":"Supplies"}'| Method | Path | Permission | Does |
|---|---|---|---|
| GET | /v1/me | Any key | The business and this key's permissions. |
| GET | /v1/summary | read | Balances, invested, spent, debts, low accounts. |
| GET | /v1/accounts | read | Every account this key can use. |
| GET | /v1/accounts/:id | read | One account with its balance. |
| GET | /v1/transactions | read | Newest first. Query: accountId, kind, limit, cursor. |
| GET | /v1/transactions/:id | read | One transaction. |
| POST | /v1/expenses | expenses:write | Take money out of an account. |
| POST | /v1/income | income:write | Put money into an account. |
| POST | /v1/transfers | transfers:write | fromAccountId, toAccountId, amount. |
| GET | /v1/debts | read | Query: status, direction. |
| GET | /v1/debts/:id | read | One debt. |
| POST | /v1/debts | debts:write | direction, counterparty, amount, accountId, dueDate. |
| POST | /v1/debts/:id/payments | debts:write | amount, accountId. |
| POST | /v1/holds | holds:write | accountId, amount, expiresInMinutes. |
| GET | /v1/holds/:id | read | One hold. |
| POST | /v1/holds/:id/capture | holds:write | amount (optional, at most the held amount). |
| POST | /v1/holds/:id/void | holds:write | Give the money back. |
| POST | /v1/spend-requests | requests:write | accountId, amount, description, receiptUrl. |
| GET | /v1/spend-requests/:id | read | Its status, and holdId once approved. |
| POST | /v1/spend-requests/:id/cancel | requests:write | Withdraw a pending request. |
| POST | /v1/transactions/:id/reverse | reversals:write | reason. |
| GET | /v1/openapi.json | None | The full OpenAPI 3.1 description. |
Run the API server
The API lives in neuctra-finance-server. It is the only part of Neuctra Finance that talks to Neuctra Authix, with a secret key, and it serves both this dashboard and your integrations.
1cd neuctra-finance-server
2cp .env.example .env # AUTHIX_APP_ID, AUTHIX_SECRET_KEY, SESSION_SECRET
3npm install
4npm run dev # http://localhost:4000Allow the server in Authix
