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. Met portal=1 (vennotenportaal, E8.1) een magic link (of de code uit dezelfde e-mail) zonder wachtwoord; ze vraagt het niveau van het portaal (acr portal-mfa, beslissing 137.10) niet-verplicht: wie een tweede stap heeft, geeft die, wie er geen heeft, krijgt acr 1. Met portal=1&level=mfa is portal-mfa verplicht: wie nog geen tweede stap heeft, stelt er in die aanmelding een in (passkey, anders een authenticator-app). Met portal=1&level=fresh daarbij max_age=0: opnieuw de link en de tweede stap (verse portaalstap, 5 minuten). Wie bij Keycloak al hoger aangemeld is (een bestuurder), krijgt de gevraagde acr zonder nieuwe vraag, nooit acr 2 of 3
GET /api/auth/callbackopenbaarrondt af: controleert state, nonce, PKCE en acr 2 of 3 (acr 1 en portal-mfa enkel na een aanmelding met portal=1; met level=mfa of level=fresh enkel portal-mfa, met level=fresh ook een auth_time van deze aanmelding, anders login_error=stepup), maakt de sessie met dat niveau (sessions.loa; het portaalniveau apart in portal_mfa, portal_mfa_at), 302 naar de app. Bij een fout 302 naar /?login_error=<mfa|expired|refused|failed>
POST /api/auth/logoutsessie, ook zonder tweede stapbeëindigt de sessie; antwoord { logoutUrl } (Keycloak-afmelding)
GET /api/mesessie, ook zonder tweede stapgebruiker en memberships: { user: { id, email, name, isPlatformAdmin }, memberships: [{ tenantId, slug, legalName, role, status }] } (status: active of terminated). Een lidmaatschap als vennoot (member) van een coöperatie die het vennotenportaal nooit had, staat er niet in (beslissing 137.13)
GET /api/healthopenbaar{ "status": "ok" } (controleert nog niet de database)

Sessie zonder tweede stap (E8.1). Een sessie van een portaalaanmelding (acr 1) bereikt enkel het vennotenportaal, GET /api/me en POST /api/auth/logout. Elke andere route antwoordt 401 { statusCode, code: "mfa_required", message, level: "board", loginUrl: "/api/auth/login" } (Zod: mfaRequiredErrorSchema); op een tenantroute eerst de rol, dus een vennoot op een bestuursroute krijgt 403. Bestuurders en lezers hebben altijd een tweede stap nodig (CLAUDE.md regel 4): het niveau van het portaal (acr portal-mfa, link of code plus tweede stap, zonder wachtwoord) telt daar nooit voor (de sessie houdt loa 1). Omgekeerd telt een bestuursaanmelding (acr 2 of 3) wel voor het portaalniveau, niet voor de verse portaalstap.

Vennotenportaal (/api/t/{slug}/portal/..., E8)​

Alles wat een vennoot ziet of doet loopt via deze routes, met de Zod-contracten in packages/shared/src/portal-api.ts, zodat een app ze later ook kan gebruiken. De web-app voegt geen eigen logica toe. Aanmelden gebeurt nu met de sessiecookie (BFF); de routes zelf hangen enkel af van de sessie (req.auth: gebruiker en niveau), niet van de cookie, zodat een app later met OIDC en PKCE een eigen token kan gebruiken (apart issue).

