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-sessiononder https,deelgenoot_sessionin dev). Geen tokens in de browser. EenAuthorization-header is enkel voor een API-sleutel; cookie én header samen geven400 ambiguous_credentials. - CSRF:
POST,PUT,PATCHenDELETEmoeten eenOriginhebben die gelijk is aanPUBLIC_URL, ofSec-Fetch-Site: same-origin. Anders403. Browsers doen dit vanzelf. - Cache: elk antwoord heeft
Cache-Control: no-store. - Foutvorm:
{ "statusCode": 4xx, "message": "..." }; bij400extraissues: [{ path, message }]; bij een regel van de database extracode(bv.DG006,23505). - Statuscodes:
401geen geldige sessie;403geen toegang (verkeerde rol, geen membership, onbekende coöperatie, ontbrekendeOrigin);400validatiefout;409conflict;404onbekende resource. - Schema's: Zod in
packages/shared/src/api.ts, gedeeld met de web-app.
Aanmelden en gebruiker
| Methode en pad | Toegang | Doet |
|---|---|---|
GET /api/auth/login?returnTo=/pad | openbaar | start 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/callback | openbaar | rondt 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/logout | sessie | beëindigt de sessie; antwoord { logoutUrl } (Keycloak-afmelding) |
GET /api/me | sessie | gebruiker en memberships: { user: { id, email, name, isPlatformAdmin }, memberships: [{ tenantId, slug, legalName, role, status }] } (status: active of terminated) |
GET /api/health | openbaar | { "status": "ok" } (controleert nog niet de database) |
Platform (platformbeheerder)
| Methode en pad | Doet |
|---|---|
GET /api/platform/status | { environment: local|int|prod, version, checks: { database, documents, keycloak }, mailTransport }; de controles hebben een korte time-out |
GET /api/platform/admins | platformbeheerders [{ userId, name, email, lastLoginAt }]; enkel lezen (toevoegen kan alleen met pnpm platform:create-admin) |
GET /api/platform/tenants | coö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/tenants | maakt 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}/people | voegt 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}/invitations | verstuurt de account-instellen-mail opnieuw. Body { email }. 204; 404 als die persoon geen lid is of al aanmeldde |
POST /api/platform/tenants/{tenantSlug}/terminate | zegt 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}/reactivate | maakt het opzeggen ongedaan. 200; 409 als ze niet opgezegd is |
POST /api/platform/tenants/{tenantSlug}/api-keys/revoke-all | incident: 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}/entitlements | de 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/grants | op 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/plan | plan 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/catalog | de catalogus van de abonnementenmodule; 503 subscriptions_not_configured zonder module |
POST /api/platform/subscriptions/catalog/sync | kopieert 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:
- De tenant komt uitsluitend uit de URL. Een
tenant_idin body, query of header wordt nooit gebruikt. - Elke route declareert zijn rollen met
@Roles('board', 'reader'). Een route zonder@Roleswordt voor iedereen geweigerd. - Onbekende coöperaties en coöperaties waar je geen lid van bent geven allebei
403, zodat niemand kan nagaan welke coöperaties bestaan. - Het hele verzoek draait in één databasetransactie met
app.tenant_id,app.user_idenapp.ipgezet vóór de eerste query. Een fout draait alles terug. - Transacties krijgen nooit
PATCHofDELETE; 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 pad | Doet |
|---|---|
GET /me | rol 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 /overview | actieve 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 /settings | vennootschapsgegevens (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 /policies | versies 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}/policies | regelversies 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}/valuations | boekwaarden 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.pdf | boekjaren (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.pdf | uitkeringstoets (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}.xlsx | de 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 /register | registerversies, 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.pdf | het 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}/extracts | uittreksels 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}/letters | brieven 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}/content | de 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 pad | Doet |
|---|---|
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-all | gelezen; 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>;liveop prod,testop 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 (
readerofboard). 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/authof/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/fullen/integrations/signing. Het rijksregisternummer schrijven of importeren met een sleutel geeft ook403, en het vennootdetail toont dan niet eens de laatste cijfers (nationalNumber.hintisnull). - Boeken (
@JournalWrite():POSTop/transactions,/exit-requestsen/imports/{id}/commit) vraagt een sleutel metjournalWrite, ook met rolboard(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 geenOrigin-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_limitedmetRetry-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_aten 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
.envbuiten 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 inderiveEntitlements(packages/shared/src/entitlements/) en loopt per verzoek, in de transactie van het verzoek. - Grenzen.
@WithinLimit('limit.members' | 'limit.capital_cents')opPOST /members,POST /transactions,/reverseen/imports/{id}/commit: stijgt het gebruik na de handeling boven de grens, dan wordt alles teruggedraaid en komt403 { 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 nietGET,HEADofOPTIONSis, zoals bij een opgezegde coöperatie. Lezen en exporteren blijven mogelijk. Idempotency-Key(8–200 tekens: letters, cijfers,._:-) opPOST /transactions,/transactions/{id}/reverse,/exit-requests/{id}/book,/exit-requests/{id}/pay,/imports/{id}/commit,POST /register,/members/{id}/extractsen/members/{id}/letters: dezelfde sleutel geeft hetzelfde antwoord (headerIdempotent-Replayed: true), ook als twee verzoeken tegelijk komen; dezelfde sleutel met een ander verzoek422 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 metPOSTMARK_WEBHOOK_TOKEN; Delivery, Bounce en SpamComplaint komen op de outbox-rij van het bericht (delivery_status). Antwoord200 { received, duplicate }.POST /api/webhooks/subscriptions: de abonnementenmodule, met headerx-subscription-signature: t=<unix>,v1=<hex>(HMAC-SHA256 van<t>.<ruwe body>metSUBSCRIPTIONS_WEBHOOK_SECRET, hoogstens 5 minuten oud).entitlements.changedwordt het plan van de coöperatie (een oudere snapshot wordt genegeerd),subscription.statussuspendedmaakt ze enkel-lezen,canceledvalt terug op het standaardpakket; niets wordt gewist.catalog.changedsynchroniseert 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: falsevoor 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.
| Code | Regel |
|---|---|
DG001 | het journaal en het audit-log zijn onveranderlijk: wijzigen of verwijderen is niet toegestaan |
DG002 | het saldo (aantal of gestort bedrag) zou negatief worden |
DG003 | het maximum aantal aandelen per vennoot zou overschreden worden |
DG004 | ongeldige 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) |
DG006 | voor dit boekjaar en deze soort bestaat al een boekwaarde: een correctie moet ernaar verwijzen (supersedesId) |
DG005 | ongeldige overdrachtsgroep: geen paar overdracht-uit en -in, ongelijk aantal of bedrag, andere datum, dezelfde vennoot, of een tegenboeking die niet de hele overdracht omvat |
DG007 | het bedrag volgt niet uit de regels: bij intekening aantal × uitgifteprijs op de boekingsdatum, bij overdracht of uittreding de oorspronkelijke inleg van de aangeduide nummers |
DG008 | geen uitgifteprijs op de boekingsdatum: geen regelversie van kracht, geen goedgekeurde boekwaarde, of een boekwaarde van nul of minder |
DG009 | ongeldige 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 |
DG010 | een bijstorting (payment): aandelen zijn altijd volgestort |
DG011 | een uittredingsaanvraag buiten de uittredingsperiode van haar boekjaar |
DG012 | aandeelnummers die in een lopende uittredingsaanvraag zitten, of een tegenboeking van de boeking van een uittredingsaanvraag |
DG013 | een stap die niet mag in een uittredingsaanvraag (volgorde, ontbrekende gegevens, vaste velden, een boeking die niet overeenkomt) |
DG015 | importeren kan niet: de import staat niet klaar, of het journaal bevat al andere boekingen |
DG017 | het 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 |
DG018 | aandelen van deze soort mogen bij een overdracht niet in die doelsoort ingedeeld worden (422) |
DG016 | omzetting 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 |
DG020 | een opgeslagen document wijzigen: inhoud, plaats of soort, een kortere bewaartermijn, een kortere vergrendeling, of wissen vóór het einde van de bewaartermijn |
DG021 | een stap die niet mag voor een uitkeringstoets: een bevestigde toets wijzigen, of bevestigen zonder goedgekeurde jaarrekening, volledige cijfers en getekend verslag |
DG022 | een uittredingsaandeel betalen zonder bevestigde uitkeringstoets die de betaaldatum dekt |
DG014 | een 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.