Guides
A bank or neobank already has the account and the transactions. FinBaze adds the books and the tax filings on top of them. This guide follows one business customer from "connect" to "VAT paid from the account". The neobank demo is the same flow as running code.
What you build#
| Step | Your side | FinBaze side |
|---|---|---|
| 1. A business signs up | createPlatformCompany, upsertPlatformUser, setPlatformUserAccess | The company and its books exist. |
| 2. Booked transactions flow in | ingestBankTransactions, reportBankBalances, reportFeedWatermark | Transactions are categorised and matched with receipts; balances are checked. |
| 3. Bookings show in your app | booking-chip next to each transaction | Chip shows how the transaction is booked. |
| 4. Receipts from the camera | receipt-button, onCameraCapture | The photo is attached and read. |
| 5. VAT return, paid from the account | vat-return, onPaymentRequested | Return is filed with the company's consent; a payment request carries the amount and the Belastingdienst reference. |
| 6. Tax money set aside | tax_reservation provider, reportTaxReservation | Advice says how much to reserve now. |
1. Company and user#
mutation {
createPlatformCompany(input: { externalId: "biz-1001", registrationCountry: NL, registrationNumber: "12345678" }) {
company { id name }
created
}
}
externalId is your customer id; calling again with the same id returns the same company. Create the user the same way (upsertPlatformUser) and give it a role on the company with setPlatformUserAccess. See Platforms and companies.
2. The bank feed#
Register one data source for your feed, attach it to every new company, then deliver. Everything you push keeps your ids, so sending a line twice is harmless.
mutation {
registerDataSource(input: {
key: "acme-bank"
type: bank_feed
name: "Acme Bank accounts"
cadence: realtime
balanceAssertions: true
autoAttach: true
}) { id key }
}
mutation Account($company: CompanyRef!) {
upsertFeedBankAccount(source: "acme-bank", company: $company, account: {
externalId: "acc-77"
iban: "NL91ABNA0417164300"
name: "Business account"
currency: EUR
}) { id health }
}
mutation Push($company: CompanyRef!, $lines: [BankTransactionFeedInput!]!) {
ingestBankTransactions(source: "acme-bank", company: $company, account: "acc-77", transactions: $lines) {
line status externalId error { code message }
}
}
A line looks like { externalId: "tx-90210", bookingDate: "2026-03-02", amountMinor: -12500, currency: EUR, counterparty: { name: "Office Supply BV", iban: "NL02ABNA0123456789" }, description: "Invoice 2026-114", endToEndId: "..." }. Amounts are in minor units: negative is money out.
- Booked only. Lines with
status: pendingare skipped. Push a line when the bank has booked it. - A booked line never changes. Changing it fails with
IMMUTABLE_TRANSACTION. Send a reversal (reverses: "<externalId>") and a new transaction. - Send balances.
reportBankBalanceswith the closing balance per day lets FinBaze check that the transactions add up. A mismatch changes nothing in the books and raisesfeed.balance_mismatch. - Send the heartbeat.
reportFeedWatermark(completeThrough: "2026-03-02")says "everything up to this date is delivered". A quiet account needs it to count as healthy, not as a gap.
Data sources covers statement files, backfill and feed health in detail.
3. A booking chip next to each transaction#
In your transaction list, mount booking-chip with your own transaction id. No FinBaze id needed.
import { FinBazeProvider, FinBazeBookingChip } from "@finbaze/embed-react";
function Row({ accountId, tx }: { accountId: string; tx: { id: string } }) {
return <FinBazeBookingChip bankAccountId={accountId} externalId={tx.id} size="sm" onBookingChanged={() => refresh()} />;
}
Create the session with components: { bookingChip: { enabled: true } }. See the booking-chip page for its options and events.
4. Receipts from the camera#
receipt-button attaches a photo to a transaction. In a web page it opens the phone's own camera through a file input. In an app, pass onCameraCapture to loadFinBaze (or FinBazeProvider) and open your scanner; resolve with { name, mime, data } or null when the user cancels.
onCameraCapture: async ({ purpose, accept, maxBytes }) => {
const photo = await myScanner.capture({ accept, maxBytes });
return photo ? { name: photo.name, mime: photo.mime, data: photo.bytes } : null;
},
Mobile wrappers are covered in Mobile.
5. VAT, paid from the account#
The VAT return is filed only with the company's authorisation, which its admin gives in the onboarding or tax-authorisation component. See Filing consent. A submit without it fails with TAX_AUTHORISATION_REQUIRED.
Register your payment provider once:
mutation {
upsertPlatformProvider(input: { action: payments, mode: both, config: { kinds: ["tax"], includePain001: false } }) { id enabled }
}
This needs the delegated.payment_requests feature in your allowance (FEATURE_NOT_ALLOWED otherwise). Then show the return and handle the Pay button:
<FinBazeProvider
publishableKey={pk}
fetchClientSecret={fetchClientSecret}
onPaymentRequested={async (request) => {
// request.lines: creditor, IBAN, amountMinor, structuredReference, endToEndId
const transfer = await openTransferScreen(request.lines, request.endToEndId);
return { status: transfer.confirmed ? "accepted" : "cancelled", partnerReference: transfer.id };
}}
>
<FinBazeVatReturn period="current" />
</FinBazeProvider>
- Pay exactly the lines in the request. The creditor and the reference are fixed by FinBaze.
- Put the line's
endToEndIdin the SEPAEndToEndId. That is what matches the payment later. - You never report "paid". The request becomes
paidwhen the transaction arrives in the feed (step 2) and matches by end-to-end id, or for tax by the reference, the Belastingdienst IBAN and the exact amount. - Without
onPaymentRequestedthere is no Pay button.
If the user must confirm with biometrics or SCA, add a step_up provider and onStepUp. See Delegated actions.
6. Money set aside for tax#
mutation {
upsertPlatformProvider(input: { action: tax_reservation, mode: webhook, config: { taxes: ["nl_vat"], cadence: "weekly" } }) { id enabled }
}
FinBaze sends tax_reservation.advised with the amount to hold back for the open period. Move the money, then tell FinBaze:
mutation { reportTaxReservation(adviceId: "…", reservedMinor: 42000, accountRef: "savings-tax") { id status } }
taxReservationAdvice(company: …) lists the advices. See Delegated actions.
7. Annual accounts#
Annual accounts follow the same consent: the company authorises nl_annual_accounts in tax-authorisation. A dedicated annual-accounts view arrives with the extended component catalogue; the component list shows what your build of the registry contains today, and nothing in your integration changes when it goes live.
Try it in the sandbox#
seedTestCompany(input: { template: nl_eenmanszaak_services })gives a sole trader with history (Demo companies).createTestClockandadvanceTestClockrun the quarter, with the simulated bank feed posting through the same path your feed uses (Test clocks).- Run the webhook receiver locally and subscribe it, so you see
bank_transaction.booked,feed.balance_mismatchandpayment_request.created.
Pitfalls#
- Pushing
pendinglines: they are skipped, so the first booked version must come from you. - Editing a booked line instead of reversing it.
- Forgetting
endToEndIdon the transaction that paid the request, so the request never becomespaid. - Caching client secrets. Each secret is single use; return a new one from
fetchClientSecretevery time.