Regels:

  1. Enkel de rol member (@Roles('member')): bestuurder en lezer krijgen 403, net als wie geen lid is of bij een andere coöperatie hoort. Omgekeerd weigeren alle bestuurs- en lezersroutes een vennoot met 403. Geen API-sleutel.
  2. Een aanmelding zonder tweede stap (acr 1, magic link) volstaat; een vennoot die zelf een tweede stap instelde, krijgt die ook gevraagd. Behalve als de coöperatie een tweede stap verplicht (Instellingen › Vennotenportaal, mfaRequired) of uittredingsverzoeken toelaat (exitRequest, beslissing 22). De tweede stap volgt de reden (beslissing 137.10, #507): met mfaRequired antwoordt elke route van een vennoot van die coöperatie op een aanmelding zonder tweede stap 401 { code: "mfa_required", level: "portal", loginUrl: "/api/auth/login?portal=1&level=mfa" } (mfaRequiredErrorSchema), ook /portal/capabilities; met enkel exitRequest blijft het portaal open met de link, ook het bekijken, en vraagt enkel indienen en intrekken van een uittredingsverzoek een verse portaalstap (hoogstens 5 minuten oud, @RequiresFreshPortalSecondFactor()): anders 401 { code: "step_up_required", level: "portal", stepUpUrl: "/api/auth/login?portal=1&level=fresh" } (stepUpRequiredErrorSchema). Geen doodlopende 401: de app stuurt de vennoot naar loginUrl of stepUpUrl met returnTo, en de vennoot komt terug waar die was. Een andere coöperatie van dezelfde persoon volgt haar eigen instelling. Beide schakelaars staan open sinds vennoten een tweede stap kunnen instellen en de waarborgen er zijn (beslissing 137.10, #507: PORTAL_MEMBER_SECOND_STEP_AVAILABLE = true). Het slot blijft in de code: staat de constante ooit op false, dan weigert PUT /settings/portal beide schakelaars (422 portal_second_step_unavailable) en negeert de API een eerder bewaarde true (portalMfaEnforced false, exitRequest en mfaRequired lezen als uit, de bewaarde waarde blijft staan).
  3. De vennoot komt uit memberships.member_id (één vennoot per account, per coöperatie), nooit uit de URL, de query of de body.
  4. Het verzoek draait als databaserol app_member met app.member_id naast app.tenant_id (migratie 0099): de database toont enkel de eigen rijen (vennoot, contactpersonen, journaal, saldi, aandeelnummers, uittreksels, brieven) en de aandelensoorten van de coöperatie, en enkel de toegelaten kolommen. Het rijksregisternummer, de notities van het bestuur en wie iets boekte of tekende zijn voor die rol niet leesbaar (42501), wat de code ook vraagt.
  5. Elk antwoord gaat door het Zod-schema van het contract: een veld dat er niet in staat, verlaat de API niet.
  6. Wat het portaal toont en toelaat, stelt de coöperatie in (E8.2, features.md §13): een schakelaar per capability (PORTAL_CAPABILITIES), met standaarden (PORTAL_CAPABILITY_DEFAULTS): extract, forwardedDocuments, meetingDocuments, changeRequest, noticeChannel, meetingQuestions en proxy aan; exitRequest en assistant uit. Eigen gegevens en aandelen (/portal/me) staan altijd aan. Een route van een capability draagt @RequiresPortalCapability(...) (apps/api/src/portal/capabilities.ts); staat die uit, dan antwoordt de API, bij lezen en schrijven, 403 { code: "portal_capability_disabled", capability, message } (portalCapabilityDisabledErrorSchema). De controle loopt in de transactie van het verzoek (PortalAccessInterceptor, voor elk verzoek van een vennoot); de app toont een capability die uit staat niet (onzichtbaar, niet gedimd). extract is de eerste route met een capability (E8.3); de andere komen met E8.4–E8.6.
  7. Mijn coöperaties (E8.3) hangt niet aan één coöperatie: GET /api/portal/cooperatives. De lijst komt uit de eigen memberships met de rol member (zoals /api/me); de cijfers van elke coöperatie worden gelezen in een eigen transactie met de context van die coöperatie, precies zoals een portaalverzoek ervan (app.tenant_id, app.user_id, app.member_id van dat lidmaatschap, databaserol app_member). Nooit één query over coöperaties heen, nooit BYPASSRLS. Een coöperatie die een tweede stap verplicht terwijl de aanmelding er geen heeft (regel 2), staat er enkel met haar naam in (access: "mfa_required", cijfers null); geen 401, zodat de andere coöperaties zichtbaar blijven.
  8. Het uittreksel in het portaal is het nieuwste uittreksel dat het bestuur voor de vennoot maakte en dat klaar (ready) of getekend (signed) is; het portaal maakt zelf geen uittreksel. Getekend: de getekende PDF. Een download staat als leesregel in de audit-trail (member_extracts, via: "portal"). Migratie 0103 laat app_member enkel de bestanden van de eigen uittreksels lezen (documents, restrictieve policy member_own_rows, enkel de kolommen om het bestand te lezen) en member_number_digits van de coöperatie.
  9. Elke route van een vennoot controleert de module module.member_portal (beslissing 137.13, Q5; #509), in PortalAccessInterceptor, vóór de tweede stap en de capability, zoals 102.1 en 118.1 voor het bestuur (MemberPortalService, apps/api/src/entitlements/member-portal.service.ts):
    • actief (vrijgegeven en in het plan, de proef of een afspraak): het portaal werkt. De eerste keer legt de API het moment vast in tenant_module_activations (migratie 0113; bron plan, trial of grant), één keer: bij een verzoek van een vennoot, bij de afleiding voor het bestuur (EntitlementsInterceptor, GET /api/t/{slug}/me), bij de start van een proef en wanneer een platformbeheerder een plan of afspraak zet. Een latere wijziging van de catalogus of van een standaardplan verandert die rij niet (PT1);
    • weggevallen (vrijgegeven, niet meer in plan, proef of afspraak, maar de eerste activering staat vast en minstens één vennoot is voor het portaal uitgenodigd, PT1): het portaal is alleen-lezen. Uitgenodigd: een lidmaatschap met de rol member (0099). Lezen (GET, HEAD, OPTIONS) blijft: de eigen gegevens, de aandelen, het uittreksel en de eigen wijzigingsverzoeken. Elke handeling (elke andere methode: wijziging aanvragen, bevestigen, intrekken, opnieuw sturen, keuze e-mail of post, en later vragen, volmacht en uittredingsverzoek) krijgt 403 { code: "module.member_portal", message, params: { state: "read_only" } } (portalModuleErrorSchema). GET /portal/capabilities geeft readOnly: true; Mijn coöperaties readOnly: true bij die coöperatie. Bij de uitrol kreeg elke coöperatie die al vennoten in het portaal had een eerste activering (bron legacy_portal, R1), en elke coöperatie die de module volgens de vroegere afleiding had (planversie in de audit-trail, proef op een plan met de module, begonnen afspraak) een met bron backfill;
    • geen portaal (vrijgegeven, maar nooit actief of geen enkele vennoot uitgenodigd): elke route van een vennoot, ook lezen, krijgt 403 { code: "module.member_portal", params: { state: "absent" } }; Mijn coöperaties en GET /api/me laten de coöperatie weg (een lidmaatschap als bestuurder of lezer blijft in /api/me);
    • tijdelijk niet beschikbaar (niet vrijgegeven voor de coöperatie: nooit, of de noodstop; PT2): geen portaal, ook geen lezen: elke route van een vennoot krijgt 403 { code: "module.member_portal", params: { state: "unavailable" } }. De coöperatie blijft in GET /api/me en op Mijn coöperaties met access: "unavailable" (naam, geen cijfers); de app zegt "Het portaal is tijdelijk niet beschikbaar.", niet dat de coöperatie geen portaal heeft.
    • Uitnodigingen voor het portaal, herinneringen en nieuwe uitnodigingslinks (E8.7, PT4) vragen MemberPortalService.assertInvitationsAllowed: enkel met de module actief, anders 403 module.member_portal. Er bestaat nog geen route die een vennoot uitnodigt of herinnert. Aanmelden met link of code blijft werken voor een vennoot die al uitgenodigd was.
    • Een opgezegde of geschorste coöperatie blijft leesbaar zolang het bestuur leest (V4, regel van TenantGuard); de module verandert daar niets aan.
    • app_member leest geen entitlement-tabel: migratie 0113 geeft hem enkel app.member_portal_entitlement_rows() (SECURITY DEFINER, zonder parameter, enkel de coöperatie van app.tenant_id): de planrij met enkel de sleutel van het portaal, de data van de proef en van de afspraken voor het portaal, en twee booleans: hadModule (de eerste activering staat vast) en invitedMembers (minstens één vennoot in het portaal). De eerste activering legt een vennoot vast met app.record_member_portal_activation(source), het bestuur met app.record_module_activation(key, source); geen van beide neemt een coöperatie als parameter. De API leidt de staat af met dezelfde afleiding als voor het bestuur (EntitlementsService.derive, met de vrijgave-flags).
Methode en padDoet
GET /api/t/{slug}/portal/capabilitieswat het portaal van deze vennoot toont: { capabilities: { extract, forwardedDocuments, meetingDocuments, changeRequest, noticeChannel, meetingQuestions, proxy, exitRequest, assistant }, mfaEnforced, readOnly } (portalCapabilitiesResponseSchema); readOnly: de coöperatie verloor de module (regel 9), de app toont bovenaan "Het portaal van {coöperatie} is enkel nog te raadplegen. Neem contact op met het bestuur." en geen handelingen
GET /api/portal/cooperativesMijn coöperaties (E8.3), elke coöperatie waar de aangemelde persoon vennoot is, op naam: { cooperatives: [{ slug, legalName, status, access (open|mfa_required|unavailable), readOnly, memberNumber, shares, paidCents }], totals: { cooperatives, shares, paidCents } } (portalCooperativesResponseSchema). Een coöperatie zonder portaal staat er niet in; access: "unavailable" (naam, geen cijfers) als het portaal tijdelijk niet beschikbaar is (module niet vrijgegeven, regel 9); readOnly bij een coöperatie die de module verloor (regel 9). memberNumber zoals de coöperatie het toont ("0042"); shares en paidCents over de aandelen die de vennoot nu heeft; totals telt enkel de coöperaties met access: "open". Zonder lidmaatschap als vennoot een lege lijst; geen API-sleutel; ook met enkel de magic link
GET /api/t/{slug}/portal/sharesMijn aandelen (E8.3, #510): { cooperative, member: { memberNumber, displayName, status, activeSince, exitedOn }, register: { version, signedAt } | null, totals: { shares, paidCents }, holdings: [{ shareClassId, code, name, quantity, paidCents, shareNumbers }], transactions: [{ id, seq, effectiveDate, type, shareClassCode, quantity, amountCents, shareNumbers, isReversal, isReversed, counterpartyName }] } (portalSharesResponseSchema), boekingen nieuwste eerst. Enkel de eigen rijen: van de andere vennoot van een eigen overdracht enkel de naam, zoals in een zin: voornaam en naam ("Karel De Smet") of de naam van de rechtspersoon (counterpartyName, app.portal_transfer_counterparty, migraties 0114 en 0120; beslissingen 137.20 en 146.11), geen notitie, niet wie boekte. status en de datums komen uit member_status; register is de geldende getekende versie (nummer en datum, geen PDF; 137.21). Een memberId of tenantId in de query telt niet. Altijd aan
GET /api/t/{slug}/portal/extractcapability extract: { extract: { asOfSeq, generatedAt, signed, signedAt, upToDate } | null } (portalExtractResponseSchema); upToDate is false als de vennoot boekingen heeft na het uittreksel. Uit: 403 portal_capability_disabled
GET /api/t/{slug}/portal/extract.pdfcapability extract: de PDF van dat uittreksel (de getekende als het getekend is), Content-Disposition: attachment; filename="uittreksel-jjjj-mm-dd[-getekend].pdf"; zonder uittreksel 404. Uit: 403 portal_capability_disabled. Nooit het rijksregisternummer (het sjabloon heeft het niet)
GET /api/t/{slug}/portal/meeigen gegevens in deze coöperatie: { cooperative: { slug, legalName }, member: { id, memberNumber, kind, displayName, firstName, lastName, legalName, tradeName, enterpriseNumber, street, postalCode, city, country, email, phone, language, noticeChannel, noticeChannelSince }, holdings: [{ shareClassId, code, name, quantity, paidCents }] } (portalMeResponseSchema). Nooit het rijksregisternummer

Mijn gegevens en wijzigingsverzoeken (E8.4, #193)​

Contracten in packages/shared/src/change-requests-api.ts; tabel member_change_requests (migraties 0104 en 0117, #533, beslissing 137.24). Een vennoot vraagt een wijziging van zijn e-mailadres, postadres of telefoonnummer aan met een reden (naam, geboortedatum en rijksregisternummer nooit via het portaal, 137.24.2); een nieuw e-mailadres bevestigt hij eerst met een link naar dat adres; het bestuur keurt goed (de vennoot wordt in dezelfde transactie aangepast, met de reden in de audit-trail) of wijst af met een bericht; de vennoot krijgt een e-mail via de outbox. Status: unverified (e-mail, wacht op de link) → open → approved | rejected; uit unverified of open kan de vennoot intrekken (withdrawn); een unverified e-mailverzoek vervalt na portal.change_request.expire_days dagen (7; expired, het niet-bevestigde adres gewist, ook gemaskeerd in de audit-trail van de tabel sinds 0117). Het gevraagde e-mailadres van een verzoek dat niet in het register kwam, wordt gewist (Q8 van docs/vragen-po-2026-10-06-portaal5.md): meteen bij de intrekking (door de guard, ook van een bevestiging van de 2:32-keuze), en portal.change_request.rejected_retention_days dagen (30) na een afwijzing door het bestuur (app.wipe_rejected_member_change_requests); tot dan ziet het bestuur het bij het afgehandelde verzoek. De rij blijft (soort, data, beslissing, bericht). Een open verzoek vervalt nooit; zolang verzoeken openstaan, krijgt elke bestuurder (geen lezer) hoogstens één herinnering per portal.change_request.reminder_days dagen (14) per coöperatie (Q2): enkel als minstens één open verzoek al zo lang bij het bestuur ligt (sinds de indiening, of sinds de bevestiging van het adres), en enkel als zijn laatste herinnering in die coöperatie minstens zo oud is (of er nog geen was). Ze zegt hoeveel verzoeken openstaan en hoeveel daarvan langer dan de termijn (melding change_request.reminder in de app en per e-mail volgens zijn voorkeur; nooit één per verzoek). De sleutel tegen dubbels is de dag van de run (change_request.reminder:jjjj-mm-dd): een tweede run die dag voegt niets toe, en na gemiste runs volgt één herinnering. Dat doet de dagelijkse job portal.change_requests (06:45 en bij de start). Afgehandeld, ingetrokken of vervallen is definitief (DG063); goedkeuren vóór de bevestiging 409 DG064; één lopend verzoek per vennoot en soort (409 DG065); enkel de vennoot trekt een verzoek in (DG063 voor de API van het bestuur); hoogstens portal.change_request.mails_per_day (5) bevestigingsmails per vennoot in 24 uur (429 DG066). De drie grenzen van de link en de drie termijnen zijn platforminstellingen (Platformbeheer, eenheden hours, minutes, count, days), geen constanten; de database leest ze zelf (app.platform_setting).

De keuze e-mail of post (art. 2:32 WVV) zet de vennoot zelf (beslissing 14; capability noticeChannel, PO: een coöperatie mag ze uitzetten, dan legt het bestuur de keuze vast), als uitdrukkelijke handeling (137.24.9): boven de knop "Per e-mail ontvangen" staat de tekst van een versie (NOTICE_EMAIL_CONSENT_TEXTS, nu wvv232-email.nl.1, met de SHA-256 van de tekst); de body noemt die versie en members.notice_channel_text_version bewaart ze, zodat de audit-trail van de vennoot wie, wanneer en welke tekst toont. E-mail vraagt een adres waarvan vaststaat dat de vennoot het leest (137.24.1): al bevestigd (email_verified_at), of hetzelfde als het adres waarmee hij aanmeldde (users.email uit het ID-token van de aanmelding met link of code; dan zet de database email_verified_at); anders gaat er eerst een bevestigingslink naar het adres in het register (soort notice_email, dezelfde token, termijn en grenzen; nooit een verzoek voor het bestuur, niet in de lijsten) en geldt de keuze vanaf de bevestiging. Alles in app.set_member_notice_channel en app.confirm_member_notice_email (SECURITY DEFINER); app_member mag members.notice_channel niet meer zelf schrijven. Een bestuurder legt de keuze vast zoals voorheen, ook met een niet-bevestigd adres (zonder tekstversie).

Het token van de bevestigingslink: 256 willekeurige bits, gemaakt wanneer de outbox de mail verstuurt (soort member.change_request.verify; de payload noemt enkel het verzoek), in de database enkel als SHA-256, één keer bruikbaar, portal.change_request.link_hours (24) uur geldig. Het staat in het fragment van de link (/portaal/{slug}/gegevens/bevestigen#token=…, voor de 2:32-keuze /portaal/{slug}/gegevens/oproepingen-bevestigen#token=… met het sjabloon notice-channel-confirm-nl), dus nooit in een URL die een server of toegangslog ziet; de web-app stuurt het in de body. Nooit in de outbox, een log of de audit-trail (de hash is daar gemaskeerd en blijft uit de volledige export).

Methode en padDoet
GET /api/t/{slug}/portal/change-requests (member, capability changeRequest){ requests: [{ id, kind (email|address|phone), status, email, address: { street, postalCode, city, country } | null, phone, reason, createdAt, verificationExpiresAt, verifiedAt, decidedAt, decisionMessage }] } (portalChangeRequestsResponseSchema), nieuwste eerst. Enkel de eigen verzoeken; nooit wie besliste of de hash
POST /api/t/{slug}/portal/change-requests (member, changeRequest){ kind: "email", email, reason } of { kind: "address", address: { street, postalCode, city, country }, reason } of { kind: "phone", phone, reason } (cijfers met een optionele +, spaties, punten, schuine strepen, streepjes of haakjes, minstens 6 cijfers, hoogstens 50 tekens; leeg: geen telefoonnummer meer, het verzoek heeft dan phone: null en goedkeuren maakt het nummer in het register leeg, Q5) (portalChangeRequestCreateSchema; reden 1–1000 tekens) → 201 met het verzoek. Een memberId of tenantId in de body telt niet: de vennoot en de coöperatie komen uit de sessie. E-mail: status unverified en na de commit de bevestigingsmail (sjabloon change-request-verify-nl) naar het nieuwe adres; adres en telefoon: open. Hetzelfde als in het register (of een leeg nummer zonder nummer in het register) 422 change_request_unchanged; al een lopend verzoek van die soort 409 DG065; een e-mailverzoek boven de limiet van bevestigingsmails 429 DG066
POST /api/t/{slug}/portal/change-requests/verify (member, changeRequest){ token } → 200 met het verzoek (open, verifiedAt). Verlopen 410 verification_expired; fout, al gebruikt of van een andere vennoot 404 verification_invalid. Via app.verify_member_change_request(hash) (SECURITY DEFINER): enkel die functie mag een verzoek van unverified naar open zetten
POST /api/t/{slug}/portal/change-requests/{id}/resend-verification (member, changeRequest)202: een nieuwe link (de vorige werkt niet meer). Niet unverified 409 change_request_not_unverified; binnen portal.change_request.resend_minutes (5) minuten na de vorige aanvraag (ook als de outbox de vorige mail nog niet verstuurde) 429 verification_recently_sent; boven de limiet 429 DG066. De database beslist onder een lock op de vennoot en het verzoek (app.request_member_change_request_verification): gelijktijdige aanvragen zetten samen hoogstens één mail klaar
POST /api/t/{slug}/portal/change-requests/{id}/withdraw (member, changeRequest)intrekken → 200; het gevraagde e-mailadres is meteen gewist (email: null, Q8). Niet van de vennoot 404; afgehandeld 409 DG063
GET /api/t/{slug}/portal/notice-channel (member, capability noticeChannel){ pending: { id, email, createdAt, verificationExpiresAt } | null } (portalNoticeChannelStateSchema): een keuze voor e-mail die op haar bevestiging wacht
PUT /api/t/{slug}/portal/notice-channel (member, noticeChannel){ channel: "post" } of { channel: "email", textVersion } (portalNoticeChannelUpdateSchema) → 200 { outcome, member, pending } (portalNoticeChannelResultSchema). applied: geldt nu (post altijd; e-mail met een bevestigd adres of het adres van de aanmelding), met vandaag als datum (noticeChannelSince); de audit-trigger op members bewaart tijdstip, gebruiker en tekstversie. confirmation_sent: na de commit een link naar het adres in het register, e-mail geldt vanaf de bevestiging; confirmation_pending: zo'n link ging al. Post kiezen trekt een wachtende bevestiging in. Zonder textVersion 400; een andere versie dan de huidige 422 notice_channel_text_outdated; zonder e-mailadres 422 notice_channel_email_required; boven de limiet van bevestigingsmails 429 DG066. Een memberId of tenantId in de body telt niet
POST /api/t/{slug}/portal/notice-channel/resend (member, noticeChannel)202 { pending }: een nieuwe link voor de wachtende bevestiging (zelfde wachttijd en limiet als hierboven); geen wachtende 404; het adres in het register is niet meer het adres van de bevestiging 409 notice_channel_email_changed (ook in de database: app.request_member_change_request_verification geeft changed, en de outbox stuurt dan niets)
POST /api/t/{slug}/portal/notice-channel/confirm (member, noticeChannel){ token } → 200 zoals GET /portal/me (e-mail geldt, email_verified_at gezet). Verlopen 410 verification_expired; het adres in het register intussen gewijzigd 409 notice_channel_email_changed (de trigger members_withdraw_notice_email trok de bevestiging al in toen het adres in het register wijzigde, wie het ook wijzigde; de guard wiste toen het adres en bewaarde de reden in withdrawal_reason = 'address_changed', waarop dit antwoord steunt; het schrijft niets, dus het blijft 409 bij elke nieuwe poging); door de vennoot zelf ingetrokken (post gekozen, withdrawal_reason = 'member') 404; fout, gebruikt of van een andere vennoot 404 verification_invalid. Het token van een gewone e-mailwijziging werkt hier niet, en omgekeerd
GET /api/t/{slug}/change-requests?state=open|closed (board, reader){ requests: [{ id, member: { id, memberNumber, displayName }, kind, status, current: { email, address, phone }, email, address, phone, reason, createdAt, verifiedAt, decidedAt, decidedByName, decisionMessage }], openCount, unverifiedCount } (boardChangeRequestsResponseSchema). open: unverified en open; closed: de rest (expired en withdrawn zonder e-mailadres, rejected zonder e-mailadres na rejected_retention_days; een telefoonverzoek zonder nummer heeft phone: null). Nooit een bevestiging van de 2:32-keuze. Een lezer leest, zonder knoppen (137.24.5); goedkeuren en afwijzen 403. Vennoot 403
POST /api/t/{slug}/change-requests/{id}/approve (board){ message? } → 200 met het verzoek. In één transactie: het verzoek approved (wie en wanneer zet de database), de vennoot aangepast (e-mail met email_verified_at = moment van de bevestiging; adres; telefoon, leeg bij een verzoek zonder nummer), met als reden "Wijzigingsverzoek van de vennoot: {reden}" in de audit-trail; daarna een mail (change-request-decided-nl) naar het adres dat het register nu heeft, en bij een e-mailwijziging een melding (change-request-email-changed-nl, via de outbox) naar het oude adres met het nieuwe deels verborgen (j•••@voorbeeld.be, 137.24.7). Geen API-sleutel; Idempotency-Key
POST /api/t/{slug}/change-requests/{id}/reject (board){ message } (verplicht, 1–2000 tekens) → 200. De vennoot verandert niet; mail met het bericht als er een e-mailadres is. Geen API-sleutel; Idempotency-Key

Zonder de module module.member_portal weigeren al deze handelingen (POST, PUT) 403 module.member_portal, en lezen enkel zolang de coöperatie de module gehad heeft (regel 9).

Tweede stap van een vennoot (#507, beslissing 137.5 en 137.10)​

De tweede stap staat in Keycloak (één account per persoon, over coöperaties heen); de API leest en verwijdert ze met zijn beheerclient (deelgenoot-admin). Een nieuwe tweede stap instellen gebeurt altijd in Keycloak: zonder tweede stap met GET /api/auth/login?portal=1&level=mfa (Keycloak biedt eerst een passkey aan, anders een app), met een tweede stap met POST /api/t/{slug}/portal/second-factors/setup (hieronder) en de verse portaalaanmelding die het antwoordt. Niet met Keycloaks kc_action: die tilt het gevraagde niveau naar dat van de soort tweede stap, en vraagt zo het wachtwoord (dat een vennoot niet heeft). Keycloak mailt het account bij elke instelling of verwijdering langs zijn eigen weg; voor de twee handelingen hieronder mailt de API zelf, in naam van de coöperatie (second-factor-wiped-nl, second-factor-removed-nl).

Methode en padDoet
GET /api/t/{slug}/members/{id}/second-factor (board, reader){ account, factors: [{ kind: passkey|app, since }], wipeable, lastWipe: { at, byName } | null } (memberSecondFactorSchema): heeft de vennoot een portaalaccount in deze coöperatie, welke soort tweede stap en sinds wanneer (nooit de naam die de vennoot ze gaf), en de laatste keer dat het bestuur ze wiste. Een vennoot van een andere coöperatie 404; geen API-sleutel
POST /api/t/{slug}/members/{id}/second-factor/wipe (board)"Toestel kwijt": wist elke tweede stap van het account (en de herstelcodes; het wachtwoord blijft), beëindigt elke sessie van het account (Deelgenoot en Keycloak) en mailt de vennoot → 200 zoals GET. Vraagt een verse tweede stap van de bestuurder (401 step_up_required, level: board, zoals een API-sleutel aanmaken); geen API-sleutel. Rij in de audit-trail van deze coöperatie (action: second_factor.wipe, entity: member_second_factor, after: { account, factors }), geschreven door app.log_second_factor_wipe (migratie 0119), die zelf nog eens nakijkt: enkel een bestuurder van de coöperatie, enkel een vennoot met een portaalaccount in die coöperatie. 409 second_factor_no_account (geen portaalaccount), 409 second_factor_wipe_staff (het account is ook bestuurder of lezer, ergens, of platformbeheerder), 404 voor een vennoot van een andere coöperatie (ook met een tenantId in de body), lezer 403. Ook zonder tweede stap (wipeable geldt voor elk portaalaccount dat geen bestuurder, lezer of platformbeheerder is): dan wist het niets in Keycloak, maar beëindigt het elke sessie, schrijft het de rij en mailt het (een verloren toestel met enkel de link). Faalt Keycloak: 502 identity_provider_failed, niets bewaard in Deelgenoot en geen mail; wat Keycloak al wiste, blijft gewist. Opnieuw wissen maakt het af (rij, sessies, afmelden in Keycloak, mail); het antwoord vraagt Keycloak niets meer. Geen herstel met enkel een mail: bij de volgende aanmelding die een tweede stap vraagt, stelt de vennoot een nieuwe in
GET /api/t/{slug}/portal/second-factors (member){ factors: [{ id, kind, label, since }], requiredBy: [naam], requiredForBoard } (portalSecondFactorsResponseSchema): de eigen tweede stappen, en waarom de laatste niet weg mag (P5): de coöperaties van deze persoon die een tweede stap vragen voor hun hele portaal (requiredBy), of het account is ook bestuurder of lezer (requiredForBoard)
POST /api/t/{slug}/portal/second-factors/setup (member){ kind: passkey|app } (portalSecondFactorSetupRequestSchema) → 200 { loginUrl }: zet in Keycloak de vereiste actie webauthn-register-passwordless of CONFIGURE_TOTP op het account (bij de andere die het al had, elk één keer) en antwoordt met de verse portaalaanmelding (/api/auth/login?portal=1&level=fresh&returnTo=/portaal/{slug}/gegevens/tweede-stap): de link en de huidige tweede stap opnieuw, dan de instelling, terug naar de pagina. Rondt de vennoot ze niet af, dan vraagt Keycloak ze bij de volgende aanmelding; ze voegt enkel toe. Heeft het account al een tweede stap, dan enkel met het niveau van het portaal (anders 401 mfa_required, level: portal): een aanmelding met enkel de link zet niets klaar. Een andere soort 400; bestuurder of lezer zonder vennootschap 403; faalt Keycloak: 502 identity_provider_failed
DELETE /api/t/{slug}/portal/second-factors/{id} (member)Verwijdert één eigen tweede stap → 200 zoals GET, en mailt de vennoot. Vraagt een verse portaalstap van hoogstens vijf minuten (401 step_up_required, level: portal, stepUpUrl: /api/auth/login?portal=1&level=fresh). De laatste niet zolang een regel er een vraagt: 409 { code: "second_factor_last_required", params: { cooperatives, board } } (secondFactorLastRequiredErrorSchema); dan enkel vervangen (eerst een nieuwe instellen). Een id van een ander account, het wachtwoord of de herstelcodes 404. Weggevallen module: 403 module.member_portal

Instellingen › Vennotenportaal (bestuur)​

Methode en padDoet
GET /api/t/{slug}/settings/portal (board, reader){ capabilities, mfaRequired, mfaEnforced, isDefault } (portalSettingsSchema): de schakelaars (zonder bewaarde keuze de standaarden, isDefault: true), de eigen keuze van het bestuur voor een verplichte tweede stap, en wat geldt (mfaEnforced = mfaRequired of exitRequest). Met het slot van beslissing 137.10 (PORTAL_MEMBER_SECOND_STEP_AVAILABLE = false, nu niet) lezen capabilities.exitRequest, mfaRequired en mfaEnforced als false. Lezen kan ook zonder de module. Een lezer leest mee (beslissing 137.10, Q7), zoals bij Instellingen › AI en › E-mail; vennoot 403
PUT /api/t/{slug}/settings/portal (board){ capabilities: { …alle negen… }, mfaRequired } (portalSettingsUpdateSchema, strikt: een onbekende of ontbrekende capability, een waarde die geen boolean is of een tenantId in de body 400) → 200 zoals GET. capabilities.exitRequest: true of mfaRequired: true → 422 { code: "portal_second_step_unavailable", params: { switches: ["exitRequest" | "mfaRequired", …] } } (portalSecondStepUnavailableErrorSchema), er wordt niets bewaard, enkel met het slot van beslissing 137.10 (nu niet: beide mogen true). Lezer en vennoot 403. Elke schakelaar wordt bewaard, zodat een keuze een latere wijziging van de standaard niet volgt. Vraagt module.member_portal (anders 403 { code: "module.member_portal" }); geen API-sleutel. Bewaard in tenant_settings.portal_capabilities en portal_mfa_required (migratie 0102); de audit-trigger op tenant_settings zet wie, wanneer, oud en nieuw in de audit-trail. app_member mag enkel die twee kolommen (en tenant_id) van de eigen coöperatie lezen

Platform (platformbeheerder)​

Methode en padDoet
GET /api/platform/status{ environment: local|int|prod, version, checks: { database, documents, keycloak }, mailTransport, knowledge: { status, corpus, shipped, active } }; de controles hebben een korte time-out. knowledge vergelijkt de platformcorpus in deze release (shipped, uit het manifest van WVV boek 6) met de actieve versie in de database (active, zonder valid_to): ok dezelfde, stale een andere (het laden bij de uitrol mislukte), missing nog geen, none de release bevat geen corpus, unknown niet te lezen. Een waarschuwing, geen controle: /api/health/ready kijkt er niet naar (beslissing 85)
GET /api/platform/adminsplatformbeheerders [{ userId, name, email, lastLoginAt }]; enkel lezen (toevoegen kan alleen met pnpm platform:create-admin)
GET /api/platform/tenantscoöperaties (planReview: het plan is nog het handmatige Plus van migratie 0042, beslissing 60; de UI toont "plan nakijken") [{ id, slug, legalName, enterpriseNumber, createdAt, status, terminatedAt, terminationReason, apiKeysEnabled, planReview, 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, platform: de terugvalwaarde van een platforminstelling voor een grens die geen bron noemt, vandaag enkel limit.ai_budget_cents), 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/settings, PUT /api/platform/settings/{key}platforminstellingen (beslissing 109): [{ key, value, unit (cents|percent|share), minValue, maxValue, description, updatedAt, updatedByName }]; PUT { value } binnen de grenzen van de sleutel (400 erbuiten, ook een breuk bij cents of percent; 404 bij een onbekende sleutel; enkel bestaande sleutels, die een migratie aanmaakt). Vandaag ai.budget_fallback_cents, ai.budget_warn_pct, ai.budget_stop_pct, ai.review_threshold.<target>, ai.ocr_low_confidence_pct (85; onder die OCR-score is de tekst van een gescande statutenversie van lage kwaliteit, beslissing 119.4) en ai.platform_embed_max_cents (50; het plafond van één run die de wet inbedt met de sleutel van het platform, beslissing 145.7). In de API binnen 15 seconden van kracht (meteen op dezelfde instantie), in de worker bij de volgende aanroep
GET /api/platform/ai/answer-problems?since=wat er mis was met de antwoorden van de modellen (beslissing 145.5, #523): { since, counts: [{ feature, model, code, outcome, count }] } over alle coöperaties sinds die dag (YYYY-MM-DD; zonder since de laatste 30 dagen, 400 bij een andere vorm). Via app.platform_ai_answer_problem_counts, die zelf de platformbeheerder controleert: nooit een coöperatie, een rij, een pad of een tijdstip. Kleine aantallen worden niet onderdrukt (open punt van 145.5)
GET /api/platform/ai/platform-embedde vectoren van de wet en het eigen AI-verbruik van het platform (beslissing 145.7): { chunks, embedded, calls, costMicrocents }, het verbruik van deze maand (Europe/Brussels) via app.platform_ai_usage_month, die zelf de platformbeheerder controleert; enkel aantallen
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. Voor de rol member ook app.member_id, als databaserol app_member (zie Vennotenportaal).
  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, modulesWithData, limits, usage, trial, trialAvailable, trialPlanCode, readOnly, plans }. Enkel in een database zonder pakketrij (migratie 0042 vult er altijd een minimale catalogus in) 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; modulesWithData (beslissing 118.1): de modules waarvan de coöperatie eigen rijen heeft (module.assembly: vergaderingen, module.reports: jaarverslagen, module.documents: archiefdocumenten buiten die van de jaarverslagen, module.calendar: processen, module.ai: AI-voorstellen; het vennotenportaal heeft nog geen eigen tabel); buiten het plan opent zo'n module alleen-lezen, een module zonder gegevens toont haar teaser
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, inbreng (paidCents) en boekwaarde (bookValueCents: aantal × laatst gekende boekwaarde) 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) zonder e-mailadres (withoutEmail) en zonder bruikbaar e-mail- of postadres (unreachable: dezelfde regel als de oproeping, convocationRecipient; beslissing 121.11); registerDrift: wat niet in een getekend register staat (zie GET /register/drift)
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 /settings/email (board, reader)afzender van de e-mails van de coöperatie aan vennoten (E5.5, #306; features.md §5): `{ fromAddress, fromName, replyTo, domain: { name, dkim, returnPath, checkedAt }
PUT /settings/email (board){ fromAddress, fromName, replyTo } (elk null of '' = leeg; adressen in kleine letters, een naam zonder ", <, > of regeleinde; anders 400). Met een domein blijft het adres op dat domein: een adres op een ander domein (of geen adres) 409 sender_domain_in_use, dat gaat via POST /settings/email/domain/replace (#440). Zonder domein verandert het adres vrij. Antwoord zoals GET; elke wijziging in de audit-trail (tenant_mail_senders)
POST /settings/email/domain (board)maakt het domein van fromAddress aan bij Postmark (Return-Path pm-bounces.<domein>) en bewaart de DNS-records (DKIM TXT, Return-Path CNAME). Opnieuw voor hetzelfde domein: niets bij Postmark, antwoord zoals GET. Zonder adres 422 no_from_address; het domein van het platform (MAIL_FROM) 422 sender_domain_reserved; een domein hoort bij één coöperatie (unieke index); Postmark weigert 422 sender_domain_refused (met de melding van Postmark); Postmark onbereikbaar 502 sender_domain_unavailable; geen accounttoken 503 sender_domains_unavailable (dezelfde regel als senderDomains.available van GET)
POST /settings/email/domain/verify (board)laat Postmark de DKIM- en Return-Path-records nu opzoeken en bewaart de status per record met checkedAt; wacht er een nieuw domein (pendingDomain), dan enkel dat nieuwe domein. Zijn beide records van het nieuwe domein geverifieerd, dan wordt het het actieve domein (fromAddress wordt dat van het nieuwe domein) en verwijdert de app het oude in Postmark; lukt dat niet, dan blijft het antwoord 200 met cleanup (domein en fout). Zonder domein 422 no_domain; fouten van Postmark zoals hierboven (de bewaarde status blijft dan staan)
POST /settings/email/domain/replace (board)"Domein vervangen" (#440, beslissing 130.5): { fromAddress } op het nieuwe domein. Maakt het domein aan in Postmark en bewaart het als pendingDomain; de mails blijven van het huidige adres vertrekken tot verify beide records van het nieuwe vindt. Zonder domein 422 no_domain; hetzelfde domein 422 sender_domain_same; het domein van het platform 422 sender_domain_reserved; al een nieuw domein 409 sender_domain_replace_pending; een oud domein nog op te ruimen 409 sender_domain_cleanup_pending; fouten van Postmark en 503 zoals bij POST /domain. Een tenantId in de body telt niet
DELETE /settings/email/domain/replace (board)"Vervangen annuleren": verwijdert het nieuwe domein eerst in Postmark, dan uit de rij. Faalt Postmark (422/502 zoals hierboven), dan verandert er niets. Geen nieuw domein 422 no_pending_domain
DELETE /settings/email/domain (board)"Domein verwijderen": de mails vertrekken meteen weer vanaf MAIL_FROM met Reply-To = replyTo (fromAddress blijft bewaard), en de app verwijdert het domein in Postmark. Lukt dat niet, dan 200 met cleanup (domein en fout). Zonder domein 422 no_domain; met een nieuw domein in afwachting 409 sender_domain_replace_pending; een oud domein nog op te ruimen 409 sender_domain_cleanup_pending. POST /domain weigert ook 409 sender_domain_cleanup_pending zolang er een domein op te ruimen is
POST /settings/email/domain/cleanup (board)"Opnieuw proberen": verwijdert het op te ruimen domein (cleanup) opnieuw in Postmark. Gelukt: cleanup wordt null; mislukt: 200 met de nieuwe fout. Een domein dat Postmark niet meer kent telt als verwijderd. Niets op te ruimen 422 no_cleanup. Elke stap (vervangen gevraagd, geannuleerd, nieuw domein geverifieerd, oud domein opgeruimd, opruimen mislukt, verwijderd) is een eigen rij in de audit-trail (domain_step)
GET /rules (board, reader)Instellingen › Regels (beslissing 140.9): alle regels die gelden op date (standaard vandaag): { date, version, rules, statutesNotReviewed } (statutesNotReviewed: beslissing 154, zie GET /overview). Een regel heeft ook aiAdjusted naast aiSuggestionId (117.3, voor Regel-detail sinds beslissing 154). version is de regelversie dan (kind derived: vastgelegd uit de instellingen; board: door het bestuur), of null zolang de coöperatie geen regelversie heeft (dan zijn de regels afgeleid uit de instellingen). Per regel de waarde, de bron met haar bewijs (article, articleNumber, quote / legalBasis / reason, decision), statuteValue (een eigen keuze die afwijkt van de statuten of de wet: de waarde waarvan ze afwijkt), toDecide (te beslissen) en fromSettings (velden overgenomen uit de instellingen, nog niet nagekeken)
GET /rules/{parameter} (board, reader)één regel (?shareClassId= voor een regel per soort): current en history, elke regelversie waarin ze veranderde, nieuwste eerst
POST /rules/preview (board)dezelfde body als POST /rules; schrijft niets (beslissing 140.11). 200 { validFrom, later }: per latere versie en per gewijzigde regel { validFrom, parameter, shareClassId, carried, planned }: carried de wijziging geldt ook daar, anders planned de waarde die die versie al zet. Dezelfde fouten als POST /rules
POST /rules (board)Vanaf vandaag en met carry: true neemt een nieuwe versie haar wijzigingen mee in de latere versies die de regel niet anders zetten (140.11): het scherm toont eerst POST /rules/preview en stuurt carry pas na bevestiging. Zonder carry, en voor een wijziging in het verleden, weigert een versie vóór een latere versie (409 rules_later_version). Verder: een nieuwe regelversie vanaf validFrom (decisionRef, note?, changes: per regel parameter, shareClassId, value, source en haar bewijs, zoals bij het bevestigen van parameters): de regels die dan gelden, met deze wijzigingen; schrijft de oude kolommen mee (140.6). Overdracht van aandelen mag zonder tekst, beperkingen en doelsoorten ({}: elke soort met dezelfde nominale waarde); een lege lijst doelsoorten weigert met 422 parameter_value_invalid (RG-3; een lege lijst van vroeger blijft leesbaar en betekent "naar geen enkele andere soort"). 422 met de code van een fout bewijs (parameter_needed: "niet bepaald" voor Uitgifteprijs of Scheidingsaandeel, 140.13; rule_value_applied: "niet bepaald" over een waarde die de app op die datum toepast, 140.13; "geen maximum" en "geen minimum" zijn waarden (shares: null, CS1); parameter_needs_reason, parameter_article_unknown, parameter_not_law_default, …) of rules_unchanged; 422 rule_min_above_max met params { shareClassId, code, min, max, date } als het minimum per vennoot van een soort hoger is dan haar maximum, in de nieuwe versie of in een latere versie waarin de wijziging meegaat (beslissing 140.22; een botsing die de versie ervoor al had met dezelfde waarden, telt niet). Hetzelfde voor POST /rules/preview; 409 rules_fiscal_year (het boekjaar wijzigt enkel met een nieuwe statutenversie, A3), 409 rules_later_version met params.date (er is al een versie gepland vanaf die datum, beslissing 140.11), 409 rules_settings_same_day / rules_class_policy_same_day (de oude kolommen hebben al een versie op die dag). Antwoord 201 { id }
GET, POST /policiesversies van het uittredingsbeleid (begin boekjaar, venster); currentId = de versie die vandaag geldt. De regels schrijven ze mee (140.6). POST antwoordt 410 rules_moved sinds PR 3b2: het venster, de betaaltermijn en de jaarrekening van het scheidingsaandeel wijzigen met POST /rules (Instellingen › Regels)
GET, POST /share-classes, PATCH /share-classes/{id}soorten met de regels die vandaag gelden en wat uitgegeven en gestort is. POST (Soort toevoegen, CS2: Samuel 07/10/2026) neemt code, name en rules: validFrom, decisionRef, issuePrice en redemption, elk { value, source, … } met het bewijs van de bron zoals een wijziging van POST /rules; de jaarrekening van het scheidingsaandeel is die van de coöperatie, een meegegeven valuationAccounts telt niet. Verwijst een regel naar de nominale waarde zonder dat de uitgifteprijs er een heeft: 422 parameter_nominal_value_needed. De soort en die twee regels komen in één transactie: een geweigerde regel (422, met dezelfde codes als POST /rules, ook parameter_needed voor "niet bepaald", 140.13) laat geen soort achter. Met een veld van vroeger (maxPerMember, minPerMember, transferTargetIds, votingRights, profitRights, transferRestrictions, policy) antwoordt het 410 rules_moved en schrijft het niets: die regels zet het bestuur daarna met POST /rules. De code wijzigt niet meer. PATCH wijzigt enkel name, active en memberLimitsSource; met een regelveld (maxPerMember, minPerMember, transferTargetIds, votingRights, profitRights, transferRestrictions) antwoordt het 410 rules_moved en schrijft het niets (PR 3b2: die wijzigen met POST /rules). Het antwoord geeft minPerMember, maxPerMember, transferTargetIds en allowedTargetIds (vandaag, de eigen soort inbegrepen) zoals de regels ze zetten
GET, POST /share-classes/{id}/policiesregelversies van een soort (Uitgifteprijs en Scheidingsaandeel), zoals de regels ze schrijven. POST antwoordt 410 rules_moved sinds PR 3b2: ze wijzigen met POST /rules. De teksten van het register zijn regels (beslissing 140.12): Stemrecht en Bestemming van de winst met perClass, Overdracht van aandelen van de soort met restrictions en targetClassIds (doelsoorten van deze coöperatie, anders 422 parameter_share_class_unknown). Een andere schrijfweg weigert de database (409 DG080)
GET /share-classes/rule-sources (board, reader)de bron van de regels per soort (E1.5): { policy, sources }. policy is de jongste algemene beleidsversie met bevestigde parameters die vandaag geldt (validFrom ≤ vandaag in Brussel; een latere versie zonder parameters, zoals een nieuwe uittredingsperiode onder Instellingen, bevestigt niets en laat de bronnen staan) met confirmedAt, confirmedByName en de statutenversie waaruit ze bevestigd werd (of null); sources zijn haar bevestigde parameters per soort (issue_price, redemption, max_per_member, min_subscription, share_transfer) met value en het bewijs van de bron: statutes met article en quote, law met legalBasis, manual met reason. Geen beleidsversie of geen parameters: policy: null of sources: [], en de app toont geen bron. Of de regel van de soort nog overeenkomt met de parameter, beslist dit endpoint niet (E1.4)
POST /share-classes/valuations (board)één boekwaarde voor alle actieve soorten (fiscalYearEnd, bookValuePerShareCents, equityCents?, accountsApprovedOn?, decisionRef, note?): één rij per soort, gekoppeld aan het boekjaar (fiscalYearId). Eigen vermogen en datum van goedkeuring komen van het boekjaar; meegegeven moeten ze overeenkomen (422 valuation_accounts_mismatch met fiscalYear en request), een leeg boekjaar vullen ze in, een boekjaar dat niet bestaat wordt aangemaakt. 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 fiscalYearId, sharesOutstanding en controlValuePerShareCents (het eigen vermogen van de coöperatie gedeeld door alle aandelen, of die volgens de jaarrekening, pro rata de nominale waarde als de soorten verschillen: dezelfde berekening als het boekjaar) en signals voor de geldende waarde; POST zoals hierboven voor één soort; 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|unreachable&page=&pageSize=lijst met zoeken en filters (max. 100 per pagina; contact: zonder e-mailadres, het kanaal voor oproepingen, of unreachable: zonder bruikbaar e-mail- of postadres zoals de oproeping beslist, beslissing 121.11), total, activeTotal en activeUnreachable (actieve vennoten zonder bruikbaar adres, los van de filter). Per vennoot de status uit het journaal (status, firstAdmittedOn, activeSince, exitedOn), de mede-houder van een koppel (coHolderName, E2.5; zoeken vindt hem ook) 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)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). partnerFirstName/partnerLastName bestaan niet meer (E2.5, beslissingen.md 53): de partner van een koppel is de contactpersoon co_holder, en die velden geven 400
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. postReturnedAt (E5.8, beslissing 121.15): een brief naar het postadres kwam terug; het adres wordt niet gebruikt tot het wijzigt (de markering vervalt dan vanzelf). Het detail geeft ook contacts; notes enkel voor board (lezers krijgen null)
PUT, DELETE /members/{id}/contacts/{role} (board)tweede contactpersoon: co_holder (persoon; mede-houder, staat mee in het register, op het uittreksel en op brieven), partner (persoon; in de app "Contactpersoon (niet in het register)": nooit in het register, geen oproeping) of representative (rechtspersoon), { name, email?, phone?, language?, reason?, changeKind?, decisionRef? }. De verkeerde rol voor de soort vennoot 400; DELETE zet removed_at (de rij blijft), 404 als er geen is, en neemt { reason?, changeKind?, decisionRef? } als JSON-body (niet in de URL). Een mede-houder toevoegen (ook opnieuw na verwijderen) of verwijderen vraagt reason en changeKind (correction = rechtzetting, change = wijziging), een change ook decisionRef (bv. "RvB 12/10/2026, punt 4"); een andere naam vraagt reason en is zonder changeKind een correction. Ontbreekt er iets: 422 met code reason_required, change_kind_required of decision_required (beslissing 84). Enkel e-mail, telefoon of taal van de mede-houder, en de andere rollen: een reden mag, hoeft niet. Antwoord: het detail van de vennoot
Reden van een registerwijziging (#287)PATCH /members/{id} neemt ook reason?, changeKind?, decisionRef?. Een andere naam (firstName, lastName, legalName), enterpriseNumber of memberNumber vraagt reason (422 reason_required; het vennotennummer sinds beslissing 103); een waarde die gelijk blijft telt niet. Adres en handelsnaam: een reden mag. Overal geldt: changeKind of decisionRef zonder reason 422 reason_required, een change zonder decisionRef 422 decision_required. De reden, aard en besluit komen in audit_log (reason, change_kind, decision_ref) bij elke rij die het verzoek schrijft; een import zet zelf Import <nummer>, met het nummer van de import per coöperatie (number in de antwoorden van /imports, sinds beslissing 103; oudere auditrijen houden Import <id>). Dezelfde regels met een API-sleutel
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); POST neemt approvedOn en equityCents over van de geldende boekwaarden van dat jaar als ze voor alle soorten gelijk zijn, en koppelt ze; PATCH de cijfers van de goedgekeurde jaarrekening (approvedOn, equityCents, netAssetsCents, resultCents, unavailableEquityCents, unavailableEquityNote, accountsShares met accountsSharesReason: de aandelen volgens de jaarrekening als ze van het journaal verschillen); fromJournal geeft aandelen en inbreng op het einde van het jaar, position de stand van inbreng tot boekwaarde op de laatste dag (zie GET /capital-position; met het saldo en de boekwaarde van dat boekjaar zelf); PATCH geeft 409 approval_in_use als approvedOn leeg of later wordt terwijl een bevestigde toets erop steunt; een wijziging aan een toetsontwerp wist ook het verslag van de commissaris; een approvedOn (gezet, gewijzigd of gewist, ook overgenomen van de boekwaarden) zet de deadline van de open stap "Jaarrekening neerleggen bij de NBB" in de jaarcyclus opnieuw: de vroegste van approvedOn + 30 dagen en de zeven maanden (art. 3:10; beslissing 124.1, zie POST /processes/annual-cycle)
GET /fiscal-years/missingboekjaren met een boekwaarde maar zonder boekjaar ("nog aan te maken"), met wat aanmaken overneemt
GET /fiscal-years/{id}/book-valueboekwaarde per aandeel: het voorstel uit de jaarrekening (equityCents ÷ sharesUsed, de aandelen volgens de jaarrekening of anders het journaal, afgerond op de cent; method: pro_rata als de soorten een verschillende nominale waarde hebben), per soort het voorstel en de geldende boekwaarde, en signals: accounts_changed (vastgelegd ≠ jaarrekening zoals ze nu is, met de gewaardeerde uittredingen), shares_differ (verschil op de inbrengrekening in de afstemming), no_accounts (boekwaarde zonder boekjaar, datum of eigen vermogen). De app past een vastgelegde boekwaarde nooit zelf aan
POST /fiscal-years/{id}/valuations (board){ decisionRef, note?, correction?, classes: [{ shareClassId, bookValuePerShareCents }] }: de boekwaarde per soort op het eigen vermogen en de datum van het boekjaar (409 book_value_not_ready zonder die twee; 409 DG006 zonder correction als een soort al een waarde heeft). Zodra elke actieve soort een waarde heeft, is de stap book_value van de jaarcyclus gedaan
POST /fiscal-years/{id}/valuations/{valuationId}/accept (board){ note }: het verschil tussen een vastgelegde boekwaarde en de jaarrekening aanvaarden; het signaal zwijgt tot de jaarrekening opnieuw wijzigt (409 no_difference als er geen verschil is)
GET, POST /distribution-tests, GET, PATCH /distribution-tests/{id}, PATCH …/requests/{exitRequestId}, POST …/report, …/signed (PDF), …/auditor-review (PDF), …/confirm, …/cancel, …/defer-requests, GET …/report.pdf, …/signed.pdf, …/auditor-review.pdfuitkeringstoets (E7, #255): POST { testedOn, requestsUntil?, payBy? } maakt een ontwerp op de laatst goedgekeurde jaarrekening (409 no_approved_accounts, accounts_incomplete) met de uittredingsaanvragen ontvangen tot en met requestsUntil (standaard het einde van het laatste uittredingsvenster) die geboekt, gewaardeerd en niet betaald zijn en niet in een andere bevestigde toets die nog loopt, tenzij hun getoetste bedrag daar op is (requests, met het bedrag op het moment van de toets: na een gedeeltelijke betaling het restbedrag, #351; notValued: nog zonder boekwaarde). payBy ligt tussen de toets en drie maanden erna (standaard één maand; anders 400 pay_by_out_of_range); coversUntil is payBy, horizonUntil is payBy + twaalf maanden (de liquiditeitstest). De geplande uitkering is de som van de lijst (409 planned_from_requests bij een eigen bedrag); PATCH …/requests/{id} { excluded } haalt een aanvraag uit de toets of zet ze terug. Rubrieken van de liquiditeitstest zoals in beslissingen.md 6, plus plannedInvestmentsCents, loanRepaymentsCents (verplicht om te bevestigen), currentReceivablesCents en risksNote; quickRatio wordt berekend. Een wijziging laat het verslag vervallen. Bevestigen vraagt alles ingevuld, het getekende verslag en met hasAuditor de beoordeling; daarna 409 DG021 op elke wijziging. Een bevestigde toets met een negatieve balanstest dekt geen betaling: zijn te betalen aanvragen worden opgeschort met de toets als reden; POST …/defer-requests { deferredOn } doet dat op vraag van de raad. POST …/cancel { reason? } annuleert een ontwerp (status cancelled, met wie en wanneer; een bevestigde toets: 409 DG021). Een geannuleerde toets wijzigt niet meer, dekt geen betaling en staat enkel in de lijst met GET /distribution-tests?cancelled=true; niets wordt verwijderd. Toetsen van vóór #255 (requestsUntil null) dekken zoals voorheen elke betaling binnen twaalf maanden
POST /distribution-tests/{id}/signing/report/send · …/signing/auditor-review/send (PDF) · …/signing/{report|auditor-review}/refresh · …/cancel · GET …/signing-audit.pdftekenen via de ondertekendienst (#256), enkel met de integratie aan (anders 409 signing_not_enabled; opladen blijft): report/send { signerUserIds? } maakt het verslag opnieuw met een handtekeningveld per ondertekenaar (standaard alle bestuurders; geen bestuurder: 400 signer_not_board) en verstuurt het; auditor-review/send verstuurt de opgeladen beoordeling naar de commissaris (headers X-Auditor-Name, X-Auditor-Email, URL-gecodeerd), met een handtekeningveld onderaan de laatste pagina. De webhook of refresh neemt de getekende PDF en de audittrail over (bucket legal); dan kan de toets bevestigd worden. Een wijziging aan het ontwerp, een nieuw verslag, een opgeladen getekende PDF of het annuleren van de toets annuleert wat wacht. Elke toets toont signing.report en signing.auditorReview (status, ondertekenaars), signingEnabled en boardSigners. Het model (signing_requests, signing_request_signers) is het begin van E6.1
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. Het tweede contact van een persoon is de mede-houder (kolom "Mede-houder" en "Tweede contact"), anders de contactpersoon (partner); laat de export een contactpersoon weg (vennoot met mede-houder én contactpersoon), dan staat de melding in de header X-Export-Notice (beslissingen.md 73)
GET /exports/members/notice (zelfde filters){ membersWithContactNotExported }: het aantal vennoten in die export met zowel een mede-houder als een contactpersoon; de app toont de melding vóór het downloaden
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. statuteWarnings (E1.4): [{ key: statutoryMax, memberId, memberName, code, held, statutoryMax, setting, source, article, legalBasis, inForceOn }] voor een vennoot die na de boeking meer aandelen heeft dan het bevestigde maximum per vennoot (de parameter van de beleidsversie met parameters die op de datum van de boeking gold, validFrom ≤ effectiveDate; geen bevestigde versie op die datum: geen waarschuwing; beslissing 83) terwijl het maximum van de soort het toelaat; source (statutes, law of manual) is de bron van die parameter, met article (bron statuten, zoals bewaard), legalBasis (bron wet) en inForceOn (bron statuten: de inwerkingtreding van de statutenversie; bron eigen keuze: de validFrom van de beleidsversie; bron wet: null), voor "volgens de statuten (art. …) van kracht op …", "volgens de wet (art. …)" of "volgens de eigen keuze van het bestuur, van kracht op …" (B1 in beslissing 83); "niet bepaald" heeft geen waarde en geeft geen waarschuwing; een antwoord van een oudere API zonder deze velden leest als source: statutes met legalBasis en inForceOn null; een waarschuwing, geen controle: ze telt niet mee in ok en POST /transactions boekt ongewijzigd ("Toch boeken")
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). statutes (E1.4): de bevestigde parameter exit_window van de beleidsversie met parameters die op date (de datum van ontvangst, zoals de instelling) gold, { months, lastDay, inWindow, source, article, legalBasis, inForceOn } (beslissing 83; de bron en haar velden zoals bij statuteWarnings van POST /transactions/preview) (de eerste months maanden van het boekjaar van date, geteld zoals de instelling, in kalendermaanden vanaf de eerste dag van het boekjaar: tot en met de dag vóór dezelfde dag months maanden later, of de laatste dag van die maand als ze die dag niet heeft, nooit na het einde van het boekjaar, beslissing 123.3; months: null = het hele jaar), of null zonder bevestigde parameter of met "niet bepaald"; buiten die periode toont de app een waarschuwing, de instelling beslist ("Toch registreren")
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)
GET /capital-position?on=van inbreng tot boekwaarde op een dag (standaard vandaag): shares en contributionCents (inbreng, uit het journaal), uniformNominalCents (null als de soorten een verschillende nominale waarde hebben), expectedLedgerCents (het verwachte saldo van de inbrengrekening, dezelfde berekening als de afstemming) = subscriptionsToDateCents + otherToDateCents (tegenboekingen) − exitSharesToDateCents; ledger: het laatst ingevulde saldo op of vóór die dag met het verschil op zijn eigen datum; bookValue: de laatst gekende boekwaarde (goedgekeurd tegen die dag), met totalCents = aandelen × boekwaarde per soort, perShareCents (null als ze per soort verschilt) en unvaluedShares
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}/revalue (board){ decidedOn, decisionRef, note? }: een herwaardering is een apart besluit van de raad (beslissing 115), dus de datum (decidedOn, niet in de toekomst) en de verwijzing (decisionRef, ten hoogste 200 tekens) zijn verplicht; ontbreekt er een, of ligt de datum in de toekomst, dan 400 met issues op dat veld (date in the future). Het scheidingsaandeel opnieuw bepalen met de geldende boekwaarde van hetzelfde boekjaar, binnen het plafond, nadat de boekwaarde waarmee het gewaardeerd is vervangen werd (replacedValuation op de aanvraag). Enkel met betaalstatus to_pay of deferred, anders 409 EXIT_STEP. De vervaldatum blijft; het oude bedrag en het besluit staan in revaluations (decidedOn, null voor een herwaardering van vóór beslissing 115, en decisionRef; at is het tijdstip van de invoer, dat ook in de audit-trail blijft). Beide staan in de payload van de overgang revalue of revalue_paid. Na een gedeeltelijke betaling nooit onder wat betaald is (409 DG033 met params.reason = below_paid en params.remainingCents negatief); precies dat bedrag zet de betaalstatus op paid (#351). Was het restbedrag na een gedeeltelijke betaling opgeschort, dan krijgt deferredReason het nieuwe restbedrag; deferredOn blijft (#358). Een opschorting met een eigen reden van de raad blijft ongewijzigd
POST /exit-requests/{id}/defer · /pay (board){ reason, deferredOn } of { paidOn, paidAmountCents, testsRef? }: betalen vraagt een bevestigde uitkeringstoets met de aanvraag op haar lijst, een balanstest die slaagt, een betaaldatum van de toets tot en met payBy en ten hoogste het getoetste bedrag (#255), of een toets van vóór #255 die de betaaldatum dekt; anders 409 DG022 met params.reason (not_in_test, expired met payBy, balance_fails met testedOn, above_amount met amountCents). De betaling wordt aan die toets gekoppeld (distribution_test_id); testsRef is standaard die toets. Elke uittreding toont testsCovered (een toets dekt vandaag een betaling) en test (de laatste bevestigde toets met de aanvraag op de lijst: testedOn, payBy). Gedeeltelijk betalen kan (#351, beslissing 111.1): elke betaling is een rij in exit_payments (append-only); onder één toets in meerdere keren, samen ten hoogste het getoetste bedrag (above_amount met ook availableCents). Meer dan nog verschuldigd is 409 DG033 (params.reason = above_owed, params.remainingCents wat nog verschuldigd is); € 0 enkel voor een scheidingsaandeel van € 0. Na de betaling: alles betaald → paid; nog iets verschuldigd → deferred (beslissing 111.1) met deferredReason "Restbedrag € … na een betaling van € … onder de uitkeringstoets van … (art. 6:120 §1 WVV)"; was het verzoek al opgeschort, dan blijft deferredOn. Volgende schijven gaan vanuit deferred, onder dezelfde toets zolang die dekt of onder een volgende. paidAmountCents is de som van de betalingen, paidOn de laatste (null zonder betaling, ook na een import met € 0 betaald). Elke uittreding heeft remainingCents (wat nog verschuldigd is; ook in de lijst, #358), het detail ook payments (paidOn, amountCents, testsRef, distributionTestId, oudste eerst). Een volgende toets neemt enkel het restbedrag op
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), elk met number (per coöperatie, bij de proefrun gegeven; de audit-trail noemt de import "Import "), 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, en een melding (level: info, bv. contact_without_partner: het tweede contact van een persoon zonder partnernaam wordt contactpersoon, niet in het register) evenmin; summary.warnings telt enkel de waarschuwingen. Een persoon krijgt een mede-houder enkel als het bestand de partner noemt (partnerFirstName en partnerLastName, of coHolderName: de kolom "Mede-houder" van de export); staan beide er, dan moeten ze dezelfde persoon noemen (hoofdletters en spaties buiten beschouwing), anders fout co_holder_partner_mismatch. 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); drift (zoals GET /register/drift)
GET /register/driftwat niet in een getekend register staat (#287, beslissing 84): current (de geldende versie of null), unsignedBookings (boekingen erna), changedMembers (vennoten van die versie van wie wat het register toont gewijzigd is: nummer, naam met mede-houder, handelsnaam en ondernemingsnummer, adres), basis (content: vergeleken met de inhoud van de versie, een teruggedraaide wijziging telt niet meer; audit_trail: voor een versie van vóór migratie 0052, de wijzigingen in de audit-trail sinds die versie), layoutChanges ({ numbering, fullAddress }, beslissing 103: vennoten van die versie van wie de gegevens niet wijzigden maar de regel in het register wel, omdat het aantal cijfers van het vennotennummer veranderde of omdat het register nu het volledige adres van een persoon toont; niet in changedMembers; 0 bij basis: audit_trail), cooperativeChanged (naam, rechtsvorm, zetel of ondernemingsnummer van de coöperatie, of wat het register van een aandelensoort toont, verschilt van die versie; voor een versie van vóór migratie 0055 uit de audit-trail) en awaiting ({ version, asOfSeq, upToDate }: false als er sindsdien geboekt of gewijzigd werd, ook aan de coöperatie, null als dat niet te bepalen is)
GET /register/changesde audit-trail gefilterd op die wijzigingen sinds de geldende versie: per rij at, byName, de vennoot (memberId, memberNumber, memberName), fields (enkel de velden van het register: memberNumber, firstName, lastName, legalName, tradeName, enterpriseNumber, street (bij een persoon enkel als de versie het volledige adres toont, beslissing 103), postalCode, city, country, coHolder; before en after), reason, changeKind, decisionRef. Bij basis: content enkel de vennoten van wie de gegevens vandaag nog afwijken (niet wie enkel door layoutChanges afwijkt)
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, POST /archive, GET, PATCH /archive/{id}documentarchief (E6.1, archive_documents, migratie 0045; zoals alle routes van /archive, /documents/archive-index, /exports/documents.*, /documents/recipients en /documents/forward enkel met de module module.documents, anders 403 { code: "module.documents" }, beslissing 82; lezen (GET) mag ook zonder de module zodra ze vrijgegeven is (beslissing 102)): verslagen, jaarrekeningen, oproepingen, notulen en overige stukken (report, annual_accounts (de jaarrekening die de AV goedkeurt, zonder handtekening: nagekeken gaat ze zonder bevestiging mee met de oproeping; migratie 0121, beslissing 146.6), convocation, minutes, attendance_list (de getekende aanwezigheidslijst van een vergadering, E4.1, beslissing 90), other; een algemene brief is other, brieven aan vennoten staan onder /letters) die de raad opmaakt, nakijkt en tekent. GET ?kind=&fiscalYear=&status=&q= filtert (q: deel van de titel), elk document met status (draft, reviewed, signed, cancelled: de gedeelde documentmachine), file en signed (SHA-256), signing (laatste ondertekenverzoek met ondertekenaars), signingAudit, signingEnabled en events (wat een bestuurder nu mag). POST (board) { kind, title, fiscalYear?, meetingId?, correctsId? } maakt een leeg ontwerp (meetingId is een vergadering van de coöperatie, foreign key sinds migratie 0056; anders 400, 23503); de coöperatie is die van het pad, een tenantId in de body telt niet. correctsId wijst naar het getekende document dat dit vervangt (anders 409 DG026): een correctie is een nieuw document. PATCH (board) wijzigt enkel een ontwerp. Niets wordt verwijderd
POST /archive/{id}/file (PDF) · /review · /reopen · /cancel · /signed (PDF) (board)file vervangt het bestand van een ontwerp (bucket files); review (ontwerp → nagekeken, vraagt een bestand); reopen (nagekeken → ontwerp, annuleert wat bij de aanbieder wacht); signed zet de opgeladen getekende PDF (bucket legal, Object Lock) en maakt het document signed, onveranderlijk; cancel annuleert een ontwerp of nagekeken document. Elke overgang is een rij in status_transitions (entity_type = document); een stap die de machine niet kent geeft 409 forbidden_transition, een stap die het document nu niet kan nemen (bv. een bestand bij een nagekeken document) 409 DG026
POST /archive/{id}/signing/send · /signing/refresh · /signing/cancel (board)tekenen via de integratie (het algemene model van #256, onderwerp archive_document): send { signers: [{ userId } of { name, email }] } (een bestuurder van deze coöperatie, of iemand buiten de app; 400 signer_not_board, 409 signing_not_enabled zonder integratie); refresh vraagt de stand aan de aanbieder, de webhook doet hetzelfde en maakt het document signed (bron feature); cancel stopt het ondertekenen en laat het document nagekeken
GET /archive/{id}/file.pdf, /signed.pdf, /signing-audit.pdfhet bestand, de getekende PDF en het auditcertificaat van de aanbieder (#21, 404 als het ontbreekt); elke download laat een leesregel na in de audit-trail
GET /documents/archive-index?q=&kind=&fiscalYear=&status=het documentarchief als één lijst (E6.2), alleen lezen: de archiefdocumenten, de registerversies en de brieven aan vennoten, elk uit hun eigen tabel (geen kopie). { items, total, signedTotal, fiscalYears }; elke rij { source: archive|register|letter, id, title, date, kind, status, fiscalYear, member, signers, sha256, downloads: { original, signed, signingAudit }, forwardable }. kind: register, report, annual_accounts, convocation, minutes, attendance_list, letter, other; status: draft, reviewed, awaiting_signature, issued (brief zonder handtekening), signed, cancelled. downloads zijn de bestaande routes hierboven en hieronder; total en signedTotal tellen zonder filters. Het boekjaar van een registerversie of brief is dat waarvan de periode de datum bevat. forwards: wanneer het document meegestuurd werd, [{ memberId, memberNumber, memberName, at, byName }] uit de leesregels document.forward (enkel voor board, leeg voor een reader; nooit het e-mailadres)
GET /reports/{boekjaar}de verslagen van een boekjaar (E4.5–E4.7, #167–#169; docs/plan-e4.md §6, beslissing 96), enkel met de module module.reports (zoals alle routes van /reports; anders 403 { code: "module.reports" }, zoals het archief, beslissing 82 C6; lezen (GET) mag ook zonder de module zodra ze vrijgegeven is (beslissing 102)). {boekjaar} is het jaar van de laatste dag van het boekjaar (fiscal_years; anders 404 fiscal_year_not_found). Antwoord { fiscalYear, parameters: { reportNames: { issues, exits }, minutesSigners }, asOfSeq, bookings, boardMembers, reports }; bookings: de boekingen gedateerd in het boekjaar (de bron van 6:108 §2 en het neerleggingsstuk); boardMembers (enkel voor board): wie kan tekenen. reports: één per soort, exits (verslag 6:120 §2), issues (verslag 6:108 §2) en filing (stuk bij de jaarrekening: 6:124 en 6:108 §2), elk { kind, document, issuePriceJustification, modalities, warnings, facts }. document is het archiefdocument (kind = report, zoals bij /archive) of null zolang het verslag niet opgemaakt is. facts (exits): de verzoeken ontvangen in het boekjaar (received_on, niet de uitwerkingsdatum): received (alle verzoeken = exits + refused + withdrawn + undecided, beslissing 107; in het verslag "5 = 2 aanvaard + …": de som telt verzoeken, beslissing 111.2), exitedMembers, partialExitMembers (wie na zijn uittredingen nog aandelen hield: een gedeeltelijke uittreding telt als uitgetreden; bij 0 valt de bijzin "waarvan …" weg, beslissing 111.4), perClass, exits met compensation (paid met amountCents betaald en owedCents verschuldigd, to_pay met dueOn, suspended, from_accounts met het boekjaar waarvan de jaarrekening het bedrag geeft) en remainsShareholder, revisedOn en revisedByDecision (de laatste herwaardering met besluit, revalue in status_transitions, als die na het einde van het boekjaar viel, anders null, beslissingen 111.3 en 115: revisedOn is de datum van het besluit (payload.decidedOn, revisedByDecision true) en het verslag zet "herzien bij besluit van …" bij het bedrag; een herwaardering van vóór beslissing 115 heeft geen besluitdatum: dan is revisedOn de dag van de invoer en staat er "herzien, ingevoerd op …"), refused met reden, undecided, withdrawn (enkel een aantal), en voor oudere verzoeken olderPayments (betaald in het boekjaar, met owedCents), suspended (opgeschort op het einde van het boekjaar, art. 6:120 §1, met revisedOn zoals bij exits) en sinceYearEnd ({ suspendedCount, suspendedCents, paidCount, paidCents }: de stand nu als er sinds het einde van het boekjaar iets betaald, opgeschort of hervat werd, anders null; één regel in het verslag). facts (issues): de intekeningen gedateerd in het boekjaar zonder die in het boekjaar tegengeboekt, existingSubscribers, newSubscribers, perClass (aantal, betaalde vergoeding, prijzen per aandeel, statutoryPrice als elke intekening een statutaire prijs had, partlyStatutory als maar een deel, article), subscriptions (met statutoryPrice: de parameters die golden op de datum van die intekening, beslissing 107), justificationNeeded. facts (filing): per soort atStart, mutations (per soort boeking) en atEnd, totalAtEnd, en de issues. warnings: undecided_requests, compensation_pending, justification_missing, minutes_signers_missing, filing_without_identity (bij filing met report_names.issues: het stuk noemt geen namen en zegt het), outdated (journaal, aanvragen of parameters gewijzigd sinds het document werd opgemaakt). Namen staan in de facts (de app); in de PDF enkel met report_names, en zonder namen ook geen vennotennummers. Nooit een rijksregisternummer
POST /reports/{boekjaar}/{soort}/generate (board){ issuePriceJustification?, modalities? } (tekst van het bestuursorgaan, max. 5000 tekens; leeg wist; weggelaten blijft de vorige tekst; issuePriceJustification niet bij exits). Rendert het sjabloon (report-6120-nl, report-6108-nl, filing-shares-nl) in een nieuw ontwerp in het archief (titel bv. "Verslag over de uittredingen (art. 6:120 §2 WVV), boekjaar 2026", fiscalYear, en bij exits en issues de gewone AV van dat boekjaar als meetingId als die er is), of opnieuw in het bestaande ontwerp. Een eerste neerleggingsstuk neemt de verantwoording van het verslag 6:108 §2 over. Nagekeken of getekend: 409 report_not_draft (eerst heropenen; getekend blijft getekend). Antwoord: het verslag zoals in GET. Idempotency-Key; een tenantId in de body geeft 400
POST /reports/{boekjaar}/{soort}/review · /reopen · /cancel · /signed (PDF) · /signing/send · /signing/refresh · /signing/cancel (board); GET /reports/{boekjaar}/{soort}/file.pdf · /signed.pdf · /signing-audit.pdfde stappen van het archiefdocument van het verslag, zoals onder /archive/{id}/…, maar binnen de module Verslagen (zo werkt het ook zonder de module Documenten). De verslagen 6:120 §2 en 6:108 §2 tekent wie de notulen van de algemene vergadering tekent (minutes_signers, beslissingen 8 en 112.4; minutesSigners in GET; een bevestigde waarde met bron wet leest als de wettelijke standaard van vandaag, art. 6:79); het neerleggingsstuk het bestuursorgaan. Het verslag 6:120 §2 zegt bij het eerste blok "Verzoeken ontvangen in het boekjaar: stand op [datum van het verslag]" (beslissing 111.3). send { signers } zoals bij het archief. cancel annuleert een ontwerp of nagekeken verslag (getekend: 409); de volgende generate maakt een nieuw document. Het neerleggingsstuk noemt nooit namen, ook niet met report_names.issues; dan staat er wel een waarschuwing op dat de statuten de identiteit van de inschrijvers vragen en het stuk aan de jurist voor te leggen is (beslissing 107). outdated vergelijkt wat de PDF toont (zonder de datum van opmaak), dus ook de som van de verzoeken, de gedeeltelijke uittredingen en betalingen, de regel met de stand nu, de statutaire prijs per intekening en de waarschuwing. Nog niet opgemaakt: 404 report_not_generated; een onbekende soort 400
GET /exports/documents.csv · .xlsxdezelfde lijst met dezelfde filters (CSV zoals de andere exports: ;, dd/mm/jjjj, UTF-8 met BOM): document, datum, soort, status, boekjaar, getekend door, volledige SHA-256; geen andere persoonsgegevens (een brief met het vennootnummer, niet de naam). Leesregel export.documents
GET /documents/recipients?q= (board)vennoten om naar te sturen (hoogstens 25, op naam of nummer): { items: [{ id, memberNumber, displayName, email }] }, email null als er geen is
POST /documents/forward (board)Meesturen: { source, id, memberId } stuurt de getekende PDF als bijlage naar het e-mailadres van de vennoot (Postmark-sjabloon document-forward-nl, via de outbox; de outbox bewaart enkel pad en SHA-256 van het bestand, de handler leest het bij het versturen). 202 { outboxId } (zonder adres: het antwoord blijft bij de Idempotency-Key bewaard). Zonder antwoordadres van de coöperatie (PUT /settings/email, #306) zegt de mail dat antwoorden niet gelezen worden en verwijst ze naar het bestuur, met de zetel als die gekend is; met een antwoordadres vraagt ze te antwoorden, en krijgt ze dat adres als Reply-To (velden replyNote en contactText, gekozen bij het versturen). Afzender zoals bij /settings/email. Niet getekend 409 document_not_signed; registerversie 422 forward_not_allowed; een brief naar een andere vennoot dan de geadresseerde 422 forward_wrong_member; vennoot zonder e-mailadres 422 member_without_email; PDF boven 7 MB 422 attachment_too_large. Leesregel document.forward (source, id, memberId, sha256, outboxId; geen adres). Een tenantId in de body telt niet
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
GET /statutes (board, reader)de statutenversies (E1.2), nieuwste inwerkingtreding eerst: [{ id, title, deedDate, deedBy, effectiveFrom, source, sourceReference, note, hasFile, textSource, ocrConfidence, ocrLow, ocrLowPages, textStatus, textError, replacedById, confirmed, articleCount, createdAt, status, parameters }]; confirmed: er steunen bevestigde parameters op. textStatus (beslissing 119.4): reading zolang de OCR-job van een scan loopt, ready, of failed met textError (ocr_unavailable, unreadable_pdf, scan_too_long, scan_unreadable, no_articles, article_too_long, layout_conflict, read_failed, effective_from_taken: gelezen, maar een andere versie met dezelfde inwerkingtreding telt al mee; beslissing 124.3). textSource: text, ocr of scan_text (beslissing 145.11). ocrLow: de score van een scan (ocr of scan_text), of die van één van zijn bladzijden, ligt onder de platforminstelling ai.ocr_low_confidence_pct (85 bij de start; beslissing 145.11). ocrLowPages: hoeveel bladzijden van de scan onder die instelling liggen, berekend bij het lezen; null zonder scores per bladzijde (gelezen vóór migratie 0122, of geen scan). status (E1.5): in_force voor de versie met de jongste effectiveFrom ≤ vandaag (in Brussel) onder de versies die niet vervangen zijn en waarvan de tekst klaar is (textStatus: 'ready'; er is er hoogstens één, want per datum is hoogstens één versie niet vervangen met een tekst die klaar is, migratie 0089); future voor een latere inwerkingtreding, superseded voor een vroegere, replaced voor een rechtgezette versie (replacedById), die nooit meetelt. Een versie telt pas mee als haar tekst klaar is (beslissing 123.5): zolang de OCR-job loopt is haar status reading, mislukte hij failed, en de vorige versie blijft geldend, ook voor de parameters, de signalen (pre_wvv) en de kennis van de coöperatie, tot de tekst gelezen is (read-text slaagt) of de versie rechtgezet is met een versie die wel klaar is. parameters: { statutes, law, manual }, het aantal parameters per bron van de jongste beleidsversie met parameters die uit deze versie bevestigd werd en vandaag geldt, zoals bij rule-sources (geldt er nog geen, de eerstvolgende; anders nullen)
POST /statutes/upload?deedDate&effectiveFrom[&title&deedBy&note&replaces&replaceReason] (board)een nieuwe versie uit een PDF als body (application/pdf, max. 20 MB) → 201 met de artikelen. De PDF wordt bewaard (klasse legal, soort statutes, bucket legal), de tekst gelezen met pdftotext en per artikel gesplitst op de koppen "Artikel N" (ook bis, ter, …; de tekst vóór artikel 1 is artikel 0, "Aanhef"); kop- en voetregels van de pagina's (paginanummers, "DERDE BLADZIJDE", de kanttekst van het Staatsblad, regels die op de meeste pagina's terugkomen) vallen weg en de regels van een alinea worden samengevoegd tot doorlopende tekst, alinea's gescheiden door een witregel, een kop, "§" of opsommingspunt op een eigen regel (docs/plan-ai.md, Stand A4.1). Een tekst-PDF is meteen textStatus: 'ready' met textSource: 'text'. Een scan (geen of te weinig tekstlaag: minder dan 200 letters en cijfers, of meer dan de helft van de pagina's zonder tekst; of meer dan de helft van de pagina's één paginagrote afbeelding, ook met een tekstlaag van de scanner) wordt per bladzijde gelezen (beslissing 145.11): heeft elke bladzijde een bruikbare tekstlaag (minstens 200 letters en een score van minstens ai.ocr_low_confidence_pct), dan is de versie meteen textStatus: 'ready' met textSource: 'scan_text' en ocrConfidence (de score over alle bladzijden; tot 400 bladzijden). Anders wordt ze niet in het verzoek gelezen (beslissing 119.4): de versie wordt bewaard met textStatus: 'reading' en zonder artikelen, en in dezelfde transactie gaat een job op de pg-boss-wachtrij statutes.ocr (payload { tenantId, versionId, userId }). De job laat de documentdienst de bladzijden zonder bruikbare tekstlaag lezen (A4.1, Tesseract, POST /ocr, met ?pages= als niet alle bladzijden nodig zijn) en voegt dan in één transactie de artikelen toe met textStatus: 'ready', textSource: 'ocr' (alles met OCR) of 'scan_text' (ook bladzijden uit de tekstlaag) en ocrConfidence (0–100, gewogen over alle bladzijden) met de score van elke bladzijde (statute_versions.ocr_page_confidences, migratie 0122; enkel scores, nooit tekst); lukt het niet, dan textStatus: 'failed' met textError. Een OCR die even onbereikbaar is, probeert de job opnieuw (twee keer, met backoff); daarna ocr_unavailable. Klaar of mislukt: wie de job vroeg (userId, wie oplaadde of opnieuw probeerde) krijgt één melding in de app; klaar ook wie de versie opliet, als dat iemand anders is; enkel wie nog bestuurder is van de coöperatie (beslissing 123.6, 124.2; statutes.text_ready of statutes.text_failed met de reden, een link naar de versie, nooit een e-mail), in dezelfde transactie als de status; een tweede levering van de job maakt geen tweede melding. Meer dan 60 pagina's voor de OCR, of meer dan 400 in een scan, wordt al bij het opladen geweigerd: 422 scan_too_long; onleesbaar voor pdftotext 422 unreadable_pdf; al een versie die die inwerkingtreding bezet 409 statutes_effective_from_taken: een versie die geen mislukte scan is en niet rechtgezet (geen schakel van haar keten is klaar), dus ook een versie waarvan de rechtzetting nog op het lezen van een scan wacht (mislukt die, dan telt ze weer op haar datum; beslissing 124.3). Botst het vervallen toch op een andere versie op die datum (een race), dan wordt de scan failed maar blijft de keuze staan (log statute_rectification_lapse_conflict). Met replaces (E1.5) komt de nieuwe versie in de plaats van een verkeerd ingevoerde versie, ook op dezelfde inwerkingtreding: in dezelfde transactie krijgt die replacedById, met replaceReason (verplicht bij replaces, anders 400) als reden. Een rechtzetting telt pas zodra de vervangende versie (het einde van de keten van vervangingen) textStatus: 'ready' heeft (beslissing 123.5): met een scan die nog gelezen wordt, blijft de rechtgezette versie meetellen voor status, de parameters (reconfirm en het signaal reconfirm pas daarna), pre_wvv en de kennis; ze heeft wel al replacedById, wordt niet opnieuw rechtgezet en haar indeling ligt vast. Hetzelfde geldt voor POST /statutes/{id}/replace met een vervanger die nog gelezen wordt. Mislukt het lezen van een scan die als rechtzetting gekozen is (beslissing 124.3c), dan vervalt die keuze in dezelfde transactie: de rechtgezette versie krijgt weer replacedById: null (en replacedAt, replacedReason null; de audit-trail bewaart ze) en telt weer mee. In een keten A → B → C (scan) vervalt enkel B → C; A blijft rechtgezet door B. Een mislukte versie die zelf rechtgezet is, blijft een schakel: niets vervalt. Wie de keuze maakte (de gebruiker van de audit-rij die replaced_by_id zette) krijgt een melding in de app, statutes.rectification_lapsed (de reden en dat er niets verandert aan welke versie geldt, link naar de lijst van versies; nooit een e-mail; enkel wie nog bestuurder is, 124.2); was dat ook wie het lezen startte, dan krijgt die enkel deze melding, niet ook statutes.text_failed. Wordt een scan klaar op een inwerkingtreding waar een andere versie die niet vervangen is al klaar is (een vervallen rechtzetting die opnieuw gelezen werd zonder ze opnieuw te kiezen), dan wordt hij failed met effective_from_taken. Onbekend 404, al vervangen 409 statutes_version_replaced. Bij een fout blijft niets achter
POST /statutes/{id}/read-text (board)"Opnieuw proberen" (beslissing 119.4): een versie waarvan de OCR-job mislukte (textStatus: 'failed') gaat terug naar reading, met een nieuwe job in dezelfde transactie → 200 met de versie. Ook een scan waarvan de rechtzetting verviel (beslissing 124.3c). Met body { rectifies, reason } ("Opnieuw lezen" in "Rechtzetten", beslissing 124.3, Samuel 05/10/2026) wordt de scan in dezelfde transactie ook gekozen als rechtzetting van rectifies, wachtend op de tekst: lukt het lezen, dan gaat de rechtzetting in; mislukt het, dan vervalt de keuze weer met de melding. De fouten van replace gelden; enkel één van beide velden 400. Zonder body enkel opnieuw lezen. Een versie die niet mislukt is 409 statute_text_not_failed; een rechtgezette versie 409 statutes_version_replaced (de vervangende versie telt); onbekend (ook van een andere coöperatie) 404
POST /statutes/{id}/replace (board){ replacementId, reason } (E1.5; reason verplicht, max. 500 tekens, getoond als "Rechtgezet op …: …"): een verkeerd ingevoerde versie rechtzetten met een versie die er al is en zelf niet vervangen is → 200 met de vervangen versie. Eenmalig en onomkeerbaar; de vervangen versie blijft bewaard met haar PDF en artikels. Zichzelf 422 statutes_replace_self, een vervanger die niet bestaat (of van een andere coöperatie is) 422 statutes_replacement_unknown, een vervanger die zelf vervangen is 409 statutes_replacement_replaced, een scan waarvan het lezen mislukte (textStatus: 'failed') 409 statutes_replacement_failed (beslissing 124.3b: "Lees deze scan eerst opnieuw, of kies een PDF met tekstlaag"; de database weigert hetzelfde met DG043, migratie 0089), al vervangen 409 statutes_version_replaced. Een vervanger die nog gelezen wordt mag (124.3a). Beleidsversies die uit de vervangen versie bevestigd werden, blijven gelden tot het bestuur opnieuw bevestigt (E1.3)
GET /statutes/parameters (board, reader)de parameters (E1.3) van de jongste beleidsversie met parameters die vandaag geldt: { policy, reconfirm, parameters, toConfirm, supersededChoices }. policy: { id, validFrom, decisionRef, confirmedAt, confirmedByName, statuteVersion } of null. reconfirm: { replacementId } als de statutenversie van de beleidsversie rechtgezet is (de parameters blijven gelden tot het bestuur opnieuw bevestigt), anders null. Per parameter: value zoals bevestigd, applied wat de app toepast (de oproepingstermijn nooit korter dan 15 dagen, floor: { legalBasis }), de bron met haar bewijs (article, articleNumber, quote, aiSuggestionId als de waarde van een AI-voorstel overgenomen is · legalBasis · reason, decision), chosenByName, chosenAt en signals: reconfirm, article_missing (het artikelnummer bestaat niet meer in de rechtgezette tekst), below_floor. supersededChoices: eigen keuzes van de vorige beleidsversie die de statuten nu regelen (het statuut wint). settings: de instellingen van vandaag als parameterwaarden ({ parameter, shareClassId, value }: boekjaar, uittredingsvenster, betaaltermijn; per actieve soort maximum en minimum per vennoot, scheidingsaandeel, uitgifteprijs), het eerste voorstel van het formulier. deviations: bevestigde parameters die afwijken van die instellingen ({ parameter, shareClassId, confirmed, setting }), voor E1.4. policy.sections: per rubriek wie ze nakeek en bevestigde (reviewedByName, reviewedAt). Bron undetermined ("niet bepaald"): geen waarde (value: {}). Signaal requirements_missing bij admission zonder vereisten. toConfirm (beslissing 114): parameters zonder bevestigde rij die het bestuur nog moet bevestigen, afgeleid bij het lezen (niets opgeslagen): [{ kind: 'confirm_split', parameter: 'minutes_signers_board', shareClassId: null, from: 'minutes_signers', proposal: { value, source: statutes|law, article, articleNumber, quote, legalBasis }, applied: { value, legalBasis } }] wanneer de beleidsversie van vandaag minutes_signers met bron statutes heeft en geen rij voor minutes_signers_board (bevestigd vóór 112.4). proposal is de statutaire waarde (anders de wet), applied wat tot dan geldt (de wet, WVV 6:63). Weg zodra een beleidsversie met een rij voor minutes_signers_board geldt, met welke bron ook
POST /statutes/parameters/preview (board)dezelfde body als POST /statutes/parameters; schrijft niets (beslissing 140.10, R3). 200 { validFrom, changes }: per regel waarvan de toegepaste waarde wijzigt { parameter, shareClassId, current, next, statuteValue: null, ownChoice, adopted, statutesSilent } (statutesSilent: de statuten bepalen het niet en de app blijft current toepassen, beslissing 140.13) (current/next null: niet bepaald), en per eigen keuze die blijft ownChoice: true met next = current en statuteValue (wat de statuten zeggen). Dezelfde fouten als het bevestigen, ook 409 rules_later_version en 422 rule_min_above_max (140.22)
POST /statutes/parameters (board){ statuteVersionId, validFrom, decisionRef, parameters: [{ parameter, shareClassId?, value, source, articleNumber?, article?, quote?, reason?, decision?, aiSuggestionId? }], reviewedSections, adoptStatuteValues? } → 201 met de nieuwe stand. Schrijft ook een regelversie op validFrom en die statutenversie (beslissing 140.10): een eigen keuze blijft gelden met de waarde van de statuten ernaast, tenzij ze in adoptStatuteValues ([{ parameter, shareClassId }]) staat; de oude instellingen van de nieuwe beleidsversie zijn die van de regels. 409 rules_later_version als er een latere regelversie is; 422 rule_min_above_max als de regels van die versie een minimum per vennoot boven het maximum van dezelfde soort zouden hebben (beslissing 140.22); 409 rules_class_policy_same_day als een gekozen Uitgifteprijs of Scheidingsaandeel op een dag valt die al een klassebeleid met andere waarden heeft. aiSuggestionId (A4.4, bron "statuten (AI, citaat)"): enkel bij source: statutes en een voorstel van deze coöperatie voor statute_parameters van dezelfde statuteVersionId met de parameter als veld, niet afgewezen; anders 422 parameter_ai_suggestion_invalid met params.parameter (de database weigert hetzelfde met DG034). Per aandelensoort (beslissing 117.4): het veld geeft één waarde voor alle soorten, een waarde voor die soort (gekoppeld op code of naam), of een waarde van een soort die de coöperatie niet kent en die de bestuurder met de hand koppelde; een soort van een andere coöperatie nooit (422 parameter_ai_suggestion_invalid; de database DG036 en de vreemde sleutel). Een citaat dat het voorstel niet in de statuten vond, is nooit het citaat van de parameter (422 parameter_ai_quote_not_found, de database DG037); een oproepingstermijn onder 15 dagen wordt niet uit een voorstel overgenomen (422 parameter_below_floor, beslissing 117.9). De API zet bij elke parameter met aiSuggestionId of de bestuurder de voorgestelde waarde aanpaste (aiAdjusted, beslissing 117.3); een aiAdjusted in de body telt niet. Per soort zegt aiClassLabel van welke waarde van het voorstel de rij komt (de soort zoals de statuten ze noemen, of null voor de waarde voor alle soorten): die moet in het voorstel staan (anders 422 parameter_ai_suggestion_invalid) en de API vergelijkt met precies die waarde; zonder aiClassLabel met de waarde die zij op code of naam aan de soort koppelt. GET /statutes/parameters geeft aiAdjusted mee (null zonder voorstel of van vóór migratie 0073). Het voorstel wordt FOR SHARE gelockt tot de bevestiging commit. De beslissing over het voorstel blijft een aparte stap (POST /ai/suggestions/{id}/decision), ná de bevestiging. reviewedSections moet elke rubriek bevatten (shares_admission, exit_exclusion, general_meeting, board, profit_recognition; anders 422 sections_not_reviewed met params.sections), en parameters elke parameter, per actieve aandelensoort voor de parameters per soort (anders 422 parameters_incomplete); per rubriek wordt een rij in tenant_policy_section_reviews geschreven (audit-trail). source: undetermined ("niet bepaald") kan enkel waar de wet geen standaard geeft (422 parameter_has_law_default); admission vraagt requirements behalve bij de bron law (422 parameter_needs_requirements). Een beslisregel mag deelstemmingen per soort hebben (classVotes, E4.1, beslissing 93): enkel soorten van de coöperatie (422 parameter_share_class_unknown). agm_deadlines (106.2): elke termijn { description, kind, term: { value, unit: days|months }, article } met kind uit proxies, member_agenda_items, audit_documents, other (beslissing 133.6); zonder kind slaat de API other op, een andere waarde geeft 422 parameter_value_invalid. De database weigert een nieuwe rij met een termijn zonder soort of met een andere soort (23514, migratie 0096); rijen van vóór die migratie blijven zoals ze zijn en GET geeft ze zonder kind terug: de app leest ze als other. Schrijft een nieuwe beleidsversie (tenant_policies, met de operationele regels van de versie die op validFrom geldt) met haar parameters, in één transactie. statutes: articleNumber uit de indeling van de versie en quote (anders 422 parameter_needs_article, 422 parameter_article_unknown); law: enkel de standaard uit packages/shared/src/wvv-defaults.ts (422 parameter_no_law_default, 422 parameter_not_law_default), de API zet het wetsartikel; manual: reason verplicht (422 parameter_needs_reason) en optioneel decision: { body: board|general_meeting, date } of { body: internal_rules, article }; een oproepingstermijn onder 15 dagen 422 parameter_below_floor; een parameter die de statuten van deze versie al gaven 422 parameter_statutes_win. Verder 422 parameter_scope, parameter_twice, parameter_value_invalid, parameter_source_fields, parameters_before_statutes; een rechtgezette versie 409 statutes_version_replaced; een versie waarvan de tekst niet klaar is (reading of failed) 409 statute_text_not_ready (beslissing 123.5; de database weigert hetzelfde met DG042, migratie 0084); al een beleidsversie op validFrom 409 policy_valid_from_taken; in dezelfde transactie wordt de statutenversie kennis van de coöperatie (E1.6, beslissing 86): één bron Statuten per bevestigde versie, één stuk per artikel, geldig vanaf de inwerkingtreding; opnieuw bevestigen uit dezelfde versie laadt niets opnieuw
GET /statutes/deviations (board)de afwijkingssignalen van vandaag (E1.4; enkel voor het bestuur, een reader krijgt 403, beslissing 83): { policyId, deviations, open }, open signalen eerst. Regels op de bevestigde parameters van de beleidsversie die vandaag geldt, de instellingen en de bewaarde statutenversies (apps/api/src/statutes/deviations.ts), nooit een model; een signaal blokkeert niets. Per signaal: een vaste key (bv. setting_mismatch:max_per_member:{shareClassId}, pre_wvv), kind, parameter, shareClassId, confirmed en setting (de waarden), article of legalBasis, en status open of acknowledged met acknowledgement: { reason, byName, at }. Soorten: setting_mismatch (een instelling wijkt af van de parameter zoals de app hem toepast: uittredingsperiode, betaaltermijn, boekjaar; per soort maximum en minimum per vennoot, scheidingsaandeel, uitgifteprijs), max_looser (het maximum van de soort laat meer toe dan de statuten, of er is geen; membersAbove: hoeveel vennoten er vandaag meer hebben dan het statutaire maximum, enkel een aantal), below_floor (oproepingstermijn korter dan 15 dagen: "niet geldig: strijdig met dwingend recht (art. 6:70 §1)"; de app past 15 toe; legalBasis art. 6:70 §1), reconfirm (de parameters steunen op een rechtgezette versie; reconfirmParameters met articleMissing), pre_wvv (de jongste akte van de niet-rechtgezette versies waarvan de tekst klaar is, is van vóór 01/01/2020; statuteVersion, legalBasis art. 39 §1 wet 23 maart 2019, beslissing 81), confirm_split (beslissing 114: minutes_signers_board te bevestigen; key confirm_split:minutes_signers_board, confirmed het voorstel met article, setting de wet die tot dan geldt met legalBasis WVV 6:63; niet te dempen: een acknowledgement geeft 422 deviation_needs_confirmation, het signaal verdwijnt enkel door te bevestigen), law_check ("Controleer": de bevestigde parameters schenden een controle van de wet uit lawChecks, packages/shared/src/law-checks.ts, beslissing 117.7: de AV-datum later dan zes maanden na het einde van het boekjaar, art. 3:1 §1; met een erkenning een dividend boven 6 % of stemrecht per aandeel zonder plafond van hoogstens 10 %; key law_check:{regel}, check: { reason, params } met reason een i18n-sleutel, legalBasis het artikel, confirmed de gelezen waarden)
POST /statutes/deviations/acknowledgements (board){ key, reason } ("Bewust zo laten…", beslissing 16; reason verplicht, max. 500 tekens, anders 400) → 201 met de nieuwe stand. Schrijft een rij in statute_deviation_acknowledgements (append-only, audit-trail) met een vingerafdruk van de beleidsversie met parameters, de key, confirmed en setting: het signaal blijft zichtbaar als acknowledged tot een van die vier wijzigt, en gaat dan vanzelf weer open. Voor pre_wvv ("Kennis genomen", beslissing 81) is { key } genoeg: de rij krijgt de vaste reden kennis genomen en een vingerafdruk van de statutenversie (confirmed), niet van de beleidsversie; het signaal blijft acknowledged tot er een nieuwere statutenversie is. Een signaal dat er vandaag niet is 422 deviation_unknown; confirm_split (beslissing 114) 422 deviation_needs_confirmation; al gedempt 409 deviation_acknowledged. Een tenantId in de body telt niet (de rij hoort bij de coöperatie van de URL)
GET /knowledge/search (board, reader)enkel met de module module.ai, anders 403 { code: "module.ai" } (beslissing 102; als lezende route ook zonder de module zodra die vrijgegeven is). ?q=…&scope=all|platform|tenant&date=JJJJ-MM-DD&limit=1..20 (A1.5): hybride zoeken in de wet (platform) en de eigen kennis van de coöperatie, zoals die golden op date (standaard vandaag). Per treffer { chunkId, sourceId, scope, kind, sourceTitle, sourceVersion, statuteVersionId, article, heading, page, content, uri, score }. De statuten (E1.6): kind: statutes, sourceTitle Statuten en article 12 geven het citaat "Statuten art. 12"; sourceVersion is de dag waarop de statutenversie in werking trad, statuteVersionId de versie zelf (anders null). Een rechtgezette versie blijft vindbaar tot het bestuur bevestigt uit de versie die haar vervangt; dan neemt die haar plaats in (beslissing 86). Nooit kennis van een andere coöperatie (RLS). Het tekstdeel zoekt hier altijd strikt (elk woord), ook als ai.answer.search_mode loose of hybrid is: die keuze geldt voor de tekstkant enkel bij de vragen in het paneel (A5.3); hier bepaalt ze alleen of er een vector gevraagd wordt
GET /statutes/{id} (board, reader)de versie met articles: [{ id, position, number, title, body, page }] in volgorde; samengevoegde artikels vallen weg
GET /statutes/{id}/statutes.pdf (board, reader)de originele PDF (leesregel in de audit-trail)
PATCH /statutes/{id}/articles/{articleId} (board){ number?, title?, page? }: nummer, titel en pagina; de tekst (body) wijzigt nooit. Een nummer dat een ander artikel van de versie al heeft 409 statutes_number_taken
POST /statutes/{id}/articles/{articleId}/split (board){ at, number, title? }: knipt de tekst op teken at; het eerste deel blijft, de rest wordt een nieuw artikel erna (page: null), de volgende schuiven op. Een leeg deel 422 split_empty
POST /statutes/{id}/articles/{articleId}/merge-next (board)voegt de tekst van het volgende artikel toe; dat artikel blijft bewaard met removed_at maar valt uit de indeling (één transactie). Het laatste artikel 409 statutes_no_next_article
POST /statutes/{id}/articles/reorder (board){ articleIds }: de nieuwe volgorde van alle artikels van de versie, elk één keer (anders 422 statutes_order_incomplete)

De indeling van een vervangen versie wijzigt niet meer: nummer en titel, splitsen, samenvoegen en volgorde geven 409 statutes_version_replaced (E1.5). Zodra parameters uit een versie bevestigd zijn, ligt haar indeling vast (E1.2): splitsen, samenvoegen, volgorde en een ander nummer geven 409 statutes_layout_confirmed (titel en pagina mogen); een fout zet je recht met een vervangende versie. GET /statutes geeft per versie ook replacedAt en replacedReason (de rechtzetting) en reconfirm (de parameters die vandaag gelden steunen op deze rechtgezette versie); het overzicht (GET /overview) heeft rulesNotReviewed (beslissing 154: { statuteVersionId, title } van de nieuwste statutenversie die telt en waarop nog geen regelversie steunt, ook de vervanging van een rechtgezette; anders null; voor bestuurders en lezers; vervangt parametersToReconfirm) en ruleToConfirm (beslissing 114: de eerste regel die na de splitsing te bevestigen is, enkel voor een bestuurder; anders null), rule-sources heeft policy.reconfirm. GET /rules geeft hetzelfde als statutesNotReviewed, los van date.

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, statutes-api.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
}
}

Vergaderingen (/api/t/{slug}/meetings, E4)​

De algemene vergaderingen en de raden van bestuur (E4.2, #164; docs/plan-e4.md §4; migratie 0056). Alle routes enkel met de module module.assembly, anders 403 { code: "module.assembly" } (beslissing 82); lezen (GET) mag ook zonder de module zodra ze vrijgegeven is (beslissing 102). board schrijft, reader leest; de coöperatie is die van het pad, een tenantId in de body telt niet. Geen DELETE: een vergadering die niet doorgaat wordt geannuleerd. Elke wijziging antwoordt met het dossier zoals het daarna is. Een gesloten vergadering verandert niet meer (409 DG027). Schema's: packages/shared/src/meetings-api.ts.

Methode en padDoet
GET /meetings?year=de vergaderingen die in het kalenderjaar year (standaard dit jaar) gehouden worden, op dag en uur: { year, meetings: [{ meeting, agendaItems }], board, years }. board is de teller van de raden van bestuur tegen board_meetings_min: { minimum, article, counted, remaining, deadline } (counted: de raden van het jaar die niet geannuleerd zijn; remaining en minimum null zonder parameter; deadline 31/12 van het jaar). years: de jaren met een vergadering en dit jaar, nieuwste eerst
GET /meetings/proposal?kind=&fiscalYear=wat "Nieuwe vergadering" voorstelt: { kind, title, fiscalYear, heldOn, startsAt, statutoryDate, remoteAllowed, agenda }. Voor een ga is fiscalYear verplicht (422 fiscal_year_required) en is statutoryDate { rule, article, heldOn, startsAt }: de tekst van agm_date met het artikel en, met een gestructureerde regel, de eerste statutaire dag na het einde van dat boekjaar (De Broeikas, boekjaar 2026: 06/06/2027 17:00); zonder regel null, zonder gestructureerde regel heldOn null (beslissing 101). remoteAllowed uit remote_participation. agenda: het sjabloon (hieronder)
POST /meetings (board){ kind: ga|special_ga|board, heldOn, startsAt?, endsAt?, title?, place?, remoteAllowed?, fiscalYear?, dateDeviationReason? } → 201 met het dossier, status draft. Een ga: fiscalYear is het boekjaar van de jaarrekening (zonder: het boekjaar dat het laatst vóór heldOn eindigde); heldOn ligt na het einde ervan (422 held_on_before_fiscal_year_end met fiscalYear, fiscalYearEnd, beslissing 94); één per boekjaar (409 general_meeting_exists); een andere dag of een ander uur dan de statutaire regel vraagt dateDeviationReason (422 date_deviation_reason_required met expectedOn, expectedAt, article), die in de audit-trail komt; een reden zonder afwijking wordt niet bewaard. Een RvB of buitengewone AV heeft het kalenderjaar van heldOn als fiscalYear. remoteAllowed bij een AV enkel als de parameter remote_participation op heldOn het toelaat (422 remote_not_allowed met article; standaard aan uit de wet, art. 6:75 §1, beslissingen 78.5 en 106.4: de statuten hoeven niets te zeggen). De agenda van een ga komt uit het sjabloon (beslissing 97): de verslagen 6:108 §2 en 6:120 §2, het bijzonder verslag als recognition_so of recognition_nrc aanstaat, de jaarrekening, de bestemming van het resultaat, de kwijting aan de bestuurders en, met has_auditor, aan de commissaris; alle punten wettelijk, elk met zijn templateKey (report_issues, report_exits, special_report, annual_accounts, profit_allocation, discharge_directors, discharge_auditor, discharge_audit_members; migratie 0064, één per vergadering). Een RvB en een buitengewone AV beginnen met een lege agenda
GET /meetings/{id}het dossier: { meeting, agenda, participants, snapshot, proxies, questions, statutoryDate, warnings, events, documents, closing, proxyOwnChoice }. meeting.remoteProcedure en meeting.inspectionFrom: de procedure voor deelname op afstand en "ter inzage vanaf" van de vergadering (beslissing 125.7, migratie 0090; null = vijftien dagen vóór de vergadering). proxyOwnChoice ({ parameters, legalBasis } of null; niet bij een raad van bestuur): een volmachtregel als eigen keuze, strenger dan de wet, die de app niet toepast op de vergaderdag (beslissing 121.1, 125.9; hetzelfde signaal als op de pagina Statuten). agenda in volgorde, zonder geschrapte punten; participants zonder verwijderde, eerst de vennoten, dan op naam; snapshot de geldende versie van de momentopname of null. warnings (waarschuwen, niet weigeren): date_deviation (expectedOn, expectedAt, article) en late_general_meeting (fiscalYearEnd, latestOn: later dan zes maanden na het einde van het boekjaar, art. 3:1 §1; latestOn is de dag vóór dezelfde dag zes maanden na de dag na het einde, of de laatste dag van die maand als ze die dag niet heeft: tot 31/12 → 30/06, tot 14/01 → 14/07, tot 30/06 → 31/12; beslissing 123.3) bij een gewone AV; bij een AV of buitengewone AV ook attendance_list_missing (gehouden of gesloten zonder gekoppelde getekende aanwezigheidslijst, beslissing 90), proxy_invalid (count: aanvaarde volmachten gemarkeerd bij de momentopname) en proxy_holder_absent (count: vertegenwoordigde vennoten waarvan de volmachtdrager niet aanwezig of op afstand is; ze tellen niet mee, beslissing 106.5). events: de statusovergangen die de bestuurder nu kan maken (hold pas vanaf heldOn). documents: de archiefstukken met meeting_id van deze vergadering, nieuwste eerst: { id, kind, title, fiscalYear, status, hasFile, signedAt, report, createdAt } (report: exits, issues of filing als het een verslag van §6 is). closing (enkel bij held, anders null): { missingDecisions: [{ agendaItemId, position, title, decisionType, missingSubjects? }], undecidedProxies: [{ proxyId, grantorName, holderName, invalidCode }], warnings: [attendance_list_missing] } (missingSubjects: bij een kwijting de bestuurders of commissarissen zonder besluit). Een agendapunt heeft templateKey (het punt van het sjabloon, of null). De besluiten staan op GET /meetings/{id}/decisions
PATCH /meetings/{id} (board){ title?, heldOn?, startsAt?, endsAt?, place?, remoteAllowed?, fiscalYear?, dateDeviationReason?, remoteProcedure?, inspectionFrom? } (remoteProcedure 1–4000 tekens, inspectionFrom een datum, elk null om leeg te maken; één keer per vergadering, beslissing 125.7: elke oproeping neemt ze over en legt ze vast bij het verzenden; een tweede of verdaagde vergadering krijgt die van de eerste als voorstel, 126.5) zolang de vergadering niet gesloten is, met dezelfde regels als POST; terug op de statutaire dag valt de reden weg. De soort wijzigt niet. endsAt (beslissing 150.4, ook bij POST): het verwachte einduur na startsAt, of null; een later beginuur voorbij het einduur gaat samen met een nieuw of leeg endsAt in hetzelfde verzoek, alleen 422 ends_before_start (de database weigert het ook). Is de momentopname van de stemmen genomen, dan wijzigt heldOn niet meer (409 meeting_frozen; de database weigert ook held_on en kind, DG027, migratie 0059)
POST /meetings/{id}/transition (board){ event: plan|hold|cancel, reason? }: draft → planned → held, draft|planned → cancelled. hold pas vanaf de dag van de vergadering (409 meeting_not_held_yet); een overgang die de machine niet kent 409 forbidden_transition. Elke overgang is een rij in status_transitions (entity_type = meeting). Sluiten is POST /meetings/{id}/close. cancel van een vergadering met een verstuurde oproeping (#401, beslissing 121.9) vraagt convocationMessage: { kind: cancellation, body? } (het bericht van annulering naar de ontvangers van de laatst verstuurde versie, zie POST …/messages; body 1–5000 tekens, de toelichting onder de vaste zin) of { kind: none, reason } (uitdrukkelijk geen bericht, reden 1–2000 tekens; niets vertrekt, de reden staat in de verzendlog en de audit-trail); zonder die keuze 422 convocation_message_required met params.convocationId, en de database weigert het annuleren zonder bericht ook (DG053). Het bericht, de verzendingen en het annuleren zijn één transactie. Zonder verstuurde oproeping, of bij een andere overgang, 422 convocation_message_not_possible. Een ontwerp van de oproeping wordt mee geannuleerd. Idempotency-Key (het annuleren kan het bericht versturen)
PUT /meetings/{id}/agenda (board){ items: [{ id?, title, explanation?, decisionType, relatedMemberId?, relatedParticipantId?, ruleChoice? }] }: de hele agenda in de nieuwe volgorde, in één transactie. Een punt met id blijft (titel en toelichting wijzigen mag), een punt zonder id is nieuw, een weggelaten punt wordt geschrapt (removed_at, niets wordt verwijderd). Een wettelijk punt blijft (422 statutory_item_required) en houdt zijn soort besluit (422 statutory_item_type); een punt van een andere vergadering 422 unknown_agenda_item; een punt met een besluit blijft (409 DG027)
POST /meetings/{id}/participants/sync (board)laadt de deelnemers opnieuw: een AV of buitengewone AV uit het register op heldOn (elke vennoot met aandelen die dag, volgens effective_date; rol member, bron register), een RvB uit de gebruikers met memberships.role = board (rol director, bron board, als voorstel; beslissing 88). Wie er niet meer bij hoort, wordt verwijderd (removed_at); deelnemers met de hand blijven. Kan zolang er geen momentopname is (409 snapshot_taken; daarna laadt de momentopname de vennoten) of, bij een RvB, zolang er geen aanwezigheid is (409 attendance_registered). → 200 met het dossier
POST /meetings/{id}/participants (board){ role: director|auditor|chair|secretary|scrutineer|observer, displayName, memberId?, userId?, representedByText?, coHolderOfMemberId? } → 201 met het dossier. Een vennoot (member) komt enkel uit het register (400). memberId en userId van deze coöperatie (422 unknown_member, 422 unknown_user; userId is een bestuurder of lezer, een portaalaccount van een vennoot telt niet); dezelfde persoon in dezelfde rol één keer (409 participant_exists). representedByText (naam en functie van de wettelijke vertegenwoordiger, beslissing 98) enkel bij een rechtspersoon of een deelnemer zonder vennoot (422 represented_by_text_legal_entity_only). De eerste auditor op een gewone AV zonder has_auditor voegt het wettelijke punt "Kwijting aan de vennoten belast met de controle" toe (plan §12). coHolderOfMemberId: de deelnemer is de mede-houder van die vennoot (member_contacts.role = co_holder, anders 422 not_co_holder; niet samen met memberId, migratie 0061); hij oefent het stemrecht van zijn vennoot enkel uit met een aanvaarde aanwijzing (art. 6:21, kind: designation, beslissing 105.7)
PATCH /meetings/{id}/participants/{participantId} (board){ displayName?, representedByText?, memberId? }; een vennoot uit het register houdt de naam van het register (422 register_participant). memberId koppelt een bestuurder (uit de bestuurssync, met enkel een gebruiker), de commissaris of een lid van het bureau aan zijn vennoot (null ontkoppelt): pas dan vallen bij discharge_abstain_self zijn eigen stemmen weg bij zijn kwijting (beslissing 7). Niet voor een vennoot uit het register (422 register_participant) of een mede-houder (422 co_holder_not_member); een vennoot van deze coöperatie (422 unknown_member); dezelfde vennoot in dezelfde rol één keer (409 participant_exists)
DELETE /meetings/{id}/participants/{participantId} (board)verwijdert een deelnemer met de hand of uit het bestuur (removed_at, niets wordt gewist); een vennoot uit het register volgt het register (422 register_participant). → 200 met het dossier
POST /meetings/{id}/snapshot (board){ reason? }: de momentopname van de stemmen, het register op heldOn per vennoot (shares_per_class, votes met votesFor en de statutaire voting_rights van die dag, has_co_holder; een koppel één keer, beslissing 73), en de vennoten als deelnemers. Enkel bij een AV of buitengewone AV (422 snapshot_general_meeting_only), vanaf heldOn (409 before_meeting_day). Wordt ook genomen bij de eerste aanwezigheid. Opnieuw nemen vraagt een reden (422 snapshot_reason_required) en kan tot het eerste besluit (409 snapshot_after_decision; de database: DG027): een nieuwe versie, de vorige krijgt superseded_at. Een latere boeking verandert de momentopname niet. → 200 met de aanwezigheidslijst
GET /meetings/{id}/attendancede aanwezigheidslijst: { meetingId, kind, heldOn, remoteAllowed, canRegister, classes, snapshots, canRetakeSnapshot, canSuspend, suspensionsToReview, rows, tally } (canSuspend: een AV die niet gesloten of geannuleerd is en nog geen besluit heeft, issue #337; suspensionsToReview: op een tweede of verdaagde vergadering de schorsingen van de eerste die hier nog niet beslist zijn, [{ suspensionId, memberId, name, basis, reason }], issue #374). rows per deelnemer (eerst de vennoten op naam): participant, memberNumber, memberKind, coHolderName, sharesPerClass en votes (uit de momentopname, of ervoor uit het register op heldOn zoals nu geboekt), attendance, proxy (de volmacht of aanwijzing van de vennoot die niet afgewezen of verwijderd is: { holderParticipantId, holderName, status, invalidCode, kind }), holderAbsent (vertegenwoordigd door een drager die niet aanwezig of op afstand is: "volmachtdrager niet aangemeld: telt niet mee", beslissing 106.5), bureau en suspension (het geschorste stemrecht van de vennoot op deze vergadering { id, basis, reason, createdAt } of null, issue #337). tally: { counted, present, remote, represented, absent, open, votesPresent, votesTotal, sharesPresent, sharesTotal, votingRightsOther, holderAbsent, suspended, sharesSuspended, quorum }; de aandelen en stemmen van een vennoot met geschorst stemrecht tellen nergens mee: niet in sharesTotal (de basis van het quorum), votesTotal, sharesPresent of votesPresent, ook niet via een volmacht voor hem (art. 6:78; beslissingen 105.4, 106.6); suspended en sharesSuspended tellen ze; bij een AV telt het de vennoten van de momentopname met presentVotes (een vertegenwoordigde vennoot enkel zolang zijn drager aanwezig of op afstand is; holderAbsent telt de andere), en quorum per regel van de besluiten op de agenda { ruleKey, decisionTypes, quorum, met, waived } (quorumOf; follows opgelost, ook quorum_dissolution → quorum_amendment; waived op een tweede vergadering na no_quorum). Bij een RvB telt het de deelnemers, zonder stemmen of quorum (beslissing 99)
PUT /meetings/{id}/attendance/{participantId} (board){ status: present|represented|remote|absent, representedByParticipantId?, representedByText? } → 200 met de aanwezigheidslijst. Vanaf heldOn (409 before_meeting_day, beslissing 92), niet bij een geannuleerde vergadering (409 meeting_cancelled). remote enkel met remoteAllowed en, op een AV, de parameter remote_participation op heldOn (422 remote_not_allowed, beslissing 106.4) en op een AV nooit voor de voorzitter, secretaris of stemopnemers, ook niet in hun rol als vennoot (422 bureau_not_remote, art. 6:75 §1). Een rechtspersoon is present of remote via zijn wettelijke vertegenwoordiger, zonder volmacht: representedByText hier of al op de deelnemer (422 legal_representative_required). represented: op een AV enkel met een aanvaarde volmacht of aanwijzing van de vennoot aan die deelnemer (422 proxy_required; een mede-houder zonder aanvaarde aanwijzing van zijn vennoot: 422 designation_required). Registreren mag vóór de drager aankomt; de stemmen tellen pas zolang hij aanwezig of op afstand is (beslissing 106.5), op een RvB door een andere bestuurder (422 board_representation_directors_only, beslissing 98). De eerste aanwezigheid van een AV neemt de momentopname
GET /meetings/{id}/attendance.pdfde aanwezigheidslijst op papier (templates/documents/attendance-list-nl.fodt, beslissing 90): alle vennoten van de momentopname (of van het register op heldOn) alfabetisch met nummer, aandelen per soort, stemmen en een vak om te tekenen; bij een mede-houder "Stemrecht: uitgeoefend door … (art. 6:21 WVV)", bij een rechtspersoon de vertegenwoordiger, bij een volmacht "tekent hier als volmachtdrager", bij een aanwijzing "Stemrecht: uitgeoefend door … (aanwijzing, art. 6:21 WVV): tekent hier"; wie op afstand geregistreerd is staat erop als "op afstand" zonder vak. Daaronder "Andere aanwezigen": wie deelneemt zonder vennoot te zijn (bestuurders, commissaris, secretaris, genodigden, een mede-houder), één keer per persoon met zijn rollen en een vak om te tekenen, zonder stemmen (beslissing 106.7). Een vennoot met geschorst stemrecht staat erop met "Stemrecht geschorst (art. 6:41 WVV): reden" (art. 6:21 bij no_designated_holder, geen artikel bij other) en 0 stemmen; het totaal van de stemmen telt hem niet. Nooit een rijksregisternummer. Enkel bij een AV (422 attendance_list_general_meeting_only)
POST /meetings/{id}/vote-suspensions (board){ memberId, basis: unpaid_call|no_designated_holder|other, reason } → 201 met de aanwezigheidslijst: het stemrecht van een vennoot uit het register van deze vergadering geschorst (issue #337; unpaid_call art. 6:41, no_designated_holder art. 6:21, other zonder artikel; beslissingen 105.4, 106.6). Hij blijft op de lijst; zijn aandelen tellen nergens mee (quorumbasis, aanwezige aandelen, stemming en deelstemmingen per soort, ook niet via een volmacht of aanwijzing voor hem); de volmachten die hij voor anderen draagt tellen wel. Enkel op een AV (422 vote_suspension_general_meeting_only; de database DG035), enkel vóór het eerste besluit van de vergadering (409 DG035, ook de database): al vanaf ontwerp (draft) of gepland (planned), en op een gehouden vergadering zolang er geen besluit is, niet gesloten (409 DG027) of geannuleerd (409 meeting_cancelled); een vennoot die geen deelnemer member is 422 not_a_member_participant; al geschorst 409 vote_suspension_exists (één actieve schorsing per vennoot en vergadering). In de audit-trail. Gaat niet vanzelf mee naar een tweede of verdaagde vergadering: die beslist elke schorsing van de eerste vóór haar eerste besluit (POST …/vote-suspensions/review, beslissing 118.3); een vennoot die daar rechtstreeks geschorst wordt, telt als beslist; op dezelfde grond als op de eerste vergadering vult de app carried_from_id naar die schorsing zelf in (beslissing 120.1), zodat de audit-trail dezelfde keten toont als via de lijst, en wordt die schorsing vóór het eerste besluit opgeheven, dan geldt hij als "niet meer geschorst" met die reden (de rij houdt dan de eigen reden van deze vergadering, niet die van de eerste zoals bij review). Een tenantId in de body wordt genegeerd
POST /meetings/{id}/vote-suspensions/review (board){ suspensionId, decision: resuspend|lift, reason } → 201 met de aanwezigheidslijst: een tweede of verdaagde vergadering beslist een schorsing die op de eerste vergadering nog liep (issue #374, beslissing 118.3, migratie 0074). resuspend ("opnieuw geschorst"): een actieve schorsing hier, met de grond van de eerste en reason (in het scherm standaard de reden van de eerste, aanpasbaar); lift ("niet meer geschorst"): een rij die meteen opgeheven is, met de reden van de eerste en reason als lifted_reason (de database zet lifted_at en lifted_by); ze telt niet. Beide met carried_from_id naar de schorsing van de eerste vergadering, in de audit-trail. reason verplicht (400). Geen tweede of verdaagde vergadering 422 not_a_second_meeting; suspensionId geen lopende schorsing van de eerste vergadering (een andere vergadering, of op de eerste opgeheven) 422 not_a_first_meeting_suspension; al beslist (of de vennoot is hier rechtstreeks geschorst) 409 vote_suspension_reviewed (de database: één per schorsing en vergadering, 23505); na het eerste besluit 409 DG035; een vergadering van een andere coöperatie 404. Tot alle beslist zijn, weigert het eerste besluit met 409 DG039 (ook de database), zegt het tabblad Besluiten blockedBy: suspensions_to_review en blijft de waarschuwing first_meeting_suspensions in het dossier
POST /meetings/{id}/vote-suspensions/{suspensionId}/lift (board){ reason } → 200 met de aanwezigheidslijst: de schorsing opgeheven met een reden; de database zet lifted_at en lifted_by (audit-trail). Enkel vóór het eerste besluit (409 DG035); al opgeheven 409 vote_suspension_lifted; zonder reden 400; een schorsing van een andere vergadering of coöperatie 404. Er is geen DELETE
POST /meetings/{id}/proxies (board){ grantorMemberId, holderParticipantId, receivedOn, kind? } → 201 met het dossier: een volmacht (kind: proxy, standaard) van een vennoot aan een deelnemer van de vergadering, of de aanwijzing van zijn mede-houder om het stemrecht op de gezamenlijke aandelen uit te oefenen (kind: designation, art. 6:21; geen volmacht, beslissing 105.7), status received. Een aanwijzing gaat enkel naar de deelnemer die mede-houder van die vennoot is (422 designation_not_co_holder; de database DG030, migratie 0064); een volmacht aan de eigen mede-houder kan niet (422 co_holder_needs_designation); kind wijzigt niet (DG030); kan vóór heldOn (beslissing 92), niet na (422 proxy_received_after_meeting). Enkel bij een AV of buitengewone AV (422 proxies_general_meeting_only), niet bij een geannuleerde of gesloten vergadering (409 meeting_cancelled, 409 DG027). Onbekende vennoot of deelnemer: 422 unknown_member, 422 unknown_participant; aan zichzelf: 422 proxy_holder_is_grantor; één vertegenwoordiging (volmacht of aanwijzing) per vennoot die niet afgewezen of verwijderd is (409 proxy_exists). De regels van de statuten volgen bij het aanvaarden. Een bijlage (documentId) kan nog niet via de API: er is geen route die een documents-id teruggeeft
PATCH /meetings/{id}/proxies/{proxyId} (board){ event: accept }, { event: reject, reason }, { event: keep, reason } of { event: lapse } → 200 met het dossier. keep: een aanvaarde gemarkeerde volmacht blijft met de reden van het bestuur (invalidKeptReason, in de audit-trail; anders 409 proxy_not_marked); een nieuwe markering wist de reden (beslissing 106.5). lapse ("vervallen", beslissing 140.16): een aanvaarde volmacht gemarkeerd na een wijziging van de regels of bij het meenemen (invalidCheck rules of carry) wordt afgewezen met de vaste reden "Vervallen door een wijziging van de regels" (anders 409 proxy_not_marked_by_rules). invalidCheck in het dossier zegt welke controle markeerde: snapshot (de momentopname, met invalidSnapshotVersion), rules (een regelversie die geldt op de vergaderdag, opnieuw gecontroleerd bij elke regelversie voor een algemene vergadering in ontwerp of gepland, op of na vandaag en zonder besluit; enkel proxy_not_allowed, proxy_holder_not_member, proxy_max_exceeded; een latere versie die haar weer laat slagen, haalt de markering weg) of carry (meegenomen naar een tweede vergadering). Een markering rules of carry wordt beslist vóór het eerste besluit (409 DG081). De machine (proxyMachine, migratie 0061): received → accepted, received|accepted → rejected; afgewezen is definitief (409 forbidden_transition; de database DG027). accept toetst validateProxy met de statuten op heldOn (proxy_allowed, proxy_only_members, proxy_max_per_holder) tegen het register op heldOn (de momentopname als die er is): 422 proxy_not_allowed, proxy_holder_not_member (een volmachtdrager telt als vennoot enkel als hij zelf aandelen heeft op heldOn; een mede-houder of een deelnemer zonder vennoot is geen vennoot), proxy_max_exceeded, proxy_shares_transferred (de volmachtgever heeft die dag geen aandelen). Een rechtspersoon die via zijn wettelijke vertegenwoordiger deelneemt en een aanwijzing (art. 6:21) tellen niet mee voor het maximum. Een aanwijzing wordt aanvaard ook als de statuten geen volmachten of enkel vennoten als volmachtdrager toelaten; ze faalt enkel als de aangewezene geen mede-houder is (designation_not_co_holder) of de vennoot die dag geen aandelen heeft (proxy_shares_transferred). Voor een andere vennoot is een mede-houder een gewone volmachtdrager en geen vennoot (proxy_holder_not_member). Afwijzen of verwijderen van een volmacht die de aanwezigheidslijst gebruikt: 409 proxy_in_use (pas eerst de aanwezigheid aan). Elke stap staat in status_transitions (meeting_proxy)
DELETE /meetings/{id}/proxies/{proxyId} (board)verwijdert een volmacht (removed_at, niets wordt gewist) → 200 met het dossier; een afgewezen volmacht blijft (409 proxy_rejected), een gebruikte ook (409 proxy_in_use)
Volmachten bij de momentopnameelke versie van de momentopname toetst de aanvaarde volmachten opnieuw (beslissing 92), in de volgorde van ontvangst. Een volmacht die niet meer slaagt, blijft accepted en krijgt invalidCode en invalidSnapshotVersion in het dossier; ze telt tot het bestuur ze afwijst. Het dossier waarschuwt (warnings: [{ code: proxy_invalid, count }]), de rij van de volmachtgever in GET /attendance draagt proxy.invalidCode en de papieren lijst zegt "bij de momentopname niet meer geldig: …". Een latere versie die slaagt, wist de markering. Beide staan in de audit-trail
POST /meetings/{id}/questions (board){ memberId?, question, agendaItemId? } → 201 met het dossier; source = board (het portaal volgt met E8). agendaItemId (E6.5, beslissing 122.9, migratie 0085): een huidig agendapunt van deze vergadering waar de vraag bij hoort (art. 6:77; de notulen zetten ze onder dat punt, zonder onder "Varia en vragen"); een punt van een andere vergadering of coöperatie of een verwijderd punt 422 unknown_agenda_item (de database ook: samengestelde foreign key 23503, verwijderd punt DG050). Niet bij een geannuleerde (409 meeting_cancelled) of gesloten vergadering (409 DG027)
PATCH /meetings/{id}/questions/{questionId} (board){ question?, answer?, agendaItemId? } (answer: null wist het antwoord, agendaItemId: null de koppeling) → 200 met het dossier; answeredAt zet de database. Na het sluiten verandert enkel het antwoord (409 DG027, ook de koppeling)
POST /meetings/{id}/close (board){ acknowledgeWarnings? } → 200 met het dossier, status closed (closedAt, closedBy zet de database). Enkel vanuit held (409 meeting_not_held; gesloten: 409 DG027). Elk agendapunt met decisionType ≠ none heeft een rij in meeting_decisions; een kwijting op een AV een rij per bestuurder of commissaris die deelneemt (zonder hen volstaat één rij; 409 decisions_missing met params.items, en missingSubjects). Elke volmacht die bij de momentopname gemarkeerd werd, is beslist: behouden met een reden (keep) of afgewezen (409 proxies_undecided met params.proxies, beslissing 106.5). Een AV of buitengewone AV zonder gekoppelde getekende aanwezigheidslijst sluit enkel met acknowledgeWarnings: true (409 close_warnings met params.warnings; beslissing 90: waarschuwen, niet blokkeren); de bevestigde waarschuwingen staan in de payload van de overgang in status_transitions. Een gewone AV (ga) waarop het punt annual_accounts ("goedkeuring van de jaarrekening") een besluit adopted heeft, sluit de stap general_meeting van de jaarcyclus van het boekjaar erna (ProcessEvents, general_meeting.closed, afgewerkt op heldOn; definitie annual_cycle versie 3 en 4: een lopende cyclus van versie 2 vink je met de hand af). Een AV zonder quorum of met verdaagde jaarrekening (art. 6:84) sluit de stap niet; de tweede vergadering die de jaarrekening goedkeurt wel (beslissing 106.8). Daarna verandert niets meer (API en database, 409 DG027), behalve het antwoord op een vraag en het koppelen van stukken
GET /meetings/{id}/decisionshet tabblad Besluiten (PR 7, plan §18): { meetingId, kind, secondMeetingReason, blockedBy, classes, votingClassIds, voters, votesPresent, sharesPresent, sharesTotal, sharesSuspended, cap, dischargeAbstainSelf, items, secondMeetings, secondMeetingReasons }. blockedBy: closed, cancelled, before_meeting_day, (AV zonder momentopname) snapshot_required, (tweede of verdaagde vergadering zonder besluit met schorsingen van de eerste die nog niet beslist zijn, issue #374) suspensions_to_review of (AV zonder besluit met een volmacht gemarkeerd na een wijziging van de regels of bij het meenemen, nog niet behouden of vervallen, beslissing 140.16) proxies_to_decide, anders null. votingClassIds: de soorten met uitgegeven aandelen op heldOn (issuedClassIdsOf, beslissing 105.2). voters (AV): wie nu stemmen uitbrengt, uit de momentopname en de aanwezigheid: { participantId, name, memberId, ownVotes, proxyVotes, votesPerClass, represented: [{ memberId, name, kind, votes, carried }] } (een vertegenwoordigde vennoot enkel via een drager die aanwezig of op afstand is, 106.5). cap: voting_cap op heldOn ({ basisPoints, basis }). items per agendapunt met een besluit: { item, ruleKey, rule, ruleBasis, subjects, decisions, canAdjourn, newWithCarriedProxies } (subjects: { participantId, name, role, linkedToMember }; newWithCarriedProxies: een punt dat nieuw is op een tweede vergadering terwijl er volmachten overgenomen zijn, beslissing 113.6); rule de regel op heldOn met follows opgelost (decisionRulesOn), subjects bij een kwijting de bestuurders (rol director) of, voor de commissaris of de vennoten belast met de controle, de deelnemers met rol auditor (of de deelnemer die het punt noemt); decisions: { decision, subjectName, warnings, ballots }; warnings: discharge_proxy_self (holderName, grantorNames), discharge_subject_not_member (subjectName), carried_proxy_new_item (holderName, grantorNames), suspended_voting_rights (shares, memberNames: "x aandelen met geschorst stemrecht tellen niet mee (art. 6:78 WVV)", issue #337); ballots: de lijnen van de laatste versie { participantId, name, memberId, memberName, represented, choice, votes, votesCounted }. canAdjourn: het punt annual_accounts van een gewone AV die zelf geen verdaagde vergadering is. secondMeetingReasons: no_quorum en/of adjourned als een besluit dat zegt en er nog geen tweede vergadering om die reden gepland is. Bij een RvB geen voters, rule of quorum (beslissing 99)
PUT /meetings/{id}/decisions/{agendaItemId} (board)het besluit op één agendapunt → 200 met het tabblad. Vier vormen: { mode: ballots, participantId?, ballots: [{ participantId, choice? , lines?: [{ memberId, choice: for|against|abstain|null }] }], note? } (AV): per deelnemer die stemt één choice voor al zijn lijnen (zijn eigen stemmen en die van elke vennoot die hij vertegenwoordigt), of lines per vennoot met zijn eigen keuze, null neemt niet deel (steminstructies, beslissing 113.1; precies één van beide, anders 400; een vennoot die hij niet vertegenwoordigt 422 not_a_ballot_line); wie niet in ballots staat neemt niet deel. De API telt met castVotes en evaluateDecision: het plafond voting_cap per stemming op de deelnemende stemmen en binnen elke soort (nooit onder 1 stem, beslissing 105.1), af te trekken van de volmachten, de laatst aanvaarde eerst (113.1); de deelstemmingen over de soorten met uitgegeven aandelen, een genoemde soort zonder uitgegeven aandelen overgeslagen (skipped, 112.2); staking verworpen (failure: tie), only_abstentions (een aanwezige soort of stemming met enkel onthoudingen, 112.3), no_votes_cast, majority, no_voters en een vervallen deelstemming (lapsed: enkel als niemand van de soort aanwezig of vertegenwoordigd is, 105.2, 112.3), het quorum op de aanwezige aandelen (een tweede vergadering na no_quorum zonder quorum waar de regel het zegt: quorumWaived). Bewaard: ruleKey, ruleSnapshot, de stemmen, votesPresent, sharesPresent, sharesTotal, classVotes (per soort voor, tegen, onthouding, cast, passed, failure, lapsed, capRemoved, skipped), capApplied (per begrensde deelnemer, in de hoofdstemming of met shareClassId), capRemoved, quorumMet, quorumWaived, outcome, failedPart, failure, warnings, en de lijnen in meeting_ballots (per vennoot, met de stemmen vóór en na het plafond, ook per soort). Een kwijting (op een AV) noemt de bestuurder of commissaris (participantId, 422 discharge_subject_required; bij een ander punt 422 discharge_subject_only); met discharge_abstain_self tellen zijn eigen stemmen niet mee (vóór het plafond, 113.9), die als volmachtdrager wel, met de waarschuwing discharge_proxy_self (beslissingen 7, 105.3, 113.8: enkel met de parameter aan); is hij niet aan zijn vennoot gekoppeld, dan valt niets weg en zegt de uitslag discharge_subject_not_member. Een overgenomen volmacht die stemt over een punt dat nieuw is op een tweede vergadering geeft carried_proxy_new_item (113.6). Geschorst stemrecht (issue #337): de lijnen van die vennoot vallen weg vóór het plafond, ook als een volmachtdrager ze uitbrengt (422 not_a_ballot_line/not_a_voter), en sharesPresent en sharesTotal zijn zonder zijn aandelen; de uitslag bewaart suspended_voting_rights (memberIds, shares) voor de notulen. { mode: not_voted, participantId?, note } (AV): niet gestemd, met reden. { mode: adjourn, note? }: de jaarrekening drie weken uitgesteld (art. 6:84), enkel annual_accounts van een gewone AV die zelf niet verdaagd is (422 adjourn_not_possible; de database DG032, 409), outcome: adjourned; de bestemming van het resultaat en de kwijting die nog geen besluit hebben, krijgen mee adjourned (art. 6:83, beslissing 113.2). { mode: board, outcome: adopted|rejected|not_voted, votes?: { for, against, abstain }, note? } (RvB, geen toets). Een andere vorm dan de soort vergadering toelaat 422 decision_mode_not_allowed; een punt zonder besluit 422 no_decision_asked; vóór heldOn 409 before_meeting_day; een AV zonder momentopname 409 snapshot_required; een deelnemer die geen stemmen uitbrengt 422 not_a_voter (params.participantId); twee keer dezelfde deelnemer 400. Opnieuw invoeren is een nieuwe versie (revision, de database telt; de stembriefjes van een vorige versie blijven, meeting_ballots is append-only en een stembriefje hoort bij de laatste versie: DG032). Na het sluiten 409 DG027. Een tenantId in de body wordt genegeerd
POST /meetings/{id}/second-meeting (board){ reason: no_quorum|adjourned, heldOn, startsAt?, endsAt?, place?, remoteAllowed?, title?, agendaItemIds? } → 201 met { dossier, proxies }: een nieuwe vergadering (ontwerp) van dezelfde soort en hetzelfde boekjaar met secondMeetingOf en secondMeetingReason (beslissing 93). Enkel na een AV of buitengewone AV (422 second_meeting_general_only) die gehouden of gesloten is (409 meeting_not_held); no_quorum vraagt een besluit no_quorum, adjourned een verdaagde jaarrekening (409 second_meeting_not_needed); adjourned valt standaard drie weken na de eerste, een andere dag vraagt dateDeviationReason (422 adjournment_date_reason_required met expectedOn; bewaard, met de waarschuwing adjournment_date in het dossier, ook bij PATCH; 113.3); één per eerste vergadering en reden die niet geannuleerd is (409 second_meeting_exists, ook een unieke index); heldOn na de eerste (422 second_meeting_before_first); endsAt na startsAt (422 ends_before_start), niet overgenomen van de eerste (beslissing 150.4); remoteAllowed zoals bij POST /meetings. De agenda: de punten zonder quorum (of de verdaagde punten: de jaarrekening en wat mee verdaagd werd) en de andere punten van de eerste vergadering in agendaItemIds (422 unknown_agenda_item), in hun volgorde, met hun soort besluit, wettelijk karakter en templateKey (zo sluit de stap general_meeting bij de vergadering die de jaarrekening goedkeurt, 106.8), en carried_from_item_id (een punt zonder is nieuw). De deelnemers met de hand of uit het bestuur gaan mee, de vennoten komen uit het register op de nieuwe heldOn. De aanvaarde volmachten en aanwijzingen gaan mee (art. 6:80; 113.4, 113.7), als received met carriedFromProxyId (overgang carry), en worden opnieuw aanvaard met de statuten en het register van de nieuwe dag (overgang accept met reden "Overgenomen van de eerste vergadering (art. 6:80 WVV)"); een die niet meer slaagt, wordt ook aanvaard, met de code als markering (invalidCheck: carry), te behouden met een reden of te laten vervallen vóór het eerste besluit (beslissing 140.16, 409 DG081). Niet mee: de volmachtgever heeft geen aandelen op de nieuwe dag (proxy_shares_transferred), de drager neemt niet deel (holder_not_participant), een aanwijzing aan wie geen mede-houder meer is (designation_not_co_holder). Meldt de vennoot zich zelf aan (present of remote), dan vervalt een overgenomen aanwijzing: afgewezen met reden "Vervallen: de vennoot neemt zelf deel (art. 6:21 WVV)". proxies: [{ kind, grantorName, holderName, result: accepted|marked|not_carried, code, remainingShares }]; remainingShares bij een gedeeltelijke overdracht sinds de eerste vergadering (113.5), ook als partialTransfer op de volmacht in het dossier. Een tenantId in de body wordt genegeerd
GET /meetings/{id}/minutesde notulen (E6.3/E6.4 PR 1, #181, migratie 0078; features.md §7): altijd 200, ook vóór ze bestaan. { meeting, minutes, canCreate, editable, lockedBecause, attendees, tally, bureau, questions, warnings, attendanceLists, dischargeAbstainSelf, signersRule, signers, suggestedSigners, document, canGenerate, opening, remote, items, varia, closing, dischargeAbstainSelfGround, convocations }. E6.5 (#398, beslissing 122, migratie 0085): remote de technische problemen of incidenten bij deelname op afstand ({ text, updatedAt, updatedByName }, bewaard als deel remote; de PDF zegt "Technische problemen of incidenten: geen" of de tekst waar deelname op afstand mogelijk was); per punt ruleGround (de verwijzing van de regel op de vergaderdag, "art. 6:85 WVV" of "art. 27 van de statuten", null bij een eigen regel of zonder artikel) voor de regel "Quorum: vereist …"; second_meeting.items na geen quorum [{ title, ground, rules, waived }] (de punten zonder quorum op de eerste vergadering, met art. 6:85 of 6:86 WVV of het artikel van de statuten, anders null; beslissing 125.15: ook de grond van de regel die gevolgd wordt, de ontbinding "art. 2:71 §1 en art. 6:85 WVV"; rules de regels zelf [{ source, article }], zodat de zin voor meerdere punten elk artikel één keer noemt; 125.18: de PDF splitst de punten met en zonder waived); dischargeAbstainSelfGround het artikel van discharge_abstain_self als het uit de statuten komt; convocations elke verzonden versie van de oproeping, oudste eerst (AV; beslissing 125.22): [{ version, sentOn, postedOn, byEmail, byPost, countsForTerm }] (de verzenddag, de postdatum of null, en of ze ook per e-mail vertrok); lateConvocation de te late dagen van de oproeping met de reden van het bestuur, dezelfde lezing als het dossier (beslissing 130.1): [{ kind (sent|posted), version, sentVersions, counts, on, daysBefore, lastSendOn, reason, awaitingPost }], per verzonden versie die te laat vertrok of waarvan de brieven te laat gepost werden, ook als ze niet voor de termijn telt, per versie en het verzenden vóór het posten (130.6); de PDF zet ze in aparte rijen onder de oproeping, met meer dan één verzonden versie voorafgegaan door "versie {n}: " en bij de versie die telt "(telt voor de termijn)" na de reden ("Te late oproeping: verstuurd op …, x dagen vóór de vergadering en na de laatste verzenddag van … (art. 6:70 §1 WVV). Reden: …", "Te late postdatum: per post op …, na de laatste verzenddag van … (art. 6:70 §1 WVV). Reden: …"); signersRule.ground en signersRule.confirmedOn (de dag van bevestiging, voor een eigen regel; beslissing 125.16: de regel en de beleidsversie van de vergaderdag, niet van vandaag); remote_participation ook bij een RvB (zonder artikel); een vraag heeft agendaItemId. minutes: { id, correctsId, archiveDocumentId, archiveStatus, createdAt, createdByName } van de geldende notulen (het einde van de keten van correcties), null zolang er geen zijn; canCreate: een gehouden of gesloten vergadering zonder notulen. lockedBecause: not_created, meeting_not_held (ontwerp, gepland of geannuleerd), document_not_draft (de PDF is nagekeken of getekend; heropenen helpt bij een nagekeken PDF) of document_cancelled (de PDF is geannuleerd; een nieuwe PDF genereren opent de tekst weer), anders null en editable: true. Wat het bestuur schrijft: opening, varia (varia en vragen) en closing als { text, updatedAt, updatedByName }; items: elk huidig agendapunt in volgorde (ook een punt dat na het aanmaken op de agenda kwam, dan leeg) { item, discharge, discussion, incidents, updatedAt, updatedByName, decisions, subjects }. Wat uit de app komt, nooit ingetikt: meeting (datum, uur, plaats, dateDeviationReason), attendees (de deelnemers zonder verwijderde: { participantId, name, role, memberId, attendance, representedByName, representedByKind, holderAbsent, coHolderName, representedByText, shares, votes, bureau, suspension }; representedByKind proxy of designation (aanwijzing van de mede-houder, art. 6:21) bij represented, holderAbsent een volmachtdrager die niet aangemeld is (telt niet mee, 106.5), coHolderName de mede-houder (73), representedByText de wettelijke vertegenwoordiger van een rechtspersoon (98), shares en votes de aandelen en stemmen van een vennoot op de dag (de momentopname; bij een RvB en voor wie geen vennoot is null)), tally (zoals de aanwezigheidslijst, AttendanceService, niet opnieuw geteld: aanwezige stemmen en aandelen, vertegenwoordigden, geschorste aandelen, holderAbsent), per punt decisions (zoals het tabblad Besluiten: uitslag, stemmen, sharesPresent/sharesTotal, warnings met suspended_voting_rights en namen, revision, note, de stembriefjes), discharge (een kwijting, apart te tonen, art. 6:83) en subjects (bij een kwijting de bestuurders of commissaris, één besluit elk: { participantId, name, role, linkedToMember }). E6.4 PR 4, de AV: bureau { chair, secretary, scrutineers } (namen, uit de rollen van de deelnemers; ook bij een RvB met voorzitter of secretaris); questions de vragen van vennoten met antwoord zoals in het dossier ([{ question: { id, memberId, askedAt, question, answer, answeredAt, source }, memberName }], oudste eerst; een vraag hangt niet aan een agendapunt, in de PDF onder varia); dischargeAbstainSelf (discharge_abstain_self op de vergaderdag, beslissing 7: de eigen stemmen van een bestuurder die aan zijn vennoot gekoppeld is, tellen niet mee bij zijn kwijting); warnings over de vergadering als geheel: date_deviation { reason } (101, 113.3), second_meeting { reason, firstMeetingId, firstMeetingTitle, firstMeetingHeldOn } (93), suspension_reviewed { memberId, memberName, basis, decision: resuspend|lift, reason } (op een tweede of verdaagde vergadering elke schorsing van de eerste die hier opnieuw beslist is, 118.3, 120.1), remote_participation (een AV met remoteAllowed, 106.4) en attendance_list_missing (een gehouden of gesloten AV zonder getekende aanwezigheidslijst, 90; enkel op het scherm, het genereren weigert niet); attendanceLists de getekende aanwezigheidslijsten van een AV (archiefdocumenten attendance_list, getekend, gekoppeld aan de vergadering), oudste eerst: [{ documentId, title, signedAt, signedOn, sha256 }] met de dag van opladen (Belgische datum) en de SHA-256 van het getekende bestand. signersRule: minutesSignersFor (board voor een RvB, general voor een AV): { text, source, article, confirmed, notice }, weigert nooit (beslissing 114; notice "ondertekenaars volgens art. 6:63 WVV, nog niet bevestigd" of "… art. 6:79 WVV …", 125.20). signers: de gekozen ondertekenaars [{ participantId?, name, role }], tot dan []. suggestedSigners: het voorstel (E6.4 PR 3): bij een AV het bureau (voorzitter, secretaris, stemopnemers), bij een RvB de voorzitter en de secretaris als die er zijn, anders de aanwezige (of op afstand deelnemende) bestuurders. document: het archiefdocument van de PDF (zoals bij /archive: status, events, file, signed, signing, signingEnabled), null vóór de eerste PDF. canGenerate: gesloten vergadering, minstens één ondertekenaar, en geen nagekeken of getekende PDF. Ook voor reader
POST /meetings/{id}/minutes (board)maakt de notulen → 201 met de notulen: één rij per huidig agendapunt, de teksten leeg. Enkel op een gehouden of gesloten vergadering (409 meeting_not_held; de database DG041); bestaan ze al 409 minutes_exist (de database: één eerste notulen per vergadering, 23505). Idempotency-Key
PUT /meetings/{id}/minutes/sections/{key} (board)bewaart één deel → 200 met de notulen. key opening, remote (technische problemen of incidenten, E6.5), varia of closing met { text }, of de id van een huidig agendapunt met { discussion?, incidents? } (minstens één; enkel wat gegeven is verandert). Tekst getrimd, hoogstens 20.000 tekens (400); leeg is null. De database zet per deel wie en wanneer (*_updated_at, *_updated_by), in de audit-trail. Een agendapunt dat na het aanmaken op de agenda kwam, krijgt zijn rij bij het eerste bewaren. Geen notulen 404 minutes_not_found; een onbekend deel of een punt van een andere vergadering 404 minutes_section_not_found; de PDF niet meer in ontwerp (nagekeken, getekend, geannuleerd) 409 DG041 (ook de database; heropenen zet de PDF terug in ontwerp). Ook na het sluiten van de vergadering, zolang de PDF een ontwerp is. Een tenantId in de body wordt genegeerd
PUT /meetings/{id}/minutes/signers (board)wie tekent, in de volgorde van de handtekeningvakken (E6.4 PR 3, #182, migratie 0079): body [{ participantId?, name, role }], 1 tot 10 (anders 400) → 200 met de notulen. participantId is een deelnemer van deze vergadering (anders 422 signer_not_participant); zonder participantId mag het iemand anders zijn (een naam). De keuze wordt nooit geweigerd omdat ze van de regel van de statuten of de wet afwijkt (beslissing 114). Enkel zolang de tekst mag wijzigen (409 DG041; de database controleert ook de vorm, 23514)
POST /meetings/{id}/minutes/generate (board)maakt de PDF van de notulen (templates/documents/minutes-nl.fodt) → 200 met de notulen. De titel per soort vergadering, de feiten (datum, aanvang, plaats, een afwijkende datum met de reden), bij een AV de vergadering als geheel (verdaagde of tweede vergadering met de eerste, opnieuw beslist geschorst stemrecht, deelname op afstand, art. 6:75), de opening met eerst het bureau ("De vergadering wordt voorgezeten door …; secretaris: …; stemopnemers: …"), de aanwezigen met hun hoedanigheid en aanwezigheid ("vertegenwoordigd door … (volmacht)", "stemrecht uitgeoefend door … (aanwijzing, art. 6:21 WVV)") en bij een vennoot zijn aandelen en stemmen, de mede-houder, de wettelijke vertegenwoordiger, "Volmachtdrager niet aangemeld: telt niet mee" en "Stemrecht geschorst (art. …): reden", en de telling (bij een AV de aanwezige aandelen en stemmen en de zin over geschorst stemrecht, art. 6:78), de agenda, per punt de bespreking, de besluiten met uitkomst en stemmen (de kwijting per persoon als afzonderlijke stemming; met discharge_abstain_self "De eigen stemmen van … tellen niet mee bij deze stemming."), de waarschuwingen en de incidenten, varia met daaronder de vragen en hun antwoord, sluiting, per getekende aanwezigheidslijst "Bijlage: aanwezigheidslijst, getekend, opgeladen op dd/mm/jjjj, SHA-256 …" (een eigen getekend document, niet samengevoegd; zonder lijst geen bijlage en geen weigering), "Ondertekend volgens: … (wet, WVV 6:63)" of "(statuten, art. …)" met de melding van beslissing 114 zolang de regel niet bevestigd is, en een handtekeningvak met naam en hoedanigheid per gekozen ondertekenaar (met DocuSeal-tekstvelden {{Handtekening n;role=Bestuurder n;type=signature}} als de ondertekenintegratie aan staat; meeting_minutes.signature_tags). Een nieuw ontwerp in het archief (kind = minutes, meetingId en fiscalYear van de vergadering), of opnieuw in het huidige ontwerp (het bestand wordt vervangen); na cancel een nieuw archiefdocument. Enkel op een gesloten vergadering (409 meeting_not_closed: de besluiten liggen dan vast), met minstens één ondertekenaar (422 minutes_signers_missing); een nagekeken of getekende PDF niet (409 minutes_not_draft). Geen notulen 404 minutes_not_found. Idempotency-Key
POST /meetings/{id}/minutes/review · /reopen · /cancel · /signed (PDF) · /signing/send · /signing/refresh · /signing/cancel (board); GET /meetings/{id}/minutes/file.pdf · /signed.pdf · /signing-audit.pdf (board, reader)de stappen van het archiefdocument van de notulen, zoals onder /archive/{id}/… en de verslagen, binnen de module Vergaderingen (ook zonder de module Documenten); elke stap antwoordt met de notulen. review: de tekst, de ondertekenaars en de PDF liggen vast (DG041); reopen zet ze terug in ontwerp; cancel annuleert een ontwerp of nagekeken PDF (getekend: 409), de volgende generate maakt een nieuw document. signed: de getekende PDF als body (application/pdf), enkel op een nagekeken PDF. signing/send { signers: [{ userId } | { email }] }: per gekozen ondertekenaar, in dezelfde volgorde, een bestuurder van de coöperatie of een e-mailadres (de naam is die van de ondertekenaar); een ander aantal 422 minutes_signers_mismatch, een userId die geen bestuurder is 400 signer_not_board, zonder integratie 409 signing_not_enabled. Een PDF met tekstvelden gaat met de rollen Bestuurder n naar DocuSeal, een PDF zonder krijgt de velden onderaan de laatste bladzijde. Nog geen PDF: 404 minutes_not_generated
GET /meetings/{id}/convocationsde oproeping van een algemene vergadering (E5.1, #172, migratie 0080; features.md §5): { convocations, canCreate }, elke versie nieuwste eerst met { id, meetingId, version, status (draft|sent|cancelled), subject, body, proxyPreferredBy, archiveDocumentId, snapshot, sentAt, sentBy, postedOn, postedBy, postedRecordedAt, latePostedReason, createdAt, createdBy, updatedAt, updatedBy }. proxyPreferredBy: "Volmachten bij voorkeur vóór" (#402, beslissing 132.1, migratie 0095), een vraag op het volmachtformulier, nooit een termijn; ligt vast met de verzending. snapshot zet de database bij het verzenden: de vergadering (titel, datum, uur, plaats) en de agenda met de toelichting per punt zoals verstuurd, en per punt ruleChoice, de gekozen regel van een punt van de soort overig (migratie 0092, beslissing 129.2; een versie verzonden vóór 0092 heeft hem niet en de app neemt dan de huidige regel van het punt). awaitingPost: de ids van de verzonden versies die enkel per post vertrokken en nog geen datum "op de post gedaan op" hebben (beslissing 129.1): hun termijn telt pas vanaf die datum. canCreate: een geplande algemene of buitengewone algemene vergadering zonder ontwerp. Een vergadering van een andere coöperatie 404
POST /meetings/{id}/convocations (board){ subject?, body? } (getrimd, 1–200 en 1–20.000 tekens, anders 400) → 201 met de nieuwe versie als ontwerp; het versienummer (1, 2, …) zet de database. Enkel voor een geplande algemene of buitengewone algemene vergadering (409 DG046, ook de database; een raad van bestuur krijgt geen oproeping); een ontwerp bestaat al 409 DG047 (ook de database). Een verzonden oproeping verandert niet meer (DG045): een wijziging na verzending is een nieuwe versie. Verzenden en de verzendlijst: E5.3 (…/send); het verzendlog per vennoot: E5.4 (…/deliveries). Een tenantId in de body wordt genegeerd. Idempotency-Key
PUT /meetings/{id}/convocations/{convocationId} (board)de wizard, stap 2 (E5.2, #173): { subject, body, proxyPreferredBy? }, subject en body elk een tekst (getrimd, 1–200 en 1–20.000 tekens) of null om het veld leeg te maken; beide sleutels verplicht (anders 400) → 200 met de oproeping. proxyPreferredBy (#402, beslissing 132.1): een datum jjjj-mm-dd of null om leeg te maken, weggelaten blijft hij; op of vóór de vergaderdag, anders 422 proxy_preferred_after_meeting met params { proxyPreferredBy, heldOn } (het verzenden toetst opnieuw, de vergaderdag kan intussen verschoven zijn). De agenda en de toelichting per punt blijven bij PUT /meetings/{id}/agenda (E4); de procedure voor deelname op afstand en "ter inzage vanaf" bij PATCH /meetings/{id} (beslissing 125.7): een ontwerp heeft ze niet zelf (null), het verzenden kopieert die van de vergadering in de versie en bevriest ze. Enkel een ontwerp: verzonden of geannuleerd 409 DG045 (ook de database). Een oproeping van een andere vergadering of coöperatie 404; een tenantId in de body wordt genegeerd documentsDelivery? (beslissing 134.3, #469, migratie 0101): de stukken met de e-mail, none (niet meesturen, ter inzage op de zetel; standaard), attachment (als PDF in bijlage), link (een persoonlijke downloadlink in de e-mail) of portal (nog niet beschikbaar: 422 portal_not_available); weggelaten blijft hij. Per post gaan de stukken nooit mee. Ligt vast met de verzending (DG045, ook de database).
GET /meetings/{id}/convocations/{convocationId}/checkde wizard, stap 3 (E5.2): de controle vóór het verzenden, op ?sendOn=jjjj-mm-dd (standaard vandaag; vóór vandaag 400). deadline: { heldOn, sendOn, termFrom, periodDays, recordedDays, source (statutes|law|manual), article, floorApplied, floorBasis, lastSendOn, daysBefore, inTime }; termFrom { version, sentOn }: de eerste verzonden versie waarvan de dag voor de termijn telt zolang de vergaderdag, het uur, de plaats en de agenda (met de regels) dezelfde blijven (beslissing 125.22; bij een versie enkel per post de datum "op de post gedaan op", beslissing 128.11), anders null; daysBefore en inTime rekenen vanaf termFrom.sentOn, anders vanaf sendOn: de oproepingstermijn uit de statuten (of een eigen keuze) die gelden op de verzenddag, anders 15 dagen (art. 6:70 §1); een kortere statutaire termijn wordt getoond maar de app past 15 dagen toe (floorApplied, beslissingen 66 en 77); lastSendOn = vergaderdag min de termijn (06/06/2027 → 22/05/2027, beslissing 100). statutoryDeadlines: bij een gewone AV de termijnen van agm_deadlines (106.2) teruggerekend vanaf de vergaderdag, met passed als de dag vóór de verzenddag ligt (enkel informatie). recipients: elke vennoot van het register op de verzenddag, op vennootnummer, met channel (email enkel met de keuze e-mail, een adres en de datum van de vraag, features.md §1; een adres dat het bestuur met die keuze vastlegde vraagt geen verificatie, de verificatie van beslissing 14 hoort bij het portaal, E8.4; anders post met straat en gemeente; anders unreachable, beslissing 5), emailIssue (missing|no_date), het e-mail- of postadres, en copy aan de mede-houder enkel per e-mail naar een eigen adres terwijl de vennoot per e-mail opgeroepen wordt (beslissing 73; anders noCopyReason), en coHolderName: de mede-houder van de vennoot, met of zonder kopie (#402: de vennoot krijgt dan ook de aanwijzing stemrecht, beslissing 132.6). Een contactpersoon (rol partner) krijgt niets. counts: { email, post, unreachable, copies }. documents: de verslagen en andere stukken van de vergadering die niet geannuleerd zijn (art. 6:82), met hun status. inspection { from, latest, late, replyTo }: "ter inzage vanaf" van de vergadering (anders latest, vijftien dagen vóór de vergadering) en het antwoordadres (reply-to, anders het afzenderadres); replyTo: null blokkeert niet (beslissing 125.6): de wizard waarschuwt en de zin over de inzage eindigt bij de zetel. missing: subject, body, agenda, remoteProcedure (de vergadering laat deelname op afstand toe zonder procedure), inspectionFrom (later dan vijftien dagen vóór de vergadering). amendmentsWithoutExplanation: de punten van de soort statutenwijziging, doelwijziging of ontbinding (beslissing 125.8), of overig met de regel van een statutenwijziging, zonder toelichting, als { title, kind } met kind amendment, object_change of dissolution: per soort een eigen waarschuwing (beslissing 126.2). previousLateReason: de reden om in te vullen in het venster van het verzenden (beslissingen 128.3, 129.4 en 130.4): zolang deze versie de ononderbroken reeks van de nieuwste verzonden versie voortzet, de recentste reden in die reeks, gegeven bij het verzenden of bij een te late postdatum; anders null. proxyPreferredBy { date, beforeSendOn, afterMeeting } (#402, beslissing 132.1; null zonder datum): "volmachten bij voorkeur vóór" met twee waarschuwingen, de datum ligt vóór de verzenddag (beforeSendOn), of na de vergaderdag omdat de vergadering sindsdien verschoof (afterMeeting; het verzenden weigert dat met 422 proxy_preferred_after_meeting). Niets ervan blokkeert hier; dat beslist het verzenden (E5.3) Per stuk ook kind (de soort in het archief: of ze een handtekening vraagt, S14) en hasFile (er is een PDF, de getekende als die er is) en sizeBytes (een bestand van vóór F2 zonder rij in documents: zijn echte grootte op het volume; ontbreekt het, dan telt het als stuk zonder PDF). documentsDelivery { choice, withoutFile, documentsBytes, estimatedMailBytes, limitBytes, tooLarge, linkExpiresOn, portalAvailable } (beslissing 134.3, #469): withoutFile de titels van stukken zonder PDF (het verzenden weigert ze bij attachment en link), estimatedMailBytes de grootste e-mail met de stukken als bijlage (oproeping en formulieren van #402 meegerekend, base64), limitBytes 10 MB (Postmark), tooLarge als die erboven gaat: de wizard zegt het en stelt de link voor; linkExpiresOn de dag na de vergadering (de link werkt tot en met de vergaderdag); previousVersionLink: de laatste verstuurde versie vóór deze stuurde de stukken als link mee (beslissing 138, L2): stuurt deze versie geen stukken mee, dan waarschuwt de wizard dat die links vervallen (blokkeert niet).
POST /meetings/{id}/convocations/{convocationId}/send (board)"Versturen" (E5.3, #174; features.md §5; beslissingen 4, 5, 36, 73), in één transactie: de controle opnieuw (zonder onderwerp, tekst of agendapunt 422 convocation_incomplete met params.missing; na de laatste verzenddag enkel met { acknowledgeLate: true }, anders 409 convocation_late met params { lastSendOn, heldOn }, en met een reden lateReason (E5.8, beslissing 121.4; 1–2000 tekens, anders 422 late_reason_required met params.periodDays): de app beslist niet wat een te late oproeping betekent; de reden ligt vast met de verzending (convocation.lateReason, in de audit-trail) en staat in het dossier als waarschuwing late_convocation { kind (sent|posted), version, sentVersions, counts, sentOn, daysBefore, lastSendOn, reason, awaitingPost } (de lange vorm van beslissing 125.12; awaitingPost: een versie enkel per post zonder postdatum, de termijn telt nog niet, beslissing 129.1; één per te late dag van elke verzonden versie, ook een die niet voor de termijn telt, met counts bij de versie die telt als er meer dan één verzonden is, beslissing 130.6); binnen de termijn wordt geen reden bewaard), de status sent (de database zet wie en wanneer en bevriest vergadering en agenda in snapshot), de verzendlijst (convocation_deliveries: één rij per vennoot van het register op de verzenddag langs het kanaal van de controle, plus de kopie aan de mede-houder; naam en adres bevroren; onbereikbaar blokkeert niet), de brieven in het archief (soort convocation, gekoppeld aan de vergadering, nagekeken en onder Object Lock: de oproeping "Aan de vennoten", ernaast één leeg voorbeeld van het volmachtformulier en, als een vennoot een mede-houder heeft, van de aanwijzing stemrecht (#402, beslissing 132.2; titels "… · volmachtformulier (leeg voorbeeld)" en "… · aanwijzing stemrecht (leeg voorbeeld)"), en bij post één PDF met een brief per vennoot met het adres in het venster, direct gevolgd door zijn formulieren), per e-mail-ontvanger één bericht in de outbox (sjabloon convocation-v2-nl sinds beslissing 150, bijlagen de oproeping en de formulieren van de vennoot: aanwijzing-stemrecht-{datum}-v{n}.pdf bij een mede-houder en volmacht-{datum}-v{n}.pdf, met enkel de naam, beslissing 121.2; ook bij de kopie aan de mede-houder). Statuten zonder volmachten (proxy_allowed uit): geen volmachtformulier, wel de aanwijzing. De formulieren liggen vast met de brief (letter.forms): een nasturing per post (…/deliveries/{id}/resend, 121.13) krijgt dezelfde, met de mede-houder van de verzenddag. proxyPreferredBy na de vergaderdag 422 proxy_preferred_after_meeting, en bij een gewone AV de stap "oproeping verstuurd" van de jaarcyclus (convocation.sent). Al verzonden of geannuleerd 409 DG045. Antwoord zoals GET …/send. Idempotency-Key; een tenantId in de body telt niet De stukken (beslissing 134.3, #469): met documentsDelivery attachment gaan de PDF's van de stukken (getekend als die er is) mee als bijlage na de formulieren, met in de zin over de inzage "U vindt ze ook als bijlage bij deze oproeping."; boven 10 MB voor één e-mail 422 documents_too_large met params { bytes, limitBytes }. Met link krijgt elke e-mail (ook de kopie aan de mede-houder) een eigen link (convocation_document_links: enkel de SHA-256 van het token, de stukken bevroren, vervalt de dag na de vergadering) en de zin "U kunt ze ook downloaden via de persoonlijke link in de e-mail met deze oproeping, tot en met {datum}." (legal-check); het sjabloon toont de knop "De stukken downloaden". Een stuk zonder PDF 422 documents_without_file met params.titles (enkel als de stukken meegaan: met none vertrekt de oproeping, de stukken liggen ter inzage); portal 422 portal_not_available. Ontwerpen en niet getekende stukken (status draft, of reviewed van een soort die een handtekening vraagt) gaan mee, maar nooit stil (beslissing 138.8, #498). Of een soort een handtekening vraagt, staat op één plaats (ARCHIVE_DOCUMENT_KIND_REQUIRES_SIGNATURE in packages/shared/src/enums.ts, S14): report, convocation, minutes en attendance_list wel, annual_accounts (de jaarrekening, beslissing 146.6) en other (een toelichting, een presentatie) niet; een onbekende soort telt als "vraagt een handtekening". Een nagekeken stuk van een soort zonder handtekening is af en gaat zonder bevestiging mee. De andere: met bijlage of link enkel met acknowledgeUnfinishedDocuments: [{ id, status }, …] dat elk van die stukken noemt met zijn huidige status (de bevestiging "Deze stukken zijn nog een ontwerp of niet getekend: … Toch meesturen?"), anders 409 documents_unfinished met params.documents [{ id, title, status }] (ook als een stuk sinds de controle een ontwerp werd, of van nagekeken terug naar ontwerp ging: een bevestiging geldt enkel voor de status die ze noemde; enkel ids 400); een stuk zonder PDF weigert eerst (422). De bevestiging ligt vast met de brief (letter.documents.unfinished { documents [{ archiveDocumentId, title, status }], confirmedBy, confirmedAt }, onveranderlijk zoals de rest van een verzonden versie, DG045, en in de audit-trail); geen migratie. De testmail vraagt geen bevestiging (enkel naar de bestuurder zelf). De brief per post en een nasturing per post zeggen enkel dat de stukken ter inzage liggen; een nasturing krijgt dezelfde stukken als de verzending (letter.documents).
POST /meetings/{id}/convocations/{convocationId}/test-mail (board)"Testmail versturen" (beslissing 134.4, #470): { memberId? } → 202 { to, memberId, memberName, subject }. De e-mail die die vennoot zou krijgen als het ontwerp vandaag vertrok, met dezelfde bijlagen (de oproeping met enkel de naam en de formulieren van de vennoot, #402), maar naar het e-mailadres van de bestuurder die het vraagt en naar niemand anders (de body heeft geen veld voor een adres); het onderwerp begint met [TEST] . Zonder memberId de eerste vennoot per e-mail van de lijst; een vennoot per post, onbereikbaar of niet op de lijst 422 test_mail_member, niemand per e-mail 422 no_email_recipients, een account zonder e-mailadres (API-sleutel) 422 test_mail_no_address. Langs dezelfde weg als de echte e-mail: de outbox (mail.convocation) en het eigen afzenderdomein van de coöperatie of het platform. Geen verzending in de verzendlog, geen document in het archief, geen statuswijziging; de bijlagen liggen als kortstondige bestanden (soort test_mail, klasse ephemeral, 7 dagen). Een regel in de audit-trail (read op convocations, { id, testMail: convocation, memberId }; wie en wanneer zijn de kolommen van de trail; nooit een rijksregisternummer of adres). Enkel een ontwerp (409 DG045); onvolledig 422 convocation_incomplete. Profiel testMail: 5 per minuut per bestuurder (de aangemelde gebruiker, ook vanaf een ander adres; UserRateLimitGuard) én 5 per minuut per client-adres, elk per route. Idempotency-Key; een tenantId in de body telt niet Met de stukken zoals bij het verzenden (beslissing 134.3, #469): dezelfde bijlagen en dezelfde grens van 10 MB, of een eigen testlink (test, zonder verzending) die hoogstens 7 dagen werkt en nooit langer dan tot en met de vergaderdag (beslissing 138.15; de e-mail noemt die dag; ook de database, DG067).
GET /meetings/{id}/convocations/{convocationId}/sendwat vertrok (E5.3): { convocation, sentByName, postedByName, lastSendOn, counts { email, post, unreachable, copies }, emails { queued, accepted, delivered, bounced, failed }, lettersDocumentId, unfinishedDocuments }; unfinishedDocuments { documents [{ id, title, status }], confirmedByName, confirmedAt }: de ontwerpen en niet getekende stukken die meegingen en wie dat bevestigde (beslissing 138.8), anders null; lastSendOn: de laatste verzenddag van deze versie (vergaderdag min de termijn), waarmee de wizard een te late postdatum herkent. emails per e-mail de laatste stand: in de outbox, aanvaard door Postmark (accepted, met de message-id), bezorgd of gebounced (webhook), mislukt (de outbox gaf op; sinds E5.4 ook een gebeurtenis failed op de verzending). Niet verzonden 404
GET /meetings/{id}/convocations/{convocationId}/send/postlist.pdfde postlijst (beslissing 4; templates/documents/convocation-postlist-nl.fodt): de bevroren brieven per post op vennootnummer (naam, adres, "nagestuurd" voor een latere herzending), de versie van de oproeping, de PDF van de brieven met haar SHA-256, wie verstuurde en "op de post gedaan op … door …" zodra ingevoerd. Met een leesregel in de audit-trail (convocations, file: postlist). Lezers ook
GET /meetings/{id}/convocations/{convocationId}/send/letters.pdf?print=duplex|simplexde brieven per post als één PDF om af te drukken, een brief per vennoot op een eigen blad, direct gevolgd door zijn formulieren (#402, beslissing 132.2: bij een mede-houder de aanwijzing stemrecht, dan het volmachtformulier); een leesregel in de audit-trail. Elke vennoot begint op een nieuw blad (beslissing 133.11): een even aantal bladzijden per vennoot, met een lege bladzijde achteraan waar nodig (gemarkeerd in de PDF). print=duplex (standaard, "recto-verso"): de PDF zoals bewaard bij het verzenden (samengevoegd met pdfunite, dezelfde SHA-256 als in het archief). print=simplex ("enkelzijdig, zonder lege bladzijden"): afgeleid bij het downloaden zonder de lege bladzijden (pdf-lib), bestandsnaam met -enkelzijdig; het archief verandert niet. Een PDF van vóór 133.11 heeft geen lege bladzijden en komt in beide gevallen terug zoals hij is. Een andere waarde 400. Zonder brieven per post 404. Lezers ook
POST /meetings/{id}/convocations/{convocationId}/posted (board)"op de post gedaan op" (beslissing 4): { postedOn, postedByName?, latePostedReason? }, één keer; wie het invoerde en wanneer zet de database. postedByName "gepost door" (E5.8, beslissing 121.5; 1–200 tekens, geen regeleinde): wie de brieven naar de post bracht; leeg is het de gebruiker die het invoert (postedByName in het antwoord blijft die gebruiker, convocation.postedByName de opgegeven naam). Een postdatum die verschilt van de dag waarop de e-mails vertrokken is geen fout: de app waarschuwt met art. 2:32 WVV. Een postdatum na de laatste verzenddag (art. 6:70 §1) wordt nooit geweigerd, maar vraagt een reden latePostedReason (beslissing 129.3; 1–2000 tekens, anders 422 late_posting_reason_required met params.lastSendOn); de reden ligt vast met de postdatum (convocation.latePostedReason, migratie 0092, DG045), staat in de audit-trail en in het dossier als waarschuwing late_convocation met de postdatum; binnen de termijn wordt geen reden bewaard. Niet vóór de verzenddag en niet in de toekomst (409 DG045, de database), al ingevuld 409 DG045, geen brieven per post 422 no_letters. Voegt per brief van de verzendlijst een gebeurtenis posted toe. Antwoord zoals GET …/send. Idempotency-Key
GET /meetings/{id}/convocations/{convocationId}/deliverieshet verzendlog (E5.4, #175; features.md §5; beslissingen 4, 5, 73): { convocation, sentByName, postedByName, lastSendOn, unfinishedDocuments, counts, rows } (lastSendOn en unfinishedDocuments zoals bij GET …/send). Per verzending (op vennootnummer, de kopie na de vennoot, een nasturing erbij): { id, memberId, memberNumber, recipient (member|co_holder), recipientName, channel, email, address, status, bounceType, bounce (hard|soft), createdAt, lastEventAt, resendsId, resentBy { id, status }, needsAttention, canResend, addressReturned, events [{ kind, occurredAt, bounceType, postedOn, recordedByName }] }; naam en e-mail- of postadres zoals bevroren, niets meer. status volgt uit de gebeurtenissen: een e-mail queued (in de outbox), accepted, dan het laatste woord van Postmark of de outbox (delivered, bounced, failed); een brief queued tot posted, returned als hij terugkwam (E5.8); unreachable. Hard bounce: Postmark-type HardBounce, BadEmailAddress of ManuallyDeactivated, de rest soft (enkel een label). Geen "geopend": open-tracking staat uit. needsAttention: de eigen oproeping van de vennoot is gebounced, mislukt, onbereikbaar of teruggekeerd (ook een nasturing die terugkwam) en nog niet nagestuurd; canResend: daarbij heeft de vennoot nu een postadres zonder de markering "post teruggekeerd" (addressReturned), en na een teruggekeerde brief een ander adres dan dat van die brief (beslissing 121.15: één keer naar hetzelfde adres). counts { email, queued, accepted, delivered, bounced, failed, post, posted, returned, resent, unreachable, attention } over de hele lijst; ?channel=email|post|unreachable en ?status=<status>|attention filteren rows (een andere waarde 400). Niet verzonden 404. Lezers ook. rows en counts zijn de lijst van de oproeping zelf; de berichten erna (#401, beslissing 121.9) staan in messages (oudste eerst): { id, convocationId, kind (cancellation|correction|none), subject, intro (de vaste zin; null bij none), body, reason, sentAt, sentByName, postedOn, postedByName, postedRecordedByName, postedRecordedAt, counts { email, post, unreachable, copies, resent, attention }, emails { queued, accepted, delivered, bounced, failed }, hasLetters, rows } met rows zoals hierboven, met dezelfde opvolging als de rijen van de oproeping: needsAttention, canResend, de nasturingen van het bericht (resendsId) en "post teruggekeerd"; counts telt de rijen van de lijst per kanaal (nasturingen apart in resent). canSendMessage: een rechtzetting kan nu (de vergadering is gepland en dit is de laatst verstuurde versie). cancellationPreview { subject, intro, email, post } (bij canSendMessage, anders null): de annulering zoals ze nu zou vertrekken, met hoeveel ontvangers per e-mail (de kopieën inbegrepen) en per post; het venster om te annuleren toont ze Per rij van de oproeping documentLink (beslissing 134.3, #469; anders null): { id, status (active|expired|revoked|superseded|cancelled), revokedCause (revoked|resent_by_post|superseded|cancelled), expiresOn, revokedAt, revokedByName, revokeReason, downloads [{ title, at }] }, elke download van de link. Waarom een link niet meer werkt (beslissing 138.5, 138.14; #497; migratie 0106): revoked ingetrokken door het bestuur (revokedCause: revoked, met de reden) of door een nasturing per post (resent_by_post: de database trekt bij het nasturen de links van de eigen e-mails van die vennoot voor die vergadering in, van alle versies); superseded een nieuwe versie: de link van een vorige versie vervalt voor een ontvanger (de vennoot of de kopie aan de mede-houder) zodra zijn nieuwe e-mail vertrekt (de provider aanvaardt ze), en bij het verzenden voor wie van de nieuwe versie geen e-mail krijgt (per post, onbereikbaar, niet meer op de lijst, geen kopie meer); cancelled de vergadering is geannuleerd (alle links, ook testlinks, met of zonder bericht); expired na expiresOn. revokedAt en revokedByName: wanneer en door wie (bij een nieuwe versie: wie die verstuurde). Een rechtzetting laat de links; verschuift de vergaderdag, dan schuift expiresOn van elke link die nog werkt mee (de dag na de nieuwe vergaderdag; een testlink blijft binnen zijn 7 dagen).
POST /meetings/{id}/convocations/{convocationId}/messages (board)"Bericht versturen" (#401, beslissing 121.9): { kind: correction, body } (1–5000 tekens) → 201 zoals GET …/deliveries. Naar dezelfde ontvangers langs dezelfde kanalen, uit de bevroren verzendlijst van de versie, nooit uit het register van vandaag: een e-mail naar het adres van toen (en de kopie aan de mede-houder), een brief naar het adres van de laatste nasturing per post of anders van de brief, een onbereikbare vennoot als rij unreachable. In één transactie: het bericht (convocation_messages, wie en wanneer zet de database; daarna onveranderlijk), de verzendingen in dezelfde lijst (message_id), het bericht "Aan de vennoten" en de brieven per post in het archief (soort convocation van de vergadering, onder Object Lock; sjabloon letter-nl), per e-mail-ontvanger één bericht in de outbox (mail.convocation_message, sjabloon convocation-message-nl, de PDF met enkel de naam als bijlage, beslissing 121.2). Onderwerp "Rechtzetting bij de oproeping: {titel} van {datum}", vaste zin 'Bij de oproeping van {verzenddatum} voor de vergadering "{titel}" van {datum}:' (voorstel, legal-check); de vergadering heet zoals in de oproeping, met haar titel zoals verstuurd (beslissing 131.1), ook in de titel van de documenten in het archief. De verstuurde oproeping blijft onveranderd; een andere datum, uur, plaats of agenda is een nieuwe oproeping met een nieuwe termijn. Enkel zolang de vergadering gepland is (409 meeting_not_planned) en voor de laatst verstuurde versie (409 DG053); niet verstuurd 404. Een annulering gaat mee met POST /meetings/{id}/transition (onderwerp "Annulering: {titel} van {datum}", vaste zin 'De vergadering "{titel}" van {dag datum} om {uur}, waarvoor u op {verzenddatum} werd opgeroepen, gaat niet door.'). Bij de commit vraagt de database een volledig bericht: de brief "Aan de vennoten" (het eigen bestand van het bericht, nooit een document van de oproeping) en één verzending per verzending van de eigen lijst van de oproeping (DG053); elke e-mail heeft een eigen bericht in de outbox (DG054). Idempotency-Key
POST /meetings/{id}/convocations/{convocationId}/messages/test-mail (board)De testmail van een bericht (beslissing 134.4, #470, #448): { kind: correction, body, memberId? } of { kind: cancellation, body?, memberId? } → 202 zoals …/test-mail. Het bericht zoals die vennoot het vandaag zou krijgen (sjabloon convocation-message-nl, dezelfde PDF met enkel de naam), naar de eigen bestuurder, [TEST] voor het onderwerp. De vennoten uit de bevroren lijst van de oproeping die ze per e-mail kregen (zonder nasturing per post); standaard de eerste. Geen bericht, geen verzending, geen archief; de vergadering blijft gepland; een regel in de audit-trail (read op convocation_messages, { id, testMail: correction|cancellation, memberId }). Zoals het bericht zelf: de laatst verstuurde versie (409 DG053; niet verstuurd 404), de vergadering gepland (409 meeting_not_planned). Profiel testMail, per bestuurder en per client-adres, elk per route (eigen teller naast die van de oproeping). Idempotency-Key
POST /meetings/{id}/convocations/{convocationId}/messages/{messageId}/posted (board)"op de post gedaan op … door …" van de brieven van een bericht: { postedOn, postedByName? }, één keer (409 DG053), niet vóór de dag van het bericht en niet in de toekomst (409 DG053, de database); een gebeurtenis posted per brief van de lijst (een nasturing heeft haar eigen postdatum, POST …/deliveries/{deliveryId}/posted). Zonder brieven per post in de lijst 422 no_letters (de database: DG053). Antwoord zoals GET …/deliveries. Idempotency-Key
GET /meetings/{id}/convocations/{convocationId}/messages/{messageId}/letters.pdf?print=duplex|simplex (board)de brieven per post van een bericht (annulering of rechtzetting) als één PDF om te printen. Zoals bij de oproeping (beslissing 133.11, #496) begint elke vennoot op een nieuw blad: een even aantal bladzijden per brief, met een gemarkeerde lege bladzijde achteraan waar nodig. print=duplex (standaard, "recto-verso"): de PDF zoals bewaard bij het verzenden. print=simplex ("enkelzijdig, zonder lege bladzijden"): afgeleid bij het downloaden zonder de lege bladzijden, bestandsnaam met -enkelzijdig; het archief verandert niet. Een bericht van vóór #496 heeft geen lege bladzijden en komt in beide gevallen terug zoals het is. Een andere waarde 400; zonder brieven 404
GET /meetings/{id}/convocations/{convocationId}/deliveries.csvhet verzendlog als CSV (puntkomma, dd/mm/jjjj, UTF-8 met BOM, zoals de andere exports), met dezelfde filters: nr., ontvanger, rol, kanaal, e-mailadres, adres, status, bouncetype, aangemaakt, laatste gebeurtenis, op de post gedaan, nasturing, bericht (leeg voor de rijen van de oproeping zelf; anders "Annulering|Rechtzetting van {dag}", #401: de rijen van de berichten staan na die van de oproeping, met dezelfde filters). Met een leesregel in de audit-trail (convocation_deliveries, { convocationId, file: csv, rows, filters }). Lezers ook
POST /meetings/{id}/convocations/{convocationId}/deliveries/{deliveryId}/resend (board)"Per post nasturen" (E5.4): een gebouncede of mislukte e-mail, of een onbereikbare vennoot die nu een adres heeft, krijgt een brief per post: een nieuwe verzending (channel post, resendsId, het huidige adres van de vennoot bevroren, de naam zoals bij het verzenden), de brief met het adres in het venster in het archief (soort convocation van de vergadering, "… · nagestuurd per post (nr. …)"; de periodezin telt vanaf vandaag, de stukken ter inzage zoals nu), en "nagestuurd" op de postlijst. Eén keer per verzending; niet voor een e-mail die vertrok of bezorgd is, een brief, de kopie aan de mede-houder of een nasturing (409 DG049), behalve een brief of nasturing die terugkwam (E5.8, beslissing 121.15); zonder postadres 422 no_address (eerst het contact aanvullen); een postadres met de markering "post teruggekeerd", of na een teruggekeerde brief nog hetzelfde adres, 422 post_returned (eerst het adres wijzigen), tenzij met { confirmAddress: true, reason }: de uitdrukkelijke bevestiging "dit adres is juist" met een reden (1–2000 tekens, beslissing 125.13), die met de nieuwe verzending en met de wijziging van de vennoot in de audit-trail staat ("Adres bevestigd als juist na teruggekeerde post: {reden}"); de markering op het adres verdwijnt (beslissing 127.2, app.confirm_member_address, migratie 0091) en de vennoot is weer bereikbaar per post; komt de brief opnieuw terug, dan wordt het adres opnieuw gemarkeerd. Per rij returnedAtAddress: hoe vaak post naar het huidige adres al terugkwam (400 voor een bevestiging zonder reden of omgekeerd). De begeleidende regel (cover van convocation-nl, met de datum van vandaag) volgt het eerste kanaal van de reeks: na een e-mail die niet bezorgd kon worden de zin van beslissing 121.13, voor een vennoot die toen onbereikbaar was die van 125.2; na een brief van de lijst die terugkwam de zin van 125.10, naar een ander adres ("Deze oproeping werd op {datum} per post verstuurd. Omdat die brief teruggekomen is, ontvangt u ze hierbij opnieuw per post op het adres dat sindsdien gewijzigd is.") of, na confirmAddress, naar hetzelfde adres zonder de laatste woorden ("… opnieuw per post.", 125.13); een nagestuurde brief die terugkwam, houdt de regel van het begin van de reeks (133.13). Langs de jurist (#298). Antwoord zoals GET …/deliveries (zonder filters). Een rij van een bericht na de oproeping (#401) volgt dezelfde regels: de nasturing is een rij van hetzelfde bericht (message_id, resendsId), de brief is het bericht zelf (zijn dag, vaste zin en tekst; sjabloon letter-nl) met het huidige adres in het venster, in het archief als "Oproeping {titel} · versie {n} · annulering|rechtzetting · nagestuurd per post (nr. …)", niet op de postlijst. Erboven één begeleidende regel met de datum van vandaag (beslissing 132.11, cover van letter-nl), volgens het eerste kanaal van de rij: na een e-mail die niet bezorgd kon worden, of voor een vennoot die toen onbereikbaar was; na een brief die met het bericht zelf per post vertrok (eerste verzending per post) en terugkwam, de zin van beslissing 133.12, naar een ander adres ("… opnieuw per post op het adres dat sindsdien gewijzigd is.") of, na confirmAddress, naar hetzelfde adres ("… opnieuw per post."); een nagestuurde brief die terugkwam, houdt de regel van het begin van de reeks (e-mail, onbereikbaar of brief van het bericht; 133.13). Idempotency-Key; een tenantId in de body telt niet
POST /meetings/{id}/convocations/{convocationId}/deliveries/{deliveryId}/document-link/revoke (board)"Link intrekken" (beslissing 134.3, #469): { reason? } (hoogstens 2000 tekens) → 200 zoals GET …/deliveries. De downloadlink van die e-mail werkt meteen niet meer (410); wie en wanneer zet de database, met de reden in de audit-trail. Geen link 404; al ingetrokken of vervallen (nieuwe versie, annulering, nasturing per post) 409 DG061 (ook de database). Er komt geen nieuwe link. Idempotency-Key; een tenantId in de body telt niet
POST /meetings/{id}/convocations/{convocationId}/posted/proof (board)het afgiftebewijs van de post (E5.8, beslissing 121.5), optioneel: de PDF als body (Content-Type: application/pdf), of een foto ervan (image/jpeg of image/png, hoogstens 15 MB, een png hoogstens 16 megapixels en een jpg 50, beslissing 125.14) die de API omzet naar één A4-pagina PDF in een worker thread met een tijdslimiet van 10 s en een geheugenlimiet; een png wordt eerst volledig nagekeken (CRC per chunk, beeldgegevens precies zo groot als de header zegt) (rechtop volgens de EXIF-oriëntatie; het archief bewaart de SHA-256 van die PDF; 400 proof_not_image als de bytes geen jpg of png zijn, 400 proof_image_too_large, 413 boven 15 MB), één keer, pas na de postdatum (409 DG052, ook de database). Een archiefstuk van soort convocation van de vergadering ("… · afgiftebewijs post"), nagekeken en onder Object Lock zoals de brieven; convocation.postedProofDocumentId; de postlijst noemt zijn SHA-256. Geen PDF 400 not_pdf. Antwoord zoals GET …/send
GET /meetings/{id}/convocations/{convocationId}/posted/proof.pdfhet afgiftebewijs zoals opgeladen, met een leesregel in de audit-trail; zonder 404. Lezers ook
POST /meetings/{id}/convocations/{convocationId}/deliveries/{deliveryId}/returned (board)"post teruggekeerd" (E5.8, beslissing 121.15): een brief per post die op de post ging, kwam terug. Eén keer, als gebeurtenis returned (een brief die nog niet gepost is, een e-mail of een tweede keer 409 DG051, ook de database). De database markeert het postadres van de vennoot (members.post_returned_at, door wie) als het nog het adres van de brief is: de vennoot is per post onbereikbaar (de controle van een volgende oproeping zet hem op unreachable met addressIssue: post_returned) tot het adres wijzigt; dan vervalt de markering vanzelf. De brief vraagt opnieuw aandacht; nasturen enkel naar een ander adres (422 post_returned). Ook voor een brief van een bericht na de oproeping (#401). Antwoord zoals GET …/deliveries. Idempotency-Key
POST /meetings/{id}/convocations/{convocationId}/deliveries/{deliveryId}/posted (board)de dag dat een nagestuurde brief op de post ging: { postedOn }, één keer, als gebeurtenis posted met wie het invoerde (niet vóór de dag van de nasturing, 409 DG049; niet vóór de verzenddag en niet in de toekomst, 409 DG048). Enkel voor een nasturing; de brieven van de lijst gaan samen via …/posted (409 DG049). Antwoord zoals GET …/deliveries. Idempotency-Key
GET /meetings/{id}/convocations/{convocationId}/deliveries/{deliveryId}/letter.pdfde brief van een nasturing per post zoals bewaard (van de oproeping, of van een bericht erna: annulering|rechtzetting-{datum}-nagestuurd-….pdf), met een leesregel in de audit-trail (zoals het archief); een andere verzending 404. Lezers ook
GET /meetings/{id}/convocations/{convocationId}/preview.pdf"Voorbeeld van de brief" (E5.2): de oproeping (templates/documents/convocation-nl.fodt, met de huisstijl) voor één vennoot van de lijst (?memberId=; niet in het register op de verzenddag 422 unknown_member), anders de eerste per post, anders de eerste; met ?sendOn= zoals check. Wordt niet bewaard; draagt "VOORBEELD – niet verstuurd. Tekst na te kijken (legal-check)". Inhoud: adres in het venster (per post), onderwerp, de vaste openingszin en de tekst van het bestuur, datum, uur, plaats en deelname op afstand, de agenda met de toelichting per punt, de regels voor volmachten uit de statuten of de wet en daarna de zinnen over de formulieren in bijlage (#402, beslissing 132.7; bij een mede-houder de variant met de aanwijzing), de stukken ter inzage (art. 6:82). De formulieren zelf zitten niet in dit voorbeeld. Een GET zoals de aanwezigheidslijst: lezers en een vervallen module lezen hem ook
GET /meetings/{id}/documents/linkable (board)archiefstukken die nog bij geen vergadering horen en niet geannuleerd zijn, nieuwste eerst (hoogstens 200), in de vorm van documents
PUT /meetings/{id}/documents/{documentId} (board)koppelt een archiefstuk (ontwerp, nagekeken of getekend) aan de vergadering → 200 met het dossier; ook na het sluiten (plan §2), niet bij een geannuleerde vergadering (409 meeting_cancelled). Opnieuw koppelen verandert niets. Een stuk van een andere vergadering 409 document_linked_elsewhere (eerst daar ontkoppelen), een geannuleerd stuk 422 document_cancelled, een stuk dat niet in het archief van deze coöperatie staat 422 unknown_document. Migratie 0062: een nagekeken of getekend stuk wijzigt enkel meeting_id, verder blijft het zoals 0045 zegt
DELETE /meetings/{id}/documents/{documentId} (board)ontkoppelt een stuk → 200 met het dossier; niet na het sluiten (409 DG027, ook in de database en via PATCH /archive/{id}); een stuk dat niet bij deze vergadering hoort 404
POST /meetings/{id}/documents/attendance-list (board)de getekende aanwezigheidslijst als PDF (Content-Type: application/pdf) → 201 met het dossier: een archiefstuk van soort attendance_list ("Getekende aanwezigheidslijst …", boekjaar en vergadering), door de stappen van het archief in één transactie (ontwerp met het bestand, nagekeken, getekend door opladen; elk in status_transitions). Enkel bij een AV of buitengewone AV (422 attendance_list_general_meeting_only) die gehouden of gesloten is (409 attendance_list_before_meeting); ook na het sluiten. Geen PDF 400 not_pdf
GET /meetings/{id}/documents/{documentId}/file.pdf · …/signed.pdfhet stuk zoals opgemaakt of getekend, met een leesregel in de audit-trail (zoals het archief); 404 als het niet bij deze vergadering hoort. Ook voor reader

De persoonlijke link in een e-mail met de oproeping (beslissing 134.3, #469). Buiten /api/t/{slug} en zonder sessie: de coöperatie volgt uit het token (app.document_link_tenant, SECURITY DEFINER, enkel de SHA-256 van het token), daarna gaat alles in één transactie met app.tenant_id langs RLS. Profiel publicDownload (30 per minuut per client-adres). Cache-Control: no-store, Referrer-Policy: no-referrer, X-Robots-Tag: noindex; het token staat niet in het log van de API (enkel het routepatroon). Wel leesbaar: in de e-mail zelf (en dus bij Postmark), in outbox.payload (mail.model.documentsUrl) zolang het bericht wacht of opnieuw geprobeerd wordt (tot ongeveer twee dagen; verstuurd: geen payload meer, opgegeven: de link wordt uit de payload gehaald), en in het toegangslog van de edge-Caddy, dat de volledige URL bewaart (apart issue; vragenlijst S9).

RouteWat
GET /api/public/meeting-documents/{token}met Accept: application/json { cooperative, meetingTitle, heldOn, validUntil, documents [{ id, title, fileName, sizeBytes }] }, anders een eenvoudige Nederlandstalige pagina met een download per stuk. Enkel de stukken van die vergadering zoals bevroren bij het verzenden. Geen link 404; ingetrokken (ook door een nasturing per post) 410 document_link_revoked; vervangen door een nieuwe versie 410 document_link_superseded; vergadering geannuleerd 410 document_link_cancelled; na de vergaderdag 410 document_link_expired. De JSON van een vervallen link heeft ook cooperative (de naam van de coöperatie). De pagina voor de browser zegt in elk geval "Deze link werkt niet meer. De stukken liggen ter inzage op de zetel van {coöperatie}; vraag het bestuur om een nieuwe link." (beslissing 138, L2), met bij een nieuwe versie of een annulering een tweede zin. Een onbekend token krijgt een gewone 404 zonder naam van een coöperatie
GET /api/public/meeting-documents/{token}/{documentId}één stuk als PDF (Content-Disposition: attachment); elke download staat in het verzendlog (convocation_document_downloads, met de audit-trail). Een stuk dat niet bij de link hoort 404; ingetrokken, vervallen of verlopen 410 (ook de database: DG062)

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.

Alle routes van /processes horen bij de module Kalender: zonder module.calendar 403 { code: "module.calendar" } (beslissing 102); lezen (GET) mag ook zonder de module zodra ze vrijgegeven is.

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 waarmee het proces gestart is), meetingAnchor (een jaarcyclus: { on, source, meetingId, warnings, draft }, de dag waarvan de open stappen vóór de AV terugrekenen, beslissing 132.12 en 133: source: meeting de gewone AV van dat boekjaar in Vergaderingen, gepland (of gehouden of gesloten), geen ontwerp, niet geannuleerd en geen tweede vergadering; anders statutes met anchors.general_meeting; null voor een ander proces), approvalAnchor (een jaarcyclus: { on, source, meetingId, warnings, draft }, de dag van de stappen "Algemene vergadering houden" en "Boekwaarde per aandeel vastleggen", beslissing 133.2, 133.3 en 135.2–135.4, in deze volgorde: approval de dag van de goedkeuring van de jaarrekening (approvedOn van het boekjaar, dat voorgaat, anders de vroegste gesloten AV van dat boekjaar die het punt jaarrekening aannam; meetingId: null); meeting de geplande of gehouden vergadering van dat boekjaar (gewone AV, verdaagde vergadering of tweede vergadering) met het punt annual_accounts op de agenda dat er nog niet anders over besliste (geen quorum, verdaagd, verworpen), de vroegste; adjournment drie weken na de gewone AV die de jaarrekening verdaagde (art. 6:84, meetingId die AV), zolang geen verdaagde vergadering gepland is (legal-check: of drie weken een uiterste termijn is); meeting de geplande gewone AV (bv. gehouden zonder quorum, tot een tweede vergadering gepland is); anders statutes. Een tweede vergadering voor enkel een statutenwijziging (art. 6:85) heeft het punt niet en telt niet). warnings (beslissing 133.4, 135.2, 135.4): bij source: meeting { code: date_deviation, expectedOn, expectedAt, article } als de vergadering na de statutaire dag valt (niet bij een tweede vergadering; een dag vroeger of een ander uur waarschuwt in de kalender niet, het dossier wel) en { code: late_general_meeting, fiscalYearEnd, latestOn } later dan zes maanden na het einde van het boekjaar (art. 3:1 §1), dezelfde als in het dossier, de kalender volgt de datum toch; bij source: adjournment { code: adjournment_pending, adjournedOn, latestOn } (niet als de verdaagde vergadering als ontwerp bestaat) en eventueel late_general_meeting; bij source: approval { code: approval_differs, fiscalYearOn, meetingOn } als de datum bij het boekjaar en de gesloten AV verschillen. draft (beslissing 133.5): { meetingId, heldOn } als de vergadering die gevolgd zou worden nog een ontwerp is (een ontwerp verschuift niets), anders null. Per stap dueOn, status (pending, due, late, done, skipped), basis (wettelijke grond), title (eigen titel van een stap die de statuten toevoegen, anders null), completion, actions (wat de aanvrager nu mag), anchoredOn (general_meeting: volgt meetingAnchor; approval: volgt approvalAnchor; null: zijn eigen regel), notes (wat de kalender onder de naam van de stap toont, beslissing 135.6: bij een open stap de warnings en de draft van zijn anker ({ code: draft_meeting, meetingId, heldOn }) en bij "Stukken ter inzage leggen" { code: late_inspection, inspectionFrom } als de inzagedatum minder dan vijftien dagen vóór de vergadering ligt (127.3, 135.5); approval_differs ook bij een afgewerkte stap; anders []), link (waar de stap in de app gebeurt, onder /t/{slug}/: vergaderingen/{id} voor de vergadering die de stap volgt, anders de pagina van de stapdefinitie, bv. boekjaren; null zonder) en reminderDays (de vroegste herinnering vóór de deadline in dagen, voor "Binnen {n} dagen"; null zonder herinneringen)
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 statutaire dag (agm_date.schedule, beslissing 101: de eerste statutaire dag na het einde van het vorige boekjaar; De Broeikas 06/06/2027), zonder gestructureerde regel de wettelijke uiterste datum (einde vorig boekjaar + 6 maanden); die dag is anchors.general_meeting. De stappen vóór de AV (de oproeping, de stukken ter inzage en de statutaire termijnen) tellen terug vanaf de gewone AV van dat boekjaar als die in Vergaderingen gepland staat (ook gehouden of gesloten; een ontwerp telt niet, beslissing 133.5), anders vanaf die dag (beslissing 132.12); "Algemene vergadering houden" en "Boekwaarde per aandeel vastleggen" vallen op de vergadering die de jaarrekening goedkeurt (approvalAnchor, beslissing 133.2 en 133.3); bij het plannen, een andere heldOn, fiscalYear of inspectionFrom (PATCH /meetings/{id}) en het annuleren van een AV (ook een tweede vergadering), bij een besluit over het punt jaarrekening van een AV (PUT /meetings/{id}/decisions/{itemId}, beslissing 135.2: na een verdaging meteen drie weken later) en bij een wijziging van approvedOn van het boekjaar (ook ingevuld vanuit een boekwaarde of overgenomen bij POST /fiscal-years) schuiven de open stappen van elke lopende jaarcyclus van dat boekjaar mee, met hun herinneringen; de dagelijkse herinneringsjob (07:00) doet hetzelfde voor elke lopende jaarcyclus, zodat ook een cyclus die liep vóór de AV in de app stond of een gewijzigde oproepingstermijn bijgewerkt wordt (een stap die al op zijn dag staat, blijft onaangeroerd) (zoals bij de neerlegging hieronder); afgewerkte of overgeslagen stappen houden hun datum. Een approvedOn bij het boekjaar betekent dat de vergadering gehouden is (beslissing 135.4): "Algemene vergadering houden" schuift naar die dag en is dan afgewerkt (general_meeting.closed, completionRef het boekjaar; ook bij het starten van een cyclus als de goedkeuring al bekend is); één keer, en het wissen van de datum zet de stap niet terug open. De boekwaarde blijft open tot ze ingevoerd is. De oproeping is de uiterste verzenddag van de wizard (deadline.lastSendOn van .../check): de AV min de convocation_period die geldt op die uiterste verzenddag zelf (zoals de wizard de termijn neemt van de verzenddag; ligt die dag al voorbij, dan die van vandaag), minstens 15 dagen. parameters, bv. { convocation_days: 21 }, geeft een eigen oproepingstermijn zolang de AV niet in de app staat. Versie 4 (beslissing 106.2): elke regel van agm_deadlines (statutaire termijnen vóór de AV) geeft een stap met de omschrijving als title, het artikel als basis en als deadline de AV min de termijn (De Broeikas, AV 06/06/2027: art. 35 één maand → 06/05/2027, art. 29 tien dagen → 27/05/2027), af te vinken met de hand; een lopende cyclus houdt zijn stappen. Versie 8 (beslissing 133.1): de stap "Stukken ter inzage leggen" (inspection_documents, art. 6:82, met de hand af te vinken) op de inzagedatum van de geplande gewone AV (inspectionFrom), anders vijftien dagen vóór de AV, niet op de dag van de oproeping; een lopende cyclus krijgt die stap niet (132.12). Versie 7 (beslissing 124.1): "Jaarrekening neerleggen bij de NBB" (art. 3:10) uiterlijk zeven kalendermaanden na het einde van het vorige boekjaar, met dezelfde regel als de AV-grens: de dag vóór dezelfde dag zeven maanden na het begin van het boekjaar, of de laatste dag van die maand als ze die dag niet heeft (vorig boekjaar tot 31/12 → 31/07, tot 14/01 → 14/08, tot 30/06 → 31/01; accountsFilingDeadline). Is de jaarrekening goedgekeurd (approvedOn van het boekjaar, anders de dag van de gesloten gewone AV die het punt jaarrekening aannam), dan is de deadline van de open stap de vroegste van die grens en de goedkeuring + 30 dagen; de app zet ze opnieuw bij het starten, bij elke wijziging van approvedOn en bij het sluiten van die AV (ook in een cyclus van een oudere versie, vanaf de eigen regel van die versie), voor elke lopende cyclus van dat boekjaar (ook twee versies naast elkaar); de open herinneringen schuiven mee: een herinnering waarvan de dag voorbij is vervalt, en komt terug als de deadline later weer opschuift en haar dag nog komt (niet als een herinnering met een kleiner aantal dagen al verstuurd is; de database laat enkel die weg terug toe, migratie 0088). Een stap die te laat was en waarvan de deadline nu na vandaag valt, wordt opnieuw "te doen" (late → due, systeemovergang reschedule). 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. Soort statutes.to_confirm (beslissing 114): de ondertekenaars van de raad van bestuur te bevestigen na de splitsing; de job statutes.signals (dagelijks 06:50 en bij de start) maakt ze voor elke bestuurder, enkel in de app (geen e-mail, geen voorkeur), eenmaal per beleidsversie (dedupe_key confirm_split:minutes_signers_board:{policyId}), met link /t/{slug}/instellingen/regels/minutes_signers_board (beslissing 154; een oudere melding linkt nog naar /instellingen/statuten/parameters?focus=…, en dat adres stuurt door naar de regel). Soorten statutes.text_ready en statutes.text_failed (beslissing 123.6): de OCR-job van een gescande statutenversie is klaar of mislukt (met de reden en de hulpregel over een Word-bestand); één melding voor wie de job vroeg (userId: wie oplaadde of opnieuw probeerde) en, bij text_ready, ook voor wie de versie opliet als dat iemand anders is en nog bestuurder is (beslissing 123.6, aanvulling); een mislukte nieuwe poging enkel voor wie ze startte; nooit twee keer dezelfde melding aan dezelfde persoon; enkel in de app (geen e-mail, geen voorkeur), in dezelfde transactie als de tekststatus (dedupe_key text_ready:{versionId} of text_failed:{versionId}:{tijdstip}), met link /t/{slug}/instellingen/statuten/{versionId}; enkel wie nog bestuurder is (beslissing 124.2). statutes.rectification_lapsed (beslissing 124.3c): het lezen van een scan die als rechtzetting gekozen was, mislukte en die keuze verviel (de reden; er verandert niets aan welke versie geldt); voor wie de keuze maakte (de audit-trail), enkel als die nog bestuurder is; startte die ook het lezen, dan krijgt die enkel deze melding en niet ook text_failed; enkel in de app, in dezelfde transactie (dedupe_key rectification_lapsed:{rechtgezette versie}:{tijdstip}), met link /t/{slug}/instellingen/statuten (de lijst, waar "Rechtzetten" de scan opnieuw leest en kiest)
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 algemene outbox (de stand per gebruiker in notification_deliveries).

AI: voorstellen en instellingen (/api/t/{slug}/ai/..., A2)​

Het koppelvlak van features.md §"Twee sporen, één koppelvlak" (apps/api/src/ai/). AI schrijft nooit in het domein: een voorstel overnemen legt enkel vast wat overgenomen is; de web-app roept daarna het gewone endpoint aan (bv. POST /statutes/parameters). Schema's in packages/shared/src/ai.ts. Voorstellen, jobs en beslissingen zijn enkel voor het bestuur: een reader krijgt 403 (beslissing 17). Zonder de module AI in het pakket (beslissing 33) 403 { code: "module.ai" } op elke wijziging; lezen (GET) mag zodra de module vrijgegeven is (beslissing 102). GET /ai/settings staat buiten de module (Instellingen › AI, ook zonder de module in het plan).

Methode en padDoet
POST /ai/suggest (board, geen API-sleutel: een persoon vraagt, want een voorstel verbruikt budget en laat een model de statuten lezen){ kind, input } met enkel verwijzingen (strikt schema: een tenantId, onbekende sleutel of tekst geeft 400); vandaag kind: statutes.extract met input: { statuteVersionId }. Controles in deze volgorde, elk 409 { code } zonder job en zonder rij: geen ai_settings-rij of AI uit disabled (reason: off); max_class van de coöperatie laat de minimale klasse van de soort niet toe disabled (reason: class); de kost van de lopende kalendermaand in Brussel (sum(ai_usage.cost_microcents)) ≥ het effectieve budget × 1.000.000 × ai.budget_stop_pct / 100 budget (effectief budget: het laagste van limit.ai_budget_cents en het eigen plafond, zie ai-ontwerp.md §Budget en drempels; een budget 0 laat niets toe, zonder grens geen stop); geen worker op dit platform (AI_WORKER_URL leeg) unavailable. Daarna moet elke verwijzing bestaan in deze coöperatie (onder RLS; die van een andere coöperatie 404), en voor statutes.extract moet de tekst van de statutenversie gelezen zijn (een scan die nog gelezen wordt of mislukte: 409 statute_text_not_ready, beslissing 119.4). Dan in één transactie de rij in ai_jobs en de pg-boss-job { jobId, tenantId } op ai.job (pg-boss schrijft via dezelfde transactie: nooit een job zonder rij of een rij zonder job). 202 { job }. Eén job per doel tegelijk: staat er voor dezelfde kind en input al een job queued of running (een nieuwe poging van de worker blijft running), dan geeft de API die job terug (202 { job }, zelfde vorm) en maakt geen tweede rij of pg-boss-job; race-veilig met pg_advisory_xact_lock per coöperatie, soort en input binnen de transactie (gelijktijdige vragen wachten op elkaar en lezen dan de job van de eerste). Na het einde van die job (geslaagd, mislukt, …) maakt een nieuwe vraag een nieuwe job. De weigeringen hierboven gaan voor: met AI uit of het budget op krijgt de bestuurder de 409, ook als er nog een job loopt
GET /ai/jobs/{id} (board){ job, suggestions }: job met status (queued, running, succeeded, failed, budget, unavailable, cancelled), step, errorCode, errorMessage (een vaste korte boodschap van de worker, nooit een ruwe fout van de gateway; ai_deadline als de levering haar deadline haalde, #552), attempt en maxAttempts (de hoeveelste levering van de worker, 0 zolang de job wacht, en het maximum, 1 + de 3 nieuwe pogingen van de wachtrij: "Poging 2 van 4"), suggestionIds en de tijdstippen; suggestions de voorstellen met velden, citaten en beslissing, en bij statute_parameters ook checks en reviewThreshold zoals bij GET /ai/suggestions. Nooit de pseudoniemenkaart. Een job van een andere coöperatie 404
GET /ai/suggestions?target=&targetRef= (board)het paneel: { open, decided, pending }, het jongste open voorstel van het doel (bv. statute_parameters, met targetRef de statutenversie) en de beslisten, jongste beslissing eerst (max. 20). Elk voorstel draagt reviewThreshold (0–1): onder die zekerheid toont het paneel "Controleer" (G3; platforminstelling ai.review_threshold.<target>, gelezen bij het opvragen). Bij statute_parameters ook checks: per veld de dwingende regels die de waarde schendt (reason als i18n-sleutel, legalBasis, params; A4.3), berekend bij het opvragen met dezelfde functies als de signalen below_floor en law_check (beslissing 117.7); een controle die meer parameters leest, neemt de voorgestelde waarden samen met de bevestigde van vandaag en staat op haar eigen veld, of anders op het eerste andere veld dat ze leest en dat het voorstel geeft; zo'n veld toont ook "Controleer". pending: de jongste job voor dit doel die nog queued of running is (een job van een soort met dit doel, met targetRef als de verwijzing van die soort in input, bv. statuteVersionId; zonder targetRef elke zo'n job van het doel), in de vorm van job (zoals in POST /ai/suggest en job van GET /ai/jobs/{id}, zonder de voorstellen), of null: na een refresh volgt het paneel die job in plaats van opnieuw te laten vragen. Onder RLS: een job van een andere coöperatie nooit
POST /ai/suggestions/{id}/decision (board, geen API-sleutel){ action: 'accept', fields: 'all' | [veld] } (alles: accepted, een deel: partially_accepted), { action: 'adjust', accepted?: [veld], values: { veld: waarde }, note? } (adjusted) of { action: 'reject', note } (reden verplicht, anders 400). Eén bestuurder volstaat (beslissing 15); decided_by en decided_at zet de database. Een veld dat het voorstel niet heeft 422 suggestion_field_unknown, twee keer 422 suggestion_field_twice; al beslist 409 DG028; van een andere coöperatie 404. decision bewaart enkel veldnamen: aangepaste waarden worden nergens bewaard en de audit-trail toont geen voorgestelde waarden of citaten. Is niets van de job nog open, dan wordt zijn pseudoniemenkaart gewist. 200 met het voorstel (bij statute_parameters met checks, zoals bij GET /ai/suggestions)
GET /ai/settings (board, reader){ enabled, maxClass, monthlyBudgetCents, chatRetentionDays, updatedAt, updatedBy, budget }; monthlyBudgetCents is het eigen plafond (null: geen). budget: { entitledCents, entitledSource, entitledUntil, ownCapCents, effectiveCents, warnPct, stopPct, spentCents } (entitledCents = limit.ai_budget_cents, null = geen grens; entitledSource plan, subscription, trial, grant of platform voor de terugvalwaarde; spentCents de kost van deze maand, naar boven afgerond). Zonder rij de standaard met enabled: false (B, geen eigen plafond, 90 dagen). Nooit gateway_key_ref, ook niet in de audit-trail (gemaskeerd: enkel of hij gezet is)
PUT /ai/settings (board, geen API-sleutel){ enabled, maxClass, monthlyBudgetCents, chatRetentionDays } (strikt; monthlyBudgetCents null = geen eigen plafond): een bestuursbeslissing. Een eigen plafond boven limit.ai_budget_cents geeft 422 ai_budget_above_entitlement met params.entitledCents; daalt de entitlement later, dan geldt de laagste. De audit-trail (entity: ai_settings) bewaart wie, wanneer, en de oude en nieuwe waarden (aan/uit, klasse A/B/C, eigen plafond, bewaartermijn). Antwoord zoals GET

Vragen aan Deelgenoot (A5.3)​

De chat van features.md §9 (beslissing 136): een vraag over de statuten, de wet of het register, beantwoord met bronnen. Voor het bestuur en lezers (136.3: vragen verandert niets); een member krijgt 403 (136.5). Iedereen ziet enkel de eigen gesprekken (136.4, RLS own_chats): een gesprek of antwoord van iemand anders is 404, ook voor een andere bestuurder. Geen API-sleutel (403 api_key_not_allowed): een persoon vraagt, ook lezen van de gesprekken. Schema's in packages/shared/src/ai.ts. Vraag- en antwoordtekst staan nooit in logs, de audit-trail (gemaskeerd) of een foutmelding.

Methode en padDoet
POST /ai/ask (board, reader){ question, threadId?, context? } (strikt; question 1–2000 tekens; context { screenId?, recordKind? }: het scherm waar de vraag gesteld werd (een id van de schermcatalogus) en de soort record erop, uit member, transaction, exit, meeting, statute_version, fiscal_year, document (een enum, nooit vrije tekst, nooit een id of gegevens van het record); een tenantId of onbekende sleutel 400). Controles in deze volgorde, elk 409 { code } zonder rij: de module AI niet in het pakket disabled (reason: module); AI uit disabled (reason: off); max_class laat de klasse van knowledge.answer niet toe disabled (reason: class); het budget van de maand op budget; geen worker (AI_WORKER_URL leeg) unavailable. Een threadId die niet van de vrager is 404. Daarna in de transactie van het verzoek de vraag (en een nieuw gesprek met de eerste 60 tekens als titel); pas na de commit start de stroom. 200 text/event-stream met de gebeurtenissen hieronder; tijdens de stroom staat geen transactie open. Het antwoord (of de fout) wordt bewaard in een tweede korte transactie, en de laatste gebeurtenis draagt zijn answerId. Geen Idempotency-Key: een stroom kan niet herhaald worden; een nieuwe poging is een nieuwe vraag
GET /ai/threads (board, reader; module AI)[{ id, title, createdAt }]: de eigen gesprekken, laatst gebruikte eerst
GET /ai/threads/{id} (board, reader; module AI){ id, title, createdAt, messages: [{ id, role, content, status, messageKey, citations, screens, feedback, createdAt }] }: de vragen en antwoorden in volgorde; een bron draagt haar tekst uit de kennisbank zolang die bestaat (anders text: null). Een foutantwoord heeft messageKey (ai.chat.error.<code>) en lege content; een begroeting (136.9) status complete, messageKey ai.answer.greeting.* en lege content. Niet van de vrager 404; onder de slug van een coöperatie waar de vrager geen rol heeft 403, zoals elke route onder /api/t/{slug}
DELETE /ai/threads/{id} (board, reader; module AI)wist het gesprek, eerst de berichten (elk een regel in de audit-trail zonder tekst). 204; niet van de vrager 404. Ook met AI uit of zonder de module in het plan, zolang die vrijgegeven is (beslissing 141.6)
PATCH /ai/messages/{id}/feedback (board, reader; module AI){ feedback: -1 | 1 | null } op een antwoord (null neemt het terug); enkel het oordeel wordt bewaard (136.4). Een vraag of een bericht van iemand anders 404. 204. Met AI uit (de module in het pakket) aanvaardt de API het nog: enkel het alleen-lezen paneel laat de duimpjes weg (141.6). Ook op een begroeting (136.9) aanvaardt de API een oordeel; het paneel toont er geen duimpjes; zonder de module in het pakket 403 { code: "module.ai" }

De stroom van POST /ai/ask (server-sent events, event: <type> en data: <json>; elke 15 s een commentaarregel als keep-alive):

GebeurtenisData
thread{ threadId, questionId }, altijd eerst
status{ status }: searching (de kennisbank), thinking (het model denkt), writing (de tekst komt)
delta{ text, reset? }: het volgende stuk antwoordtekst, de pseudoniemen al terug in echte namen (een pseudoniem dat over twee stukken gesneden is, wacht tot het volledig is). reset: true: de tweede poging van de worker begint, wat eerder kwam vervalt
done{ answerId, kind, text, messageKey, citations: [{ n, chunkId, label, text }], screens: [{ id, memberId?, search? }], reason }: het volledige antwoord zoals bewaard. kind answer, no_source (geen bron gevonden; geen modelaanroep), register (een vraag over het register: een vaste tekst en de schermen), signpost of greeting (een groet, bedanking, vraag om hulp of afscheid: de vaste tekst van messageKey ai.answer.greeting.*, zonder zoekstap of model; beslissing 136.9); text null als de vaste tekst van messageKey in de plaats staat; reason een korte vaste reden voor de log (nooit tekst); de verwijzingen [n] in de tekst zijn 1..k in volgorde. screens: schermen die de gebruiker mag openen (screensFor); member enkel met een memberId als de vraag precies één vennoot noemt die gevonden wordt (136.7), anders members met search
error{ answerId?, code, messageKey, reason? } met code disabled (met reason), budget, unavailable (worker niet bereikbaar, stil langer dan AI_CHAT_IDLE_TIMEOUT_MS, of langer dan AI_CHAT_TIMEOUT_MS), truncated (het antwoord was te lang) of invalid_output (het model antwoordde twee keer niet in de vorm). Een vaste sleutel, nooit een ruwe fout. Sluit de browser de verbinding, dan breekt de API de stroom af en bewaart het antwoord ai.chat.error.cancelled

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>') (beslissing 102). Zolang de release flag voor de coöperatie uit staat (nooit vrijgegeven), is elke route dicht: 403 { code: "module.<x>" }, ook bij GET. Is de module vrijgegeven (flag aan, of de coöperatie in de bèta), dan blijft lezen (GET, HEAD, OPTIONS) toegelaten, ook zonder entitlement: gegevens van een module die wegvalt blijven leesbaar (features.md §18). Wijzigen (POST, PUT, PATCH, DELETE) vraagt de entitlement, anders 403 { code: "module.<x>" }. Uitzondering: een route met @ErasesOwnData() wist enkel eigen gegevens en mag, zoals lezen, zodra de module vrijgegeven is (DELETE /ai/threads/{id}, beslissing 141.6). 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. Afgeschermd (beslissingen 82 en 102):

    ModuleRoutes
    module.documents/archive, /documents/archive-index, /exports/documents.*, /documents/recipients, /documents/forward
    module.assembly/meetings
    module.reports/reports/{boekjaar}
    module.calendar/processes (de jaarcyclus, E3)
    module.ai/knowledge/search (A1.5), /ai behalve GET /ai/settings (Instellingen › AI)
    module.member_portalPUT /settings/portal (Instellingen › Vennotenportaal, E8.2); elke route van een vennoot (/portal, /api/portal/cooperatives), zie Vennotenportaal regel 9

    Open, want registerkern (module.register is altijd aan, beslissing 26): vennoten, aandelensoorten, transacties, uittredingen, het register en zijn versies, uittreksels, brieven, import, de andere exports en /documents/settings. Ook open (beslissing 102): de contactgegevens, /members/{id}/contacts/{role} (registerkern: de mede-houder staat in het register), /statutes, /fiscal-years, /distribution-tests en /notifications. module.contacts, module.dividend en module.loans hebben nog geen afgeschermde route. module.member_portal schermt PUT /settings/portal af (E8.2) en elke route van een vennoot (beslissing 137.13, #509): weggevallen is het portaal alleen-lezen, nooit gehad is er geen portaal.

  • 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, /members/{id}/letters, POST /archive, /archive/{id}/signing/send, /documents/forward, /ai/suggest, /ai/suggestions/{id}/decision, /reports/{boekjaar}/{soort}/generate, /reports/{boekjaar}/{soort}/signing/send, /transactions/{id}/exit-request, /distribution-tests/{id}/report, /distribution-tests/{id}/signing/report/send, de documenten en verzendingen van /meetings (zie daar) en de stukken van /statutes: dezelfde sleutel geeft hetzelfde antwoord (header Idempotent-Replayed: true), ook als twee verzoeken tegelijk komen; dezelfde sleutel met een ander verzoek (methode, pad of body) 422 idempotency_key_reused (422 en niet 409: het verzoek zelf klopt niet bij de sleutel; een 409 blijft voor een botsing met de toestand). Een mislukt verzoek bewaart niets. Per coöperatie én per gebruiker (een API-sleutel als zijn service-gebruiker; migratie 0097): een collega met dezelfde sleutel krijgt zijn eigen boeking. Coöperatie en gebruiker komen uit de sessie of de API-sleutel, nooit uit de body. 24 uur bewaard (dagelijkse job idempotency.purge).

  • Rate limits per client-adres en per route, in het geheugen van het (enige) API-proces: standaard 600 per minuut (RATE_LIMIT_PER_MINUTE), strenger als benoemde profielen (THROTTLE_PROFILES in apps/api/src/common/throttle.ts): aanmelden 20, uitnodigingen 10, webhooks 120, PDF's 10, de publieke downloadlinks (30, beslissing 134.3, #469) en de testmail (5, #470). Daarboven 429 { statusCode: 429, code: "rate_limited", message, params: { retryAfterSeconds } } met Retry-After (seconden). Het client-adres is het adres dat de edge-Caddy zag (TRUST_PROXY=1), nooit een waarde die de client zelf in X-Forwarded-For zet (deployment.md §2b). 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). Bij een e-mail van een oproeping (E5.3) komt Delivery of Bounce ook als gebeurtenis delivered of bounced op de rij van de verzendlijst, met het tijdstip van Postmark en bij een bounce het type zoals Postmark het geeft (HardBounce, SoftBounce, …). De inbox bewaart enkel recordtype, message-id, bouncetype en tijdstip, nooit het adres. 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 of geannuleerde toets wijzigen, een bevestigde annuleren, of bevestigen zonder goedgekeurde jaarrekening, volledige cijfers en getekend verslag
DG022een uittredingsaandeel betalen zonder bevestigde uitkeringstoets die het dekt: niet op de lijst van een toets, de toets verlopen (payBy), de balanstest slaagt niet, of meer dan het getoetste bedrag
DG033een betaling van een scheidingsaandeel boven wat nog verschuldigd is (params.reason = above_owed), of een scheidingsaandeel dat onder wat al betaald is zou zakken (params.reason = below_paid); params.remainingCents erbij (#351, #358, migratie 0068). De database geeft dezelfde code zonder params
DG034een parameter die een AI-voorstel als bron noemt dat er niet bij hoort: een ander doel of een andere statutenversie dan die van de beleidsversie, een veld dat het voorstel niet heeft, of een afgewezen voorstel (A4.4, migraties 0070 en 0072; 422)
DG036een parameter per aandelensoort die een AI-voorstel als bron noemt waarvan het veld geen waarde voor alle soorten en geen waarden per soort heeft, of een parameter van de hele coöperatie uit een veld met waarden per soort (beslissing 117.4, migratie 0072; 422)
DG037een parameter uit een AI-voorstel met als citaat de woorden die het voorstel niet in de statuten vond (beslissing 117.8e, migratie 0072; 422)
DG038een parameter met ai_adjusted zonder verwijzing naar een AI-voorstel, of met een verwijzing zonder ai_adjusted (beslissing 117.3, migratie 0073; 422)
DG023een boekwaarde die niet overeenstemt met de jaarrekening van haar boekjaar (eigen vermogen, datum van goedkeuring of einde van het boekjaar)
DG026een archiefdocument dat niet mag veranderen: een getekend of geannuleerd document wijzigen, een stap die de documentmachine niet kent, de inhoud na het nakijken, of een correctie van een document dat niet getekend is
DG027een vergadering of haar gegevens die niet zo mogen veranderen (E4.1, migratie 0056): een statusovergang die de vergadermachine niet kent (een nieuwe vergadering is een ontwerp), een gesloten vergadering of haar rijen wijzigen (behalve het antwoord op een vraag), de leden van een momentopname later toevoegen, de momentopname opnieuw nemen nadat een besluit is ingevoerd, een agendapunt met een besluit of een verwijderd agendapunt, deelnemer of volmacht wijzigen
DG024een ondertekenverzoek dat niet meer wacht (getekend, geweigerd of geannuleerd) wijzigen, of vernieuwen of annuleren terwijl er niets wacht
DG025statuten als bron: de data, de bron of het bestand wijzigen van een statutenversie waarop bevestigde parameters steunen; parameters toevoegen aan een beleidsversie buiten de transactie waarin ze ontstaat; of een waarde met bron statutes op een versie zonder statutenversie
DG035geschorst stemrecht (issue #337, migratie 0069, 409): schorsen of opheffen na het eerste besluit van de vergadering of op een raad van bestuur, een schorsing anders wijzigen dan één keer opheffen met een reden, een rij die al opgeheven ingevoerd wordt zonder een schorsing van de eerste vergadering te beslissen, of een beslissing over iets anders dan een lopende schorsing van de eerste vergadering voor dezelfde vennoot en grond (migratie 0074)
DG039het eerste besluit van een tweede of verdaagde vergadering terwijl een schorsing van de eerste vergadering daar nog niet beslist is, opnieuw geschorst of niet meer (issue #374, beslissing 118.3, migratie 0074; 409)
DG040de tekst van een statutenversie: de status gaat enkel van reading naar ready of failed en van failed terug naar reading; bron en OCR-score worden één keer geschreven, met de tekst (beslissing 119.4, migratie 0077; 409)
DG041notulen die niet kunnen (E6.3/E6.4, migraties 0078 en 0079; 409): het archiefdocument van notulen van soort of vergadering veranderen zolang notulen ernaar wijzen; notulen of hun tekst bij een vergadering die niet gehouden of gesloten is, de tekst of de ondertekenaars wijzigen terwijl de PDF (archiefstuk) nagekeken, getekend of geannuleerd is, de PDF vervangen terwijl de vorige niet geannuleerd is (of door iets anders dan een ontwerp van soort minutes van dezelfde vergadering), een agendapunt van een andere vergadering, een correctie van notulen die niet getekend zijn, of wijzigen bij welke vergadering notulen horen
DG043een statutenversie rechtzetten met een scan waarvan het lezen mislukte, of zo'n rechtzetting laten staan wanneer het lezen mislukt (beslissing 124.3, migratie 0089; 409); de API antwoordt eerst 409 statutes_replacement_failed en laat de keuze vervallen
DG045een oproeping die niet zo mag veranderen (E5.1, migratie 0080; 409): een verzonden of geannuleerde oproeping wijzigen (enkel de datum "op de post gedaan op" één keer, niet vóór de dag van verzending en niet in de toekomst, beslissing 4, met "gepost door"; de reden van een te late oproeping ligt vast met de verzending, migratie 0087; de reden van een te late postdatum enkel samen met die datum, één keer, en ligt daarna vast, migratie 0092; "volmachten bij voorkeur vóór" ligt vast met de verzending, migratie 0095), een nieuwe oproeping die niet als ontwerp begint, de vergadering of de versie wijzigen, een brief die geen archiefstuk van soort convocation van dezelfde vergadering is
DG046een oproeping maken of verzenden voor een vergadering die geen geplande algemene of buitengewone algemene vergadering is (E5.1; 409)
DG047een tweede ontwerp van de oproeping van dezelfde vergadering (E5.1; 409)
DG048een regel van de verzendlijst of een gebeurtenis die niet past (E5.1; 409): bij een oproeping die niet verzonden is, buiten de transactie van de verzending (behalve nasturen per post), een kopie aan iemand anders dan een mede-houder van de vennoot, of naar hetzelfde e-mailadres, of terwijl de vennoot per post opgeroepen wordt (beslissing 73), een gebeurtenis die niet bij het kanaal past (bezorgd of gebounced bij post, op de post bij e-mail), een postdatum vóór de verzending of in de toekomst. De verzendlijst en haar gebeurtenissen zijn append-only (DG001)
DG049een verzending die niet per post nagestuurd kan worden (E5.4; 409, in de app, geen databaseregel): enkel de eigen oproeping van de vennoot na een bounce of een mislukte e-mail, een onbereikbare vennoot, of een brief die terugkwam (E5.8), één keer; een postdatum per verzending enkel voor een nasturing, één keer
DG051"post teruggekeerd" die niet past (E5.8, migratie 0087; 409): een brief die niet per post ging of nog niet gepost is, een tweede keer; de markering "post teruggekeerd" op het postadres van een vennoot weghalen zonder dat het adres wijzigt
DG052een afgiftebewijs van de post dat niet past (E5.8, migratie 0087; 409): vóór de postdatum, een tweede keer of weghalen, of een archiefstuk dat geen stuk van soort convocation van dezelfde vergadering is (van een andere coöperatie: de samengestelde foreign key, 23503)
DG053een bericht na een verstuurde oproeping dat niet past (#401, migratie 0093; 409): niet de laatst verstuurde versie, een vergadering die niet gepland is, een tweede annulering, een wijziging na het versturen buiten de postdatum (één keer, niet vóór de dag van het bericht, niet in de toekomst), een annulering zonder dat de vergadering geannuleerd wordt, een annulering of rechtzetting die bij de commit niet volledig is (zonder haar brief of zonder een verzending per verzending van de oproeping), een brief die niet het eigen bestand van het bericht is, een postdatum zonder brieven per post; of een vergadering met een verstuurde oproeping annuleren zonder bericht of de keuze "geen bericht"
DG054een verzending van een bericht die niet past (#401, migratie 0093; 409): buiten de transactie die het bericht verstuurt (behalve een nasturing per post van de eigen rij van een vennoot), bij none, naar een ontvanger, kanaal of adres dat niet in de eigen verzendlijst van de oproeping staat, met een outbox-bericht dat al bij een andere verzending hoort; of een nasturing van een rij uit een andere lijst (van de oproeping naar een bericht of omgekeerd)
DG061een downloadlink van de stukken die niet zo mag veranderen (#469, migratie 0101; 409): enkel één keer intrekken, met wie en wanneer van de database
DG062een download van een link die ingetrokken of verlopen is, of van een stuk dat niet bij de link hoort (#469, migratie 0101; 410)
DG063een wijzigingsverzoek dat anders verandert dan door bevestiging, intrekken of één beslissing (E8.4, migratie 0104; 409)
DG064een e-mailwijziging goedkeuren vóór de vennoot het nieuwe adres bevestigde (E8.4, migratie 0104; 409)
DG065een tweede lopend wijzigingsverzoek van dezelfde soort voor een vennoot (E8.4, migratie 0104; 409)
DG066meer bevestigingsmails voor een vennoot in 24 uur dan portal.change_request.mails_per_day (5), aanmaken en nieuwe links samen, ook van ingetrokken en vervallen verzoeken (E8.4, migratie 0104; de grens een platforminstelling sinds 0117; 429)
DG067een vervaldag van een downloadlink die de vergadering niet volgt: na de dag na de vergadering, een testlink langer dan 7 dagen, een verschuiving die niet die van de vergaderdag is, of van een link die niet meer werkt (#497, migratie 0106; 409)
DG068een downloadlink die vervalt om een reden die niet klopt: geen nieuwere verstuurde versie, de vergadering niet geannuleerd, geen nasturing per post van die vennoot, of een reden bij iets anders dan intrekken door het bestuur (#497, migratie 0106; 409)
DG069een nieuwe downloadlink voor een geannuleerde vergadering (bv. een testmail tegelijk met het annuleren; #497, migratie 0106; 409)
DG081het eerste besluit van een algemene vergadering terwijl een volmacht gemarkeerd na een wijziging van de regels of bij het meenemen nog niet behouden of vervallen is (beslissing 140.16, migratie 0118; 409)
DG085een bevestiging van de 2:32-keuze (soort notice_email) voor een ander adres dan dat in het register (#533, beslissing 137.24.1, migratie 0117; 409)
DG050een vraag van een vennoot gekoppeld aan een agendapunt dat van de agenda verwijderd is (E6.5, migratie 0085; 422); een punt van een andere vergadering of coöperatie weigert de samengestelde foreign key (23503)
DG028een AI-voorstel dat al beslist is opnieuw beslissen (of een voorstel dat niet open begint)
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 en 23P01 (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.