Billver — Technisch integratiedossier v1.6 (Mail Verify inbound)

> Werknaam "Billver" is voorlopig. Geen domein, geen provider, geen publicatie. §16 is nieuw; §00–§15 (v1.2–v1.5) blijven geldig en volgen hieronder.

16. Mail Verify inbound — implementatie v1.6

16.1 Wat bestaat (code)

OnderdeelBestand
Provideradapters (Mailgun HMAC, Postmark Basic auth, Sandbox HMAC), scanner-interfacesrc/lib/guard/mail-inbound.ts
Pipeline: claim → alias → lease → scan → vergelijk → resultaatsrc/lib/mail-inbound.server.ts
WebhookPOST /api/public/inbound/mail/{mailgupostmarksandbox}`
OpruimtaakPOST /api/public/cron/retention + DB-functie retention_cleanup() (alleen service role)
Sessies / ontvangstlogtabellen mail_verify_sessions, inbound_mail_events (RLS aan, geen client-toegang)
Ontvanger-UI (gsm)/mail-verify — controlecode, persoonlijk adres, instructies Gmail/Outlook/Telenet/Proximus, status, resultaat
Admin-checklistDashboard → Instellingen → "Mail Verify setup" (alleen ja/nee, nooit geheime waarden)

16.2 Beveiligingsregels (geïmplementeerd)

- Authenticiteit over raw body. Mailgun: HMAC-SHA256(signing key, timestamp+token), venster 300 s. Postmark heeft geen HMAC: Basic-auth in webhook-URL, constant-time vergelijking, geen tijdstempel → alleen dedupe op MessageID. Sandbox: X-IG-Signature: t=…,v1=HMAC(t.body), alleen in ontwikkelmodus (anders 404).

- Provider zonder geheim → 503 (fail closed). Onbekende provider → 404.

- Idempotency/replay: (provider, message-id) wordt via primaire sleutel geclaimd vóór enig werk. 5 parallelle identieke leveringen → 1 verwerking.

- Alias: check+<sessie>.<vervaltijd>.<HMAC>@<domein>, TTL 30 min, gebonden aan één sessie; ongeldig/verlopen/vervalst → 200 zonder verwerking (geen orakel, geen backscatter).

- Tenantbinding: een sessie gestart vanaf een verificatielink is gebonden aan exact die factuur. Alleen dan (of bij een link van ons geconfigureerde HTTPS-domein) gebeurt een inhoudelijke vergelijking. Anders alleen exacte hash. Onbekend → NON_VERIFIABLE, nooit VERIFIED.

- Limieten: request ≤ 30 MB (413), mail ≤ 25 MB, PDF ≤ 15 MB, ≤ 10 bijlagen, ≤ 40 MIME-delen, nestdiepte 3, ≤ 5 mails per sessie, 600 req/min per provider, 10 sessies/uur per IP; limiter-fout → 503.

- Bijlagen: PDF alleen op magic bytes; gevaarlijke extensies gemarkeerd en nooit geopend; niets wordt uitgevoerd. Bestaande PDF-hardening (encryptie, JavaScript, ingesloten bestanden → geweigerd) blijft gelden.

- Malwarescan: interface MAIL_SCANNER_URL + MAIL_SCANNER_API_KEY (HTTPS, antwoord {verdict:"clean"|"infected"}). Niet ingesteld: pilot/productie → quarantaine (REVIEW, niet vergeleken); ontwikkeling → doorgelaten met label dev_bypass. Elke scannerfout → quarantaine.

- Geen URL-fetching: links in mails worden alleen als tekst gelezen; er wordt nooit iets opgehaald (geen SSRF).

- Resultaat ophalen: alleen met het sessiegeheim dat enkel het startende toestel kent (opgeslagen als hash). Fout geheim/onbekend → neutraal "unknown".

- Logging zonder PII: logregels bevatten provider, uitkomst, oordeel, aantallen en een hash-prefix; geen afzender, onderwerp of IBAN. Ruwe mail wordt nergens opgeslagen.

16.3 Bewaartermijnen (policy, geïmplementeerd in retention_cleanup + route)

GegevenTermijn
Ruwe mailnooit opgeslagen (alleen in geheugen)
Mail Verify-sessie + resultaat24 uur
Ontvangstlog (inbound_mail_events, zonder PII)30 dagen
Tijdelijke vergelijkings-PDF'stot expires_at (org comparison_retention_days) — bestand én rij
Rate-limit tellers1 dag
Afgehandelde webhook-leveringen90 dagen
Niet-afgeronde idempotency-claims7 dagen
Verificatie-eventsorg retention_days (min. 30)
Audit events, canonieke originelen, verificatietokensniet verwijderd (bewijs, append-only) — juridisch te bevestigen

Planner: dagelijks 03:17 naar de testversie (zelfde publieke-sleutelpatroon als de webhook-worker, max 6/min). Na publicatie omzetten naar de gepubliceerde URL.

16.4 Tests (werkelijk uitgevoerd, 9 okt 2026)

- bunx vitest run src/lib/guard → 124/124 (waarvan 10 nieuw voor adapters/scanner; mocks voor scanner-HTTP).

- bun tests/integration/mailverify.ts → 23/23, echte database + echte HTTP naar de lokale app: geldig/ongeldig ondertekend, replay, 5× parallel duplicaat, later duplicaat, gewijzigd IBAN onbebonden (NON_VERIFIABLE) en gebonden (BLOCKED), cross-tenant (geen VERIFIED, geen data andere tenant), link vervangen (REVIEW), kapotte MIME, PDF-spoof, meerdere bijlagen, 31 MB (413), verlopen/vervalste alias, providers zonder geheim (503), onbekende provider (404), scanner onbeschikbaar → quarantaine (in-process, echte DB), opruimtaak met fictieve oude rijen, audit onaangetast.

- bun tests/integration/remediation.ts → regressie (zie rapport).

- Niet getest: echte Mailgun/Postmark-levering, echte MX/DNS, echte malwarescanner, gepubliceerde versie, geplande run van de opruimtaak (eerste run 03:17).

16.5 Release gates Mail Verify

GateStatus
Ondertekende inbound + replay + idempotencyPASS (sandbox, echt HTTP/DB)
Mailgun/Postmark adapterPASS (unit) · NOT TESTED (live)
Fail-closed zonder provider/scannerPASS
Geen false VERIFIEDPASS
RetentiePASS (handmatige run) · NOT TESTED (geplande run)
Echt ontvangstdomein + MXBLOCKED (domein)
MalwarescannerBLOCKED (providerkeuze)
DPA met mailprovider + scannerBLOCKED (juridisch)

16.6 Setup-stappen zodra domein + provider gekozen zijn

1. Kies subdomein, bv. check.<domein> (verzin geen domein vooraf).

2. Mailgun: voeg domein toe (EU-regio), zet MX 10 mxa.eu.mailgun.org en MX 10 mxb.eu.mailgun.org (waarden uit het Mailgun-scherm overnemen), SPF v=spf1 include:mailgun.org ~all en DKIM-TXT zoals Mailgun toont. Route: match_recipient("check\+.*@check.<domein>") → forward("https://<app>/api/public/inbound/mail/mailgun") (URL op *mime* laten eindigen is niet nodig; schakel "store and notify" niet in). Secret: MAILGUN_WEBHOOK_SIGNING_KEY.

Of Postmark: inbound stream, MX inbound.postmarkapp.com, webhook https://<user>:<pass>@<app>/api/public/inbound/mail/postmark, "Include raw email content" aan. Secrets: POSTMARK_INBOUND_USER, POSTMARK_INBOUND_PASSWORD.

3. Secret MAIL_VERIFY_INBOUND_DOMAIN = het subdomein.

4. Malwarescanner: MAIL_SCANNER_URL (HTTPS) + MAIL_SCANNER_API_KEY.

5. Deploymentmodus naar pilot (sandbox gaat dan automatisch uit).

6. Planners (webhook + retentie) omzetten naar de gepubliceerde URL.

7. Controleer "Mail Verify setup": alles groen.

Inkomend: alleen MX nodig. SPF/DKIM zijn pas nodig als vanaf dit subdomein ook verzonden wordt (Mail Verify verstuurt niets).

Billver (werknaam) — Technisch integratiedossier v1.4

v1.4 behandelt drie resterende vertrouwensproblemen van Consumer Verify (feedback Redney Neve, Simpla/MyDocIT). Alles uit v1.3 blijft geldig (verderop ongewijzigd opgenomen). Legende: DONE = gebouwd en getest in deze repo · OPEN = ontwerp klaar, nog niet gebouwd · BLOCKED = wacht op externe partij (Simpla, domein, bank/PSP).

00c. Probleem 1 — Herkomst leverancier (server-to-server)

Doel: de ontvanger toont *via welk geauthenticeerd kanaal* de factuur geregistreerd werd: "Via geautoriseerde integratie geregistreerd (mydocit)". Nooit "identiteit bevestigd".

Binding (bestaand, server-side): API key → org_id (hash-only opslag, scope invoices:write, onherroepelijke revoke via DB-trigger) → invoices.org_id + invoices.source → audit invoice.registered (actor_type, key-id, sha256, iban_last4).

Handshake (ontwerp, Simpla-zijde BLOCKED)

1. Onboarding (OPEN): leverancier maakt tenant aan; legal name + btw-nummer; ownership-verificatie via (a) DNS TXT ig-verify=<random> op het afzenderdomein (tabel sender_domains bestaat), en (b) optioneel KBO/VIES-controle op btw-nummer (niet gebouwd). Eerste IBAN-goedkeuring door owner/admin, nooit uit PDF.

2. Koppeling (BLOCKED): owner maakt in Dashboard → API-sleutels een sleutel met scopes invoices:write invoices:read events:read, geeft die buiten e-mail (bv. versleuteld kanaal) aan de MyDocIT-beheerder. MyDocIT bewaart hem server-side per tenant; nooit in browser/PDF.

3. Registratie: POST /api/public/v1/invoices/prepare → POST /api/public/v1/invoices (finalize, Idempotency-Key, header X-Source: mydocit). Bestaande endpoints, ongewijzigd.

4. Feedback: webhooks + GET /api/public/v1/events?cursor=.

Adaptercontract voor Redney

VereisteDetail
AuthAuthorization: Bearer igk_…, enkel server-side, per tenant één sleutel; rotatie = nieuwe sleutel aanmaken, oude intrekken
BronX-Source: mydocit (regex ^[a-z0-9_-]{2,32}$); andere waarden → api
IdempotencyIdempotency-Key = stabiele MyDocIT document-ID; zelfde key + ander bestand → 409 idempotency_conflict
Beschermd-labelalleen tonen als response protected: true; bij timeout/5xx/409 → NIET beschermd
Fouten401 unauthorized · 403 forbidden (scope) · 409 idempotency_conflict / idempotency_in_progress (retry-after) · 429 rate_limited · 503 service_unavailable / verification_domain_not_configured
Loggingnooit volledige IBAN of API key loggen

Threat model: gecompromitteerd verzendersaccount

AanvalEffectMitigatie
Gestolen API keyAanvaller kan facturen registreren met alleen goedgekeurde IBANs (anders REVIEW/BLOCKED)Nieuwe IBAN vereist admin-goedkeuring in dashboard (geen API-pad); revoke key; audit api_key.revoked_key_used
Gestolen dashboard-login (owner)Kan IBAN goedkeuren → ernstigste scenarioOPEN: 2FA verplicht voor owner/admin, vier-ogen voor IBAN-goedkeuring, afkoelperiode + mail naar alle owners
MyDocIT-platform zelf gecompromitteerdAlle tenant-keys op dat platformOPEN: per-tenant keys (al zo), IP-allowlist per key, anomaliedetectie op volume

Label-tekst toont daarom altijd de caveat: *"Dit bewijst via welk geauthenticeerd kanaal de leverancier registreerde, niet dat het account van de leverancier nooit misbruikt werd."*

DONE: deriveProvenance() (src/lib/guard/consumer-trust.ts), getoond op /v/$token met caveat; onbekende/gespoofte bronwaarden → "Registratiekanaal onbekend". BLOCKED: echte Simpla-koppeling. OPEN: DNS-TXT ownership flow, KBO/VIES, 2FA/vier-ogen.

00d. Probleem 2 — Is de ontvangen PDF onveranderd?

SituatieWat is bewezenStatus op scherm
Alleen link/QR geopendAlleen wat de leverancier registreerde. Geen bewijs over de ontvangen bijlageOntvangen PDF: niet gecontroleerd
PDF geüpload, SHA-256 gelijkByte-identiek aan origineelOrigineel identiek
PDF geüpload, kritiek veld anders (IBAN, totaal, valuta, mededeling, betaal-QR, tekst-IBAN≠QR-IBAN, extra betaal-QR)Afwijking t.o.v. origineelKritieke betaalgegevens gewijzigd (rood)
PDF geüpload, hash anders, betaalvelden gelijkDocument gewijzigd, betaaldata gelijkDocument verschilt, betaalgegevens gelijk (geen fraudeclaim)

UX: upload is optioneel (knop + drag-drop direct op de verificatiepagina). De browser leest nooit zelf een e-mailbijlage en de pagina zegt dat expliciet. Toekomstige mailclient-add-in (Outlook/Gmail) alleen met expliciete OAuth-toestemming per bericht — OPEN, niet gebouwd.

DONE: classifyReceived() + RECEIVED_COPY, component ReceivedCheck/ReceivedBanner, op /v/$token en /verifieer.

00e. Probleem 3 — Vervangen verificatielink

Een aanvaller met mailboxtoegang vervangt PDF + knop + link + QR door een namaakdomein. Alles in die e-mail is aanvallerscontroleerbaar.

Waarom ondertekende data in de mail niet volstaat: een handtekening bewijst alleen "ondertekend door de houder van sleutel X". Als de publieke sleutel (of de URL om die op te halen) óók in de mail staat, ondertekent de aanvaller met zijn eigen sleutel en verwijst naar zijn eigen sleutel. Zonder trust anchor uit een onafhankelijk kanaal (sleutel vooraf in app/bank gepind, of opgehaald van een zelf ingetypt officieel domein) heeft de handtekening nul waarde.

Maatregelen:

1. Onafhankelijk officieel domein dat de ontvanger zelf intypt of bewaart als bladwijzer — BLOCKED (domeinkeuze).

2. User-entered route `/controleer`: neemt enkel de code, nooit een geplakte host; geen zoekfunctie op btw/factuurnummer — DONE (parseVerificationCode).

3. Hoog-entropische code via onafhankelijk kanaal (klantenportaal, brief, sms naar eerder gekend nummer): tokens ≥ 24 tekens, HMAC-afgeleid, anti-enumeratie (generiek "niet gevonden", rate limit 60/10 min) — DONE (bestaand); distributie via ander kanaal — BLOCKED (MyDocIT/klantportaal).

4. Ondertekende attestaties (Ed25519) met kid, geldigheidsvenster, revocatie en rotatie — bibliotheek DONE + getest (signAttestation/verifyAttestation), niet geactiveerd: er is nog geen onafhankelijk verdeelde keyring (vereist domein + publicatie van /.well-known/billver-keys.json of pinning in bank-/boekhoudapp). Geen privésleutel aangemaakt.

5. Geen open tokenloze factuurlookup: tokenloos werkt alleen bij byte-identieke hash — DONE (v1.1).

00f. Gefaseerd plan

FaseInhoudStatus
1Provenance-label, ontvangen-PDF-statussen, optionele upload, codeparser, attestatiebibliotheek + testsDONE
2DNS-TXT ownership-verificatie, 2FA + vier-ogen IBAN-goedkeuringOPEN
3Signing key in secrets, keyring op officieel domein, attestatie in API-responseBLOCKED (domein)
4Simpla adapter live in sandboxBLOCKED (Simpla)
5Mailclient-add-in met expliciete toestemmingOPEN

00g. Tests v1.4 (werkelijk uitgevoerd 2026-10-09)

- bunx vitest run → 103/103 geslaagd (5 bestanden; 20 nieuw in consumer-trust.test.ts): gewijzigde PDF, tekst-vs-QR, hash-only, volledige linkvervanging/phishing-URL, gespoofte bron, gecompromitteerd dashboardaccount-label, vervalste handtekening, eigen sleutel van aanvaller, gemanipuleerde claim, rotatie/revocatie, malformed. Unit-niveau, geen DB.

- bun tests/integration/remediation.ts → 34/34 (echte DB + app).

- bun tests/integration/validation.ts main → 42/42 (echte DB, opslag, API, webhook-ontvanger).

- tsgo typecheck schoon. Niet opnieuw uitgevoerd: validation.ts cron (planner), browsercontrole van de nieuwe uploadknop.

<!-- v1.3 inhoud -->

v1.3 voegt de module Consumer Verify toe (e-mailfacturen aan particulieren, feedback Redney Neve, Simpla/MyDocIT). Alles uit v1.2 blijft ongewijzigd geldig (zie verder).

00b. Consumer Verify — threat model

Uitgangspunt: een aanvaller met schrijftoegang tot de mailbox van de ontvanger kan de PDF, de e-mailknop, de link en de QR-code vervangen. E-mailknop, QR en link zitten in hetzelfde kanaal als de factuur en zijn dus geen onafhankelijke factoren.

#ScenarioWat de één-klik link toontWat WEL bewezen isWat NIET bewezen is
aEchte e-mail, PDF ongewijzigdGeregistreerde betaalgegevensLeverancier registreerde deze betaalgegevens; PDF-vergelijking → VERIFIEDDat de klik zelf bewijst dat de bijlage identiek is (pas na PDF-vergelijking)
bPDF/IBAN gewijzigd, link ongewijzigdEchte geregistreerde gegevens (afwijkend van de PDF)Ontvanger ziet het echte IBAN (gemaskeerd); PDF-vergelijking → BLOCKEDNiets automatisch zonder dat de ontvanger kijkt of vergelijkt
cPDF én link/QR vervangen door phishingdomeinNamaakpagina van de aanvallerNiet verifieerbaar via de oorspronkelijke link. Enkel de onafhankelijke route (/controleer, zelf ingetypt domein) helptEen groen scherm, logo of QR bewijst niets
dVolledig vervalste factuur van zogezegde leverancierGeen of namaakpaginaCode op /controleer → generieke "niet gevonden"Wanneer de ontvanger de echte leverancier niet kent: niets
eGecompromitteerd verzendersaccountDoor aanvaller geregistreerde gegevensAudit-trail, IBAN-goedkeuring vereist expliciete admin-actie (nooit auto-approve uit PDF)Bescherming als een admin zelf een vals IBAN goedkeurt
fGokken/misbruik van referentiesGenerieke "niet gevonden"Codes: 160-bit HMAC, niet-sequentieel; rate limit 60/10 min per IP-hash; reveal met proof-of-work + 5/u—
gMalafide leverancierCorrect geregistreerde gegevensEnkel dat de leverancier dit registreerdeBetrouwbaarheid van de leverancier of rekeninghouder (geen VoP)

Nooit geclaimd: "fraude onmogelijk", "waterdicht", "rekeninghouder geverifieerd" (geen echte VoP actief).

00c. Consumer Verify — flow

1. E-mail (HTML + platte tekst, src/lib/guard/consumer-email.ts): knop *Controleer uw factuur* naar /v/{code} op de geconfigureerde PUBLIC_VERIFY_BASE_URL. Buiten development verplicht HTTPS; in development onderwerp en tekst gemarkeerd DEMO/TEST. Alle velden HTML-ge-escaped, CR/LF uit onderwerp, geen trackingpixels, geen klik-tracking, geen query-parameters in de link. Voorbeeld: /demo/consumer-email (synthetisch).

2. Eén klik (/v/{code}): label Geregistreerde betaalgegevens — leverancier, btw, factuurnummer, bedrag, gemaskeerd IBAN, mededeling, tijdstip registratie, geverifieerd leveranciersdomein (enkel indien echt geverifieerd). Expliciet: bewijst niet dat de ontvangen factuur ongewijzigd is.

3. Optioneel: *Vergelijk mijn ontvangen factuur* → bestaande engine (VERIFIED/REVIEW/BLOCKED). Bij afwijking: "Niet betalen voordat u de leverancier via een eerder gekend contactkanaal hebt gecontacteerd."

4. Onafhankelijke route /controleer: ontvanger typt het officiële domein zelf of gebruikt een bladwijzer en geeft de verificatiecode in. Geen zoekfunctie op btw/factuurnummer. Beperking: komt de code uit dezelfde e-mail, dan is ze niet onafhankelijk (scenario c: de aanvaller kan een eigen code meegeven die op ons domein niet resolvet → "niet gevonden", of een code van een eigen aanvalleraccount → scenario e/g). Echte onafhankelijkheid vereist dat de code via een tweede kanaal komt: klantenportaal van de leverancier, papieren brief, of telefonisch via een eerder gekend nummer. Dat tweede kanaal is niet geïmplementeerd door Billver; de leverancier moet dit zelf aanbieden.

00d. Consumer Verify — API

GET /api/public/v1/invoices/{id}/consumer-email (scope invoices:read, tenant-gebonden): geeft subject, html, text, cta_url, demo, status, protected, claim: "registered_payment_data", sent: false. Verstuurt niets. Fouten: 401, 403, 404 (ander tenant/onbekend), 409 no_active_link | verify_base_not_configured (pilot/productie zonder HTTPS-basis) | cta_url_not_https, 429/503 (rate limit fail-closed). protected blijft de live afleiding: nooit true bij REVIEW/BLOCKED/ingetrokken link/open BLOCKED finding.

SPF/DKIM/DMARC: aanbevolen voor het verzenddomein van de leverancier (vermindert spoofing van de afzender), maar biedt geen bescherming tegen een gecompromitteerde mailbox van de ontvanger of een gewijzigd bericht na aflevering.

00e. Consumer Verify — tests

bunx vitest run src/lib/guard — consumer-email.test.ts (echt, pure functie): CTA/HTML/tekst, HTML-injectie in alle velden, onderwerp-headerinjectie, geen tracking, HTTPS-verplichting en DEMO-markering, ongeldige/PII-URL's geweigerd, geen claim "ongewijzigd/waterdicht". Bestaande integratiesuites (tests/integration/*) dekken token-guessing/rate limit, cross-tenant, ingetrokken token, malformed PDF, immutability en idempotency en zijn niet gewijzigd. Mobiele UX: handmatig/Playwright, zie draaiboek.

00f. Screenshot-/demodraaiboek Consumer Verify

1. /demo/consumer-email — HTML en platte tekst, DEMO-banner.

2. /v/{code} op gsm (390×844) — "Geregistreerde betaalgegevens", tijdstip, domein, waarschuwing.

3. "Vergelijk mijn ontvangen factuur" met Vedelek origineel → VERIFIED; gewijzigde versie → BLOCKED + "Niet betalen…".

4. /controleer — domein zelf intypen, code ingeven; foute code → generieke melding.

5. Dia threat model §00b met scenario c: "niet verifieerbaar via oorspronkelijke link".

(v1.2-inhoud, ongewijzigd)

Voor: Simpla BV (Redney) — eerste potentiële integratiepartner (MyDocIT als adapter #1)

Productnaam: "Billver" is een voorlopige werknaam.

Datum: 8 oktober 2026 · Status: v1.2 na laatste validatieronde (echte tests); v1.1 = remediation R1–R9 — concept ter afstemming, niet gepubliceerd, geen productiedomein.

Basis: uitsluitend de huidige code (src/lib, src/routes/api/public/v1), databaseschema/migraties (supabase/migrations) en de test-suite (src/lib/guard/*.test.ts). Dit document bevat geen secrets, geen echte klantgegevens en geen productiedomein. Alle hosts zijn https://<verificatiedomein>.

Legenda per onderdeel:

- [I] geïmplementeerd in code

- [T] gedekt door een geautomatiseerde test (unit-test van de pure logica)

- [NV] niet geverifieerd end-to-end (geen test tegen echte database/netwerk, of alleen manueel)

- [NI] niet geïmplementeerd

Machine-leesbaar contract: /docs/billver-openapi-v1.2.json (OpenAPI 3.1).

00. Validatieronde v1.2 (8 oktober 2026) — release gate met echte testresultaten

Conclusie: alle essentiële geautomatiseerde tests slagen. Het product is niet "pilot ready" zolang de handmatige punten in 00.4 (domein, modus, MyDocIT-zijde, juridisch, pentest) openstaan.

00.1 Testcommando's en echte resultaten

SuiteCommandoWat is echtResultaat
Unitbunx vitest run src/libpure logica, DB/DNS gemockt76/76 PASS
Remediationbun tests/integration/remediation.tsechte DB + lokale HTTP-API, tenants A/B34/34 PASS
Validatiebun tests/integration/validation.ts mainechte DB, opslag, HTTP-API, externe webhook-ontvanger (webhook.site); foutinjectie via proxy om de echte client42/42 PASS
Plannerbun tests/integration/validation.ts cron (na main)echte databaseplanner → preview-worker → externe ontvanger3/3 PASS
Typecheckbunx tsgo --noEmit–geen fouten
DB-linterLovable Cloud linter–6 bewuste meldingen (ongewijzigd, zie 0.4)

Synthetische testtenant: "ZZ-TEST Tenant C (validatie v1.2)", leverancier "ZZ Testleverancier BV", btw BE0999999922, publiek voorbeeld-IBAN BE68539007547034 (geen echte rekening van een klant), PDF's gegenereerd door tests/integration/make_invoice.py (tekstlaag + raster-EPC-betaal-QR + verificatie-QR).

00.2 Release gate per item

#ItemResultaatBewijs
1Planner roept de worker echt aanPASSplanner-HTTP-antwoord 200 {"attempted":2}, heartbeat bijgewerkt zonder handmatige aanroep (preview-omgeving)
2Verschuldigde levering opgepakt + herstel ontvangerPASSontvanger 503 → pending (backoff 57 s gemeten) → ontvanger 200 → planner levert, delivered, attempts=2
3Geen dubbele levering bij parallelle worker-runsPASS4 parallelle runs → ontvanger kreeg exact 1 verzoek; totaal exact 2 verzoeken na retry
4Dead letter na max pogingen + auditPASSremediation-suite (8 pogingen → failed, audit webhook.delivery_failed)
5Worker-auth en misbruikbeperkingPASSzonder/vervalst geheim 401; pad met projectsleutel max 12 runs/min globaal → 429, fail-closed
6Heartbeat zichtbaar in Instellingen → Operationele monitoringPASSingelogd gecontroleerd (browser): "Retry-worker laatste run" toont de planner-run, wachtrij 0, dead letters 0
7Positieve E2E prepare → QR → definitieve PDF → finalize → GETPASS201 VERIFIED protected:true, id = reservering, URL stabiel, verificatie-QR herkend
8SHA-256 = exacte definitieve PDFPASSantwoord-hash = lokale hash = hash van opgeslagen bewijsbestand
9Idempotente herhalingPASS200 zelfde id, protected:true, 1 record; prepare met gefinaliseerde sleutel → 409
10Aanpassing ná hashing nooit groenPASSfinalize met gewijzigde bytes → 409; ontvangercontrole → REVIEW
11Zichtbaar IBAN gewijzigd (QR origineel)PASSBLOCKED (IBAN_MISMATCH, TEXT_QR_IBAN_MISMATCH)
12Zichtbaar IBAN + betaal-QR gewijzigdPASSBLOCKED (QR_PAYMENT_MISMATCH, UNEXPECTED_PAYMENT_QR, …)
13Gemanipuleerde PDF als nieuwe registratiePASSBLOCKED, protected:false
14Fout vóór opslag (storage)PASSgeen factuur, claim vrijgegeven; retry → VERIFIED
15Fout ná opslag, vóór DB-commitPASSweesbestand verwijderd, reservering terug prepared; retry → id = reservering
16Fout ná DB-commit (link niet uitgegeven)PASS (na fix)record protected:false; retry met zelfde sleutel herstelt link, audit invoice.link_recovered
17Time-out / gecrashte verwerkerPASSclaim > 120 s wordt overgenomen; verse claim → 409 idempotency_in_progress
18Rate limiter bij DB-foutPASS503 service_unavailable, protected:false (fout geïnjecteerd; echte DB-uitval niet veroorzaakt)
19Parallel finalize, verschillende PDF'sPASS201 + 3×409, 1 factuur
20Cross-tenant / ingetrokken sleutelPASS404 / 401; heractivatie geweigerd (ook service role)
21Webhook-handtekening, manipulatie, replayPASSechte ontvangen handtekening klopt; gewijzigde body en > 300 s geweigerd; geen volledig IBAN
22Redirects niet gevolgdPASS302 → mislukte poging
23SSRF naar private IP via DNSPASSremediation-suite
24DNS-rebinding (TOCTOU tussen check en verbinding)NOT TESTEDrestrisico, zie 00.3
25Gescande PDF / vector-QRNOT TESTED (bekende beperking)vector-QR → "QR niet geverifieerd"; AI-OCR alleen adviserend
26Gepubliceerde omgevingNOT TESTEDbewust niet gepubliceerd

00.3 In deze ronde gevonden en hersteld

- Fout na DB-commit liet een factuur zonder verificatielink achter, en een retry herstelde die niet. Nu: een retry met hetzelfde verzoek geeft generatie 1 uit, maar alleen als er nooit een link bestond (een intrekking wordt nooit ongedaan gemaakt). Dit wordt gelogd als invoice.link_recovered.

- Worker-route via de publiek bekende projectsleutel had geen misbruiklimiet. Nu max 12 runs per minuut globaal, fail-closed.

- Onafgewerkt SQL-testscript verwijderd (draaide nooit; vervangen door echte API-tests).

Resterende risico's: DNS-rebinding-TOCTOU (de runtime verbindt niet met private netten, maar niet bewezen); actieve inhoud in gecomprimeerde PDF-objectstreams wordt niet gedetecteerd; geen databasetransactie over het hele registratiepad (wel compensatie + herstel, bewezen voor de vier foutpunten); de planner gebruikt de projectsleutel (geen geheim) als filter, de beveiliging zit in lease + backoff + limiet; de planner wijst naar de preview en moet na publicatie omgezet worden; retentie/opruiming (GDPR) niet geïmplementeerd.

00.4 Handmatige stappen voor de eerste Simpla-sandbox

1. In het dashboard een aparte sandbox-organisatie voor Simpla aanmaken, met een test-IBAN goedkeuren (geen echte bankgegevens).

2. API-sleutel met alleen invoices:write, invoices:read, events:read aanmaken en buiten chat/mail veilig overdragen.

3. Simpla levert een HTTPS-webhook-URL; registreren via Instellingen → Webhooks; testevent sturen; Simpla controleert de handtekening (referentie-ontvanger TypeScript/C#).

4. Simpla bouwt prepare → render met QR → finalize; toont "beschermd" alleen bij protected === true; 409/429/503 met Retry-After opnieuw proberen; pull-cursor als vangnet.

5. Samen de acceptatiematrix 00.2 #7–#13 met hun eigen PDF-rendering herhalen.

6. Pas daarna: definitief domein + PUBLIC_VERIFY_BASE_URL, planner naar gepubliceerde URL, modus pilot.

00.5 Aanwijzingen voor de volgende demofilm (niet gemaakt)

1. Dashboard → Instellingen: pilot-checklist en Operationele monitoring (heartbeat van de worker, lege dead-letter-wachtrij).

2. MyDocIT-simulator: prepare (URL + QR) → definitieve PDF met QR → finalize → Geverifieerd, beschermd; dezelfde knop nogmaals → zelfde factuur (idempotent).

3. Ontvanger op gsm: QR scannen → status, gemaskeerd IBAN, bedrag, mededeling.

4. "Ontvangen factuur controleren" met de gewijzigde PDF (alleen zichtbaar IBAN) → rode waarschuwing "Mogelijke factuurfraude — afwijking t.o.v. het geregistreerde origineel."

5. Webhooks: event verschijnt bij de ontvanger; ontvanger tijdelijk "plat" → automatische retry → geleverd.

6. Afsluiten met de release-gate (00.2) en de open punten (00.4) — geen claim "waterdicht" of "pilot ready".

0. Wijzigingen v1.1 — security & reliability remediation (8 oktober 2026)

Legenda testbewijs: [ECHT] = test tegen de echte database / echte HTTP-API van de draaiende app / echte auth-gebruikers; [UNIT] = pure logica; [MOCK] = I/O gesimuleerd. Geen enkele test is tegen een gepubliceerde omgeving uitgevoerd.

0.1 Status per risico

IDOnderwerpStatusImplementatieBewijs
R1protected bij replayDONEprotectionState() + deriveProtection(); gebruikt in 201, 200-replay, GET, webhook, simulator[ECHT] replay REVIEW/BLOCKED → false; replay == GET; [UNIT] 9 gevallen
R2Concurrency / idempotencyDONEAtomische claim: insert op PK (org_id,key) vóór elk werk; reservering prepared→finalizing via één conditionele UPDATE; unieke indexen (org_id, original_sha256) en (org_id, invoice_number, revision); stale-claim-overname na 120 s; 409 idempotency_in_progress + Retry-After[ECHT] 5 parallel met sleutel → 1; 4 parallel zonder sleutel → 1; 4 parallel finalize → 1 (id = reservering); andere PDF → 409
R3DeelfoutenDONE (deels)Compenserende verwijdering van bestand + vrijgeven claims vóór insert; ná insert is het record bewijs, zonder link → protected:falsecode-review; geen fault-injectietest
R4Ingetrokken API-sleutelsDONETrigger api_key_guard (ook service role), kolomrecht UPDATE(revoked_at), 401 + audit api_key.revoked_key_used[ECHT] herstel geweigerd (gebruiker én service role), scopes wijzigen geweigerd, 401, audit aanwezig
R5Rate limitingDONErateLimitState() → ok/limited/unavailable; API: 429 / 503; publieke functies weigeren[MOCK] RPC-fout en exception → unavailable. Echte DB-uitval niet gesimuleerd
R6Webhook-retriesDONE (lokaal) / OPEN (planner)Worker-route, lease, backoff, max 8, dead letter + audit; planner elke 5 min actief op de preview-URL[ECHT] worker 401 zonder/vervalst geheim, backoff, dead letter na 8. Planner→preview-URL nog niet end-to-end bevestigd (zie 0.4)
R7Tokenloze lookupDONEAlleen exacte hash; neutraal antwoordcode-review (opzoeking verwijderd)
R8Coherent statusmodelDONEprotected=false bij status≠VERIFIED, link ingetrokken/ontbreekt, vervangen door nieuwere revisie, open BLOCKED finding, of onleesbare staat; finding.created bevat protected:false; events in integration_events (outbox)[ECHT] open finding → false; revoke → false
R9SSRF / overigeDONEDNS-resolutie (DoH) vóór elke levering, redirects nooit gevolgd, directe Data-API-schrijfrechten op webhook_endpoints ingetrokken (alleen via gevalideerde serverfuncties); pg_net naar schema extensions[ECHT] host → 10.0.0.5 geweigerd; directe insert geweigerd. [UNIT] IP-reeksen. Restrisico: TOCTOU tussen DoH-check en fetch (Workers-runtime verbindt zelf niet met private netten)
PDFUploadcontroleDONE%PDF-, ≤15 MB (ook vóór inlezen op publieke route), versleutelde PDF's en actieve inhoud (/JavaScript, /JS, /Launch, /EmbeddedFile, /RichMedia, /XFA, /SubmitForm, /ImportData) geweigerd[ECHT] JS-PDF → 400; [UNIT] 7 gevallen; bestaande 5 originelen gescand → 0 geweigerd. Beperking: markers in gecomprimeerde objectstreams zijn niet zichtbaar

Ongewijzigd en bevestigd: QR wordt alleen server-side gedecodeerd, elke betaal-QR afzonderlijk vergeleken; vector-QR → "QR niet geverifieerd"; AI-OCR is adviserend; een IBAN uit een PDF wordt nooit automatisch goedgekeurd.

0.2 Statusmatrix protected

statuslink actiefnieuwere revisieopen BLOCKED findingstaat leesbaarprotected
VERIFIEDjanee0jatrue
VERIFIEDnee (ingetrokken/ontbreekt)–––false (verification_link_inactive)
VERIFIEDjaja––false (superseded)
VERIFIEDjanee≥1–false (open_blocked_finding)
REVIEW / BLOCKED––––false (status_review / status_blocked)
––––nee (DB-fout)false (state_unavailable)

Elk foutantwoord blijft protected:false. MyDocIT mag "beschermd" alleen tonen bij protected === true uit het laatste antwoord of GET /invoices/{id}.

0.3 Nieuwe/gewijzigde antwoorden

- 409 idempotency_in_progress (+ Retry-After: 5): identiek verzoek of finalize nog bezig → zelfde verzoek later herhalen.

- 503 service_unavailable (+ Retry-After: 30): rate limiter niet beschikbaar (fail-closed).

- 429 rate_limited heeft nu Retry-After.

- protection_reasons: string[] in 201, 200 en GET /invoices/{id}.

- 400 registration_failed: PDF versleuteld of met actieve inhoud.

- Interne route POST /api/public/cron/webhooks (geen integratiecontract; bearer = cron-geheim of projectsleutel van de planner; enkel verschuldigde leveringen, veilig bij herhaald aanroepen).

0.4 Werkelijke testresultaten (8 oktober 2026)

- bunx vitest run src/lib → 76/76 PASS (guard 48, webhook 3, remediation 25) [UNIT/MOCK].

- bun tests/integration/remediation.ts → 34/34 PASS [ECHT] tegen de echte database en de lokaal draaiende app, met twee blijvende testtenants "ZZ-TEST Tenant A/B (geautomatiseerde tests)" en twee testgebruikers itest-tenant-a/b@billver.test.

- Niet echt getest: uitval van de echte database (alleen gemockt), verlopen token (alleen unit), MyDocIT-zijde, gepubliceerde omgeving, geplande aanroep door de planner (eerste run volgt na het bijwerken van de preview), positief pad protected:true via de API (testfactuur kreeg REVIEW door de testtenant-gegevens).

- Databaselinter: 6 resterende meldingen, alle bewust: 3× "RLS zonder policy" (rate_limits, app_config, idempotency_keys — alleen server); 3× "SECURITY DEFINER uitvoerbaar door ingelogde gebruikers" (create_organization, is_org_member, has_org_role — nodig voor onboarding/RLS, antwoorden alleen voor de ingelogde gebruiker).

0.5 Release-gate eerste pilot (alles moet afgevinkt zijn)

- [x] R1–R9 DONE of gedocumenteerd restrisico (zie 0.1)

- [x] Unit- en echte integratietests groen

- [x] Linter: geen nieuwe of onverklaarde meldingen

- [ ] Planner-aanroep van de retry-worker bevestigd (Instellingen → Operationele monitoring → Retry-worker laatste run)

- [ ] Definitief HTTPS-verificatiedomein + PUBLIC_VERIFY_BASE_URL; planner-URL omzetten naar de gepubliceerde omgeving

- [ ] Modus pilot (testverwijdering verdwijnt)

- [ ] API-sleutel voor MyDocIT met minimale scopes; webhook-endpoint van MyDocIT geregistreerd en testevent geverifieerd

- [ ] MyDocIT-zijde gebouwd: prepare → render → finalize, protected alleen uit antwoord, 409/429/503 met retry, pull-cursor als vangnet

- [ ] Positief end-to-end pad met echte pilotfactuur → protected:true, en gewijzigde kopie → BLOCKED

- [ ] Retentie-/opruimjob en verwijderverzoeken (GDPR) — niet geïmplementeerd

- [ ] Verwerkersovereenkomst juridisch nagekeken, onafhankelijke pentest uitgevoerd

- [ ] Monitoring: dagelijks dead letters, open BLOCKED findings en pogingen met ingetrokken sleutels nakijken

0.6 Operationele monitoring

Dashboard → Instellingen → Operationele monitoring: webhook-wachtrij, dead letters, laatste worker-run, open BLOCKED findings, pogingen met ingetrokken sleutels (24u), beschikbaarheid rate limiter. Nieuwe auditacties: api_key.revoked_key_used, webhook.delivery_failed. Gestructureerde serverlogs: rate_limit_unavailable, webhook_worker_run (zonder IBAN, sleutels of tokens).

0. Samenvatting voor Simpla

1. MyDocIT rendert de factuur, vraagt vóór het finaliseren een verificatie-URL + QR aan (POST /api/public/v1/invoices/prepare), plaatst die QR in de PDF, en stuurt de definitieve, te verzenden PDF naar POST /api/public/v1/invoices met reservation_id. De SHA-256 van exact die bytes is het canonieke bewijs.

2. Het antwoord bevat status (VERIFIED/REVIEW/BLOCKED) en protected. MyDocIT mag een factuur alleen als beschermd tonen bij protected: true op een 201 (zie §6.3 — bij 200-replay de status opnieuw opvragen).

3. Terugkoppeling: ondertekende webhooks (push) + GET /api/public/v1/events?after=<cursor> (pull, verliest niets) + GET /api/public/v1/invoices/{id} (actuele status).

4. Bij uitval van Billver: factuur nooit als beschermd labelen; verzenden zonder Billver-cue of uitstellen is een keuze van MyDocIT (open vraag §11).

1. Feitelijke architectuur en trust boundaries

1.1 Componenten

 MyDocIT / ERP / CRM (server-side)                         Ontvanger (browser, geen account)
        |  Bearer API-key (igk_live_…)                              |  /v/<token>, /verifieer
        v                                                           v
 +----------------------------- Billver (TanStack Start, edge worker) ----------------+
 | /api/public/v1/*  (server routes, eigen API-key check)                                  |
 | server functions: dashboard (sessie-JWT + rolcheck), public (anoniem, rate-limited)     |
 | src/lib/guard/*   pure logica: IBAN, extractie, engine, QR-parse, tokens, webhooks       |
 | src/lib/invoices.server.ts  registratie, vergelijking, tokens  (service role, server)   |
 +-----------------------------------------------------------------------------------------+
        |  service role (alleen server)                 |  RLS (browser dashboard, alleen lezen + enkele updates)
        v                                               v
 Postgres (RLS op alle tabellen, triggers voor onveranderlijkheid) + privé storage-bucket "invoice-files"

1.2 Trust boundaries [I]

GrensWat wordt vertrouwdWat NIET
Integratie → APIAPI-key (SHA-256-lookup, scope, niet-ingetrokken) bepaalt de tenant (org_id).org_id uit de request wordt nooit gelezen; de tenant komt uitsluitend uit de sleutel.
PDF-bytesDe hash over de ontvangen bytes.Tekst/IBAN/QR in de PDF worden server-side gelezen en vergeleken, nooit als goedkeuring gebruikt. Een IBAN uit een PDF wordt nooit automatisch goedgekeurd.
QR-inhoudWordt server-side gedecodeerd (unpdf extractImages + jsQR).QR-data die de client aanlevert wordt nergens geaccepteerd.
Publieke URLBasis uit config PUBLIC_VERIFY_BASE_URL / PUBLIC_APP_URL.Nooit uit Host/X-Forwarded-Host (host-header spoofing).
OntvangerAlleen de token in het pad.Nooit betaalgegevens uit query-parameters.
Browser-dashboardSessie-JWT; RLS per tenant.Geen service-role sleutel in de browser; bevoorrechte schrijfacties via server functions met rolcheck.

1.3 Rollen en tenant-isolatie

- Rollen (org_role): owner, admin, user in memberships. [I]

- Controles: is_org_member(_org) en has_org_role(_org, _roles) — SECURITY DEFINER, vaste search_path, antwoorden alleen voor auth.uid() (een vreemde org_id levert false). [I] [T: alleen de pure regel "cross-tenant org id uit client input wordt geweigerd"] [NV tegen echte DB]

- RLS staat aan op alle publieke tabellen; lezen via is_org_member, beheer via has_org_role(owner|admin). [I] [NV automatisch]

- Bevoegdheden:

Actieuseradminowner
Factuur registreren (dashboard)jajaja
API-sleutel maken/intrekken–jaja
Webhooks beheren, geheim tonen–jaja
Verificatielink vernieuwen/intrekken–jaja
Tampertest–jaja
Testfactuur verwijderen––ja, en alleen in modus development (server fn én DB-functie)

- Onveranderlijkheid (DB-triggers) [I]: invoices (canonieke velden + hash niet wijzigbaar, delete geweigerd behalve via delete_test_invoice in development), invoice_files (geen update), verification_events (geen update), audit_events (append-only), approved_bank_accounts (IBAN onveranderlijk, geen delete, ingetrokken kan niet opnieuw goedgekeurd), verification_tokens (identiteit onveranderlijk, ingetrokken blijft ingetrokken).

- Modus (app_config.deployment_mode: development|pilot|production; onbekend = production, fail-closed). Huidige stand in deze omgeving: development. [I]

2. API-endpoints (exact)

Basis: https://<verificatiedomein>/api/public/v1. Alle antwoorden: content-type: application/json, cache-control: no-store. Elk foutantwoord van de registratie-endpoints bevat "protected": false.

2.0 Authenticatie

Authorization: Bearer igk_live_<43 tekens base64url> — de sleutel moet beginnen met igk_live_ en ≤ 200 tekens zijn, anders 401. Server zoekt sha256(sleutel) op in api_keys; ingetrokken = 401. last_used_at wordt bijgewerkt.

2.1 Rate limits [I]

SleutelLimiet
Schrijven (prepare, invoices POST) per API-key120 / 60 s
Lezen (invoices/{id}, events) per API-key300 / 60 s
Publieke verificatiepagina per IP-hash60 / 10 min
Publieke PDF-controle per IP-hash20 / 10 min
Reveal volledige IBAN per IP-hash+token / per IP-hash5 / uur, 20 / uur

[I][T] v1.1: rate limiting is fail-closed. Faalt de limiter-RPC, dan antwoordt de API 503 service_unavailable (Retry-After: 30, protected:false); bij overschrijding 429 rate_limited met Retry-After. Publieke pagina's weigeren dan ook (neutraal "rate_limited").

2.2 POST /invoices/prepare — stap 1

- Scope: invoices:write

- Content-type: application/json (body optioneel)

- Headers: Idempotency-Key (aanbevolen), X-Source (optioneel, ^[a-z0-9_-]{2,32}$, anders api)

- Sleutelbron, in volgorde: header Idempotency-Key → body idempotency_key → body external_ref. Verplicht, max 128 tekens.

Request:

POST /api/public/v1/invoices/prepare
Authorization: Bearer igk_live_XXXXXXXX
Idempotency-Key: mydocit-tenant42-INV-2026-0036
X-Source: mydocit
Content-Type: application/json

{ "external_ref": "INV-2026-0036" }

Response 201:

{
  "reservation_id": "6f1c0c1e-7a7b-4b8e-9d55-0b6f3c2a9e10",
  "expires_at": "2026-10-15T13:00:00.000Z",
  "verification_url": "https://<verificatiedomein>/v/igv_<40 hex>",
  "qr_svg": "<svg …>…</svg>",
  "verification_url_production_ready": false,
  "verification_url_warning": "PUBLIC_VERIFY_BASE_URL is niet ingesteld. …",
  "note": "Link is pas actief na finalize met de definitieve PDF."
}

Gedrag: dezelfde sleutel opnieuw (status prepared) geeft dezelfde reservation_id en exact dezelfde URL terug (token = HMAC over reservation_id:1). Reservering vervalt na 7 dagen (DB-default).

StatuserrorWanneer
401unauthorizedsleutel ontbreekt/onbekend/ingetrokken
403forbiddengeen invoices:write
429rate_limited
409idempotency_conflictsleutel al gefinaliseerd
503verification_domain_not_configuredmodus pilot/production zonder geldige HTTPS-basis-URL
400registration_failedsleutel leeg/te lang, of DB-fout

2.3 POST /invoices — registreren / finalize (stap 2)

- Scope: invoices:write

- Content-type: multipart/form-data

- Velden: file (PDF, %PDF--header, 1 byte – 15 MB), metadata (JSON-string; alternatief: losse formuliervelden), optioneel reservation_id (UUID), optioneel source.

- Headers: Idempotency-Key (anders metadata.external_ref), X-Source.

metadata (zod registrationSchema):

VeldTypeVerplichtRegel
invoice_numberstringja1–64
supplier_namestringja1–200
supplier_vatstringja4–32
customer_namestring\nullnee≤200
customer_vatstring\nullnee≤32
invoice_date, due_dateYYYY-MM-DD\nullnee
amountnumber\stringjatotaal incl. btw in eenheden; server rondt af op centen
amount_excl_vat, vat_amountnumber\string\nullnee
currencystringjaISO 4217, 3 letters; nooit aangenomen
ibanstringja15–40; wordt genormaliseerd
bicstring\nullnee≤11
structured_referencestring\nullnee≤40
external_refstring\nullnee≤128

Request:

curl -X POST https://<verificatiedomein>/api/public/v1/invoices \
  -H "Authorization: Bearer igk_live_XXXXXXXX" \
  -H "Idempotency-Key: mydocit-tenant42-INV-2026-0036" \
  -H "X-Source: mydocit" \
  -F "file=@final.pdf;type=application/pdf" \
  -F "reservation_id=6f1c0c1e-7a7b-4b8e-9d55-0b6f3c2a9e10" \
  -F 'metadata={"invoice_number":"2026036","supplier_name":"Voorbeeld BV","supplier_vat":"BE0123456749","customer_name":"Klant NV","invoice_date":"2026-09-29","due_date":"2026-10-13","amount":"645.94","amount_excl_vat":"533.83","vat_amount":"112.11","currency":"EUR","iban":"BE00 0000 0000 0000","bic":"GKCCBEBB","structured_reference":"+++000/0000/00000+++","external_ref":"INV-2026-0036"}'

(IBAN en referentie zijn plaatshouders.)

Response 201 (nieuw):

{
  "duplicate": false,
  "invoice_id": "6f1c0c1e-7a7b-4b8e-9d55-0b6f3c2a9e10",
  "sha256": "<64 hex over exact de geüploade bytes>",
  "status": "VERIFIED",
  "findings": [],
  "layers": { "…": "per controlelaag" },
  "qr": { "status": "decoded", "count": 2, "payment": [{ "iban_masked": "BE00 •••• •••• 0000", "amount_cents": 64594, "currency": "EUR", "reference": "…", "name": "…" }], "other": 1 },
  "verification_token": "igv_<40 hex>",
  "verification_url": "https://<verificatiedomein>/v/igv_<40 hex>",
  "verification_qr_embedded": true,
  "qr_list": [ { "…": "elke gevonden QR, gemaskeerd" } ],
  "verification_url_production_ready": false,
  "verification_url_warning": "…",
  "protected": true
}

verification_qr_embedded: true = de gereserveerde URL is als raster-QR in de PDF gevonden; false = QR's gedecodeerd maar de gereserveerde niet; null = niet vast te stellen (bv. vector-QR) — nooit aangenomen.

Response 200 (duplicaat / replay):

{ "duplicate": true, "idempotent_replay": true, "invoice_id": "…", "sha256": "…",
  "link": { "active": true, "legacy": false, "url": "https://…/v/igv_…", "generation": 1, "base": "…", "productionReady": false, "warning": "…" },
  "protected": true }

Opgelost in v1.1 (R1): elk antwoord (201, 200-replay, GET /invoices/{id}, webhook invoice.registration_accepted) berekent protected uit de actuele server-side staat via protectionState(); plus protection_reasons[]. Zie §0.2.

StatuserrorWanneer
201–nieuwe canonieke factuur
200–duplicaat: zelfde Idempotency-Key + zelfde bytes, zelfde reservering + zelfde bytes, of zelfde factuurnummer + zelfde hash
400multipart_expectedgeen multipart
400file_missinggeen file
400registration_failedgeen PDF, te groot, onbekende/vreemde/verlopen reservering, opslag- of DB-fout
401unauthorized
403forbidden
409idempotency_conflictzelfde sleutel met andere bytes, of reservering al gefinaliseerd met andere bytes
422invalid_metadatametadata geen JSON of schemafout (issues[])
422invalid_reservation_idgeen UUID
429rate_limited
503verification_domain_not_configured

Elke weigering na een geldige sleutel (400/409/422/503, niet 401/403/429) genereert ook het webhook-event invoice.registration_rejected.

Herregistratie met hetzelfde factuurnummer en andere bytes maakt een nieuwe revisie (revision+1, supersedes = vorige id) en wordt door de engine beoordeeld (DUPLICATE_CHANGED bij gewijzigde betaalgegevens → BLOCKED). Het origineel blijft onveranderd.

2.4 GET /invoices/{id} — actuele status

- Scope: invoices:read. Alleen facturen van de eigen tenant (anders 404). id moet UUID-vormig zijn.

Response 200:

{
  "invoice_id": "…", "invoice_number": "2026036", "external_ref": "INV-2026-0036",
  "status": "VERIFIED", "protected": true, "sha256": "…",
  "amount_cents": 64594, "currency": "EUR", "iban_masked": "BE00 •••• •••• 0000",
  "structured_reference": "…", "revision": 1, "supersedes": null, "superseded_by": null,
  "source": "mydocit", "created_at": "…", "qr_status": "decoded",
  "verification_link": { "active": true, "url": "https://…/v/igv_…" },
  "checks": { "total": 3, "blocked": 1, "review": 0, "last_at": "…" },
  "open_findings": [ { "rule": "IBAN_MISMATCH", "severity": "BLOCKED", "created_at": "…" } ]
}

protected = status == VERIFIED && link actief && geen nieuwere revisie. Tampertests tellen niet mee in checks. Fouten: 401, 403, 404 not_found, 429.

2.5 GET /events?after=&limit=&type= — pull-fallback

- Scope: invoices:read. after = laatst verwerkte sequence (default 0), limit 1–200 (default 100), type optioneel (^[a-z_.]{3,64}$).

{ "events": [ { "id": "<event uuid>", "sequence": 1042, "type": "finding.created", "created_at": "…", "invoice_id": "…", "data": { } } ],
  "next_after": 1042, "has_more": false }

Fouten: 401, 403, 429.

2.6 Niet-API interfaces (ter info, niet voor Simpla)

Dashboard en publieke pagina's gebruiken TanStack server functions (dashboard.functions.ts, public.functions.ts, webhooks.functions.ts). Dit zijn geen stabiele contracten.

3. Prepare → QR → definitieve PDF → finalize

MyDocIT                                   Billver
  | POST /prepare (Idempotency-Key K)  -->  reservering R (status prepared, 7 d)
  | <-- reservation_id R, verification_url U (token = HMAC(secret, "verify-token:R:1")), qr_svg
  | render definitieve PDF met QR(U)
  | POST /invoices (file=final.pdf, reservation_id=R, Idempotency-Key K)
  |                                     -->  sha256(final bytes) = canoniek bewijs
  |                                          invoice.id := R, token generatie 1 => U wordt actief
  | <-- 201 {status, protected, verification_qr_embedded, sha256}
  | verzend exact dezelfde bytes (niet opnieuw renderen/comprimeren/ondertekenen!)

3.1 SHA-256 bewijslogica [I] [T]

- sha256 = SHA-256(rauwe bytes van het multipart-bestand); geen normalisatie. Opgeslagen in invoices.original_sha256 en invoice_files(kind=original); bestand in privé storage orgId/invoiceId/original.pdf met upsert:false.

- Elke byte-wijziging na finalize (her-opslaan, PDF/A-conversie, digitale handtekening, e-mailgateway die stempels toevoegt) geeft een andere hash. De engine geeft dan REVIEW als alle kritieke betaalvelden gelijk blijven ("document verschilt, betaalgegevens gelijk"), BLOCKED bij afwijkend IBAN/totaal/valuta/referentie/betaal-QR. [T: F, G]

- Daarom: hash = de bytes die werkelijk verzonden worden. Als MyDocIT de PDF na finalize nog digitaal ondertekent, moet dat vóór finalize gebeuren (open vraag §11).

3.2 Idempotency [I] [T: beslisregel J]

- Prepare: verification_reservations met unieke (org_id, idempotency_key).

- Finalize: idempotency_keys met sleutel reg:<Idempotency-Key> per tenant, opgeslagen na succes: zelfde sleutel + zelfde hash → replay (200); andere hash → 409.

- Reservering: niet-prepared + zelfde hash → replay; andere hash → 409.

- Aanbevolen sleutel: mydocit:<mydocit-tenant-id>:<factuur-id> — stabiel over retries, uniek per factuur.

3.3 Concurrency — eerlijk beeld [NV]

- De idempotency-controle is check-then-insert, geen transactie. Twee gelijktijdige finalize-verzoeken met dezelfde sleutel:

- met reservation_id: beide gebruiken invoice.id = R en storage-pad met upsert:false; het tweede faalt op opslag of op de primaire sleutel → 400 registration_failed. Er ontstaat geen tweede canonieke factuur, maar de tweede beller krijgt een fout i.p.v. een replay (en moet GET /invoices/R doen).

- zonder reservation_id: v1.1 — unieke index (org_id, original_sha256) + (org_id, invoice_number, revision); de verliezer van de race krijgt een 200-replay van de winnaar (identieke bytes) of 409 (concurrente revisie).

- Niet-atomaire volgorde: opslag → invoice → file → extractie → findings → token. Faalt een latere stap (bv. token), dan kan er een factuur zonder actieve link bestaan; het antwoord is dan een fout (protected:false), en GET /invoices/{id} toont verification_link.active:false. v1.1: bij mislukte invoice-insert wordt het opgeslagen bestand verwijderd en worden idempotency-/reserveringsclaims vrijgegeven; na de insert is het record bewijs en blijft het (zonder actieve link → protected:false).

- Advies voor MyDocIT: altijd prepare gebruiken, per factuur één finalize tegelijk, en bij 400/time-out eerst GET /invoices/{reservation_id} voor een retry.

4. API-key lifecycle [I]

- Aanmaken: owner/admin in dashboard (createApiKeyFn). Formaat igk_live_ + 32 random bytes (base64url). Opgeslagen: key_hash = sha256(key), key_prefix (eerste 14 tekens), scopes, created_by. De volledige sleutel wordt één keer getoond.

- Scopes: momenteel altijd beide invoices:write + invoices:read (niet kiesbaar in UI) [I]. Fijnere scopes [NI].

- Gebruik: last_used_at bij elke geldige call.

- Intrekken: dashboard zet revoked_at (RLS-update voor owner/admin). Werkt onmiddellijk (volgende call 401). Audit-events api_key.created / api_key.revoked via trigger.

- Rotatie: nieuwe sleutel maken → in MyDocIT zetten → oude intrekken. Geen automatische vervaldatum [NI].

- Opgelost in v1.1 (R4): trigger api_key_guard (ook voor service role): ingetrokken = definitief; naam/hash/prefix/scopes/org onwijzigbaar. Ingelogde gebruikers hebben alleen kolomrecht UPDATE (revoked_at). Gebruik van een ingetrokken sleutel → 401 + audit api_key.revoked_key_used.

- Nooit in een browser/client van MyDocIT; alleen server-side, bij voorkeur per MyDocIT-tenant één sleutel.

5. Webhooks [I] [T: signing/replay/SSRF/backoff]

5.1 Events (exact)

EventWanneerdata (velden)
invoice.registration_acceptednieuwe canonieke factuur (ook REVIEW/BLOCKED)invoice_id, invoice_number, external_ref, idempotency_key, source, status, protected, sha256, revision, supersedes, rules[], iban_last4, verification_url, verification_qr_embedded
invoice.registration_rejectedAPI-weigering na geldige sleutelerror, http_status, idempotency_key, protected:false, [invoice_number, external_ref]
verification.checkedontvanger/publieke PDF-controle (niet tampertest)invoice_id, invoice_number, external_ref, verification_event_id, channel, result, exact_original, rules[]
finding.createdBLOCKED-afwijking bij controleidem + severity:"BLOCKED", rules[], found_iban_last4[]
verification_token.rotatedadmin vernieuwt linkinvoice_id, generation, verification_url, note
verification_token.revokedadmin trekt link ininvoice_id, protected:false

Nooit een volledig IBAN; enkel iban_last4. Replays (200) en duplicaten genereren geen event. Testknop in dashboard verstuurt verification.checked met data.test = true, invoice_id: null.

5.2 Envelope en headers

POST <uw https-endpoint>
content-type: application/json
user-agent: Billver-Webhooks/1
x-event-id: 0b1e…               (uuid, stabiel over retries)
x-event-type: finding.created
x-signature: t=1791464400,v1=<64 hex>

{"id":"0b1e…","sequence":1042,"type":"finding.created","created_at":"…","invoice_id":"…","data":{…}}

- v1 = HMAC-SHA256(secret, "<t>.<rauwe body>"), hex.

- Geheim: whsec_<64 hex>, afgeleid HMAC(VERIFY_TOKEN_SECRET, "webhook:<endpoint_id>"), niet opgeslagen; owner/admin kan het opnieuw tonen. Roteren van het webhookgeheim = endpoint verwijderen en opnieuw aanmaken.

- Replay-venster: referentie-implementatie verifyWebhookSignature weigert |now − t| > 300 s. Dit is ontvangerszijde: Simpla moet dit zelf controleren.

5.3 Levering, retries, dedupe

- Timeout 5 s, redirect: manual (3xx = mislukt), succes = 2xx.

- Backoff na poging n: min(60·2^(n−1), 21600) s → 1, 2, 4, 8, 16, 32, 64 min…; max 8 pogingen, daarna failed.

- v1.1 (R6): onafhankelijke retry-worker POST /api/public/cron/webhooks, door de databaseplanner elke 5 minuten aangeroepen (alleen als er een verschuldigde levering is). Leveringen worden eerst geleased (conditionele UPDATE), dus overlappende runs sturen geen dubbele poging. Backoff 1m·2^(n−1) tot 6u, max 8 pogingen → failed (dead letter) + audit webhook.delivery_failed. Redirects worden nooit gevolgd (3xx = fout). Vóór elke poging: URL-beleid + DNS-resolutie (alle A/AAAA moeten publiek zijn, anders geweigerd: dns_private_address). Pull-API blijft de bron van waarheid.

- Volgorde is niet gegarandeerd; levering is at-least-once. Dedupe op x-event-id (= id); orden op sequence.

- Endpoints: max 5 per tenant, alleen https, poort 443, geen IP-adressen/localhost/.local/.internal/single-label hosts, geen credentials in URL (SSRF-validatie bij aanmaak). DNS-rebinding naar interne IP's wordt niet gecontroleerd [NI].

5.4 Pull-fallback

GET /events?after=<laatste sequence> tot has_more=false. Cursor = monotone sequence (bigint identity). Events worden niet verwijderd [I]; er is geen retentie op events [NI].

5.5 Referentie-verificatie (TypeScript, Node 18+)

import crypto from "node:crypto";
export function verify(secret: string, header: string, rawBody: string, now = Math.floor(Date.now()/1000)) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header); if (!m) return false;
  if (Math.abs(now - Number(m[1])) > 300) return false;
  const exp = crypto.createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest();
  return crypto.timingSafeEqual(exp, Buffer.from(m[2], "hex"));
}

C#:

static bool Verify(string secret, string header, string rawBody, long now) {
  var m = Regex.Match(header, "^t=(\\d+),v1=([0-9a-f]{64})$"); if (!m.Success) return false;
  var t = long.Parse(m.Groups[1].Value); if (Math.Abs(now - t) > 300) return false;
  using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
  var exp = h.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
  return CryptographicOperations.FixedTimeEquals(exp, Convert.FromHexString(m.Groups[2].Value));
}

Verifieer over de rauwe body, vóór JSON-parsing.

6. Statusmodel

6.1 Statussen [I] [T]

StatusBetekenisKenmerkende regels
VERIFIEDKomt overeen met het geregistreerde origineel en een goedgekeurde bedrijfsrekening. Geen uitspraak over rekeninghouder/bankeigendom.–
REVIEWNakijken; geen fraudeclaim.HASH_MISMATCH (alleen hash), DATE_MISMATCH, VAT_AMOUNT_MISMATCH, PARTY_MISMATCH, METADATA_MISMATCH, QR_UNVERIFIED, NO_TEXT_LAYER, UNKNOWN_IBAN (registratie)
BLOCKEDKritieke afwijking t.o.v. het geregistreerde origineel.IBAN_MISMATCH, AMOUNT_MISMATCH, CURRENCY_MISMATCH, REFERENCE_MISMATCH, QR_MISMATCH / QR_PAYMENT_MISMATCH, TEXT_QR_IBAN_MISMATCH, UNEXPECTED_PAYMENT_QR, MULTIPLE_PAYMENT_QR (bij conflict), QR_IBAN_NOT_APPROVED, DUPLICATE_CHANGED, IBAN_FORMAT_INVALID

(De exacte ernst per regel staat in src/lib/guard/engine.ts; deze tabel is een samenvatting.) "Geldig IBAN-formaat" (checksum) is nooit bewijs van eigendom.

6.2 Twee soorten status

1. Registratiestatus (invoices.status): vastgezet bij registratie, daarna onveranderlijk (trigger). Wordt bv. BLOCKED als de factuur-IBAN niet goedgekeurd is of tekst-IBAN ≠ QR-IBAN.

2. Controleresultaat (verification_events.result): per ontvangerscontrole. Verandert de registratiestatus niet; een BLOCKED-controle maakt een security_findings-rij + finding.created.

6.3 protected-vlag

- In GET /invoices/{id}: status==VERIFIED && link actief && niet vervangen door nieuwere revisie. Dit is de bron van waarheid.

- Wordt false door: link intrekken (verification_token.revoked), nieuwere revisie. Link vernieuwen houdt protected maar de oude QR/URL werkt niet meer (verification_token.rotated).

- Open BLOCKED-findings veranderen protected momenteel niet; ze staan in open_findings. Simpla moet beide tonen (open vraag).

- 201 én 200-replay: protected = afgeleid uit actuele staat (§0.2).

7. Ontvangende verificatie en beperkingen

7.1 Flow [I] [NV mobiel alleen manueel]

/v/<token>: geen login; status, leverancier, factuurnummer, totaal+valuta, gemaskeerd IBAN, referentie; noindex, geen PII in query. Onbekende/misvormde/ingetrokken/verlopen tokens geven één generiek antwoord [T: G, H]. Volledig IBAN pas na HMAC-ondertekende challenge (5 min geldig) + proof-of-work (moeilijkheid 4) + rate limit; nooit bij BLOCKED. "Ontvangen factuur controleren" vergelijkt de upload met exact de factuur achter de token.

v1.1 (R7): /verifieer zonder token matcht alleen een byte-identiek origineel (exacte SHA-256 = bezit van het origineel). Er is geen opzoeking meer op btw-nummer + factuurnummer. Elk ander bestand krijgt één neutraal antwoord ("Geen exact origineel gevonden") zonder canonieke gegevens; veldvergelijking vereist de verificatielink/QR.

7.2 Beperkingen (expliciet)

- QR-spoofing: een QR is enkel een pointer. Vervangt een aanvaller de hele verificatie-QR door een eigen nep-site, dan helpt Billver niet tenzij de ontvanger het domein herkent. Mitigatie: vast, herkenbaar verificatiedomein (nog niet gekozen), domeincue op de pagina, sender_domains. [NI: definitief domein]

- Gescande PDF's / OCR: geen OCR. Zonder tekstlaag → NO_TEXT_LAYER/REVIEW. [NI]

- Vector-QR: alleen raster-QR's in afbeeldingen worden gedecodeerd (max 5 pagina's, payload ≤ 2000 tekens). Vector-getekende QR → "QR niet geverifieerd" (REVIEW), nooit verzonnen. MyDocIT moet de QR als rasterafbeelding (PNG, ≥ 40×40 px) insluiten om verification_qr_embedded:true en QR-vergelijking te krijgen.

- Meerdere IBAN's in tekst: eerste wordt gebruikt met waarschuwing.

- VoP (Verification of Payee): [NI]. Geen enkele uitspraak over naam van de rekeninghouder.

- AI-controle: alleen adviserend in dashboard, nooit statusbepalend.

8. Privacy / GDPR / retentie

OnderwerpStand
IBAN-maskering in UI, events, audit[I] (iban_last4, BE80 •••• •••• 1377)
IP-adressen[I] alleen HMAC-hash (32 hex); user-agent ≤200 tekens bewaard
Bestandsopslag[I] privé bucket, alleen server-side toegang; encryptie-at-rest = platformstandaard [NV]
organizations.retention_dayskolom bestaat [I]; geen verwijderjob [NI]
Vergelijkingsuploadsexpires_at wordt gezet (default 90 d) [I]; geen opruimjob [NI]
Events/webhook-leveringen/auditgeen retentie [NI]
Inzage/export/verwijdering betrokkene[NI]
Verwerkersovereenkomst, DPIA, register[NI]
Datalocatieniet vastgelegd in dit dossier [NV]
Onveranderlijk bewijs vs. recht op wissenspanning niet juridisch uitgewerkt [NI]

9. Implementatiehandleiding (MyDocIT als adapter #1, generiek voor ERP/CRM)

9.1 Configuratie in MyDocIT

- Per MyDocIT-tenant: BILLVER_BASE_URL, BILLVER_API_KEY (server-side secret store), BILLVER_WEBHOOK_SECRET.

- Leveranciers-IBAN moet vooraf in Billver als goedgekeurde bedrijfsrekening staan; anders wordt de registratie BLOCKED (UNKNOWN_IBAN/QR_IBAN_NOT_APPROVED).

9.2 TypeScript (server-side)

type Prep = { reservation_id: string; verification_url: string; qr_svg: string; verification_url_production_ready: boolean };
const H = (k: string) => ({ Authorization: `Bearer ${process.env.BILLVER_API_KEY}`, "Idempotency-Key": k, "X-Source": "mydocit" });

export async function protectAndSend(inv: Invoice) {
  const key = `mydocit:${inv.tenantId}:${inv.id}`;
  let prep: Prep;
  try {
    const r = await fetch(`${BASE}/api/public/v1/invoices/prepare`, { method: "POST", headers: { ...H(key), "content-type": "application/json" }, body: JSON.stringify({ external_ref: inv.id }), signal: AbortSignal.timeout(10_000) });
    if (r.status !== 201) return fallback(inv, `prepare ${r.status}`);
    prep = await r.json();
  } catch (e) { return fallback(inv, "prepare unreachable"); }

  const pdf = await renderFinalPdf(inv, { qrPng: await svgToPng(prep.qr_svg), verifyUrl: prep.verification_url }); // raster QR!
  const fd = new FormData();
  fd.set("file", new Blob([pdf], { type: "application/pdf" }), "invoice.pdf");
  fd.set("reservation_id", prep.reservation_id);
  fd.set("metadata", JSON.stringify(toMeta(inv)));

  const res = await finalizeWithRetry(fd, key, prep.reservation_id);
  if (!res.ok) return fallback(inv, res.reason);           // nooit "beschermd"
  await db.saveGuard(inv.id, { igId: res.body.invoice_id, sha256: res.body.sha256, status: res.body.status, protected: res.body.protected });
  if (res.body.status === "BLOCKED") return holdForReview(inv); // niet verzenden
  await send(inv, pdf);                                    // exact dezelfde bytes
}

async function finalizeWithRetry(fd: FormData, key: string, id: string) {
  for (let i = 0; i < 4; i++) {
    try {
      const r = await fetch(`${BASE}/api/public/v1/invoices`, { method: "POST", headers: H(key), body: fd, signal: AbortSignal.timeout(30_000) });
      if (r.status === 201) return { ok: true, body: await r.json() };
      if (r.status === 200) return { ok: true, body: await getStatus(id) };      // optioneel sinds v1.1 (replay bevat al actuele protected)
      if (r.status === 409 || r.status === 422 || r.status === 403 || r.status === 401) return { ok: false, reason: `final ${r.status}` };
      if (r.status === 400) { const s = await getStatus(id).catch(() => null); if (s) return { ok: true, body: s }; } // mogelijk race (§3.3)
    } catch { /* time-out: status opvragen */ const s = await getStatus(id).catch(() => null); if (s) return { ok: true, body: s }; }
    await sleep(2 ** i * 1000);
  }
  return { ok: false, reason: "finalize failed" };
}
const getStatus = async (id: string) => { const r = await fetch(`${BASE}/api/public/v1/invoices/${id}`, { headers: H("") }); if (r.status !== 200) throw 0; const b = await r.json(); return { ...b, invoice_id: b.invoice_id }; };

(getStatus stuurt een lege Idempotency-Key mee; die wordt bij GET genegeerd.)

9.3 Fallback-regel (verplicht)

Bij elke fout, time-out, 5xx, 503 of protected:false: geen "beschermd"-label, geen Billver-cue in de mail, de gerenderde QR mag niet als werkend worden gepresenteerd (een niet-gefinaliseerde reservering geeft op de verificatiepagina het generieke "niet gevonden"). Of de factuur toch verzonden wordt, is een keuze van MyDocIT (open vraag).

9.4 Webhook-ontvanger (pseudocode)

on POST /billver/webhook (raw body):
  if !verify(secret, header x-signature, raw): return 401
  if seen(x-event-id): return 200
  store(event); mark seen; return 200 quickly (verwerk asynchroon)
nightly/5 min: GET /events?after=<cursor> until has_more=false  -> verwerk ontbrekende, cursor := next_after

9.5 Generieke ERP/CRM-adapter

Zelfde contract; enige verschil is X-Source (bv. teamleader, odoo, exact, billit — ^[a-z0-9_-]{2,32}$) en de bron van external_ref. Integraties zonder mogelijkheid om een QR in de PDF te zetten mogen de één-staps-flow gebruiken (POST /invoices zonder reservation_id) en de URL in de begeleidende mail opnemen; gelijktijdige retries leveren sinds v1.1 nog altijd één canonieke factuur (DB-unieke index), maar gebruik toch een Idempotency-Key.

10. Veiligheids- en acceptatietestmatrix

Huidige suite: bunx vitest run → 2 bestanden, 51 tests, 51 geslaagd (8 okt 2026). Alle geautomatiseerde tests testen pure logica; geen enkele test raakt de echte database, storage of het netwerk.

#ScenarioVerwachtStand
ACanonieke PDFVERIFIED[T]
BAlleen zichtbaar IBAN gewijzigd, QR origineelBLOCKED + TEXT_QR_IBAN_MISMATCH[T] + manueel bewezen (Vedelek)
CZichtbaar IBAN + QR gewijzigdBLOCKED[T] + manueel bewezen
DCanoniek + onverwachte 2e betaal-QRBLOCKED, MULTIPLE/UNEXPECTED[T]
E/FTotaal / referentie gewijzigdBLOCKED[T]
GAlleen hash andersREVIEW, geen fraudeclaim[T]
HOngeldige / ingetrokken tokengeneriek antwoord[T] (pure functie)
ICross-tenant: key A leest factuur B404[T: regel] [NV tegen DB/API]
JIdempotency replay / conflict200 / 409, geen 2e factuur[T: beslisregel] [NV API]
KFinalize-fout → nooit beschermdprotected:false[T: regel]
LVerwijderen in pilot/productiongeweigerd[T: regel] + DB-functie [NV]
MWebhook signature, tampering, replay > 300 sgeweigerd[T]
NWebhook SSRF-URL'sgeweigerd[T]
OGelijktijdige finalize, zelfde reservering1 factuur; 2e: 400[NV] — te testen
PGelijktijdige requests zonder sleutel1 factuurPASS — echte test (4 parallel → 1)
QRetry na time-outreplay of status via GET[NV]
RVervalste webhook-signature (ander secret)401 bij ontvanger[T helper] [NV ontvanger]
SToken revoke → verificatiepaginageneriek "niet gevonden", event revoked[T logica] [NV API]
TMisvormde PDF (geen %PDF-, 0 B, >15 MB, corrupt)400 / REVIEW NO_TEXT_LAYER[I] [NV]
UNep-QR (andere EPC-IBAN) bij registratieBLOCKED[T]
VNep-verificatie-QR naar ander domeinniet detecteerbaar door Billverbeperking (§7.2)
WBillver onbereikbaarMyDocIT labelt niet beschermd[NV] — Simpla test
XRate limit overschreden429[I] [NV]
YAdmin zet ingetrokken API-key terug actief via Data-APIgeweigerdPASS — echte test (ook service role geweigerd)
ZReplay van BLOCKED-registratieprotected:falsePASS — echte test

11. Open vragen, blockers, risico's

11.1 Open vragen voor Simpla

1. Ondertekent of wijzigt MyDocIT de PDF na rendering (digitale handtekening, PDF/A, Peppol-UBL met ingesloten PDF)? Dan moet finalize ná die stap.

2. Kan de QR als rasterafbeelding (PNG) worden ingesloten, en waar op de factuur?

3. Gedrag bij uitval: verzenden zonder bescherming, of wachten/uitstellen?

4. Moet BLOCKED bij registratie verzending tegenhouden in MyDocIT?

5. Eén API-key per MyDocIT-tenant, of een partnersleutel met sub-tenants (nu [NI])?

6. Welke events zijn nodig in de MyDocIT-UI en hoe worden open findings getoond?

7. Peppol: wordt de PDF-bijlage byte-identiek doorgegeven?

8. Verwacht volume per maand en piek per minuut (huidige limiet 120/min/sleutel)?

11.2 Risico's — zie §0.1 (status per risico na v1.1)

11.3 Exacte blockers — zie §0.5 (release-gate)