FinBaze for Platforms

Concepts

Some things only your platform can do: move money from your users' accounts, confirm that it really is the user (biometrics, SCA), open the camera. FinBaze asks; you do it in your own app and tell FinBaze how it went. What you do is declared once with a provider per action:

mutation {
  upsertPlatformProvider(
    input: {
      action: payments
      mode: both # webhook, embed or both
      config: { kinds: ["tax", "supplier_batch", "wages"], includePain001: true }
    }
  ) { id enabled }
}
ActionConfigNeeds the feature
paymentskinds, includePain001, pain001Versiondelegated.payment_requests
payment_linksallowedHosts, waitSeconds (30), replaceFinbazePspdelegated.payment_links
tax_reservationtaxes (nl_vat, nl_wage_tax), cadence, minAmountMinordelegated.tax_reservation
step_upactions, jwksUrl (https) or keys, maxAgeSecondsdelegated.step_up
cameranone (embed only)none

FinBaze puts the feature in your allowance per contract. Registering without it fails with FEATURE_NOT_ALLOWED, and a request made after it was taken away fails the same way.

Payment requests#

A payment request is FinBaze asking you to pay something it has worked out: the VAT or wage tax of an accepted filing, a supplier batch, or net wages. FinBaze creates them itself when a VAT or wage tax filing is accepted with an amount due, when a pay run batch is created, and on demand with createTaxPaymentRequest and createPaymentRequestFromBatch. You get payment_request.created with every line:

