FinBaze for Platforms

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#

StepYour sideFinBaze side
1. A business signs upcreatePlatformCompany, upsertPlatformUser, setPlatformUserAccessThe company and its books exist.
2. Booked transactions flow iningestBankTransactions, reportBankBalances, reportFeedWatermarkTransactions are categorised and matched with receipts; balances are checked.
3. Bookings show in your appbooking-chip next to each transactionChip shows how the transaction is booked.
4. Receipts from the camerareceipt-button, onCameraCaptureThe photo is attached and read.
5. VAT return, paid from the accountvat-return, onPaymentRequestedReturn is filed with the company's consent; a payment request carries the amount and the Belastingdienst reference.
6. Tax money set asidetax_reservation provider, reportTaxReservationAdvice 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: pending are 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. reportBankBalances with the closing balance per day lets FinBaze check that the transactions add up. A mismatch changes nothing in the books and raises feed.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 endToEndId in the SEPA EndToEndId. That is what matches the payment later.
  • You never report "paid". The request becomes paid when 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 onPaymentRequested there 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#

  1. seedTestCompany(input: { template: nl_eenmanszaak_services }) gives a sole trader with history (Demo companies).
  2. createTestClock and advanceTestClock run the quarter, with the simulated bank feed posting through the same path your feed uses (Test clocks).
  3. Run the webhook receiver locally and subscribe it, so you see bank_transaction.booked, feed.balance_mismatch and payment_request.created.

Pitfalls#

  • Pushing pending lines: they are skipped, so the first booked version must come from you.
  • Editing a booked line instead of reversing it.
  • Forgetting endToEndId on the transaction that paid the request, so the request never becomes paid.
  • Caching client secrets. Each secret is single use; return a new one from fetchClientSecret every time.