Ga naar hoofdinhoud

API-overzicht

Wat de API vandaag aanbiedt (gebouwd tot en met M3). De geplande endpoints staan in ontwerp.md. Alles staat onder /api; tenantdata komt onder /api/t/{slug}/....

Afspraken​

  • Sessie: een httpOnly-cookie (__Host-session onder https, deelgenoot_session in dev). Geen tokens in de browser. Een Authorization-header is enkel voor een API-sleutel; cookie én header samen geven 400 ambiguous_credentials.
  • CSRF: POST, PUT, PATCH en DELETE moeten een Origin hebben die gelijk is aan PUBLIC_URL, of Sec-Fetch-Site: same-origin. Anders 403. Browsers doen dit vanzelf.
  • Cache: elk antwoord heeft Cache-Control: no-store.
  • Foutvorm: { "statusCode": 4xx, "message": "..." }; bij 400 extra issues: [{ path, message }]; bij een regel van de database extra code (bv. DG006, 23505).
  • Statuscodes: 401 geen geldige sessie; 403 geen toegang (verkeerde rol, geen membership, onbekende coöperatie, ontbrekende Origin); 400 validatiefout; 409 conflict; 404 onbekende resource.
  • Schema's: Zod in packages/shared/src/api.ts, gedeeld met de web-app.

Aanmelden en gebruiker​

Methode en padToegangDoet
GET /api/auth/login?returnTo=/padopenbaarstart de aanmelding: 302 naar Keycloak (code flow, PKCE, tweede stap gevraagd). returnTo moet een pad in de app zijn. Met stepUp=1 vraagt Keycloak opnieuw aan te melden met de tweede stap (max_age=0, step-up); de nieuwe sessie vervangt de oude
GET /api/auth/callbackopenbaarrondt af: controleert state, nonce, PKCE en acr 2, maakt de sessie, 302 naar de app. Bij een fout 302 naar /?login_error=<mfa|expired|refused|failed>
POST /api/auth/logoutsessiebeëindigt de sessie; antwoord { logoutUrl } (Keycloak-afmelding)
GET /api/mesessiegebruiker en memberships: { user: { id, email, name, isPlatformAdmin }, memberships: [{ tenantId, slug, legalName, role, status }] } (status: active of terminated)
GET /api/healthopenbaar{ "status": "ok" } (controleert nog niet de database)

Platform (platformbeheerder)​

Methode en padDoet
GET /api/platform/status{ environment: local|int|prod, version, checks: { database, documents, keycloak }, mailTransport }; de controles hebben een korte time-out
GET /api/platform/adminsplatformbeheerders [{ userId, name, email, lastLoginAt }]; enkel lezen (toevoegen kan alleen met pnpm platform:create-admin)
GET /api/platform/tenantscoöperaties [{ id, slug, legalName, enterpriseNumber, createdAt, status, terminatedAt, terminationReason, people: [{ membershipId, userId, name, email, role, lastLoginAt }] }]
GET /api/platform/tenants/{tenantSlug}één coöperatie, zelfde vorm; 404 als ze niet bestaat
POST /api/platform/tenantsmaakt tenant, Keycloak-account en bestuurder-membership. Body: { slug, legalName, legalForm?, enterpriseNumber?, firstBoardMember: { email, firstName, lastName } }. Antwoord 201 { tenant, invitationSent }; 409 bij een bestaande slug; 400 bij ongeldige invoer
POST /api/platform/tenants/{tenantSlug}/peoplevoegt een bestuurder of lezer toe. Body { email, firstName, lastName, role }. 201 { person, invitationSent }; 409 als die persoon al toegang heeft of de coöperatie opgezegd is
DELETE /api/platform/tenants/{tenantSlug}/people/{membershipId}trekt toegang in. 204; 409 bij de laatste bestuurder; 404 bij een onbekend of vreemd membership
POST /api/platform/tenants/{tenantSlug}/invitationsverstuurt de account-instellen-mail opnieuw. Body { email }. 204; 404 als die persoon geen lid is of al aanmeldde
POST /api/platform/tenants/{tenantSlug}/terminatezegt op: enkel-lezen. Body { slug, reason? }; slug moet gelijk zijn aan de coöperatie (bevestiging), anders 400. 200 met de coöperatie; 409 als ze al opgezegd is
POST /api/platform/tenants/{tenantSlug}/reactivatemaakt het opzeggen ongedaan. 200; 409 als ze niet opgezegd is
POST /api/platform/tenants/{tenantSlug}/api-keys/revoke-allincident: trekt alle API-sleutels van de coöperatie in en zet API-sleutels uit; mail aan de bestuurders. 200 { revoked, tenant }
GET /api/platform/tenants/{tenantSlug}/entitlementsde afgeleide entitlements (F1.6): { plan: { code, source, subscriptionStatus, reason }, features, limits, trial, lines, plans }; lines is elke bijdrage met key, value, source (base, plan, subscription, trial, grant), until, ref, active en bij een afspraak op maat grantId en grantedBy
POST /api/platform/tenants/{tenantSlug}/entitlements/grantsop maat toekennen: { key, value?, reason, offerRef, validFrom?, validUntil } (value verplicht bij een limit.*, null = geen grens). Nooit naar beneden: de afleiding neemt de hoogste waarde. 201 met de entitlements; audit-rij onder de coöperatie
POST /api/platform/tenants/{tenantSlug}/entitlements/grants/{grantId}/revoke{ reason }; de rij blijft. 404 als ze niet bestaat of al ingetrokken is
PUT /api/platform/tenants/{tenantSlug}/entitlements/planplan handmatig zetten (noodweg zonder abonnementenmodule): { planCode, addonKeys?, reason }; 400 bij een onbekend plan. De volgende entitlements.changed van de module neemt het weer over
GET /api/platform/flags, PUT /api/platform/flags/{key}release flags: [{ key, enabled, tenantSlugs, description, updatedAt, updatedByName }]; PUT { enabled, tenantSlugs? } (bèta: aan voor die coöperaties terwijl de flag uit staat). Binnen 15 seconden van kracht
GET /api/platform/subscriptions/catalogde catalogus van de abonnementenmodule; 503 subscriptions_not_configured zonder module
POST /api/platform/subscriptions/catalog/synckopieert de catalogus nu (anders elke 15 minuten): 200 { plans }

