By Mahmoud Consultancy
Last updated: 2026-04-15
ValideerLeeftijd is a multi-method age verification API. Companies integrate a single API endpoint to verify a user's age. The backend routes the request to the right verification method (PSD2, iDIN, or Yivi) and returns a simple ageVerified: true/false result. Birth dates are never stored — only the boolean result lives in Redis for 15 minutes.
GitHub: https://github.com/mahmoudholding/valideerleeftijd
| Layer | Technology | |-------|-----------| | Backend | Spring Boot 3 (Java 21, Maven) | | Frontend | Angular 20 (standalone components) | | Session store | Redis (15-min TTL, no PII stored) | | Infrastructure | k3s on TransIP VPS | | CI/CD | GitHub Actions → GHCR → Helm |
┌────────────────────────────────────────────────────────────────────┐
│ ValideerLeeftijd Platform │
├──────────────────────────┬─────────────────────────────────────────┤
│ Developer Portal │ REST API │
│ Angular 20 │ Spring Boot 3 │
│ port 4305 │ port 8095 │
│ │ │
│ - API key display │ POST /api/v1/verify/initiate │
│ - Docs / quick start │ GET /api/v1/verify/{sessionId} │
│ - Method picker UI │ GET /api/v1/bank/authorize │
│ - Yivi QR display │ GET /api/v1/bank/callback │
│ │ GET /api/v1/idin/authorize │
│ │ GET /api/v1/idin/callback │
│ │ POST /api/v1/yivi/result/{token} │
└──────────────────────────┴─────────────────────────────────────────┘
│
┌───────────────────┼──────────────────────┐
▼ ▼ ▼
PSD2 (Rabobank) iDIN (Rabobank) Yivi (IRMA)
OAuth2 PKCE OIDC PKCE Selective disclosure
consent = ≥18 returns birth date over18/over21 attribute
min_age ≤ 18 min_age > 18 no bank account needed
| Request | AUTO resolves to | Why |
|---------|-----------------|-----|
| minimumAge ≤ 18 | PSD2 | Bank consent implies adult account holder |
| minimumAge > 18 | IDIN | Only method that returns birth date |
| method: "PSD2" | PSD2 | Explicit override |
| method: "IDIN" | IDIN | Explicit override |
| method: "YIVI" | YIVI | Explicit — never chosen by AUTO |
YIVI is never chosen by AUTO because it depends on an IRMA server being reachable at initiation time. Clients opt in explicitly.
POST /api/v1/verify/initiateAuth: X-API-Key header required
Request:
{
"minimumAge": 18,
"redirectUri": "https://yourapp.com/callback",
"method": "AUTO"
}
method: AUTO (default), PSD2, IDIN, or YIVIResponse — PSD2 / iDIN:
{
"sessionId": "uuid",
"bankRedirect": "https://oauth.rabobank.nl/openapi/oauth2/authorize?...",
"method": "PSD2",
"qrPayload": null,
"yiviToken": null
}
Response — Yivi:
{
"sessionId": "uuid",
"bankRedirect": null,
"method": "YIVI",
"qrPayload": "https://irma-server.example.com/irma/session/TOKEN",
"yiviToken": "TOKEN"
}
GET /api/v1/verify/{sessionId}Auth: X-API-Key header required
{
"sessionId": "uuid",
"status": "PENDING|COMPLETED|EXPIRED|BIRTH_DATE_UNAVAILABLE",
"ageVerified": true,
"minimumAge": 18,
"method": "PSD2|IDIN|YIVI",
"expiresAt": "2026-04-15T14:00:00Z"
}
POST /api/v1/yivi/result/{yiviToken}Auth: X-API-Key header required
Client-triggered poll: backend polls IRMA server and updates session status.
{ "irmaStatus": "DONE|CONNECTED|CANCELLED|TIMEOUT|ERROR" }
yiviToken is unknownirmaStatus: "ERROR" if IRMA server is unreachableYivi polling flow:
qrPayload + yiviToken from initiatePOST /api/v1/yivi/result/{yiviToken} until status ≠ CONNECTEDGET /api/v1/verify/{sessionId} for final ageVerified result| Component | Status | Notes |
|-----------|--------|-------|
| Spring Boot 3 backend | ✅ Done | 189 tests (31 Cucumber), all passing |
| Angular 20 developer portal | ✅ Done | 80 tests, 95.45% branch coverage |
| PSD2 Rabobank PKCE flow | ✅ Done | Sandbox pre-configured |
| iDIN OIDC flow | ✅ Done | IdinService + IdinController |
| Yivi/IRMA integration | ✅ Done | YiviService + YiviController; QR flow in portal (PR #32) |
| Multi-method picker (API + UI) | ✅ Done | AUTO/PSD2/IDIN/YIVI; card picker in portal |
| Cucumber BDD E2E tests | ✅ Done | 31 scenarios; WireMock IRMA mock for full Yivi flow |
| API key auth + rate limiting | ✅ Done | Bucket4j 100 req/min/IP |
| Redis session management | ✅ Done | 15-min TTL, no birth date stored |
| Helm chart / CI/CD | ✅ Done | k3s on TransIP VPS |
| iDIN sandbox credentials | ⏳ Pending | Register at developer.rabobank.nl (issue #27) |
| Yivi IRMA server (prod setup) | ⏳ Pending | Self-host or Yivi-hosted; configure app.yivi.server-url |
| ID document upload | Not started | Phase 3 — Onfido/Veriff |
| Face scan (Yoti) | Not started | Phase 4 |
| EUDI Wallet | Not started | Phase 4–5 |
# Full stack (recommended)
make docker-up
# backend → http://localhost:8095
# frontend → http://localhost:4305
# redis → localhost:6383
# Tests
make test-backend # 189 tests (31 Cucumber scenarios)
make test-frontend # 80 Angular Karma tests
# Quick API smoke test — PSD2
curl -X POST http://localhost:8095/api/v1/verify/initiate \
-H "X-API-Key: vl_test_local123" \
-H "Content-Type: application/json" \
-d '{"minimumAge": 18, "redirectUri": "http://localhost:4305/callback"}'
# Quick API smoke test — Yivi (needs irma server running on :8088)
curl -X POST http://localhost:8095/api/v1/verify/initiate \
-H "X-API-Key: vl_test_local123" \
-H "Content-Type: application/json" \
-d '{"minimumAge": 18, "redirectUri": "http://localhost:4305/callback", "method": "YIVI"}'
Yivi local setup:
# Install irma CLI and start local IRMA server
irma server --no-auth --url http://localhost:8088
# Backend auto-connects via app.yivi.server-url=http://localhost:8088
| Service | Port | |---------|------| | Backend API | 8095 | | Frontend dev | 4305 | | Redis (host) | 6383 | | IRMA server (local) | 8088 |
| Decision | Reason |
|----------|--------|
| No PostgreSQL | Sessions only — Redis TTL is sufficient, avoids storing PII |
| Consent-implies-18 | PSD2 consent requires adult account holder — legally valid |
| iDIN for min_age > 18 | Only Dutch bank method that returns birth date |
| YIVI never in AUTO | Avoids hard dependency on IRMA server availability at initiation time |
| Client-triggered Yivi poll | No inbound webhook required — works in local dev and CI without ngrok |
| BIRTH_DATE_UNAVAILABLE (HTTP 422) | Client needs to distinguish unavailable vs failure |
| vl_live_* / vl_test_* key format | Distinguish prod from sandbox keys |
| PKCE on all OAuth2 flows | PSD2 security best practice |
MethodPickerComponent)
Reacties