# Bookkeeping and tax filing inside your platform Create companies and users from your backend, then show FinBaze components in your product or call the API headless. FinBaze for Platforms lets your customers do their bookkeeping and file their taxes without leaving your product. You keep the relationship and the look; FinBaze keeps the books, the ledger and the filings. ## How it fits together 1. **Your backend** creates a *company* and a hidden *user* in FinBaze with a secret key, and creates an *embed session* when a user opens a page. 2. **Your page** loads `@finbaze/embed-js` (or `@finbaze/embed-react`) with a publishable key. The SDK asks your backend for a client secret and mounts FinBaze components in iframes. 3. **FinBaze** runs the components, holds the user's token inside its own iframe and files with the tax authority. Prefer your own UI? Use the [headless hooks](/sdks/headless) or the [GraphQL API](/reference/graphql) from your backend. ## Where to start - [Quickstart](/quickstart): a VAT overview in your app in about an hour. - [Concepts](/concepts/platforms-and-companies): the model, in five short pages. - [Component gallery](/components/gallery): try every component on seeded sandbox companies. - [`llms.txt`](/llms.txt): this site as plain text for your coding assistant. > **Sandbox first** > > Everything here works in the sandbox with test keys (`fb_sk_test_…`, `fb_pk_test_…`). Companies there are fake and nothing is filed with a real tax authority. --- # Quickstart From a test key to a working embedded component, in ten steps. You will create a company and a user, mint an embed session on your server and show the tax calendar in a React page. All steps use the **sandbox**. ## 1. Get test keys In the developer portal open **Keys** and create a secret key and copy the publishable key. Test keys start with `fb_sk_test_` and `fb_pk_test_`. The secret key stays on your server. ## 2. Allow your origin Under **Embedding → Allowed origins** add the origin of the page that will show the components, for example `http://localhost:3000`. Components refuse to load anywhere else. ## 3. Get a server token Exchange the secret key for a short-lived token: ```bash curl https://sandbox.api.platform.finbaze.com/v2/oauth2/token \ -H 'content-type: application/json' \ -d '{"grant_type":"client_credentials","client_secret":"fb_sk_test_…"}' ``` Send the `access_token` as `Authorization: Bearer …` on every GraphQL request to `https://sandbox.api.platform.finbaze.com/graphql`. ## 4. Create a company ```graphql mutation { createPlatformCompany(input: { externalId: "acme-1" registrationCountry: NL registrationNumber: "12345678" }) { company { id name } created } } ``` `externalId` is *your* id; calling this again with the same id returns the same company. The returned `company.id` is the profile id the rest of the API uses. ## 5. Create a user and give them access ```graphql mutation { upsertPlatformUser(input: { externalUserId: "user-42", firstName: "Sam", lastName: "Jansen" }) { id } setPlatformUserAccess(input: { externalUserId: "user-42", profileId: "", role: admin }) { role } } ``` Users are hidden: they never get a FinBaze login or email. See [platforms and companies](/concepts/platforms-and-companies). ## 6. Add an endpoint that creates embed sessions Your page cannot hold the secret key, so it asks *your* backend. The backend authenticates your user and creates a session: ```ts app.post("/finbaze/session", requireLogin, async (req, res) => { const { data } = await finbaze(` mutation($input: CreateEmbedSessionInput!) { createEmbedSession(input: $input) { clientSecret } }`, { input: { companyId: req.user.finbazeCompanyId, user: { externalUserId: req.user.id }, components: { taxCalendar: { enabled: true } }, previousSessionId: req.body.previousSessionId, }, }); res.json({ clientSecret: data.createEmbedSession.clientSecret }); }); ``` Each client secret works **once** and for 15 minutes. Do not cache it. Pass `previousSessionId` through; it links refreshes into one chain. ## 7. Install the SDK ```bash npm install @finbaze/embed-react ``` ## 8. Show a component ```tsx import { FinBazeProvider, FinBazeTaxCalendar } from "@finbaze/embed-react"; export function Taxes() { return ( { const res = await fetch("/finbaze/session", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ previousSessionId }), }); return (await res.json()).clientSecret; }} > console.error(e)} /> ); } ``` The SDK calls `fetchClientSecret` again before the token expires, so the component keeps working. ## 9. Match your look ```tsx ``` See the [appearance reference](/components/appearance). ## 10. Next steps - Listen to [events](/components/events) such as `filing.submitted`. - Read the [embed security model](/concepts/embed-security-model) before going live. - Handle [errors](/reference/errors). --- # Guides How to build the integration for your kind of platform, and how to use each part of FinBaze. Concepts explain how one part of FinBaze works. Guides put the parts together for a kind of product. Every guide ends with how to try it in the sandbox: a seeded demo company, a test clock and a webhook receiver you can run locally. ## By partner type | You are | Start here | You will use | | --- | --- | --- | | A bank or neobank | [Banks](/guides/banks) | Bank feed push, `booking-chip`, receipts from the camera, VAT paid from the account, tax reservation | | A point-of-sale vendor | [Point of sale](/guides/pos) | Z-report as a custom record, a flow template, tips and cash differences, VAT return | | A logistics or TMS vendor | [Logistics](/guides/logistics) | Shipment record, invoice from a flow template, carrier invoices, ICP and OSS, reverse charge | | An e-commerce platform | [E-commerce](/guides/ecommerce) | Orders to invoices, OSS, refunds as credit notes | | A scheduling or workforce product | [Scheduling and hours](/guides/scheduling) | Worked hours feed, employees and employments | ## By part of FinBaze | Part | Guide | | --- | --- | | Bank data, statements, feed health | [Data sources](/guides/data-sources) | | What FinBaze says to your users | [Messages and routing](/guides/messages-and-routing) | | Invoices and payslips in your branding | [Documents](/guides/documents) | | Hiding parts of FinBaze for some companies | [Features and limits](/guides/features-and-limits) | | Accounting firms on your companies | [Firms](/guides/firms) | | Closing, moving and exporting a company | [Lifecycle and migration](/guides/lifecycle-and-migration) | | iOS, Android and React Native | [Mobile](/guides/mobile) | ## Runnable examples [Sample apps](/guides/sample-apps) lists the six sample repositories, each with a deploy button, a README and an end-to-end script that runs against the sandbox every night. ## How these guides were checked Every GraphQL block in these pages is validated against the current schema in CI, and every operation name they mention must exist in it. Flow templates in the guides are the same files that live in the sample apps, and those are parsed by the API's own template parser in the API test suite. Where a guide depends on something that is not released yet, it says so in a note. --- # Banks and neobanks Push the bank feed, show bookings next to your own transactions, capture receipts, pay the VAT from the account and set money aside for tax. 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](/guides/sample-apps) 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 ```graphql 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](/concepts/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. ```graphql mutation { registerDataSource(input: { key: "acme-bank" type: bank_feed name: "Acme Bank accounts" cadence: realtime balanceAssertions: true autoAttach: true }) { id key } } ``` ```graphql mutation Account($company: CompanyRef!) { upsertFeedBankAccount(source: "acme-bank", company: $company, account: { externalId: "acc-77" iban: "NL91ABNA0417164300" name: "Business account" currency: EUR }) { id health } } ``` ```graphql 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: ""`) 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](/guides/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. ```tsx import { FinBazeProvider, FinBazeBookingChip } from "@finbaze/embed-react"; function Row({ accountId, tx }: { accountId: string; tx: { id: string } }) { return refresh()} />; } ``` Create the session with `components: { bookingChip: { enabled: true } }`. See the [`booking-chip` page](/components/booking-chip) 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. ```ts 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](/guides/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](/concepts/filing-consent). A submit without it fails with `TAX_AUTHORISATION_REQUIRED`. Register your payment provider once: ```graphql 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: ```tsx { // request.lines: creditor, IBAN, amountMinor, structuredReference, endToEndId const transfer = await openTransferScreen(request.lines, request.endToEndId); return { status: transfer.confirmed ? "accepted" : "cancelled", partnerReference: transfer.id }; }} > ``` - 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](/concepts/delegated-actions#step-up). ## 6. Money set aside for tax ```graphql 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: ```graphql mutation { reportTaxReservation(adviceId: "…", reservedMinor: 42000, accountRef: "savings-tax") { id status } } ``` `taxReservationAdvice(company: …)` lists the advices. See [Delegated actions](/concepts/delegated-actions#tax-reservation). ## 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](/components) 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](/concepts/demo-companies)). 2. `createTestClock` and `advanceTestClock` run the quarter, with the simulated bank feed posting through the same path your feed uses ([Test clocks](/concepts/test-clocks)). 3. Run the [webhook receiver](/guides/sample-apps) 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. --- # Point of sale Turn the Z-report into bookings: sales per VAT rate, tips, cash differences, and a VAT return that follows. A point-of-sale system produces one number set per till per day: the Z-report. FinBaze books it. You push the Z-report as a **custom record**; a **flow template** turns its state change into an invoice and a journal entry, with the same rules as the data API. The [POS demo](/guides/sample-apps) contains the code of this page. > **Shipped Z-report type and template** > > FinBaze ships a `z_report` record type (the schema below, states `open` and `closed`) and a `pos-z-report` template with the `customer` and `sales` steps of section 2. To use them as they are, skip section 1 and call `activateFlowTemplate(key: "pos-z-report", version: 1)`: it copies the shipped template into your platform and activates the copy. Define your own type and template, as below, when you need more (tips, cash differences). The chart template policy can name your platform's tips, cash difference and deposit ledgers once (`tipsLedger`, `cashDifferenceLedger`, `depositLedger`); a template reads them as `{{ ledgers.tips }}`, `{{ ledgers.cash_difference }}` and `{{ ledgers.deposit }}`. ## 1. Define the record type ```graphql mutation { definePlatformRecordType(input: { key: "z_report" name: "Z-report" schema: { type: "object" required: ["till", "closedAt", "sales"] properties: { till: { type: "string" } closedAt: { type: "string" } sales: { type: "array" } tipsMinor: { type: "integer" } cashDifferenceMinor: { type: "integer" } } } states: ["open", "closed"] initialState: "open" displayTemplate: "Z-report {{data.till}} {{data.closedAt}}" }) { version } } ``` Changing the schema or states creates a new version; an identical call returns the current one. ## 2. The template The template has two steps. The invoice is a daily sales invoice to a walk-in customer, one line per VAT rate. VAT comes from the tax code, never from the line, so the VAT return picks it up like any other invoice. The journal entry books tips and the cash difference on ledgers (a journal entry line is a ledger line; lines on a relation or an invoice are not supported yet). ```yaml apiVersion: finbaze.com/flows/v1 kind: FlowTemplate metadata: { key: pos-z-report, version: 1, recordType: z_report } chart: rgs ledgers: till_cash: "" on: - transition: { to: closed } onDataChange: rebook steps: - id: customer upsert_relation: externalId: pos-walk-in legalName: Walk-in customers - id: sales create_sales_invoice: relation: "= steps.customer.id" date: "= $date(record.data.closedAt, 'Europe/Amsterdam')" currency: EUR reference: "= 'Z ' & record.data.till" finalise: true lines: - forEach: "= record.data.sales" as: row name: "= row.name" quantity: 1 priceMinor: "= $net(row.grossMinor, row.taxCode)" taxCode: "= row.taxCode" - id: cash_difference when: "= record.data.cashDifferenceMinor != 0" create_journal_entry: description: "= 'Cash difference ' & record.data.till" date: "= $date(record.data.closedAt, 'Europe/Amsterdam')" lines: - { ledger: "{{ ledgers.cash_difference }}", amountMinor: "= -record.data.cashDifferenceMinor" } - { ledger: "{{ ledgers.till_cash }}", amountMinor: "= record.data.cashDifferenceMinor" } ``` `$net(grossMinor, taxCode)` turns a gross amount into net with the tax code's rate. `onDataChange: rebook` reverses the earlier bookings and books again when a closed report is corrected. Test it without writing anything: ```graphql mutation Test($source: String!, $records: [JSON!]!) { testFlowTemplate(source: $source, format: yaml, records: $records) { valid errors { code message path } runs { recordExternalId trigger plannedObjects { stepId objectType action externalId } error { code message } } } } ``` Then `saveFlowTemplate` (saving the same text again returns the same version) and `activateFlowTemplate(key: "pos-z-report", version: )`. Activation checks that the ledgers named in `ledgers` exist in the companies' charts. The [POS demo](/guides/sample-apps) ships the first two steps (`customer` and `sales`); add `cash_difference` once you know your ledger codes. ## 3. Push the Z-report ```graphql mutation Z($company: JSON!, $data: JSON!) { upsertCustomRecord(input: { company: $company, type: "z_report", externalId: "till-1-2026-03-02", status: "closed", data: $data }) { status record { id revision effects { stepId objectType } } } } ``` with `data` like `{ "till": "1", "closedAt": "2026-03-02T22:40:00Z", "sales": [{ "name": "Food", "grossMinor": 52500, "taxCode": "NL_2019_LOW" }, { "name": "Drinks", "grossMinor": 31000, "taxCode": "NL_2012_HIGH" }], "cashDifferenceMinor": -250 }`. The record id is yours, so pushing again changes nothing; a changed `data` bumps `revision`. - Tips are not revenue. Book them in a separate journal entry step on `{{ ledgers.tips }}` if your policy names one; do not add them to a sales line. - One run is one transaction: if a step fails nothing is kept and the run is `failed`. Runs of one record go in order, so fix the template or data and `retryFlowRun`. Events: `flow_run.succeeded`, `flow_run.failed`. ## 4. Settlements and the VAT return The card settlement shows up in the bank. Push it with the [data source](/guides/data-sources) flow, or let the company connect its bank. Mount `vat-return` and `purchase-inbox` for the VAT return and incoming purchase invoices: ```tsx ``` Create the session with `components: { vatReturn: { enabled: true }, purchaseInbox: { enabled: true } }`. The VAT return needs the company's filing consent ([Filing consent](/concepts/filing-consent)). ## Try it in the sandbox `seedTestCompany(input: { template: nl_horeca_pos })` gives a hospitality sole trader with food at the low rate, drinks at the high rate and daily card settlements. Push a Z-report, check `plannedObjects` with `testFlowTemplate`, then advance a [test clock](/concepts/test-clocks) to the end of the quarter and open `vat-return`. ## Pitfalls - Sending VAT amounts on lines: tax is derived from the tax code. - Correcting a closed report by pushing a new report: update the same `externalId` instead, so the flow rebooks. - Using a ledger code the company's chart does not have: activation fails; run `activateFlowTemplate` before go-live. --- # Logistics and transport Shipments to invoices from a flow template, carrier invoices, ICP and OSS, multiple currencies and reverse charge. A transport management system knows when a shipment was delivered, what it cost and who pays. FinBaze books that. You push **shipments** as custom records; a **flow template** creates the sales invoice when a shipment is delivered and reverses it when it is cancelled. Carrier invoices go in as purchase invoices. The [TMS demo](/guides/sample-apps) contains the code of this page. ## 1. The shipment record ```graphql mutation { definePlatformRecordType(input: { key: "shipment" name: "Shipment" schema: { type: "object" required: ["reference", "customer", "currency", "lanes"] properties: { reference: { type: "string" } customer: { type: "object" } currency: { type: "string" } deliveredAt: { type: "string" } lanes: { type: "array" } } } states: ["booked", "delivered", "cancelled"] initialState: "booked" transitions: { booked: ["delivered", "cancelled"], delivered: ["cancelled"] } displayTemplate: "Shipment {{data.reference}}" }) { version } } ``` ## 2. The template ```yaml apiVersion: finbaze.com/flows/v1 kind: FlowTemplate metadata: { key: shipment-invoicing, version: 1, recordType: shipment } chart: rgs on: - transition: { to: delivered } onDataChange: rebook steps: - id: customer upsert_relation: externalId: "= record.data.customer.id" legalName: "= record.data.customer.name" vatNumber: "= record.data.customer.vatNumber" registrationCountry: "= record.data.customer.country" - id: invoice create_sales_invoice: relation: "= steps.customer.id" date: "= $date(record.data.deliveredAt, 'Europe/Amsterdam')" currency: "= record.data.currency" reference: "= record.data.reference" finalise: true lines: - forEach: "= record.data.lanes" as: lane name: "Transport {{ lane.from }} - {{ lane.to }}" priceMinor: "= lane.revenueMinor" taxCode: "= lane.taxCode" - transition: { to: cancelled } steps: - id: credit reverse: { step: invoice } ``` Each lane carries its own `taxCode` (see [Reverse charge and the EU](#reverse-charge-and-the-eu)). A `reverse` step credits what an earlier run of the record created, using what that run recorded, so editing the template later never changes what is reversed. Test with `testFlowTemplate`, then `saveFlowTemplate` and `activateFlowTemplate` with the version it returned. The syntax is in [Records and flow templates](/concepts/records-and-flows). Push the shipment and move it through its states with `upsertCustomRecord`: ```graphql mutation Ship($company: JSON!, $data: JSON!) { upsertCustomRecord(input: { company: $company, type: "shipment", externalId: "SHP-2026-0042", status: "delivered", data: $data }) { status record { id revision effects { stepId objectType } runs { id status } } } } ``` If a run fails (`flow_run.failed`), nothing of that run is kept and later runs of the record wait. Fix the data or template and call `retryFlowRun`. > **Invoice editor in your shipment page** > > Showing FinBaze's invoice editor inside your shipment page needs the `invoice-editor` component of the extended catalogue. It is not in the registry of every environment yet. Check the [component list](/components) of the sandbox you use; until it is there, create invoices through the template as above and show the result with `transactions` and your own links. ## 3. Carrier invoices A carrier invoice is a purchase invoice. Push it when it arrives and let the company approve it in `purchase-inbox`, or create it with `upsertPurchaseInvoice`: ```graphql mutation Carrier($company: CompanyRef!) { upsertPurchaseInvoice(input: { company: $company externalId: "carrier-4411" purchaseInvoice: { relation: { externalId: "carrier-dhl" } currency: EUR date: "2026-03-04" number: "INV-4411" lines: [{ name: "Linehaul NL-DE", priceMinor: 84000, taxCode: "NL_2024_TRANSFER" }] } }) { status id } } ``` Hidden modules still accept data, and a module that is switched off answers `FEATURE_DISABLED`. See [Sending data](/concepts/sending-data). ## Reverse charge and the EU Tax is derived from the tax code on the line. These are the codes this guide uses: | Situation | Tax code | | --- | --- | | Dutch customer, high rate | `NL_2012_HIGH` | | Dutch customer, low rate | `NL_2019_LOW` | | Dutch reverse charge (verlegd) | `NL_2024_TRANSFER` | | Intra-community supply to a business in another EU country (reverse charge) | `EU_Directive_2006/112/EC_Article_40` | | Consumer in another EU country (OSS) | the country's own code, for example `DE_2025_HIGH` | Which codes a company may use depends on its tax obligations: `nl_vat` allows the Dutch and EU transfer codes, `nl_oss` the EU country codes. Add the obligation when you create the company (`taxObligations`) or the `onboarding` component asks for it. - **ICP.** Supplies with the EU transfer code appear in the ICP return. Mount `icp-return` for the quarter. - **OSS.** Sales to consumers with a country code appear in the OSS return. Mount `oss-return`. - Both returns are filed with the company's consent for `nl_icp` and `nl_oss` ([Filing consent](/concepts/filing-consent)). The components show the period and status; see their pages for what is available per environment. ## Multiple currencies Set `currency` per invoice. Amounts are minor units of that currency (`priceMinor: 12000` is 120.00 EUR or 120.00 USD; for a currency without decimals it is the whole number). The books stay in the company's currency, so FinBaze converts with its own exchange rates. Use `expectedTotalMinor` on upserts to fail with `TOTAL_MISMATCH` if your prices and tax codes do not add up to what you expect. ## Try it in the sandbox `seedTestCompany(input: { template: nl_bv_logistics_oss })` gives a Dutch B.V. with EU customers and VAT, ICP and OSS obligations. Attach a [test clock](/concepts/test-clocks) to run the quarter, then open `icp-return` and `oss-return`. The nightly sandbox benchmark runs the same company through 365 days. ## Pitfalls - A lane without a tax code: the invoice step is rejected and the whole run is rolled back (`flow_run.failed`). - Cancelling a shipment that has no invoice: the `reverse` step fails with `NOTHING_TO_REVERSE`. Guard it with `when`. - Changing a finalised invoice: correct it with a credit note (`creditOf`), not an edit. --- # E-commerce Orders become invoices, EU sales fall under OSS, refunds become credit notes, payouts reconcile. A shop platform sends the merchant's orders to FinBaze as sales invoices, and payouts from the payment provider as bank transactions. FinBaze does the VAT, including OSS for sales to consumers elsewhere in the EU. ## 1. Orders to invoices One order is one invoice, keyed by your order id. Sending it again changes nothing, and `unchanged` in the answer tells you so. ```graphql mutation Order($company: CompanyRef!) { upsertSalesInvoice(input: { company: $company externalId: "order-10442" metadata: { channel: "web", orderNumber: "10442" } salesInvoice: { relation: { externalId: "cust-8812" } currency: EUR date: "2026-03-05" reference: "Order 10442" finalise: true expectedTotalMinor: 4840 lines: [ { name: "Linen shirt", quantity: 2, priceMinor: 1900, taxCode: "DE_2025_HIGH" } { name: "Shipping", priceMinor: 420, taxCode: "DE_2025_HIGH" } ] } }) { status id externalId } } ``` Before the first order, send the customer with `upsertRelation` (a consumer needs no VAT number). Prices are net minor units; tax is derived from the tax code, never taken from the line. `finalise: true` numbers and books the invoice; after that only `metadata` can change. ## 2. Which tax code Choose the tax code from the destination, as your checkout already does: | Sale | Tax code | | --- | --- | | Consumer in the Netherlands | `NL_2012_HIGH` or `NL_2019_LOW` | | Consumer in another EU country, OSS | that country's code, for example `DE_2025_HIGH`, `BE_2025_HIGH` | | Business in another EU country with a valid VAT number | `EU_Directive_2006/112/EC_Article_40` (appears in ICP) | The company needs the matching tax obligation: `nl_vat` for the Dutch and EU transfer codes, `nl_oss` for the EU country codes. Use only codes the company's obligations allow. Mount `oss-return` to show the quarter's OSS return and let the merchant file it. Filing needs the merchant's consent for `nl_oss` ([Filing consent](/concepts/filing-consent)). ## 3. Refunds are credit notes A finalised invoice cannot be edited. Refund by sending an invoice that credits it, with no lines: ```graphql mutation Refund($company: CompanyRef!) { upsertSalesInvoice(input: { company: $company externalId: "refund-10442-1" salesInvoice: { relation: { externalId: "cust-8812" }, currency: EUR, date: "2026-03-09", creditOf: { externalId: "order-10442" }, finalise: true } }) { status id } } ``` ## 4. Payouts The payment provider pays out a lump sum per day. Register a `payout_feed` data source, create the PSP account (`upsertFeedBankAccount` with `type: psp`) and push each payout with `ingestPayoutTransactions`, with the reference the bank statement will show: ```graphql mutation Payout($company: CompanyRef!) { ingestPayoutTransactions(source: "shop-payouts", company: $company, account: "psp-main", payouts: [ { externalId: "po_1NbxYZ", payoutDate: "2026-03-05", arrivalDate: "2026-03-07", amountMinor: 48700, currency: EUR, feesMinor: 300, reference: "SHOPPAY po_1NbxYZ", destination: { iban: "NL91ABNA0417164300" } } ]) { line status error { code } } } ``` FinBaze pairs the payout with the credit on the merchant's bank account when it arrives (by amount, arrival day, reference and IBAN) and books both on the suspense ledger, so the books reconcile with the bank. `payoutTransferMatches` shows the pairs. The mechanics are in [Data sources](/guides/data-sources). ## 5. Events to listen for `sales_invoice.created`, `bank_transaction.booked`, `bank_transaction.needs_review` and `company.requirements.updated` cover most integrations. See the [webhook catalogue](/reference/webhook-events) for the payloads and [Events and webhooks](/concepts/events-and-webhooks) for retries and signatures. ## Many orders at once For a backfill use a bulk job: `POST /v2/platform/bulk-jobs?object=sales_invoices` with an `Idempotency-Key` header and an NDJSON body. Every line is applied on its own; read results with `platformBulkJob(id)`. See [Sending data](/concepts/sending-data#many-objects-bulk-jobs). ## Try it in the sandbox Seed `nl_eenmanszaak_services` or `nl_bv_logistics_oss` (the latter has the OSS obligation), push a day of orders, then advance a [test clock](/concepts/test-clocks) past the quarter end and open `oss-return`. ## Pitfalls - Using a code for a country the company is not registered to sell to under OSS. - Expecting to edit a finalised invoice. Credit it. - Sending one invoice per cart line. One order is one invoice. --- # Scheduling and hours Send worked hours from your scheduling product to payroll: employees, employments, hours with breaks, approval. A scheduling product knows who worked when. FinBaze payroll turns approved hours into pay. You push **employees**, their **employments** (the contract), and **worked hours**; FinBaze keeps the books and the payroll. Plnnrr, a shift planner, is the worked example: its shifts become worked-hours entries one for one. > **What you need** > > The `payroll` feature in your allowance and the `payroll:write` scope on your key. Hidden modules still accept data; a module that is off answers `FEATURE_DISABLED`. ## 1. Employees and employments ```graphql mutation Employee($company: CompanyRef!) { upsertEmployee(input: { company: $company externalId: "plnnrr-emp-31" employee: { firstName: "Sam", lastName: "Jansen", email: "sam@example.com", employeeNumber: "31" } }) { status id } } ``` ```graphql mutation Employment($company: CompanyRef!) { upsertEmployment(input: { company: $company externalId: "plnnrr-emp-31-c1" employment: { employee: { externalId: "plnnrr-emp-31" } startDate: "2026-01-01" contractType: indefinite callType: min_max hoursPerWeek: 24 } }) { status id } } ``` The employment is what hours attach to; hours cannot move to another contract. Wage, tax numbers and the pay run setup stay with the company in FinBaze; your product does not send them. ## 2. A hours feed Register a data source of type `hours_feed` and attach it: ```graphql mutation { registerDataSource(input: { key: "plnnrr-hours", type: hours_feed, name: "Plnnrr hours", cadence: hourly, autoAttach: true }) { id key } } ``` ## 3. Push the hours ```graphql mutation Hours($company: CompanyRef!) { ingestWorkedHours(source: "plnnrr-hours", company: $company, entries: [ { externalId: "shift-9001", employment: { externalId: "plnnrr-emp-31-c1" }, workDate: "2026-03-02", startAt: "2026-03-02T08:00:00Z", endAt: "2026-03-02T16:30:00Z", breakMinutes: 30, status: "draft" } ]) { line status externalId error { code message path } } } ``` - One entry per shift. `externalId` is yours, so sending a shift again updates it. - `endAt` must be after `startAt`, and the break must be shorter than the shift. - `hourType` is an opaque id from the collective agreement's hour-type catalogue (night, weekend, overtime). Send it only if the company's agreement uses types. - `status: draft` can be corrected; `status: approved` is **final** because payroll may already have used it. Correct hours before you approve them. An approved entry cannot be changed or deleted (`OBJECT_FINALISED`). - Results are per entry (`line`, `status`, `error`), so one bad shift does not block the rest. Send corrections for draft hours by calling `ingestWorkedHours` again with the same `externalId`. ## 4. After the hours The company runs and approves its pay runs in FinBaze. Wage tax filings follow the usual consent ([Filing consent](/concepts/filing-consent), tax `nl_wage_tax`), and payment of wages can be handed to your platform with a `payments` provider and `kinds: ["wages"]` ([Delegated actions](/concepts/delegated-actions)). ## Try it in the sandbox `seedTestCompany(input: { template: nl_bv_payroll })` gives a Dutch B.V. with the wage tax obligation. The template does not create employees or pay runs yet, so create them with the calls above, push a week of hours, and read the results. ## Pitfalls - Approving hours too early. - Sending a timestamp without a zone: use ISO 8601 with `Z` or an offset. - Putting personal identifiers such as a BSN in `metadata`. Metadata is plain strings and visible to the company's users. --- # Data sources Register a feed, attach companies, deliver transactions, balances and statement files, and watch feed health. A **data source** is one of your feeds into FinBaze: a bank feed, a card or payout feed, a sales or purchase feed, an hours feed or custom records. You register it once, attach it to companies, and deliver. FinBaze tells you whether each account is healthy. ## Register and attach ```graphql mutation { registerDataSource(input: { key: "acme-bank" type: bank_feed name: "Acme Bank" cadence: daily expectedBy: "07:00" gapThresholdMinutes: 1500 historyMonths: 12 balanceAssertions: true }) { id key status cadence gapThresholdMinutes } } ``` `cadence` is `realtime`, `hourly`, `daily` or `business_daily`; with `daily` and `business_daily`, `expectedBy` (`HH:MM`, Amsterdam time) is when the day's data should have arrived. Together with `gapThresholdMinutes` it decides when an account counts as delayed or in a gap. ```graphql mutation Attach($company: CompanyRef!) { attachDataSource(key: "acme-bank", company: $company, backfillFrom: "2025-04-01") { id status backfillStatus } } ``` Use `autoAttach: true` on the source to attach every new company automatically. `pauseDataSourceAttachment` and `detachDataSource` stop deliveries for one company. Attaching with `backfillFrom` tells FinBaze how far back you will deliver history. ## Deliver | Call | What it does | | --- | --- | | `upsertFeedBankAccount` | A bank, card or PSP account. An existing Lunch Flow account with the same IBAN is taken over with its history. | | `ingestBankTransactions` | Up to 500 booked transactions per call. Result per line. | | `reportBankBalances` | Daily balances (`closing_booked`, `opening_booked`, `closing_available`, `interim_booked`); FinBaze checks them against the transactions. | | `reportFeedWatermark` | "Everything up to `completeThrough` is delivered", also the heartbeat of a quiet account. | | `ingestPayoutTransactions` | Payouts of a `payout_feed` onto a PSP account; each is paired with its bank credit (see [E-commerce](/guides/ecommerce#4-payouts)). | | `ingestSalesInvoices`, `ingestPurchaseInvoices` | Invoices of a `sales_feed` or `purchase_feed`, same fields as `upsertSalesInvoice` / `upsertPurchaseInvoice`. | | `ingestWorkedHours` | Hours of an `hours_feed`. | ```graphql mutation Balances($company: CompanyRef!) { reportBankBalances(source: "acme-bank", company: $company, account: "acc-77", balances: [ { date: "2026-03-02", type: closing_booked, amountMinor: 1254300, currency: EUR } ]) { date status differenceMinor } } ``` - The result of `ingestBankTransactions` is one entry per line (`line`, `status`, `error`). A bad line fails alone. - Errors worth handling: `PENDING_NOT_BOOKED` (you sent `pending`), `IMMUTABLE_TRANSACTION` (a booked line changed: send a reversal), `UNKNOWN_ACCOUNT`, `NOT_ATTACHED` (the company is not attached to the source), `SOURCE_INACTIVE`, `CURRENCY_MISMATCH`. - The first closing balance after attaching sets the opening balance. Later balances are assertions. - Delivered bank rows, feed invoices and feed hours are read-only for the company's users (`PARTNER_SOURCED_READ_ONLY`); they can still process, split and book them. ## Statement files If your core system exports files, not an API, upload them: ```bash curl -X POST "https://sandbox.api.platform.finbaze.com/v2/platform/data-sources/acme-bank/files?format=camt053" \ -H "authorization: Bearer $TOKEN" \ -H "content-type: application/xml" \ --data-binary @statement.xml ``` `format` is `camt053`, `camt052` or `mt940`. The answer is 202 with the file id; parsing is in the background. One file can hold accounts of many companies: FinBaze routes by account IBAN to the attached companies, and lists accounts it could not route in `unroutedAccounts`. The same booking arriving as camt.052, camt.053 and MT940 is stored once. Follow a file with `dataSourceFiles` and the events `data_source_file.processed` and `data_source_file.failed`. ### Over SFTP Register the source with `delivery: { sftp: true }` and ask FinBaze for an SFTP account for your platform. Put each file in the folder of its source, `/` in your SFTP home. FinBaze picks a file up within a minute once it has stopped changing (upload under a `.part` name and rename it when done), imports it like an upload (`receivedVia: sftp`) and moves it to `processed//`. A file it cannot take moves to `failed/`, with the reason in `.error.txt`. ## Feed health ```graphql query { feedHealth(status: [delayed, gap, balance_mismatch], first: 50) { nodes { externalId health statusSince lastDeliveryAt completeThrough } nextCursor } } ``` | Health | Meaning | | --- | --- | | `healthy` | A delivery or heartbeat arrived when expected and balances match. | | `delayed` | Nothing arrived when expected, but the silence is shorter than `gapThresholdMinutes`: past `expectedBy` for daily feeds, more than 7.5 minutes (real-time) or 90 minutes (hourly) for the others. | | `gap` | Silence longer than `gapThresholdMinutes`, or a statement number is missing. Send the missing days, or a watermark if there was nothing to send. | | `balance_mismatch` | Transactions and the reported balance disagree; nothing in the books changed. | | `stopped` | The attachment or source is paused or retired. | A delivery or a watermark counts as a sign of life. Events: `feed.delayed`, `feed.gap_detected`, `feed.balance_mismatch`, `feed.recovered`. Real-time and hourly feeds should send `reportFeedWatermark` at least once per gap threshold, otherwise a quiet account looks like a gap. ## Try it in the sandbox A seeded company already has a simulated bank account with a feed scenario (`steady`, `retail_cards`, and so on) that advances with a [test clock](/concepts/test-clocks). To exercise your own feed, create a second company without a clock, register a source and push to it. ## Pitfalls - Sending the same data under a new `externalId`: it becomes a second transaction. - Not sending balances: the books cannot be checked, and `balance_mismatch` never fires. - Forgetting to attach: deliveries answer `NOT_ATTACHED`. --- # Messages and routing Receive what FinBaze would email your users, deliver it in your channels, acknowledge it and change who sends what. FinBaze never emails your users on its own for a company on your platform. It creates a **message**, routes it by who should deliver it, and tells you. This guide is the working path; [Messages](/concepts/messages) has the full rules and the [message catalogue](/reference/message-catalogue) lists every type. ## 1. Subscribe Create a webhook endpoint with `message.created` and `message.unacknowledged` (see [Events and webhooks](/concepts/events-and-webhooks)): ```graphql mutation { createPlatformWebhookEndpoint(input: { url: "https://example.com/finbaze" eventTypes: ["message.created", "message.unacknowledged"] }) { endpoint { id } signingSecret } } ``` `signingSecret` is shown once. Verify every delivery with it (receivers in Node, Python and PHP are in the [sample apps](/guides/sample-apps)). ## 2. Deliver `data.object` of `message.created` is the message. It already has the text for each channel (`content.push`, `content.in_app`, `content.email`, `content.sms`) and a `deep_link` your app routes to. Deliver in your own channel, then acknowledge: ```graphql mutation Ack($id: ID!) { acknowledgeMessage(input: { id: $id, channel: "push", outcome: "delivered" }) { id status acknowledgedAt } } ``` Dedupe on `dedupe_key`; do not deliver after `expires_at`. Acknowledge at least the messages with `ack_required`: critical messages and those that serve as evidence (invoices and reminders you deliver). Use `acknowledgeMessages` for up to 100. `outcome: "failed"` tells FinBaze you could not deliver; the message stays unacknowledged and you get `message.unacknowledged` after `ack_deadline`. ## 3. Choose who sends what ```graphql mutation { setPlatformMessageRouting(input: { scope: "category", category: "tax_deadline", routing: "partner_delivers" }) { id routing } } ``` `routing` is `partner_delivers` (the default for notifications), `finbaze_sends`, `both` or `suppress`. A rule applies to everything (`scope: "all"`), one category or one type, for the platform or for one company (`profileId`). The most specific rule wins. `finbaze_sends` needs a verified sender domain for your platform; without one the message falls back to `partner_delivers`. `platformMessageRouting` lists your rules, and `clearPlatformMessageRouting` removes one. Lifecycle, billing and FinBaze account-security messages are never delivered for platform companies and cannot be changed (`ROUTING_NOT_CHANGEABLE`). ## 4. Settings ```graphql mutation { updatePlatformMessageSettings(input: { quietHoursStart: "22:00", quietHoursEnd: "07:30", quietHoursTimezone: "Europe/Amsterdam", digestHourLocal: 8, brandName: "Acme Books" }) { digestHourLocal quietHoursStart brandName } } ``` Critical messages ignore quiet hours and limits. More than 20 messages per person per hour, or 3 of one type, fold into an hourly digest. ## 5. Read the log ```graphql query { platformMessages(filter: { unacknowledgedOnly: true, limit: 50 }) { id type priority status routing routingReason ackDeadline } } ``` `routingReason` tells you why: `catalogue_default`, `platform_category`, `company_override`, `hard_rule`, `feature_hidden`. `resendPlatformMessage` queues a message again. ## Push notifications FinBaze sends push only to its own mobile app, for companies that are not on a platform. For a platform company you send the push yourself from `message.created`. On mobile, the push can carry an intent that opens the right component; see [Mobile](/guides/mobile). ## Pitfalls - Treating a webhook delivery as unique: deliveries are at least once. - Forgetting to acknowledge critical messages: after the deadline FinBaze raises `message.unacknowledged`, and with `criticalEmailFallback` on it may email the user itself. - Routing to `finbaze_sends` without a verified sender domain. --- # Documents Put your branding on invoices, quotes, reminders and payslips: presets, templates, or render the PDF yourself. FinBaze keeps the official copy of every invoice, quote, reminder, payslip and year document. You choose how it looks, per document type, at one of three levels. [Documents and rendering](/concepts/documents-and-rendering) explains each; this guide is the order to do things in. ## Pick a level | Level | When to choose it | Effort | | --- | --- | --- | | `finbaze_layout` | Your colours, fonts and logo are enough. | Small | | `template` | You want your own HTML for the page, rendered by FinBaze. | Medium | | `partner` | You already render PDFs and want one look everywhere. | Highest | ```graphql mutation { setPlatformDocumentRenderSetting(documentType: invoice, mode: finbaze_layout) { documentType mode timeoutSeconds } } ``` Whatever the level, required content (VAT number, address, invoice number, reverse-charge text) is checked. A rule the tax adviser has not confirmed gives a warning; others refuse activation, or reject your PDF, and FinBaze then falls back to its own layout. ## 1. A preset ```graphql mutation Preset($layout: JSONObject!) { upsertPlatformDocumentLayout(input: { documentType: invoice, name: "Acme classic", layout: $layout, companyEditable: unlocked_blocks }) { id version status warnings { ruleId field message severity } } } ``` Check a layout before you save it with `validateDocumentLayout(documentType: invoice, layout: …)`, then `activatePlatformDocumentLayout(id: …, makeDefault: true)`. `platformDocumentLayoutPreview(id: …)` returns the HTML. A company can choose among your presets (`chooseDocumentLayout`) or edit the blocks you unlock. ## 2. A template ```graphql mutation Template($source: String!, $css: String) { upsertDocumentTemplate(input: { documentType: invoice, name: "Acme", source: $source, css: $css }) { id status version } } ``` Then `validateDocumentTemplate(id: …)` renders twelve fixtures and checks the required content, and `activateDocumentTemplate(id: …, makeDefault: true)` works only after a pass. Templates are logic-less: placeholders, loops and conditions over the document data; scripts, iframes, remote images, `@import` and SVG with script are refused. `documentTemplatePreview(id: …, fixture: "default")` shows a render. ## 3. Render it yourself Set the mode to `partner` and subscribe to `document.render_requested`: ```graphql mutation { setPlatformDocumentRenderSetting(documentType: invoice, mode: partner, timeoutSeconds: 60) { mode timeoutSeconds } } ``` The event carries the job, the data model (never a BSN), a deadline and signed links to attachments such as the invoice UBL. Render the PDF and answer before the deadline: ```graphql mutation Attach($job: ID!, $pdf: String!) { attachRenderedDocument(jobId: $job, pdfBase64: $pdf) { id status } } ``` For larger files upload first (up to 10 MB, valid for an hour) and pass `uploadId` instead of `pdfBase64`. FinBaze scans the PDF (no scripts, no embedded files) and checks its text against the document's facts. Emails and payment pages wait for the job; after a rejection, a timeout or no answer FinBaze renders its own layout. Events: `document.render_requested`, `document.render_rejected`, `document.render_failed`, `document.official_copy_created`. `documentRenderJob(id: …)` shows a job. ## 4. Your own domain ```graphql mutation { addPlatformCustomDomain(hostname: "pay.acme.nl") { id status } } ``` Create the DNS records the answer lists, then `checkPlatformCustomDomain(id: …)`. FinBaze issues the certificate; `custom_domain.status_changed` follows the status. A company is served only on its own platform's domains. ## Promote from the sandbox Presets and templates travel with the `documents` policy when you promote your configuration from the sandbox (`previewPlatformPromotion`, `applyPlatformPromotion`). ## Try it in the sandbox Seed a company, finalise an invoice with `upsertSalesInvoice(… finalise: true)` and fetch it with `companyDocument(company, kind: sales_invoice_pdf, …)`; the link works for 15 minutes without a token. ## Pitfalls - Activating without validating: activation fails if the report has failures. - Answering a `partner` render after the deadline: the job has already fallen back. - Putting personal data you do not need in a template fixture. --- # Features and limits Hide parts of FinBaze for your platform or one company, see what is effective, and know what hiding removes. FinBaze decides what a company can use in two layers. The **allowance** is what FinBaze puts in your contract. **Limits** are yours: you can only narrow what the allowance gives you. There is exactly one limit: **hidden**. There are no upsell states and no segments. See [Feature allowance and limits](/concepts/feature-allowance-and-limits). ## See what you have ```graphql query { platformFeatureAllowance { version features { key } } } ``` ```graphql query Effective($company: ID!) { platformCompanyFeatures(profileId: $company) { key enabled source blockedBy } } ``` `source` and `blockedBy` tell you why a key is off for a company: not in the allowance, hidden by you, or blocked by a key it requires. ## Hide something ```graphql mutation { setPlatformFeatureLimit(input: { key: "banking", hidden: true }) { key level hidden } } ``` Add `profileId` to hide it for one company only. `clearPlatformFeatureLimit(key: "banking")` shows it again, and `platformFeatureLimits` lists what you set. Changes reach every API server within about 30 seconds. ## What hiding removes A hidden module is gone, not locked: - Its operations fail with `FEATURE_DISABLED`; its data is absent from nested results. - Components that need it cannot be added to a session (`createEmbedSession` fails with `FEATURE_DISABLED`), and mounted ones lose its parts. - Events of the module are not published for the company. - Data you send to a hidden module is still accepted (you may show the module later). A module that is **off** (not in the allowance) answers `FEATURE_DISABLED` instead. - Accounting firms working on the company see the same: a hidden module is hidden for them too. ## Feature keys you may limit Keys such as `banking`, `sales_invoices`, `purchase_invoices`, `tax_filings`, `submit_tax_filings`, `one_stop_shop_filing`, `icp_filing`, `payroll`, `forecasts`, `budgets`, `annual_accounts`, `automations`, `ai_assistant`, `custom_records` and `firm_access` are limitable. A key that is `beta` is available only where FinBaze has switched it on for you. Your allowance shows exactly what applies to you. ## Component features are separate A component's own features (for example `submit` on `vat-return`) are chosen per session in `createEmbedSession` and never override a hidden module: ```graphql mutation { createEmbedSession(input: { companyId: "…", user: { externalUserId: "user-42" }, components: { vatReturn: { enabled: true, features: { submit: false } } } }) { clientSecret } } ``` ## Try it in the sandbox Hide `banking` for one seeded company, call `platformCompanyFeatures` and watch `transactions` refuse to join a new session. Show it again and retry within a minute. ## Pitfalls - Expecting hidden to mean "visible but locked": there is no locked state. - Hiding `firm_access`: companies with a connected firm get a notice period before it ends; see [Firms](/guides/firms). --- # Accounting firms Let a company on your platform connect its accountant: invite, approve, what the firm can do, and how to end it. A company on your platform can connect one accounting firm that uses FinBaze. The firm works the company in its normal FinBaze dashboard, inside the features effective for that company. There are no firm services, no reselling and no marketplace, and **no firm billing**: you keep paying for the company, the firm pays nothing for it. [Working with accountants](/concepts/working-with-accountants) has the full rules. ## 1. Show the connect component ```graphql mutation { createEmbedSession(input: { companyId: "…", user: { externalUserId: "user-42" }, components: { accountantConnect: { enabled: true } } }) { clientSecret } } ``` ```tsx refresh()} onFirmDisconnected={({ firmId }) => refresh()} /> ``` The company admin does the rest: find the firm by KvK number or connect code, choose the taxes the firm may file, confirm the statement. The component features are `invite`, `approve` and `end`. Consent needs an `embed` or `headless` token of a company **admin**; your server token cannot consent for a company (`FORBIDDEN_TOKEN_TYPE`). ## 2. Know when a firm asks A firm can also ask for access using the company's KvK number. You hear about it as `firm.access_requested` (webhook and message). The company approves or declines it in `accountant-connect`, shown through the `notification-banner`. ## 3. Read the state ```graphql query { companyFirmEngagements(companyId: "…", includeEnded: false) { id status origin taxes filingAuthority expiresAt endsAt firm { id name hasBecon } } } ``` A server key needs the scope `platform_firms:read`. Events: `firm.connection_requested`, `firm.connected`, `firm.connection_declined`, `firm.connection_expired`, `firm.disconnect_scheduled`, `firm.disconnected`, `firm.authority_changed`. ## 4. What the firm files Nothing, unless the company hands taxes to it. For those taxes the firm files with its BECON number and FinBaze stops filing them. Other taxes stay with FinBaze under the company's own [consent](/concepts/filing-consent). Requests only an intermediary may make, such as the corporate income tax extension, need a connected firm with a BECON number; for a company without one the request is refused with `FILER_MUST_BE_INTERMEDIARY`. ## 5. `firm_access` and ending a connection Accountant connections are the feature key `firm_access`. Hide it ([Features and limits](/guides/features-and-limits)) and no new connections or requests are possible; companies with an active firm get a notice (30 days by default) and `firm.disconnect_scheduled`. Show it again before then to cancel the notice. You can end an engagement yourself: ```graphql mutation { endFirmEngagement(input: { engagementId: "…", reason: "platform_request" }) { id status endedAt endedBySide endReason } } ``` A platform may use the reasons `firm_access_hidden`, `company_closed`, `company_transferred` and `platform_request`; the last is logged and both sides are told. The scope is `platform_firms:write`. Ending is one transaction: the firm loses access on the next request, its BECON is no longer used for the company, its HMRC client authorisations for the company are cancelled, its open document requests are closed. The company's drafts and books stay, and FinBaze files the taxes the firm used to file once the company's consent exists. ## Try it in the sandbox Create two companies, connect a test firm from the FinBaze sandbox dashboard to one, and compare `companyFirmEngagements`. End it with `endFirmEngagement` and watch `firm.disconnected` arrive at your receiver. ## Pitfalls - Trying to connect a platform company through a firm's invite link, onboarding or client creation: refused with `PLATFORM_COMPANY_USE_ENGAGEMENT`. - Planning on billing the firm: there is no firm billing. - Expecting your server key to give consent: only the company's admin can. --- # Lifecycle and migration Suspend, close, archive, transfer and export a company, and bring books in from another system. Companies on your platform move through `active`, `suspended`, `closed` and `archived`, and can move to another platform or to direct FinBaze. Books, filings, documents and Digipoort or HMRC history are never deleted by any of this. [Company lifecycle](/concepts/company-lifecycle) has the state rules; this guide is the runbook. ## Customer stops paying: suspend ```graphql mutation { suspendPlatformCompany(companyId: "…", reason: "non-payment", displayMessage: "Your account is on hold.") { id lifecycle { state } } } ``` The company becomes read-only (`COMPANY_SUSPENDED` on changes); cron work such as reminders and bank sync skips it. Reactivate with `reactivatePlatformCompany`. A company FinBaze suspended cannot be reactivated by you (`SUSPENDED_BY_FINBAZE`). ## Customer stops trading: close, then archive ```graphql query { companyClosingChecklist(companyId: "…", effectiveDate: "2026-10-31") { key label blocking done filingId } } ``` Close with `closePlatformCompany(companyId: "…", effectiveDate: "2026-10-31")`. Final returns can still be filed and exported. `reopenPlatformCompany` works within the reopen window (90 days by default). When every blocking checklist item is done, `archivePlatformCompany` detaches the company from your platform. Only exports work after that, and the books are kept until `retentionUntil` (7 years in the Netherlands by default). An open blocking item gives `ARCHIVE_BLOCKED`. Events: `company.suspended`, `company.reactivated`, `company.closed`, `company.reopened`, `company.archived`. ## Customer moves platform or to FinBaze The company's admin consents in the `company-transfer` element (it shows a versioned statement). Mount it with `components: { companyTransfer: { enabled: true } }`. - **To another platform:** the element shows a one-time code, once. The receiving platform's backend calls `acceptCompanyTransfer(code: "…", externalId: "their-id")`. - **To direct FinBaze:** the admin enters an email and FinBaze emails a claim link; a FinBaze account claims the company and picks a plan. On completion your platform loses access (users, limits, data sources, sessions) and you receive `company.transferred_out`; the receiving side gets `company.transferred_in`. You never see the new owner's id. `cancelCompanyTransfer(transferId: …)` cancels a pending one. Errors: `TRANSFER_CODE_INVALID`, `TRANSFER_CODE_EXPIRED`, `TRANSFER_ALREADY_PENDING`, `TRANSFER_STATEMENT_OUTDATED`. ## Getting the books out Before a company leaves, give it its data. ```graphql mutation { requestXafExport(company: { externalId: "biz-1001" }, financialYear: 2025) { id status } } ``` Poll `platformExportRun(id: …)`; when ready its `document` carries a `downloadUrl` (valid 15 minutes), and `export.ready` is sent. The audit file is Auditfile Financieel 4.0 (validated against the official XSD, VAT per line); pass `version: "3.2"` for a receiving system that still needs 3.2. Reports (`platformReport`), documents (`companyDocument`) and scheduled exports to your own bucket are in [Getting data out](/concepts/getting-data-out). > **XAF in** > > There is no XAF import. To bring books in from another system, load the opening position as a journal entry and history as invoices and relations, with `upsertJournalEntry`, `upsertSalesInvoice`, `upsertPurchaseInvoice` and `upsertRelation`, or as a bulk job (`journal_entries`, `sales_invoices`, `purchase_invoices`, `relations`). Everything keeps your ids, so a failed load can run again. See [Sending data](/concepts/sending-data). ## Ending a user `deprovisionPlatformUser(userId: …)` revokes every token and session of the user at once and removes the user from all companies. A later `upsertPlatformUser` with the same `externalUserId` brings the user back with a new identity; old tokens stay dead. ## Audit ```graphql query { platformAuditLogs(first: 50) { edges { id at action actorKind requestId } nextCursor } } ``` Needs the `audit:read` scope and an admin role, and shows only your own companies. `requestAuditLogExport` starts an NDJSON export. ## Try it in the sandbox Seed a company, `suspendPlatformCompany`, try a write and see `COMPANY_SUSPENDED`, reactivate, then close it with the checklist. The sandbox reset removes companies not marked `preserve` every night ([Sandbox reset](/concepts/sandbox-reset)). ## Pitfalls - Archiving too early: blocking checklist items are the final returns. - Telling the company its books are deleted: they are kept until the retention date. - Handing a transfer code over by email: it works once and shows once; deliver it inside your product. --- # Mobile FinBaze in iOS, Android and React Native apps: the WebView host, camera, step-up with your own biometrics, and opening a component from a push. > **Beta, planned for Wave 6.2** > > The mobile SDKs are `0.x` betas that ship with Wave 6.2. This page describes the design they follow. The packages (`@finbaze/embed-react-native`, `finbaze-ios`, `finbaze-android`) and the API pieces named below (`registerPlatformMobileApp`, `mobileAppId` on `createEmbedSession`, `resolveEmbedIntent`) are **not in the schema or registry this version of the docs is generated from**. Code blocks marked `text` are the planned shapes, not calls you can run today. The [API changelog](/changelog) announces each when it ships. ## How it works All three SDKs host the same embed app in a WebView, as a top-level page at `https://embed.finbaze.com/m/` (sandbox: `https://sandbox.embed.finbaze.com`), not in an iframe. Native code adds what a WebView cannot do: camera and files, biometric step-up through your app, native sheets, and deep-link hand-off. The embed token stays in WebView memory and the SDK never stores it. ```text Your app SDK WebView embed.finbaze.com/m/ fetchClientSecret() <------ FinBaze(publishableKey, appId) loads /m/?pk=...&app=... onStepUpRequired() <------ stepup.required component needs approval camera / scanner <------ capture.request file bytes go back via the bridge handle(notification) ------> open the component's sheet ``` ## Register the app In the portal you register each app (`ios` or `android`, bundle id or package name, URL scheme). A session created for a mobile app can only be exchanged through the `/m/*` shell with that app id; a session without one only inside an allowed web origin. Use the publishable key of the same platform on the device. ## Camera and files The component asks for a photo or file; the SDK opens the native camera or document scanner (VisionKit, CameraX or ML Kit, or the libraries already in your React Native app) and sends the bytes back. Files over 10 MB are rejected natively. If you need your own scanner, the SDKs expose a capture callback, the same idea as `onCameraCapture` in the [browser SDK](/sdks/embed-js). ## Step-up with your own biometrics FinBaze does not implement biometrics. Banks own strong customer authentication. When a component needs approval (filing a VAT return, approving a payment request) the API answers `STEP_UP_REQUIRED` with a challenge and the SDK calls your `onStepUpRequired`. You run Face ID, BiometricPrompt or your SCA server, and complete the challenge from your backend with your own assertion, the same server-side contract as [Delegated actions: step-up](/concepts/delegated-actions#step-up). Platforms that are not banks can opt into a device-local confirmation in the portal; it is a weaker signal and is labelled as such in the audit log. ## Open a component from a push FinBaze holds no APNs or FCM credentials for partners. A message routed `partner_delivers` reaches your webhook ([Messages and routing](/guides/messages-and-routing)); for mobile it carries an action that names the component and a signed, opaque `intent` valid for 7 days. You send your own push with the intent in the payload. On tap you pass the payload to the SDK, which asks your backend for a client secret for that component and opens it. Your backend can verify an intent with `resolveEmbedIntent` before minting the session. ```text // Planned shape, iOS let finbaze = FinBaze(publishableKey: "fb_pk_test_...", mobileAppId: "fb_app_...") { request in try await api.finbazeClientSecret(component: request.component, company: request.company) } finbaze.onStepUpRequired = { challenge in try await bank.performSCA(challenge) } if finbaze.canHandle(userInfo) { finbaze.handle(userInfo, presentingFrom: rootVC) } ``` ## WebView security The SDKs only load the FinBaze embed origins in the WebView and open everything else in the system browser. They disable file access and mixed content, allow the bridge only from the FinBaze origin, turn WebView debugging off in release builds, offer screen protection for payroll components, and bundle no analytics. ## What works today The same components run in the browser through [`@finbaze/embed-js`](/sdks/embed-js), including the camera, payment and step-up callbacks. The [neobank demo](/guides/sample-apps) has a web part you can deploy now; its Expo app ships with the mobile SDKs. ## Pitfalls - Treating a leaked client secret as harmless: without app attestation it can be exchanged by any app within 15 minutes, once. Mint it only for a signed-in user, and return a new one each time. - Persisting the embed token: do not; the WebView holds it. --- # Sample apps Six runnable examples with a deploy button, a README and an end-to-end script that runs against the sandbox every night. Each sample is a public repository under `github.com/FinBaze/`. Each has a deploy configuration, a README and an end-to-end script that sets up the demo company, does what the app does, and checks the result against the sandbox. The scripts run **every night** in CI; a failure opens an issue. | Repository | Stack | Shows | Deploy | | --- | --- | --- | --- | | `neobank-demo` | Next.js | Bank feed push, `booking-chip`, camera receipts, VAT paid from the account (`onPaymentRequested`), step-up mock. See [Banks](/guides/banks). | Vercel button, Docker | | `pos-demo` | Next.js | Z-report to bookings with a flow template, VAT return, purchase inbox. See [Point of sale](/guides/pos). | Vercel button, Docker | | `tms-demo` | Next.js, Node | Shipment to invoice with a flow template, carrier invoices, ICP and OSS. See [Logistics](/guides/logistics). | Render blueprint, Docker | | `headless-demo` | Node, React | A headless session and hooks with your own UI. See [Headless hooks](/sdks/headless). | Render blueprint, Docker | | `webhook-receivers` | Node, Python, PHP | Signature verification, idempotent handling, replay. See [Webhook events](/reference/webhook-events). | Docker per language, Render | | `finbaze-embed-examples` | Vanilla, React, Vue, Svelte | Every component with appearance variants. | StackBlitz links | > **What is not in the repositories yet** > > The Expo (mobile) part of the neobank demo, push hand-off and the Kotlin webhook receiver ship with the mobile and server SDKs of Wave 6. The READMEs say so where it matters. The samples install `@finbaze/embed-react` and `@finbaze/embed-js` from npm. ## One click 1. Open `https://developers.finbaze.com/keys/new?sample=` (for example `?sample=pos-demo`) and sign in. The page sets up your sandbox for that sample with one call, `bootstrapSampleApp`. 2. Click the deploy button in the README and paste the keys it asks for. 3. Add your deployment's URL under **Embedding → Allowed origins** in the portal. 4. Run the sample's setup script once (`npm run setup:sandbox`). ## What `bootstrapSampleApp` does ```graphql mutation Bootstrap($webhookUrl: String) { bootstrapSampleApp(sample: "pos-demo", webhookUrl: $webhookUrl) { secretKey publishableKey webhookSecret companyId } } ``` - **Sandbox only.** Anywhere else it answers `SANDBOX_ONLY`. Portal sign-in only: a key cannot call it, so a leaked key cannot mint more keys. - **What it creates.** A restricted test key (`fb_rk_test_…`) with the scopes the sample needs, the publishable key, a webhook endpoint at `webhookUrl` subscribed to every event (when you give a URL; otherwise `webhookSecret` is `null`), and the sample's demo company ([Demo companies](/concepts/demo-companies)): the hospitality sole trader for `pos-demo`, the logistics B.V. for `tms-demo`, a services sole trader for the others. Seeding is idempotent, so the same company comes back. - **Shown once.** `secretKey` and `webhookSecret` are not shown again; run it again for new ones. A new bootstrap of the same sample revokes that sample's earlier keys and endpoint, so keys and endpoints do not pile up. - **Limit.** At most 5 a day per developer (`RATE_LIMITED`). Samples: `neobank-demo`, `pos-demo`, `tms-demo`, `headless-demo`, `webhook-receivers`, `finbaze-embed-examples`. Any other name is refused with a 400 that lists them. ## Run them against your own sandbox Every e2e script reads `FINBAZE_SECRET_KEY` and `FINBAZE_COMPANY_ID` (and optionally `FINBAZE_API_URL`). They use a 40-line GraphQL client and no SDK, so you can read each call. Each README shows the file layout and which file shows what. ## Offline In the FinBaze repository, `node samples/scripts/run-offline.mjs` runs every sample's e2e against a mock sandbox that validates each operation against the real schema. It proves the samples send valid operations; the nightly run against the sandbox proves the behaviour. --- # Upgrading What changes in your integration between releases, how versions work, and what to do about each change. The [changelog](/changelog) lists everything. This page lists what asks something of **your code**, newest first, and how to read versions. The current versions are on [Versions](/reference/versions). ## How versions work today - **API version.** Every webhook delivery carries `apiVersion` in its body and `X-FinBaze-Api-Version` in its headers. Until a second version exists, every endpoint gets the current one; pinning a version per platform is not available yet. Branch on fields you know, ignore fields you do not. - **GraphQL.** One schema. A change to it needs an entry in the changelog. Fields and operations are added; read the changelog before you rely on removed or renamed ones. - **Browser SDKs** (`@finbaze/embed-js`, `@finbaze/embed-react`) are `0.x` and follow semver. The controller loads from FinBaze's origin, so a component is always current; the SDK is the thin part that mounts it. Pin the SDK version in your lockfile and update it deliberately. - **Server SDK** (`@finbaze/platform-sdk`) is 1.0 and not on npm yet; the plain GraphQL calls on its page do the same work. - **Mobile SDKs** are planned as `0.x` betas with Wave 6.2; they will follow semver, and the protocol between the SDK and the embed app carries a version so that old SDK versions keep working for at least 12 months. See [Mobile](/guides/mobile). ## What asks something of you ### Filing consent (Wave 5.1) A platform company's returns are filed only with its admin's authorisation. A submit without it fails with `TAX_AUTHORISATION_REQUIRED`. What to do: mount `tax-authorisation` or `onboarding` where the company's admin sets up filing, and hide your own submit button for companies without consent (read `taxAuthorisationStatus`). See [Filing consent](/concepts/filing-consent). ### Webhook secrets and rotation When you rotate a secret (`rotatePlatformWebhookSecret`), both secrets sign for the period you choose, so the header carries two `v1` values. What to do: make your verifier accept a list of secrets and any matching `v1` ([receivers in three languages](/guides/sample-apps)), deploy the new secret, then let the old one expire. ### Sandbox resets Companies that are not marked `preserve` are removed in a nightly reset when you switch it on. What to do: seed fixtures with stable `externalId`s (`seedTestCompany` does) and make your tests idempotent. See [Sandbox reset](/concepts/sandbox-reset). ### Client secrets A secret is single use and valid for 15 minutes. If you cached one, you will see `EMBED_SECRET_USED`. What to do: return a new secret from every `fetchClientSecret` call. ## Planned: stricter operation checks for embed and headless tokens Embed and headless tokens already run only allowlisted operations. The check is moving to identify operations by the hash of their normalised document, rolled out in the sandbox first in a mode that only logs, then enforced after the gallery and the sample apps pass for a week. What to do: nothing, as long as you use FinBaze's components and hooks. If you run your own documents with a headless token, they will be refused once the check is enforced: use the server API for those. The changelog announces each step. ## When something does not work after an upgrade 1. Read the [error codes](/reference/errors) for the code you see. 2. Check the changelog entry of the release. 3. Send the `X-Request-Id` of the failing request to support. --- # Platforms and companies The objects you work with: your platform, its companies, and the hidden users who act on them. ## Platform A *platform* is you: the product that embeds FinBaze. It owns keys, allowed origins, embed settings and the list of companies. Sandbox and live are separate platforms with separate data. ## Company A *company* is one of your customers' businesses. It has its own ledger, tax obligations and documents. You create it with `createPlatformCompany` and your own `externalId`: - The same `externalId` always returns the same company, so the call is safe to retry. - `company.id` is the profile id used by every other operation. - A company belongs to exactly one platform. It cannot be seen by FinBaze accounts outside that platform. ## Users A *platform user* is a person in your product who works on one or more companies. You identify them with your own `externalUserId`. Users are **hidden**: FinBaze never sends them email, never shows a login page and they cannot sign in to FinBaze directly. Their only way in is a token that your backend mints for them. Access is per company, with a role: | Role | Can | | --- | --- | | `admin` | Everything, including submitting filings and managing access | | `member` | Day-to-day bookkeeping | | `viewer` | Read only | Disable a user (`upsertPlatformUser(disabled: true)`) or remove their access and every embed session of theirs is revoked within seconds. ## Isolation A user token only sees the companies the user has access to, and only inside your platform. A secret key only sees your platform's companies. Neither can reach another platform's data. --- # Identity and tokens Keys, server tokens, client secrets and embed tokens: what each is for and how long it lives. | Credential | Looks like | Lives | Where | Use | | --- | --- | --- | --- | --- | | Secret key | `fb_sk_live_…` / `fb_sk_test_…` | until you roll it | your server only | exchange for a server token | | Restricted key | `fb_rk_…` | until you roll it | your server only | like a secret key, limited to chosen scopes | | Publishable key | `fb_pk_live_…` / `fb_pk_test_…` | until you roll it | browsers | identifies your platform to the SDK; grants nothing | | Server token | JWT | short | your server | call the GraphQL API | | Client secret | `es_…_secret_…` | 15 minutes, **single use** | your server → browser, once | start an embed session | | Embed token | JWT, `typ: embed` | 1 hour by default | inside FinBaze's iframe only | run the component's operations | | Headless token | JWT, `typ: headless` | 1 hour by default | your page | run the headless hooks | ## From secret key to server token `POST /v2/oauth2/token` with `grant_type=client_credentials` and `client_secret=`. A restricted key can ask for a subset of its scopes with `scope`. ## From client secret to embed token 1. Your backend calls `createEmbedSession` and returns `clientSecret` to the page. 2. The SDK's hidden controller iframe (on FinBaze's origin) calls `exchangeEmbedSession`. The exchange consumes the secret atomically: a second use fails with `EMBED_SECRET_USED`. 3. The embed token is held in memory in that iframe and handed to component iframes over a private channel. It never reaches your page, local storage or cookies. 4. Before the token expires the SDK asks `fetchClientSecret` for a **new** secret and repeats. There are no refresh tokens: your backend decides, every time, whether the user may continue. A chain of refreshes ends after `embed.max_chain_hours` (12 by default); the user then needs a session minted without `previousSessionId`. ## What a token can do An embed token is pinned to one company, one user and the components in the session. It can only run FinBaze's own operations for those components, and only the features you left on. Everything else is refused (`EMBED_OPERATION_NOT_ALLOWED`). See [embed security model](/concepts/embed-security-model). ## Revoking keys Revoking or rolling a secret, restricted or publishable key refuses new token exchanges at once. Server and user tokens already minted with that key stop working on the next request: immediately on the API server that handled the revocation and normally within a second on the others. When the internal message between servers is lost, a server's cache still expires after **10 seconds**, so plan for up to 10 s in which an old token may still be accepted. The same applies to suspending your platform, disabling a platform user and removing a user's access to a company. ## Rate limits Limits count requests per rolling minute and are shared by all API servers. Over a limit the API answers HTTP `429` with code `RATE_LIMITED`; wait for the next minute and retry with backoff. | What | Counted per | Limit per minute | | --- | --- | --- | | GraphQL requests with a server or user token | key the token was minted with | 600 | | GraphQL requests with an embed or headless token | platform user | 600 | | `POST /v2/oauth2/token` with a platform key | key (by its lookup id), failed attempts included | 60 | | `createPlatformUserToken` | key | 300 | | `createPlatformCompany` | key | 30 (it calls the company registers) | | `createEmbedSession` | key | 600 | | `exchangeEmbedSession` | publishable key | 600 | | `exchangeEmbedSession` | IP address | 30 | The same limits apply in the sandbox and in live. If you need more for a key, ask FinBaze: a key can carry its own request limit. ## Revoking embed sessions `revokeEmbedSession(id)` ends one session; `revokeEmbedSessionsForUser(platformUserId)` ends all of a user's. Disabling a user or removing their access does this for you. Revocation takes effect within 15 seconds. --- # Feature allowance and limits Which parts of FinBaze a company can see, and how you narrow them further. Two layers decide what a company can use: 1. **Allowance** is set by FinBaze for your platform: the modules your contract includes (banking, purchase documents, tax filings, headless sessions, …). 2. **Limits** are set by you, as a platform default and per company. A limit can only *narrow* the allowance, never widen it. A module that is not effective is **hidden**: its operations fail with `FEATURE_DISABLED`, its data is absent from nested results and components that require it cannot be added to a session (`FEATURE_DISABLED`). Read `platformCompanyFeatures(profileId)` to see what is effective for a company. Set a limit with `setPlatformFeatureLimit`. Changes reach all servers within about 30 seconds. ## Component features Separate from modules, each component has *features* (for example `submit` on `vat-return`). You choose them per session in `createEmbedSession`. They narrow what the component's UI offers and what its token may do; they never override a hidden module. See each [component](/components) for its requirements and features. --- # Sandbox Test everything with fake companies and test keys; nothing is filed. | | Sandbox | Live | | --- | --- | --- | | API | `https://sandbox.api.platform.finbaze.com/graphql` | `https://api.platform.finbaze.com/graphql` | | Embed | `https://sandbox.embed.finbaze.com` | `https://embed.finbaze.com` | | Keys | `fb_sk_test_…`, `fb_pk_test_…` | `fb_sk_live_…`, `fb_pk_live_…` | | Data | fake, may be reset | real | The SDK picks the right embed host from the publishable key. A test key can never reach live data and the other way round. Filings, bank connections and email in the sandbox use test endpoints or are captured; nothing reaches a real tax authority, bank or customer. Allowed origins are kept per environment: add `http://localhost:3000` to the sandbox list and your production origin to the live list. ## Limits The sandbox is for building and testing, not for load. | | Sandbox | | --- | --- | | Rate limits | the same as live, see [rate limits](/concepts/identity-and-tokens#rate-limits) | | Key revocation | effective within 10 seconds, as in live | | Test clocks | at most 10 per platform; a clock moves at most 400 days ahead | | Reset | optional nightly reset at 02:00 Europe/Amsterdam (see [sandbox reset](/concepts/sandbox-reset)) | | Bulk imports | at most 100 000 lines per job | | Embed tokens | 1 hour; client secrets 15 minutes; a refresh chain ends after 12 hours | | Request log | kept 30 days | | Email | captured, never delivered | | Filings | Digipoort conformance (or a simulated delivery for companies on a test clock); HMRC sandbox | | AI | the smallest model only | | Billing | none: sandbox companies are not counted or invoiced | | Availability | one database instance with daily backups and no SLA; expect short outages for maintenance | ## Going live Promote your platform from the portal. After that, create live keys and add your production origins. The code stays the same; only the keys and hostnames change. --- # Embed security model What stops a malicious page, a stolen secret or a curious user, and what does not. ## The token never reaches your page Components run in iframes on FinBaze's origin. The embed token lives in memory in a hidden controller iframe and is handed to component iframes over private `MessagePort`s. Your page can only send options and receive events, so a script injected into your page cannot read a token. The iframes use no cookies or browser storage. ## Where components can run FinBaze serves components with a `frame-ancestors` policy built from your **allowed origins**, so a browser refuses to render them on any other site. The session can additionally be bound to one origin (`origin` in `createEmbedSession`), which the exchange enforces. ## Single-use secrets A client secret works once, for 15 minutes. If it leaks after the legitimate iframe used it, it is worthless; if it leaks before, whoever exchanges first wins and the other fails with `EMBED_SECRET_USED`, which you can see in the audit log. ## An allowlist of operations Embed tokens can only run GraphQL documents that FinBaze ships in its **operations manifest**, identified by a hash of the normalised document. Introspection and arbitrary queries are refused. The manifest also ties each operation to the components and component features that may use it, and to the company the session was created for. ## Least privilege Every write an embed can perform is a deliberate entry in the registry. Sessions list the components they need; features such as `submit` are off unless you turn them on. Viewers cannot write. ## Revocation and audit Revocation reaches every server within 15 seconds. Every change made through an embed is recorded in the company's audit log as "*user* via *platform*", with the session id. ## Residual risks - **XSS on your page.** An attacker running script on your page can call *your* session endpoint as the logged-in user, take the fresh secret to their own server and exchange it there, faking the `Origin` header (only browsers enforce it). Mitigations: the legitimate iframe usually exchanges first, secrets are single use and short lived, `origin` binding, the operation allowlist, audit and revocation. This is the same class of risk as other embedded-component products; keep your own session endpoint behind your normal authentication and CSRF protection. - **Headless sessions** hand a token to your page, by design. Enable them only if you need them; they are off by default and limited to origins you list separately. --- # Test clocks Move a sandbox company through months in minutes: invoices, reminders, filings and bank feeds follow the clock. Test clocks exist in the sandbox only. A test clock holds a frozen time; companies attached to it see that time instead of the wall clock. Advancing the clock runs one simulated day after the other, in order, and then the clock is ready again. ```graphql mutation { createTestClock(name: "Q1 run", frozenTime: "2026-01-02T08:00:00Z") { id status } } mutation { advanceTestClock(id: "…", to: "2026-04-05T08:00:00Z", options: { submitFilings: true }) { status advanceTarget } } query { testClocks { id name frozenTime status advanceTarget companies } } mutation { deleteTestClock(id: "…") } ``` Attach a company when you create it (`createPlatformCompany(testClockId: …)`) or seed one (`seedTestCompany(input: { template, testClockId })`, see [Demo companies](/concepts/demo-companies)). A company is on at most one clock. Scopes: `platform_companies:read` for `testClocks`, `platform_companies:write` for the rest. ## What a day runs For each day, in this order, for every company on the clock: 1. The bank-feed simulator posts that day's transactions through the normal bank-feed ingestion path. 2. The scheduled jobs that normally run by wall time run once, at the clock's time: recurring invoices, payment matching, paid status, reminders, payment batches, depreciation, loan schedules, automations, filing policy, tax signals (Mondays), the tax advisor (first of the month), document requests. 3. With `submitFilings`, drafts that are ready are submitted, and the simulated Digipoort answers. 4. With `closeFinancialYear`, a financial year that has ended is closed. A step that fails for one company is recorded as a warning (at most 20 are kept) and the day goes on. A failure of the loop itself marks the clock `failed` with `lastError`. Only one clock advances at a time per platform. A second `advanceTestClock` on a busy platform queues behind the first; on a clock that is already advancing it answers `TEST_CLOCK_BUSY`. ## Simulated Digipoort A clocked company's filings go to a simulated Digipoort, not to conformance. A delivery is accepted (status 500) unless the amount due ends in `.13`, which is rejected (status 510), so you can test both outcomes. The simulator follows the statuses of the Digipoort Happyflow tests; the real status codes and messages of your own company's live filings may differ. Filing for a seeded company needs the same consent as any other: a company has to grant the tax authorisation before `submitFilings` submits for it. ## Events `test_clock.ready` and `test_clock.failed` are platform-level events. Both carry `testClockTime`, the clock's time when the event was written. Events about a clocked company also carry `testClockTime`, next to the real `created`. Their JSON schemas are published with the event catalogue. | Event | Data | | --- | --- | | `test_clock.ready` | the clock, `advancedFrom`, `days`, `warnings` | | `test_clock.failed` | the clock and `error.message` | ## Errors | Code | When | | --- | --- | | `SANDBOX_ONLY` | the call is made outside the sandbox | | `TEST_CLOCK_NOT_FOUND` | no such clock on your platform (also for another platform's clock) | | `TEST_CLOCK_LIMIT` | more than 10 clocks on the platform | | `TEST_CLOCK_BACKWARDS` | `to` is not after the clock's time | | `TEST_CLOCK_RANGE_TOO_LARGE` | one advance covers more than 400 days | | `TEST_CLOCK_BUSY` | the clock is already advancing | ## Limits - Deleting a clock deletes the companies on it. - Rows keep their real `created` timestamp. Only business logic (due dates, periods, reminders, filings) follows the clock. - Some jobs still use wall time. The following are converted: recurring invoices, reminders, payment batches, depreciation, loan schedules, automations, document requests, paid status, payment matching, tax filings, filing policy, tax signals and the tax advisor. Anything else runs on the real date. - A 365-day run of the logistics template is measured nightly against a 60 minute budget. --- # Demo companies Seed realistic test companies in the sandbox, with history, from seven templates. `seedTestCompany` creates a platform company and fills it with demo data: customers, invoices, bank transactions, a filing history. Sandbox only. ```graphql mutation { seedTestCompany(input: { template: nl_bv_logistics_oss, testClockId: "…", users: [""] }) { id externalId legalName } } ``` | Template | Company | | --- | --- | | `nl_eenmanszaak_services` | Dutch sole trader, services, quarterly VAT | | `nl_bv_logistics_oss` | Dutch BV, EU customers (OSS and ICP returns) | | `nl_bv_payroll` | Dutch BV with loonheffingen (5 employees and a year of monthly pay runs are the target data set; the seeder creates the wage-tax obligation today) | | `nl_horeca_pos` | Dutch hospitality business with point-of-sale takings | | `uk_ltd_vat` | UK limited company, VAT | | `uk_ltd_itsa` | UK sole trader in Making Tax Digital for Income Tax (ITSA) | | `us_corp_1120` | US C-corporation filing Form 1120 (company, obligation and bank account; no bookkeeping data yet) | Options: `externalId` (default `seed-