Deze routes openen geen tenantcontext. De padparameter heet hier bewust niet slug: die naam is voorbehouden aan de tenantguard.

Tenantroutes (/api/t/{slug}/...)​

Regels voor elke tenantroute:

  1. De tenant komt uitsluitend uit de URL. Een tenant_id in body, query of header wordt nooit gebruikt.
  2. Elke route declareert zijn rollen met @Roles('board', 'reader'). Een route zonder @Roles wordt voor iedereen geweigerd.
  3. Onbekende coöperaties en coöperaties waar je geen lid van bent geven allebei 403, zodat niemand kan nagaan welke coöperaties bestaan.
  4. Het hele verzoek draait in één databasetransactie met app.tenant_id, app.user_id en app.ip gezet vóór de eerste query. Een fout draait alles terug.
  5. Transacties krijgen nooit PATCH of DELETE; versies van regels en boekwaarden ook niet (een wijziging is een nieuwe versie of correctie).

Gebouwd in M3, M4 en M4b (lezen: board en reader; wijzigen en boeken: board; gebruikers: enkel board):

Methode en padDoet
GET /merol en entitlements van de coöperatie (F1.3): { role, plan: { code, name, source, subscriptionStatus }, features, entitled, released, limits, usage, trial, trialAvailable, trialPlanCode, readOnly, plans }. Zonder catalogus is plan.code none: geen grenzen, alle vrijgegeven modules open. plans is de kopie van de catalogus van de module (isDefault, trialDays). features = entitled én released (canUse); usage meet limit.members (vennoten die niet uitgetreden zijn) en limit.capital_cents (gestort kapitaal); readOnly is terminated, suspended of null
POST /trial (board, geen API-sleutel)start de enige proef van de coöperatie op het proefpakket van de catalogus (zijn trial_days), zonder betaalgegevens. 201 { planCode, startedAt, endsAt }; 409 trial_used (al gehad), 409 trial_not_needed (niet op het standaardpakket) of 409 trial_not_offered (de catalogus heeft geen proefpakket)
GET /overviewactieve vennoten, uitgegeven aandelen en gestort kapitaal per soort; unsignedBookings (boekingen na het laatste getekende register), currentRegister, awaitingRegister (de versie die op handtekening wacht), en de cijfers van dit jaar (year: toegetreden, uitgetreden, overdrachten), en notices: actieve vennoten per kanaal voor oproepingen (email, post) en zonder e-mailadres (withoutEmail)
GET, PATCH /settingsvennootschapsgegevens (company), memberNumberMode (automatic/manual), memberNumberDigits, extractSigning (none/one_board_member), transferClassMode (same: aandelen houden hun soort tenzij de bestuurder een andere kiest; board_decides: bij elke overdracht kiest de raad van bestuur de soort), ledgerContributionAccount (111900) en ledgerPayableAccount (482000): de rekeningen die de afstemming noemt
GET, POST /policiesversies van het uittredingsbeleid (begin boekjaar, venster); currentId = de versie die vandaag geldt
GET, POST /share-classes, PATCH /share-classes/{id}soorten met de regels die vandaag gelden en wat uitgegeven en gestort is. POST vraagt ook de eerste regelversie (policy); de code wijzigt niet meer. Per soort minPerMember en maxPerMember (saldo na elke boeking 0 of binnen de vork), memberLimitsSource (bron) en transferTargetIds (toegelaten doelsoorten bij overdracht; null = alle soorten met dezelfde nominale waarde); het antwoord geeft ook allowedTargetIds (vandaag, de eigen soort inbegrepen)
GET, POST /share-classes/{id}/policiesregelversies van een soort
POST /share-classes/valuations (board)één boekwaarde voor alle actieve soorten (fiscalYearEnd, bookValuePerShareCents, equityCents, accountsApprovedOn, decisionRef, note?): één rij per soort. Een jaar met al een waarde 409 DG006 (met params.codes), tenzij correction: true: dan vervangt het de geldende waarde van elke soort
GET, POST /share-classes/{id}/valuationsboekwaarden per boekjaar, met sharesOutstanding en controlValuePerShareCents; een tweede waarde voor hetzelfde jaar moet supersedesId hebben (anders 409 DG006)
GET /members?q=&status=active|exited|pending|all&shareClassId=&contact=no_email|post|email&page=&pageSize=lijst met zoeken en filters (max. 100 per pagina; contact: zonder e-mailadres, of het kanaal voor oproepingen), total en activeTotal. Per vennoot de status uit het journaal (status, firstAdmittedOn, activeSince, exitedOn), de partner van een koppel (partnerName) en de handelsnaam (tradeName), en per soort de aandeelnummers (shareNumbers)
POST /members, GET, PATCH /members/{id}toevoegen, detail met saldo, nummers en historiek, bewerken. memberNumber enkel bij zelf ingeven; de soort vennoot wijzigt niet. Toetredings- en uittredingsdatum volgen uit het journaal en zijn geen velden meer
Identificatie van een vennoot (M6)partnerFirstName/partnerLastName (een koppel is één vennoot), tradeName en enterpriseNumber (ook bij een persoon met een eenmanszaak), foreignVatNumber (landcode + nummer), nationalNumber (enkel persoon; schrijven met controle op het controlegetal, null wist het). Het antwoord geeft nooit het nummer, enkel nationalNumber: { set, hint } (laatste twee cijfers)
PATCH /members/{id} (contact, E2)ook language (nl|fr|en), noticeChannel (email|post), noticeChannelSince (datum, niet in de toekomst), marketingConsent (true bewaart het moment van de eerste toestemming, false trekt ze in). email als kanaal vraagt een e-mailadres en een datum op de vennoot zoals hij bewaard wordt (400 met issues op email of noticeChannelSince); een nieuw e-mailadres zet emailVerifiedAt terug op null. Het detail geeft ook contacts; notes enkel voor board (lezers krijgen null)
PUT, DELETE /members/{id}/contacts/{role} (board)tweede contactpersoon: partner (persoon) of representative (rechtspersoon), { name, email?, phone?, language? }. De verkeerde rol voor de soort vennoot 400; DELETE zet removed_at (de rij blijft), 404 als er geen is. Antwoord: het detail van de vennoot
GET, POST /fiscal-years, GET, PATCH /fiscal-years/{id}, POST /fiscal-years/{id}/accounts (PDF), GET /fiscal-years/{id}/accounts.pdfboekjaren (E7): POST { endsOn } volgens het boekjaar van de coöperatie (400 als het geen einde van een boekjaar is); PATCH de cijfers van de goedgekeurde jaarrekening (approvedOn, equityCents, netAssetsCents, resultCents, unavailableEquityCents, unavailableEquityNote); fromJournal geeft aandelen en gestort kapitaal op het einde van het jaar
GET, POST /distribution-tests, GET, PATCH /distribution-tests/{id}, POST …/report, …/signed (PDF), …/auditor-review (PDF), …/confirm, GET …/report.pdf, …/signed.pdf, …/auditor-review.pdfuitkeringstoets (E7): POST { testedOn } maakt een ontwerp op de laatst goedgekeurde jaarrekening (409 no_approved_accounts, accounts_incomplete) met de te betalen scheidingsaandelen als geplande uitkering; balanstest als signaal (netAssetTestPasses, availableAfterCents), liquiditeitstest met de rubrieken van beslissingen.md 6; coversUntil is twaalf maanden na de toets. Een wijziging laat het verslag vervallen. Bevestigen vraagt alles ingevuld, het getekende verslag en met hasAuditor de beoordeling; daarna 409 DG021 op elke wijziging
GET /members/{id}/national-number (board)het volledige rijksregisternummer; elke inzage is een leesregel in de audit-trail
GET, POST /users, PATCH, DELETE /users/{membershipId}leden en rollen (board/reader); uitnodigen maakt zo nodig een Keycloak-account aan. Nooit nul bestuurders (409)
GET /exports/members.{xlsx|csv} (zelfde filters als de lijst), GET /exports/transactions.{xlsx|csv}export met status en aandeelnummers; elke export komt als leesactie in de audit-trail
GET /exports/full (board)de volledige export als ZIP (README.txt, manifest.json, data/*.json, excel/*.xlsx, documents/…), gemaakt in één momentopname; nooit rijksregisternummers of sleutels. De leesregel export.full (bestanden, rijen, problemen, SHA-256 van het manifest) wordt bij het voltooien geschreven
GET /exports/full/history (board)de laatste 20 exports uit de audit-trail: [{ at, byName, files, rows, problems, manifestSha256 }]
GET /transactions?type=subscription|transfer|redemption|redemption_without_request|reversal&memberId=&shareClassId=&from=&to=&page=&pageSize=het journaal, laatste boeking eerst, met nummers, tegenpartij, wie tegenboekte, bij een uittreding haar aanvraag (exitRequestId, null zonder) en bij een omzetting conversion (fromCode, fromNumbers, toCode, toNumbers) op beide rijen. redemption_without_request: uittredingen die niet tegengeboekt zijn en geen aanvraag hebben (het signaal van de afstemming)
POST /transactions/preview (board)de controles van de stap Controle zonder te boeken: datum, uitgifteprijs, nummers in bezit, maximum per vennoot, omzetting (classConversion), beslissing; het berekende bedrag, de nummers (bij een omzetting ook conversion: doelsoort en nieuwe nummers), het saldo na de boeking per vennoot en het volgende journaalnummer
POST /transactions (board)boekt subscription (memberId, shareClassId, quantity), transfer (fromMemberId, toMemberId, shareClassId, shareNumbers, optioneel targetShareClassId: de soort na overdracht; zonder = dezelfde soort, anders krijgt de overnemer nieuwe nummers in die soort en gaat de inbreng mee) of redemption (memberId, shareClassId, shareNumbers), telkens met effectiveDate, decisionRef en optioneel note. De server berekent het bedrag; faalt een controle, dan 409 met code: BOOKING_CHECKS en de gefaalde checks (ook minPerMember per betrokken vennoot); met board_decides zonder targetShareClassId 422 target_class_required, een doelsoort die niet toegelaten is 422 target_class_not_allowed (ook op /preview); een omzetting naar een soort met een andere nominale waarde (of nummers met een verschillende inbreng) 422 class_conversion_value_mismatch. Antwoord 201 { entries }
GET /exit-requests?view=open|to_pay|deferred|closed|all&memberId=uittredingsaanvragen met actions (wat nu kan) en waitingFor (effective_date of accounts)
GET /exit-requests/window?date=uittredingsperiode en uitwerkingsdatum voor een datum van ontvangst (effectiveOn null = buiten de periode)
GET /reconciliation?fiscalYearEnd=afstemming met de boekhouding voor een boekjaar (standaard het laatst afgesloten): inbreng begin, inschrijvingen, uittredingen, overige, einde; verschil inbreng − scheidingsaandeel van uittredingen tot dan (een uittreding telt in het boekjaar van haar boeking zodra het scheidingsaandeel bekend is; het betaalde bedrag zodra betaald); verwacht saldo, ingevuld saldo en verschil voor beide rekeningen; signalen (uittredingen zonder aanvraag, aanvragen niet gewaardeerd)
PUT /reconciliation/{fiscalYearEnd}/{contribution|payable} (board){ balanceCents, note? }: het saldo volgens de boekhouding; een nieuwe invoer vervangt de vorige (audit-trail)
GET /reconciliation/{fiscalYearEnd}.xlsxde afstemming als spreadsheet voor de boekhouder (leesactie in de audit-trail)
POST /exit-requests (board)aanvraag: memberId, shareClassId, shareNumbers, receivedOn, note?. Buiten de periode 409 DG011; nummers in een andere aanvraag 409 DG012; een gedeeltelijke terugneming die onder het minimum zakt (de nummers van andere open aanvragen tellen als weg) 409 DG017 met params: { min, code }
GET /exit-requests/{id}detail met de regels, het scheidingsaandeel per nummergroep (breakdown) en de gebruikte of voorlopige jaarrekening
POST /exit-requests/{id}/accept · /reject · /withdraw (board){ decisionRef, acceptedOn } of { reason, closedOn }; een stap die nu niet kan geeft 409 met code: EXIT_STEP
POST /exit-requests/{id}/book (board)boekt de uittreding op de uitwerkingsdatum (vanaf die datum)
POST /exit-requests/{id}/value (board)legt het scheidingsaandeel en de vervaldatum vast
POST /exit-requests/{id}/defer · /pay (board){ reason, deferredOn } of { paidOn, paidAmountCents, testsRef? }: betalen vraagt een bevestigde uitkeringstoets die de betaaldatum dekt (getest op of vóór die dag, betaling binnen twaalf maanden), anders 409 DG022. De betaling wordt aan die toets gekoppeld (distribution_test_id); testsRef is standaard die toets. Elke uittreding toont testsCovered (een toets dekt vandaag)
GET /transactions/{id}/exit-request/proposal?valuationFiscalYear=&receivedOn=voor een uittreding zonder aanvraag (na een import, of van vóór de uittredingsmodule), volgens het uittredingsbeleid: het boekjaar van de waardering (policyFiscalYear, uit exitValuationAccounts: het jaar van de uittreding, of de jaarrekening goedgekeurd op de uitwerkingsdatum; nooit gewoon de laatste boekwaarde), het voorstel voor het gevraagde jaar of dat van het beleid (amountCents = aantal × boekwaarde onder het plafond, breakdown, null zonder boekwaarde), het plafond (capCents), de boekwaarden van de soort (valuations) en de uiterste betaaldatum (dueOn) met haar anker uit het beleid (dueAnchor: de uittreding, de beslissing, gelijkgesteld aan receivedOn, of de AV die de jaarrekening van die boekwaarde goedkeurde)
POST /transactions/{id}/exit-request (board)scheidingsaandeel vastleggen voor een uittreding zonder aanvraag: { receivedOn, valuationFiscalYear, amountCents, dueOn, note? }. Er komt een aanvraag die geboekt is (op die uittreding), gewaardeerd en te betalen; de datum van de beslissing is onbekend en wordt gelijkgesteld aan receivedOn (recordedAfterRedemption: true op de aanvraag). Daarna defer en pay zoals bij elke aanvraag. Geen uittreding, tegengeboekt of al een aanvraag 409 EXIT_REDEMPTION; geen boekwaarde voor dat boekjaar 409 EXIT_NO_VALUATION (params: { code, year }); boven het plafond van de soort 409 EXIT_ABOVE_CAP (params: { capCents, code }); een ander bedrag of boekjaar dan het voorstel zonder note 409 EXIT_NOTE_REQUIRED. Antwoord 201 met de aanvraag
POST /transactions/{id}/reverse (board)tegenboeking met effectiveDate (tussen de boeking en vandaag), note (reden, verplicht) en optioneel decisionRef; een overdracht wordt als geheel tegengeboekt
GET /imports (board)proefruns en imports (zonder rijen) en journalEmpty
GET /imports/template.xlsx (board)het standaardsjabloon (Vennoten, Transacties, Uitleg)
POST /imports/dry-run (board){ fileName, mapping, payload: { members, bookings, skipped } } (schema's in import-api.ts, max. 25 MB): controles per rij; een transfer_in zonder nummers wordt gekoppeld op datum en aantal, en in een andere soort is het een omzetting (nieuwe nummers van de database; met nummers fout conversion_numbers, een redemption mag paidAmountCents, paidOn, valuationFiscalYear, receivedOn meekrijgen: er komt een afgesloten uittredingsaanvraag (geboekt en betaald); een ander bedrag dan aantal × boekwaarde onder het plafond waarschuwing payout_differs, geen boekwaarde payout_no_valuation, enkel bij een uittreding (payout_not_redemption) en met bedrag en datum (payout_incomplete); zonder beslissing waarschuwing conversion_without_decision (met board_decides ook bij een overdracht in dezelfde soort: transfer_class_without_decision), meerdere kandidaten transfer_ambiguous), daarna alles geboekt in een savepoint dat altijd teruggedraaid wordt. Antwoord: summary en issues (level, sheet, row, field, code, problem in het Nederlands, value met gemaskeerde rijksregisternummers). Enkel een fout weigert een rij; een waarschuwing (bv. invalid_email: het adres wordt leeggemaakt) niet. Een boeking van een geweigerde vennoot krijgt zelf een fout (member_refused): geen rij valt weg zonder fout of skipped-melding
GET /imports/{id}, GET /imports/{id}/errors.xlsx (board)de proefrun met haar meldingen; het foutenrapport
POST /imports/{id}/commit (board){ payload }: exact de rijen van een proefrun zonder fouten (hash), in één transactie, enkel in een leeg journaal. 409 import_changed bij andere rijen, 409 DG015 als het journaal niet leeg is
GET /registerregisterversies, nieuwste eerst, met ondertekenaars en hun status en actions (refresh, upload, cancel; leeg voor een lezer); lastSeq; signing (staat de integratie aan)
POST /register (board)nieuwe versie tot de laatste boeking: renderen, opslaan met SHA-256, alle bestuurders als ondertekenaars; met de integratie meteen naar de ondertekendienst. Een wachtende versie wordt eerst geannuleerd. Antwoord 201 met de versie; 502 signing_failed als de dienst faalt (dan bestaat er geen versie)
GET /register/{id}/pdf, GET /register/{id}/signed.pdf, GET /register/{id}/signing-audit.pdfhet origineel, de getekende PDF of de audittrail van de ondertekendienst (application/pdf; die laatste enkel als de versie via de integratie getekend is, anders 404, zie signingAuditSha256); elke download komt als leesactie in de audit-trail
POST /register/{id}/upload (board)de getekende PDF als body met Content-Type: application/pdf (max. 20 MB, moet met %PDF- beginnen): de versie wordt geldend, de vorige vervangen. Geen PDF 400 not_pdf, te groot 413 too_large, versie wacht niet (meer) 409 DG014
POST /register/{id}/refresh (board)vraagt de status op bij de ondertekendienst (zonder publieke webhook, bv. lokaal); zodra iedereen tekende, wordt de versie geldend
POST /register/{id}/cancel (board)annuleert een wachtende versie, ook bij de ondertekendienst
GET, POST /members/{id}/extractsuittreksels van een vennoot; POST (board) maakt er een tot de laatste boeking: ready, of awaiting_signature als de coöperatie uittreksels laat tekenen
GET /extracts/{id}/pdf, /signed.pdf, /signing-audit.pdf; POST /extracts/{id}/upload · /refresh · /cancel (board)zoals bij het register
GET, POST /members/{id}/lettersbrieven aan een vennoot; POST (board) { kind, transactionId } (intekening, overdracht uit/in: de eigen boeking van de vennoot), { kind, exitRequestId } (uittreding, scheidingsaandeel) of { kind: 'tax_shelter', year }. Een boeking van een andere soort 400 letter_mismatch; tegengeboekt, geweigerde uittreding, nog geen scheidingsaandeel of geen intekening in dat jaar 409 DG014
GET /letters/{id}/pdf, /signed.pdf, /signing-audit.pdf; POST /letters/{id}/upload · /refresh · /cancel (board)zoals bij het uittreksel
GET /documents/texts, PUT · DELETE /documents/texts/{kind}/{block} (board)de eigen teksten ({ texts }); PUT { body } (max. 5000 tekens) met enkel de variabelen van dat document (400 unknown_variables met variables), een onbekend blok 400 unknown_block; DELETE zet de standaardtekst terug. Standaarden en variabelen: packages/shared/src/document-texts.ts
GET, PUT /documents/settings (board)stijl en ondertekenen van brieven: { accentColor: '#rrggbb', font, senderLine, letterSigning: { <soort brief>: 'none' | 'one_board_member' } }
POST, GET, DELETE /documents/logo (board)het logo als body (image/png of image/jpeg, max. 1 MB, gecontroleerd op de bytes: anders 400 not_image, te groot 413)
POST /files?kind=scan[&class=fiscal&fiscalYear=2025] (board)een scan (statuten, jaarrekening) als body, application/pdf, image/png of image/jpeg (max. STORAGE_MAX_UPLOAD_MB, standaard 50 MB). Gestreamd naar de opslag met SHA-256 en versleuteld met de tenantsleutel; het type wordt op de bytes gecontroleerd. Ander of vermomd type 415 unsupported_type, te groot 413 too_large, fiscaal zonder boekjaar 400. Antwoord 201 met het bestand (StoredFile)
GET /files/{id}een opgeslagen bestand: soort, klasse (legal, fiscal, ephemeral), type, grootte, SHA-256, bewaartermijn. Een bestand van een andere coöperatie is 404
GET /files/{id}/contentde inhoud via de API, als bijlage (Content-Disposition: attachment, nosniff, sandbox-CSP), gecontroleerd tegen de SHA-256 (bij een verschil breekt de download af); leesactie in de audit-trail
GET /files/{id}/link{ url, expiresAt }: een presigned link rechtstreeks naar de opslag (standaard 5 minuten), enkel voor bestanden in de legal-bucket (register, uittreksels, brieven); anders 404 no_link. Leesactie in de audit-trail
POST /documents/preview/{kind} (board)een voorbeeld-PDF met verzonnen gegevens, de eigen teksten en de stijl of het eigen sjabloon; niets wordt bewaard
GET /documents/templates, GET /documents/templates/default/{kind}, GET /documents/templates/{id}/file (board)de eigen sjablonen, het standaardsjabloon (.fodt) als vertrekpunt, en een opgeladen sjabloon
POST /documents/templates/{kind}?fileName=… (board)een eigen sjabloon als body (application/octet-stream, .fodt/.odt/.docx, max. 5 MB). Geen sjabloon, macro's of een zip-bom 400 invalid_template; rendert niet met voorbeelddata 422 template_render_failed. Wordt het actieve sjabloon van dat document
DELETE /documents/templates/{kind} (board)terug naar het standaardsjabloon (het eigen sjabloon blijft bewaard, inactief)
GET, PUT, DELETE /integrations/signing (board)de ondertekenintegratie. PUT { provider: 'docuseal', enabled, apiKey?, newWebhookToken? }: de sleutel is enkel te schrijven (terug komt secretHint, de laatste vier tekens); de eerste keer, of met newWebhookToken, staat de webhook-URL eenmalig in het antwoord (webhookUrl). Aanzetten zonder sleutel 400 no_key. DELETE zet uit en wist sleutel en webhooktoken
POST /integrations/signing/test (board)test de opgeslagen sleutel bij de dienst: { ok, message }
GET /api-keys (board){ enabled, keys: [{ id, name, prefix, role, journalWrite, allowedCidrs, status, createdByName, createdAt, expiresAt, lastUsedAt, lastUsedIp, revokedAt, revokedByName }] }; status is active, expired of revoked. Nooit de sleutel zelf
POST /api-keys (board, tweede stap in de laatste 5 minuten){ name, role: 'reader'|'board', journalWrite, allowedCidrs?, expiresAt } → 201 { apiKey, key }; key staat enkel in dit antwoord. expiresAt is de laatste geldige dag (morgen tot 365 dagen); journalWrite enkel met board. Zonder verse tweede stap 401 step_up_required met stepUpUrl
POST /api-keys/{id}/revoke (board)trekt in (geen DELETE: de rij blijft voor de audit-trail); 409 als hij al ingetrokken is
PUT /api-keys/settings (board){ enabled }: API-sleutels aan of uit voor de coöperatie

Schema's: packages/shared/src/tenant-api.ts, journal-api.ts, exit-api.ts, register-api.ts, letters-api.ts, documents-api.ts, document-texts.ts en share-numbers.ts. Aandeelnummers zijn in de API een lijst van reeksen [{ from, to }] (beide inbegrepen).

Een nieuwe route (voorbeeld):

@Controller('t/:slug/members')
export class MembersController {
@Get()
@Roles('board', 'reader')
list(@Transaction() tx: Tx) {
return tx.select().from(members); // RLS toont enkel de rijen van deze coöperatie
}

@Post()
@Roles('board')
create(@Transaction() tx: Tx, @CurrentTenant() tenant: TenantAccess, @Body() body: unknown) {
const input = parseBody(createMemberRequestSchema, body);
return tx.insert(members).values({ ...input, tenantId: tenant.tenantId }); // nooit een tenant uit de body
}
}

Processen en meldingen (/api/t/{slug}/..., F4)​

Processen met stappen en deadlines (de jaarkalender, later wizards en goedkeuringen) en de meldingen erover. Elke statuswijziging volgt de statusmachines in packages/shared/src/state/ en laat een rij na in status_transitions (ook voor registerversies en uittredingen). Een verboden overgang geeft 409 forbidden_transition, een rol zonder recht 403 transition_not_allowed.

Methode en padDoet
GET /processes?status= (board, reader)processen met hun stappen: code, version, subjectType/subjectId (bv. fiscal_year, 2027-01-01), status, anchors (de datums waaruit de deadlines berekend zijn) en per stap dueOn, status (pending, due, late, done, skipped), basis (wettelijke grond), completion en actions (wat de aanvrager nu mag)
GET /processes/{id} (board, reader)één proces
POST /processes/annual-cycle (board){ year, generalMeetingOn?, parameters? }: de jaarcyclus van het boekjaar dat in year begint. Zonder AV-datum de wettelijke uiterste datum (einde vorig boekjaar + 6 maanden); parameters uit de statuten, bv. { convocation_days: 21 }. Al gestart: 409 process_exists
POST /processes/{id}/cancel (board){ reason }; open herinneringen vervallen
POST /processes/steps/{id}/complete (board){ completedOn?, reason? }: afvinken met de echte datum (niet in de toekomst: 400 date_in_future). Een stap die enkel een feature afwerkt (completion: feature) kan niet met de hand: 409 completion_by_feature
POST /processes/steps/{id}/skip (board){ reason }: de stap is dit jaar niet van toepassing
GET /notifications?unread=&limit= (board, reader; geen API-sleutel){ unread, items: [{ id, kind, title, body, link, createdAt, readAt }] }: enkel de eigen meldingen
POST /notifications/{id}/read, POST /notifications/read-allgelezen; een melding van iemand anders 404
GET, PUT /notifications/preferences{ preferences: [{ kind, channel: 'email'|'inapp', mode: 'immediate'|'digest'|'off' }] }; digest enkel voor e-mail. Zonder voorkeur: meteen

Features sluiten hun stap zelf af met ProcessEvents.emit(tx, '<feature>.<gebeurtenis>', { scope, ref }) in de transactie van hun werk (bv. convocation.sent). Herinneringen gaan elke dag om 07:00 (Brussel) uit (reminders.tick), mails via de outbox notification_deliveries en pg-boss.

API-sleutels voor integraties​

Voor koppelingen zonder browser (boekhouding, een script, een synchronisatie): een bestuurder maakt een sleutel in Instellingen › Integraties. Ontwerp en veiligheid: epic #32.

GET /api/t/de-broeikas/members HTTP/1.1
Authorization: Bearer dg_live_ab12cd34_<43 tekens>
  • Formaat: dg_<live|test>_<prefix>_<geheim>; live op prod, test op int en lokaal (een sleutel werkt nooit in een andere omgeving). Het geheim is 256 bits; de app bewaart enkel de SHA-256 en de prefix. Enkel in de header, nooit in een URL.
  • Wie: een sleutel is een serviceaccount met een membership (reader of board). De gewone rolcontrole en RLS gelden; de audit-trail toont de naam van de sleutel bij elke wijziging.
  • Waar: enkel /api/t/{slug}/... van de eigen coöperatie (een andere slug: 403, zoals een onbekende coöperatie). Nooit /api/platform, /api/auth of /api/me (403 api_key_not_allowed).
  • Nooit met een sleutel (@NoApiKey(), 403 api_key_not_allowed): GET /members/{id}/national-number, /users, /api-keys, GET /exports/full en /integrations/signing. Het rijksregisternummer schrijven of importeren met een sleutel geeft ook 403, en het vennootdetail toont dan niet eens de laatste cijfers (nationalNumber.hint is null).
  • Boeken (@JournalWrite(): POST op /transactions, /exit-requests en /imports/{id}/commit) vraagt een sleutel met journalWrite, ook met rol board (403 journal_write_required).
  • Uittreksels en brieven die de aanmakende bestuurder moet tekenen: 422 signer_required (een sleutel kan niet tekenen; zie #38).
  • CSRF: met een Authorization-header geen Origin-controle (er rijdt geen cookie mee).
  • Fouten: 401 invalid_api_key (onbekend, fout geheim, ingetrokken, vervallen, andere omgeving, of API-sleutels staan uit bij de coöperatie; altijd hetzelfde antwoord), 403 ip_not_allowed (buiten de IP-allowlist), 429 rate_limited met Retry-After (standaard 60 verzoeken per minuut per sleutel, API_KEY_RATE_LIMIT_PER_MINUTE).
  • Intrekken werkt meteen: een sleutel wordt bij elk verzoek opgezocht, nooit gecachet. last_used_at en het IP worden hoogstens één keer per minuut bijgewerkt, buiten de verzoektransactie.
  • Meldingen: alle bestuurders krijgen een mail bij aanmaken en intrekken, en 14 dagen voor een sleutel vervalt. Meer dan tien geweigerde pogingen in vijf minuten op één prefix is een platformsignaal in het log.
  • Voor wie een sleutel gebruikt: bewaar hem in een secret manager of een .env buiten git, nooit in een browser of een mobiele app; één sleutel per koppeling; roteer door een tweede sleutel te maken, over te schakelen en de oude in te trekken.

Entitlements, grenzen, idempotentie en rate limits (F1)​

  • Modules. Een route van een module draagt @RequiresFeature('module.<x>'). Zonder entitlement, of met de release flag uit: 403 { code: "module.<x>" }. De afleiding (plan of Start, plus proef, plus afspraken op maat) staat in deriveEntitlements (packages/shared/src/entitlements/) en loopt per verzoek, in de transactie van het verzoek.
  • Grenzen. @WithinLimit('limit.members' | 'limit.capital_cents') op POST /members, POST /transactions, /reverse en /imports/{id}/commit: stijgt het gebruik na de handeling boven de grens, dan wordt alles teruggedraaid en komt 403 { code: "limit.<y>", limit, usage }. Een coöperatie die al boven haar grens zit (na een downgrade) kan nog verbeteren en verlagen.
  • Opgeschort abonnement. 423 { code: "TENANT_SUSPENDED" } op elke aanvraag die niet GET, HEAD of OPTIONS is, zoals bij een opgezegde coöperatie. Lezen en exporteren blijven mogelijk.
  • Idempotency-Key (8–200 tekens: letters, cijfers, . _ : -) op POST /transactions, /transactions/{id}/reverse, /exit-requests/{id}/book, /exit-requests/{id}/pay, /imports/{id}/commit, POST /register, /members/{id}/extracts en /members/{id}/letters: dezelfde sleutel geeft hetzelfde antwoord (header Idempotent-Replayed: true), ook als twee verzoeken tegelijk komen; dezelfde sleutel met een ander verzoek 422 idempotency_key_reused. Een mislukt verzoek bewaart niets. Per coöperatie, 24 uur bewaard.
  • Rate limits per client-adres: standaard 600 per minuut, aanmelden 20, uitnodigingen 10, webhooks 120, PDF's 10; daarboven 429. Los daarvan blijft de limiet per API-sleutel.

Webhooks van providers​

Elke webhook gaat door de inbox (webhook_deliveries, F1.8): eerst bewaren, dan verwerken; (provider, event_id) is uniek, zodat dezelfde gebeurtenis twee keer één keer verwerkt wordt. Mislukt de verwerking, dan probeert een job het later opnieuw (tot 10 keer).

  • POST /api/webhooks/postmark: basic auth met POSTMARK_WEBHOOK_TOKEN; Delivery, Bounce en SpamComplaint komen op de outbox-rij van het bericht (delivery_status). Antwoord 200 { received, duplicate }.
  • POST /api/webhooks/subscriptions: de abonnementenmodule, met header x-subscription-signature: t=<unix>,v1=<hex> (HMAC-SHA256 van <t>.<ruwe body> met SUBSCRIPTIONS_WEBHOOK_SECRET, hoogstens 5 minuten oud). entitlements.changed wordt het plan van de coöperatie (een oudere snapshot wordt genegeerd), subscription.status suspended maakt ze enkel-lezen, canceled valt terug op het standaardpakket; niets wordt gewist. catalog.changed synchroniseert de catalogus. Typen: packages/shared/src/subscriptions/contract.ts.

Webhook van de ondertekendienst​

POST /api/webhooks/signing/{tenantId}/{integrationId}/{token}: openbaar (geen sessie, geen CSRF-controle), want de dienst roept hem aan. De URL komt uit PUT /integrations/signing en wordt in de console van de dienst ingesteld.

  • De API zet de tenantcontext uit de URL en vergelijkt de SHA-256 van het token met de opgeslagen hash, onder row-level security. Een fout token, of het token van een andere coöperatie, geeft 401.
  • De inhoud van het bericht is enkel een hint: de API vraagt de echte status op bij de dienst, met de sleutel van de coöperatie. Hetzelfde bericht twee keer verwerken verandert niets, en door de inbox gebeurt het ook niet (type, object en tijdstip vormen de sleutel). Antwoord 200 { handled }; handled: false voor een bericht over iets dat de app niet kent, of dat later opnieuw verwerkt wordt.
  • Werkt ook nadat de integratie uitgezet werd, zolang de sleutel er nog is: wat eerder verstuurd werd, kan afronden.

Elke nieuwe route krijgt een test op toegang (401, 403 voor een andere coöperatie, lezer mag niet schrijven) en op isolatie, naar het voorbeeld van apps/api/test/integration/tenancy.test.ts.

Databasefouten van het journaal​

Bij een boeking die een regel schendt, geeft Postgres een eigen foutcode. De API vertaalt die naar een 4xx-antwoord met die code, zodat de web-app een duidelijke melding toont.

CodeRegel
DG001het journaal en het audit-log zijn onveranderlijk: wijzigen of verwijderen is niet toegestaan
DG002het saldo (aantal of gestort bedrag) zou negatief worden
DG003het maximum aantal aandelen per vennoot zou overschreden worden
DG004ongeldige tegenboeking: geen origineel, al tegengeboekt, tegenboeking van een tegenboeking, andere vennoot of soort, niet het exacte tegengestelde, of een overdracht die niet als groep wordt teruggedraaid (of een gewone boeking die dat wel wordt)
DG006voor dit boekjaar en deze soort bestaat al een boekwaarde: een correctie moet ernaar verwijzen (supersedesId)
DG005ongeldige overdrachtsgroep: geen paar overdracht-uit en -in, ongelijk aantal of bedrag, andere datum, dezelfde vennoot, of een tegenboeking die niet de hele overdracht omvat
DG007het bedrag volgt niet uit de regels: bij intekening aantal × uitgifteprijs op de boekingsdatum, bij overdracht of uittreding de oorspronkelijke inleg van de aangeduide nummers
DG008geen uitgifteprijs op de boekingsdatum: geen regelversie van kracht, geen goedgekeurde boekwaarde, of een boekwaarde van nul of minder
DG009ongeldige aandeelnummers: opgegeven bij een intekening, niet in bezit, aantal klopt niet, een overdracht-in met andere nummers dan de overdracht-uit (of met nummers bij een omzetting), of nummers die al iemand heeft
DG010een bijstorting (payment): aandelen zijn altijd volgestort
DG011een uittredingsaanvraag buiten de uittredingsperiode van haar boekjaar
DG012aandeelnummers die in een lopende uittredingsaanvraag zitten, of een tegenboeking van de boeking van een uittredingsaanvraag
DG013een stap die niet mag in een uittredingsaanvraag (volgorde, ontbrekende gegevens, vaste velden, een boeking die niet overeenkomt)
DG015importeren kan niet: de import staat niet klaar, of het journaal bevat al andere boekingen
DG017het minimum aantal aandelen per vennoot in een soort wordt niet gehaald: na de boeking (of uittredingsaanvraag) heeft de vennoot er geen meer of minstens het minimum
DG018aandelen van deze soort mogen bij een overdracht niet in die doelsoort ingedeeld worden (422)
DG016omzetting van soort bij overdracht kan niet (422): de nominale waarde van beide soorten verschilt op de boekingsdatum, of de overgedragen nummers hebben een verschillende inbreng per aandeel
DG020een opgeslagen document wijzigen: inhoud, plaats of soort, een kortere bewaartermijn, een kortere vergrendeling, of wissen vóór het einde van de bewaartermijn
DG021een stap die niet mag voor een uitkeringstoets: een bevestigde toets wijzigen, of bevestigen zonder goedgekeurde jaarrekening, volledige cijfers en getekend verslag
DG022een uittredingsaandeel betalen zonder bevestigde uitkeringstoets die de betaaldatum dekt
DG014een stap die niet mag voor een registerversie of uittreksel: een gegenereerd document wijzigen, een ongeldige statusovergang, een geldende versie zonder getekende PDF, of de getekende PDF van een getekende versie vervangen

Verder: 22023 (ongeldige waarde in een functie van het platformbeheer, 400), P0002 (niet gevonden, 404), 42501 (geen recht of tenant komt niet overeen met de context; antwoord 403 zonder details), 23505 (bestaat al, 409), 23503 (verwijzing naar een vennoot of soort van een andere coöperatie of een onbestaande rij), 23514 (een rij die niet bij haar type past, bv. een negatief aantal bij een intekening).

Opgezegde coöperatie. Op /api/t/{slug}/... antwoordt de tenantguard met 423 { code: "TENANT_TERMINATED" } op elke aanvraag die niet GET, HEAD of OPTIONS is. Lezen en de volledige export blijven mogelijk.