{
  "id": "pr_4b1f…",
  "kind": "tax",
  "amountMinor": 124000,
  "currency": "EUR",
  "dueDate": "2026-10-31",
  "endToEndId": "FB7KQ2M4XH3TD5N6P8RA",
  "lines": [
    {
      "creditorName": "Belastingdienst",
      "iban": "NL86INGB0002445588",
      "amountMinor": 124000,
      "structuredReference": "1234567891230010",
      "endToEndId": "FB7KQ2M4XH3TD5N6P8RA-001"
    }
  ]
}
  • Pay exactly these lines. Creditor IBANs are fixed when the request is created, from verified data. You cannot change them, and a request whose lines you edit will not be matched.
  • Put the endToEndId in the SEPA EndToEndId (the line's, for a batch), and for tax the structuredReference as the payment reference. Supplier and wage batches come with a signed pain001Url.
  • Report progress with reportPaymentRequestStatus: sent, accepted, scheduled, failed or cancelled. You can never report paid. A request becomes paid (or partially_paid) only when the bank transaction shows up in the company's books and is matched by end-to-end id, or for tax by the betalingskenmerk, the Belastingdienst IBAN and the exact amount. Push the bank feed with the data API and it happens by itself.
  • approvePaymentRequest records which user confirmed it in your app (and, when you require it, the step-up).

In an embedded vat-return, the Pay button creates the request and calls your page:

const finbaze = await loadFinBaze(publishableKey, {
  fetchClientSecret,
  onPaymentRequested: async (request) => {
    const transfer = await openTransferScreen(request.lines, request.endToEndId);
    return { status: transfer.confirmed ? "accepted" : "cancelled", partnerReference: transfer.id };
  },
});

Without the callback there is no Pay button.

With a payment_links provider, FinBaze asks you for the link before it sends an invoice: you get payment_link.requested and have waitSeconds to answer with providePaymentLink(requestId, url). The link must be https on one of your allowedHosts. The invoice email, the PDF and the public page then show your link, and FinBaze's own payment buttons are hidden for that invoice (replaceFinbazePsp). Past the deadline the invoice goes out without it, with FinBaze's own options; a link that arrives later still shows on the public page. A payment that reaches the bank is matched by the invoice number in the reference as usual.

Tax reservation#

tax_reservation.advised says how much a company should have set aside now for its open VAT or wage tax period (VAT collected minus deductible so far; wage tax withheld on approved or paid pay runs). A new advice supersedes the previous one for the period. Tell FinBaze what you reserved with reportTaxReservation(adviceId, reservedMinor, accountRef); taxReservationAdvice(company) lists them.

Step-up#

With a step_up provider, high-risk actions by your users need your confirmation: tax_filing.submit, pay_run.approve, bank_details.change (adding, changing or deleting a creditor's bank account) and payment_request.approve. tax_authorisation.grant is also available: list it to have every filing consent confirmed. Your server tokens are not affected.

  1. The user does the action in the embedded component. FinBaze answers STEP_UP_REQUIRED with a challenge (id, nonce, action, summary, valid for five minutes) and sends step_up.required.
  2. The component hands the challenge to your page (onStepUp). Run your biometrics or SCA.
  3. Your backend signs a JWS for it and the page returns it. The component retries the action with FinBaze-Step-Up: <challengeId>.<jws>.
import { SignJWT, importPKCS8 } from "jose";

export async function signStepUp(challenge: Challenge, user: { externalUserId: string }, amr: string[]) {
  const key = await importPKCS8(process.env.STEP_UP_PRIVATE_KEY!, "ES256");
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({
    challenge_id: challenge.id,
    nonce: challenge.nonce,
    action: challenge.action,
    amr, // e.g. ["bio"] or ["sca"]
  })
    .setProtectedHeader({ alg: "ES256", kid: "step-up-1" })
    .setIssuer(PLATFORM_ID) // your platform id
    .setAudience("finbaze")
    .setSubject(user.externalUserId)
    .setIssuedAt(now)
    .setExpirationTime(now + 120) // at most maxAgeSeconds
    .sign(key);
}

With the SDK you do not need jose: finbaze.platform.stepUp.sign (also exported as signStepUp) builds the same assertion with Node's crypto and returns the FinBaze-Step-Up header value (<challengeId>.<jws>):

const header = await finbaze.platform.stepUp.sign({
  challenge, // { id, nonce, action } from step_up.required or the STEP_UP_REQUIRED error
  issuer: PLATFORM_ID,
  subject: user.externalUserId,
  privateKey: process.env.STEP_UP_PRIVATE_KEY!, // PEM (PKCS#8) or JWK
  algorithm: "ES256", // or "EdDSA", "RS256"
  keyId: "step-up-1",
  amr: ["bio"],
});
onStepUp: async (challenge) => {
  const confirmed = await confirmWithBiometrics(challenge.summary);
  if (!confirmed) return null;
  return { jws: await api.signStepUp(challenge) };
},

FinBaze verifies it against your jwksUrl (cached ten minutes, key rotation by kid) or your registered keys. Only ES256, EdDSA and RS256 are accepted. The assertion must name this challenge, its nonce and action, the user it was for, and live no longer than maxAgeSeconds. A challenge works once.

With the SDK#

const { platform } = finbaze;
await platform.providers.upsert({ action: "payments", mode: "webhook", config: { /* debtor account, limits */ } });

// A request for an accepted VAT filing; FinBaze marks it paid when the bank transaction arrives.
const request = await platform.paymentRequests.createForTaxFiling({ externalId: "acme" }, taxFilingId);
await platform.paymentRequests.reportStatus({ paymentRequestId: request.id, status: "scheduled", partnerReference: "bank-123" });

// Answer payment_link.requested before its deadline.
await platform.paymentLinks.provide({ requestId, url: "https://pay.example.com/i/42" });

// Tax reservation.
const [advice] = await platform.taxReservation.advice({ externalId: "acme" });
await platform.taxReservation.report({ adviceId: advice.id, reservedMinor: advice.amountMinor });

reportStatus never accepts paid: FinBaze marks a request paid when the matching transaction arrives. The events (payment_request.created, payment_request.paid, payment_link.requested, tax_reservation.advised, step_up.required) are typed: isControlEvent(event) narrows an event from constructPlatformEvent to PlatformControlEvent.

Camera#

With a camera provider (or the onCameraCapture callback) the components ask your page to take the photo: { purpose, accept, maxBytes } in, { name, mime, data } or null out. Without it the component opens the phone's own camera through a file input.