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)
| Onderdeel | Bestand | ||
|---|---|---|---|
| Provideradapters (Mailgun HMAC, Postmark Basic auth, Sandbox HMAC), scanner-interface | src/lib/guard/mail-inbound.ts | ||
| Pipeline: claim → alias → lease → scan → vergelijk → resultaat | src/lib/mail-inbound.server.ts | ||
| Webhook | POST /api/public/inbound/mail/{mailgu | postmark | sandbox}` |
| Opruimtaak | POST /api/public/cron/retention + DB-functie retention_cleanup() (alleen service role) | ||
| Sessies / ontvangstlog | tabellen 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-checklist | Dashboard → 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)
| Gegeven | Termijn |
|---|---|
| Ruwe mail | nooit opgeslagen (alleen in geheugen) |
| Mail Verify-sessie + resultaat | 24 uur |
Ontvangstlog (inbound_mail_events, zonder PII) | 30 dagen |
| Tijdelijke vergelijkings-PDF's | tot expires_at (org comparison_retention_days) — bestand én rij |
| Rate-limit tellers | 1 dag |
| Afgehandelde webhook-leveringen | 90 dagen |
| Niet-afgeronde idempotency-claims | 7 dagen |
| Verificatie-events | org retention_days (min. 30) |
| Audit events, canonieke originelen, verificatietokens | niet 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
| Gate | Status |
|---|---|
| Ondertekende inbound + replay + idempotency | PASS (sandbox, echt HTTP/DB) |
| Mailgun/Postmark adapter | PASS (unit) · NOT TESTED (live) |
| Fail-closed zonder provider/scanner | PASS |
| Geen false VERIFIED | PASS |
| Retentie | PASS (handmatige run) · NOT TESTED (geplande run) |
| Echt ontvangstdomein + MX | BLOCKED (domein) |
| Malwarescanner | BLOCKED (providerkeuze) |
| DPA met mailprovider + scanner | BLOCKED (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
| Vereiste | Detail |
|---|---|
| Auth | Authorization: Bearer igk_…, enkel server-side, per tenant één sleutel; rotatie = nieuwe sleutel aanmaken, oude intrekken |
| Bron | X-Source: mydocit (regex ^[a-z0-9_-]{2,32}$); andere waarden → api |
| Idempotency | Idempotency-Key = stabiele MyDocIT document-ID; zelfde key + ander bestand → 409 idempotency_conflict |
| Beschermd-label | alleen tonen als response protected: true; bij timeout/5xx/409 → NIET beschermd |
| Fouten | 401 unauthorized · 403 forbidden (scope) · 409 idempotency_conflict / idempotency_in_progress (retry-after) · 429 rate_limited · 503 service_unavailable / verification_domain_not_configured |
| Logging | nooit volledige IBAN of API key loggen |
Threat model: gecompromitteerd verzendersaccount
| Aanval | Effect | Mitigatie |
|---|---|---|
| Gestolen API key | Aanvaller 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 scenario | OPEN: 2FA verplicht voor owner/admin, vier-ogen voor IBAN-goedkeuring, afkoelperiode + mail naar alle owners |
| MyDocIT-platform zelf gecompromitteerd | Alle tenant-keys op dat platform | OPEN: 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?
| Situatie | Wat is bewezen | Status op scherm |
|---|---|---|
| Alleen link/QR geopend | Alleen wat de leverancier registreerde. Geen bewijs over de ontvangen bijlage | Ontvangen PDF: niet gecontroleerd |
| PDF geüpload, SHA-256 gelijk | Byte-identiek aan origineel | Origineel identiek |
| PDF geüpload, kritiek veld anders (IBAN, totaal, valuta, mededeling, betaal-QR, tekst-IBAN≠QR-IBAN, extra betaal-QR) | Afwijking t.o.v. origineel | Kritieke betaalgegevens gewijzigd (rood) |
| PDF geüpload, hash anders, betaalvelden gelijk | Document gewijzigd, betaaldata gelijk | Document 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
| Fase | Inhoud | Status |
|---|---|---|
| 1 | Provenance-label, ontvangen-PDF-statussen, optionele upload, codeparser, attestatiebibliotheek + tests | DONE |
| 2 | DNS-TXT ownership-verificatie, 2FA + vier-ogen IBAN-goedkeuring | OPEN |
| 3 | Signing key in secrets, keyring op officieel domein, attestatie in API-response | BLOCKED (domein) |
| 4 | Simpla adapter live in sandbox | BLOCKED (Simpla) |
| 5 | Mailclient-add-in met expliciete toestemming | OPEN |
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.
| # | Scenario | Wat de één-klik link toont | Wat WEL bewezen is | Wat NIET bewezen is |
|---|---|---|---|---|
| a | Echte e-mail, PDF ongewijzigd | Geregistreerde betaalgegevens | Leverancier registreerde deze betaalgegevens; PDF-vergelijking → VERIFIED | Dat de klik zelf bewijst dat de bijlage identiek is (pas na PDF-vergelijking) |
| b | PDF/IBAN gewijzigd, link ongewijzigd | Echte geregistreerde gegevens (afwijkend van de PDF) | Ontvanger ziet het echte IBAN (gemaskeerd); PDF-vergelijking → BLOCKED | Niets automatisch zonder dat de ontvanger kijkt of vergelijkt |
| c | PDF én link/QR vervangen door phishingdomein | Namaakpagina van de aanvaller | Niet verifieerbaar via de oorspronkelijke link. Enkel de onafhankelijke route (/controleer, zelf ingetypt domein) helpt | Een groen scherm, logo of QR bewijst niets |
| d | Volledig vervalste factuur van zogezegde leverancier | Geen of namaakpagina | Code op /controleer → generieke "niet gevonden" | Wanneer de ontvanger de echte leverancier niet kent: niets |
| e | Gecompromitteerd verzendersaccount | Door aanvaller geregistreerde gegevens | Audit-trail, IBAN-goedkeuring vereist expliciete admin-actie (nooit auto-approve uit PDF) | Bescherming als een admin zelf een vals IBAN goedkeurt |
| f | Gokken/misbruik van referenties | Generieke "niet gevonden" | Codes: 160-bit HMAC, niet-sequentieel; rate limit 60/10 min per IP-hash; reveal met proof-of-work + 5/u | — |
| g | Malafide leverancier | Correct geregistreerde gegevens | Enkel dat de leverancier dit registreerde | Betrouwbaarheid 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
| Suite | Commando | Wat is echt | Resultaat |
|---|---|---|---|
| Unit | bunx vitest run src/lib | pure logica, DB/DNS gemockt | 76/76 PASS |
| Remediation | bun tests/integration/remediation.ts | echte DB + lokale HTTP-API, tenants A/B | 34/34 PASS |
| Validatie | bun tests/integration/validation.ts main | echte DB, opslag, HTTP-API, externe webhook-ontvanger (webhook.site); foutinjectie via proxy om de echte client | 42/42 PASS |
| Planner | bun tests/integration/validation.ts cron (na main) | echte databaseplanner → preview-worker → externe ontvanger | 3/3 PASS |
| Typecheck | bunx tsgo --noEmit | – | geen fouten |
| DB-linter | Lovable 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
| # | Item | Resultaat | Bewijs |
|---|---|---|---|
| 1 | Planner roept de worker echt aan | PASS | planner-HTTP-antwoord 200 {"attempted":2}, heartbeat bijgewerkt zonder handmatige aanroep (preview-omgeving) |
| 2 | Verschuldigde levering opgepakt + herstel ontvanger | PASS | ontvanger 503 → pending (backoff 57 s gemeten) → ontvanger 200 → planner levert, delivered, attempts=2 |
| 3 | Geen dubbele levering bij parallelle worker-runs | PASS | 4 parallelle runs → ontvanger kreeg exact 1 verzoek; totaal exact 2 verzoeken na retry |
| 4 | Dead letter na max pogingen + audit | PASS | remediation-suite (8 pogingen → failed, audit webhook.delivery_failed) |
| 5 | Worker-auth en misbruikbeperking | PASS | zonder/vervalst geheim 401; pad met projectsleutel max 12 runs/min globaal → 429, fail-closed |
| 6 | Heartbeat zichtbaar in Instellingen → Operationele monitoring | PASS | ingelogd gecontroleerd (browser): "Retry-worker laatste run" toont de planner-run, wachtrij 0, dead letters 0 |
| 7 | Positieve E2E prepare → QR → definitieve PDF → finalize → GET | PASS | 201 VERIFIED protected:true, id = reservering, URL stabiel, verificatie-QR herkend |
| 8 | SHA-256 = exacte definitieve PDF | PASS | antwoord-hash = lokale hash = hash van opgeslagen bewijsbestand |
| 9 | Idempotente herhaling | PASS | 200 zelfde id, protected:true, 1 record; prepare met gefinaliseerde sleutel → 409 |
| 10 | Aanpassing ná hashing nooit groen | PASS | finalize met gewijzigde bytes → 409; ontvangercontrole → REVIEW |
| 11 | Zichtbaar IBAN gewijzigd (QR origineel) | PASS | BLOCKED (IBAN_MISMATCH, TEXT_QR_IBAN_MISMATCH) |
| 12 | Zichtbaar IBAN + betaal-QR gewijzigd | PASS | BLOCKED (QR_PAYMENT_MISMATCH, UNEXPECTED_PAYMENT_QR, …) |
| 13 | Gemanipuleerde PDF als nieuwe registratie | PASS | BLOCKED, protected:false |
| 14 | Fout vóór opslag (storage) | PASS | geen factuur, claim vrijgegeven; retry → VERIFIED |
| 15 | Fout ná opslag, vóór DB-commit | PASS | weesbestand verwijderd, reservering terug prepared; retry → id = reservering |
| 16 | Fout ná DB-commit (link niet uitgegeven) | PASS (na fix) | record protected:false; retry met zelfde sleutel herstelt link, audit invoice.link_recovered |
| 17 | Time-out / gecrashte verwerker | PASS | claim > 120 s wordt overgenomen; verse claim → 409 idempotency_in_progress |
| 18 | Rate limiter bij DB-fout | PASS | 503 service_unavailable, protected:false (fout geïnjecteerd; echte DB-uitval niet veroorzaakt) |
| 19 | Parallel finalize, verschillende PDF's | PASS | 201 + 3×409, 1 factuur |
| 20 | Cross-tenant / ingetrokken sleutel | PASS | 404 / 401; heractivatie geweigerd (ook service role) |
| 21 | Webhook-handtekening, manipulatie, replay | PASS | echte ontvangen handtekening klopt; gewijzigde body en > 300 s geweigerd; geen volledig IBAN |
| 22 | Redirects niet gevolgd | PASS | 302 → mislukte poging |
| 23 | SSRF naar private IP via DNS | PASS | remediation-suite |
| 24 | DNS-rebinding (TOCTOU tussen check en verbinding) | NOT TESTED | restrisico, zie 00.3 |
| 25 | Gescande PDF / vector-QR | NOT TESTED (bekende beperking) | vector-QR → "QR niet geverifieerd"; AI-OCR alleen adviserend |
| 26 | Gepubliceerde omgeving | NOT TESTED | bewust 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
| ID | Onderwerp | Status | Implementatie | Bewijs |
|---|---|---|---|---|
| R1 | protected bij replay | DONE | protectionState() + deriveProtection(); gebruikt in 201, 200-replay, GET, webhook, simulator | [ECHT] replay REVIEW/BLOCKED → false; replay == GET; [UNIT] 9 gevallen |
| R2 | Concurrency / idempotency | DONE | Atomische 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 |
| R3 | Deelfouten | DONE (deels) | Compenserende verwijdering van bestand + vrijgeven claims vóór insert; ná insert is het record bewijs, zonder link → protected:false | code-review; geen fault-injectietest |
| R4 | Ingetrokken API-sleutels | DONE | Trigger 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 |
| R5 | Rate limiting | DONE | rateLimitState() → ok/limited/unavailable; API: 429 / 503; publieke functies weigeren | [MOCK] RPC-fout en exception → unavailable. Echte DB-uitval niet gesimuleerd |
| R6 | Webhook-retries | DONE (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) |
| R7 | Tokenloze lookup | DONE | Alleen exacte hash; neutraal antwoord | code-review (opzoeking verwijderd) |
| R8 | Coherent statusmodel | DONE | protected=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 |
| R9 | SSRF / overige | DONE | DNS-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) |
| Uploadcontrole | DONE | %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
| status | link actief | nieuwere revisie | open BLOCKED finding | staat leesbaar | protected |
|---|---|---|---|---|---|
| VERIFIED | ja | nee | 0 | ja | true |
| VERIFIED | nee (ingetrokken/ontbreekt) | – | – | – | false (verification_link_inactive) |
| VERIFIED | ja | ja | – | – | false (superseded) |
| VERIFIED | ja | nee | ≥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]
| Grens | Wat wordt vertrouwd | Wat NIET |
|---|---|---|
| Integratie → API | API-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-bytes | De 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-inhoud | Wordt server-side gedecodeerd (unpdf extractImages + jsQR). | QR-data die de client aanlevert wordt nergens geaccepteerd. |
| Publieke URL | Basis uit config PUBLIC_VERIFY_BASE_URL / PUBLIC_APP_URL. | Nooit uit Host/X-Forwarded-Host (host-header spoofing). |
| Ontvanger | Alleen de token in het pad. | Nooit betaalgegevens uit query-parameters. |
| Browser-dashboard | Sessie-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:
| Actie | user | admin | owner |
|---|---|---|---|
| Factuur registreren (dashboard) | ja | ja | ja |
| API-sleutel maken/intrekken | – | ja | ja |
| Webhooks beheren, geheim tonen | – | ja | ja |
| Verificatielink vernieuwen/intrekken | – | ja | ja |
| Tampertest | – | ja | ja |
| 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]
| Sleutel | Limiet |
|---|---|
Schrijven (prepare, invoices POST) per API-key | 120 / 60 s |
Lezen (invoices/{id}, events) per API-key | 300 / 60 s |
| Publieke verificatiepagina per IP-hash | 60 / 10 min |
| Publieke PDF-controle per IP-hash | 20 / 10 min |
| Reveal volledige IBAN per IP-hash+token / per IP-hash | 5 / 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).
| Status | error | Wanneer |
|---|---|---|
| 401 | unauthorized | sleutel ontbreekt/onbekend/ingetrokken |
| 403 | forbidden | geen invoices:write |
| 429 | rate_limited | |
| 409 | idempotency_conflict | sleutel al gefinaliseerd |
| 503 | verification_domain_not_configured | modus pilot/production zonder geldige HTTPS-basis-URL |
| 400 | registration_failed | sleutel 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):
| Veld | Type | Verplicht | Regel | ||
|---|---|---|---|---|---|
| invoice_number | string | ja | 1–64 | ||
| supplier_name | string | ja | 1–200 | ||
| supplier_vat | string | ja | 4–32 | ||
| customer_name | string\ | null | nee | ≤200 | |
| customer_vat | string\ | null | nee | ≤32 | |
| invoice_date, due_date | YYYY-MM-DD\ | null | nee | ||
| amount | number\ | string | ja | totaal incl. btw in eenheden; server rondt af op centen | |
| amount_excl_vat, vat_amount | number\ | string\ | null | nee | |
| currency | string | ja | ISO 4217, 3 letters; nooit aangenomen | ||
| iban | string | ja | 15–40; wordt genormaliseerd | ||
| bic | string\ | null | nee | ≤11 | |
| structured_reference | string\ | null | nee | ≤40 | |
| external_ref | string\ | null | nee | ≤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.
| Status | error | Wanneer |
|---|---|---|
| 201 | – | nieuwe canonieke factuur |
| 200 | – | duplicaat: zelfde Idempotency-Key + zelfde bytes, zelfde reservering + zelfde bytes, of zelfde factuurnummer + zelfde hash |
| 400 | multipart_expected | geen multipart |
| 400 | file_missing | geen file |
| 400 | registration_failed | geen PDF, te groot, onbekende/vreemde/verlopen reservering, opslag- of DB-fout |
| 401 | unauthorized | |
| 403 | forbidden | |
| 409 | idempotency_conflict | zelfde sleutel met andere bytes, of reservering al gefinaliseerd met andere bytes |
| 422 | invalid_metadata | metadata geen JSON of schemafout (issues[]) |
| 422 | invalid_reservation_id | geen UUID |
| 429 | rate_limited | |
| 503 | verification_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)
| Event | Wanneer | data (velden) |
|---|---|---|
invoice.registration_accepted | nieuwe 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_rejected | API-weigering na geldige sleutel | error, http_status, idempotency_key, protected:false, [invoice_number, external_ref] |
verification.checked | ontvanger/publieke PDF-controle (niet tampertest) | invoice_id, invoice_number, external_ref, verification_event_id, channel, result, exact_original, rules[] |
finding.created | BLOCKED-afwijking bij controle | idem + severity:"BLOCKED", rules[], found_iban_last4[] |
verification_token.rotated | admin vernieuwt link | invoice_id, generation, verification_url, note |
verification_token.revoked | admin trekt link in | invoice_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]
| Status | Betekenis | Kenmerkende regels |
|---|---|---|
| VERIFIED | Komt overeen met het geregistreerde origineel en een goedgekeurde bedrijfsrekening. Geen uitspraak over rekeninghouder/bankeigendom. | – |
| REVIEW | Nakijken; geen fraudeclaim. | HASH_MISMATCH (alleen hash), DATE_MISMATCH, VAT_AMOUNT_MISMATCH, PARTY_MISMATCH, METADATA_MISMATCH, QR_UNVERIFIED, NO_TEXT_LAYER, UNKNOWN_IBAN (registratie) |
| BLOCKED | Kritieke 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
| Onderwerp | Stand |
|---|---|
| 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_days | kolom bestaat [I]; geen verwijderjob [NI] |
| Vergelijkingsuploads | expires_at wordt gezet (default 90 d) [I]; geen opruimjob [NI] |
| Events/webhook-leveringen/audit | geen retentie [NI] |
| Inzage/export/verwijdering betrokkene | [NI] |
| Verwerkersovereenkomst, DPIA, register | [NI] |
| Datalocatie | niet vastgelegd in dit dossier [NV] |
| Onveranderlijk bewijs vs. recht op wissen | spanning 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.
| # | Scenario | Verwacht | Stand |
|---|---|---|---|
| A | Canonieke PDF | VERIFIED | [T] |
| B | Alleen zichtbaar IBAN gewijzigd, QR origineel | BLOCKED + TEXT_QR_IBAN_MISMATCH | [T] + manueel bewezen (Vedelek) |
| C | Zichtbaar IBAN + QR gewijzigd | BLOCKED | [T] + manueel bewezen |
| D | Canoniek + onverwachte 2e betaal-QR | BLOCKED, MULTIPLE/UNEXPECTED | [T] |
| E/F | Totaal / referentie gewijzigd | BLOCKED | [T] |
| G | Alleen hash anders | REVIEW, geen fraudeclaim | [T] |
| H | Ongeldige / ingetrokken token | generiek antwoord | [T] (pure functie) |
| I | Cross-tenant: key A leest factuur B | 404 | [T: regel] [NV tegen DB/API] |
| J | Idempotency replay / conflict | 200 / 409, geen 2e factuur | [T: beslisregel] [NV API] |
| K | Finalize-fout → nooit beschermd | protected:false | [T: regel] |
| L | Verwijderen in pilot/production | geweigerd | [T: regel] + DB-functie [NV] |
| M | Webhook signature, tampering, replay > 300 s | geweigerd | [T] |
| N | Webhook SSRF-URL's | geweigerd | [T] |
| O | Gelijktijdige finalize, zelfde reservering | 1 factuur; 2e: 400 | [NV] — te testen |
| P | Gelijktijdige requests zonder sleutel | 1 factuur | PASS — echte test (4 parallel → 1) |
| Q | Retry na time-out | replay of status via GET | [NV] |
| R | Vervalste webhook-signature (ander secret) | 401 bij ontvanger | [T helper] [NV ontvanger] |
| S | Token revoke → verificatiepagina | generiek "niet gevonden", event revoked | [T logica] [NV API] |
| T | Misvormde PDF (geen %PDF-, 0 B, >15 MB, corrupt) | 400 / REVIEW NO_TEXT_LAYER | [I] [NV] |
| U | Nep-QR (andere EPC-IBAN) bij registratie | BLOCKED | [T] |
| V | Nep-verificatie-QR naar ander domein | niet detecteerbaar door Billver | beperking (§7.2) |
| W | Billver onbereikbaar | MyDocIT labelt niet beschermd | [NV] — Simpla test |
| X | Rate limit overschreden | 429 | [I] [NV] |
| Y | Admin zet ingetrokken API-key terug actief via Data-API | geweigerd | PASS — echte test (ook service role geweigerd) |
| Z | Replay van BLOCKED-registratie | protected:false | PASS — 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)?