API Endpoints¶
Base URL¶
| Environment | Subdomain | Notes |
|---|---|---|
| Local dev | localhost:8000 |
Direct to backend; use for curl, Python scripts, pytest |
| Dev | dev.sven-relijveld.com |
Requests proxied through nginx (/api/* → backend) |
| Production | www.sven-relijveld.com |
Same nginx proxy; managed TLS cert |
| UIGen admin (dev) | admin.dev.sven-relijveld.com |
UIGen shim proxy → UIGen serve |
| UIGen admin (prod) | admin.sven-relijveld.com |
UIGen shim proxy → UIGen serve |
| CMS admin (dev) | cms.dev.sven-relijveld.com |
Strapi admin UI and REST API |
| CMS admin (prod) | cms.sven-relijveld.com |
Strapi admin UI and REST API |
The frontend always uses relative /api/* paths — no host is hardcoded in client code. Vite proxies requests to localhost:8000 in development; nginx forwards them to the backend container in deployed environments. See Frontend Architecture.
Authentication¶
Public endpoints¶
All endpoints under /api/ are public by default — no authentication required.
Admin API¶
Admin write endpoints share the same /api/ prefix as public read endpoints but require a bearer token enforced at the router level.
Admin bearer token
Pass Authorization: Bearer <api-admin-token> as a request header. The token is stored in Azure Key Vault (api-admin-token secret) and injected at deploy time via API_ADMIN_TOKEN. A missing or incorrect token returns 401. Set API_ADMIN_TOKEN=<value> in .env to use admin endpoints locally.
UIGen admin portal¶
The UIGen admin portal at admin.sven-relijveld.com and admin.dev.sven-relijveld.com uses a two-step token flow.
CMS API¶
CMS API endpoints are public (no auth needed for reads). The Strapi admin panel at /admin requires Strapi admin credentials — separate from the portfolio admin token.
UIGen token flow
(1) Frontend calls GET /api/uigen-token → (2) backend returns {token, role} → (3) AdminModal opens popup to <uigen-subdomain>/auth?token=<token> → (4) shim writes sessionStorage["uigen_auth"] and redirects to / → (5) UIGen injects Authorization: Bearer <token> on every backend call.
Guest vs admin access
Guest (no bearer on /api/uigen-token): read-only, 1 hour expiry. Admin (valid api-admin-token bearer): echoed back, full write access. An invalid bearer returns 401 immediately — no guest fallback.
Endpoints¶
GET /api/health — Health Check
Check if the API is running.
Accessible via the nginx proxy at /api/health. A legacy GET /health route is also available for infra health probes but is excluded from the OpenAPI schema.
Response (200):
Example:
POST /api/contact — Contact Form Submission
Submit a contact form message.
Request Body:
{
"name": "John Doe",
"email": "john@example.com",
"message": "I'd like to discuss a project opportunity.",
"company": "Example Corp"
}
Fields:
name(required): Full name of the sender (min 2 characters)email(required): Valid email addressmessage(required): Message content (min 10, max 5000 characters)company(optional): Company name
Success Response (200):
{
"success": true,
"message": "Thank you for your message! I'll get back to you soon. A confirmation email has been sent to your address."
}
Error Response (400):
{
"detail": [
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
}
]
}
Error Response (500):
Example:
GET /api/download-cv — CV Download
Download a CV PDF by language.
Query Parameters:
lang(optional):english(default) ornederlands
Response: PDF file (application/pdf) as a file download.
Error Response (404):
Example:
GET /api/projects — Projects List
Return all project cards.
Response (200): Array of project card objects.
[
{
"title": "Scalable GenAI Framework for Unstructured Data",
"description": "...",
"longDescription": "...",
"technologies": ["Azure Databricks", "LangChain"],
"category": "Generative AI",
"status": "Production",
"impact": "...",
"demo": "/project/genai-framework",
"image": "https://...",
"client": "Witteveen+Bos"
}
]
Example:
GET /api/projects/{project_id} — Project Detail
Return full detail for a single project by slug.
Path Parameters:
project_id: project slug (e.g.genai-framework,data-platform-migration,metadata-ingestion,azure-batch-optimization,healthcare-cost-analysis,realtime-bi-integration)
Response (200): Project detail object with overview, challenge, solution, results, responsibilities, and optional embeddedApp / publicationUrl / thesisUrl.
Error Response (404):
Example:
GET /api/experience — Experience List
Return all work history entries, newest first.
Query Parameters:
top(optional): integer — return only the N most recent entries (e.g.?top=4for a homepage summary)
Response (200): Array of work entry objects.
[
{
"company": "Witteveen+Bos",
"role": "Solution Architect Azure Databricks Data & AI",
"period": "February 2025 – December 2025",
"location": "Through OneDNA",
"type": "Current",
"description": "...",
"responsibilities": ["..."],
"technologies": ["Azure Databricks", "MLflow 3.0"],
"note": null,
"additionalProjects": null,
"achievement": null
}
]
Example:
GET /api/experience/{index} — Experience Entry
Return a single work history entry by 0-based index.
Path Parameters:
index: integer, 0-based (0 = most recent entry)
Error Response (404):
Example:
GET /api/skills — Skills
Return all skill categories.
Response (200): Array of skill category objects.
[
{
"title": "Cloud Platforms",
"iconName": "Cloud",
"iconColor": "text-blue-600",
"skills": [
{ "name": "Microsoft Azure", "projects": ["All Enterprise Projects"] }
]
}
]
Example:
GET /api/awards — Awards
Return all awards ordered by sort_order.
Response (200): Array of award objects.
[
{
"id": "...",
"title": "...",
"organization": "...",
"year": "2024",
"description": "...",
"iconName": "Trophy",
"bgColor": "bg-yellow-100"
}
]
Example:
GET /api/certifications — Certifications
Return all certifications ordered by sort_order.
Response (200): Array of certification objects.
[
{
"id": "...",
"title": "...",
"description": "...",
"iconType": "azure",
"link": "https://...",
"linkText": "View certificate"
}
]
Example:
Admin write endpoints — projects, experience, skills
Create, update, and delete database content. All admin endpoints share the /api/ prefix with public endpoints. Bearer token is enforced at the router level — see Authentication above.
Projects:
POST /api/projects— create a project record (returns{"id": "...", "slug": "..."})PUT /api/projects/{slug}— replace a project (full update)PATCH /api/projects/{slug}— partial update a projectDELETE /api/projects/{slug}— delete a project (204)PATCH /api/projects/reorder— set sort order; body:{"order": ["slug1", "slug2", ...]}
Experience:
POST /api/experience— create a work entry (returns{"id": "..."})PUT /api/experience/{entry_id}— replace a work entry (UUID path param)PATCH /api/experience/{entry_id}— partial update a work entryDELETE /api/experience/{entry_id}— delete a work entry (204)PATCH /api/experience/reorder— set sort order; body:{"order": ["uuid1", "uuid2", ...]}
Skills:
POST /api/skills/categories— create a skill categoryPUT /api/skills/categories/{category_id}— replace a skill categoryPATCH /api/skills/categories/{category_id}— partial updateDELETE /api/skills/categories/{category_id}— delete a category (204, cascades to skills)POST /api/skills/categories/{category_id}/skills— add a skill to a categoryPUT /api/skills/{skill_id}— replace a skillPATCH /api/skills/{skill_id}— partial update a skillDELETE /api/skills/{skill_id}— delete a skill (204)
Error Responses:
401— missing or invalid bearer token404— resource not found503— database not configured
GET /api/uigen-token — UIGen Token
Get a scoped token for the UIGen API explorer. See UIGen token flow above.
Headers (optional): Authorization: Bearer <api-admin-token>
Response (200):
Error Response (401): returned when an Authorization header is present but the token does not match api-admin-token.
GET /api/feature-flags — Feature Flags
Get the current feature flag state as read from Azure App Configuration.
Response (200):
| Flag | Description |
|---|---|
cvGeneration |
CV generate button visible in CV download dialog |
portfolioContentApi |
Frontend fetches portfolio content from API instead of static build-time data |
Caching and fallback
Flag values are cached for 30 seconds per instance. Labels are environment-specific (dev / prod). Falls back to the FEATURE_CV_GENERATION env var if App Configuration is unreachable.
PATCH /api/feature-flags — Update Feature Flags (admin)
Toggle one or more feature flags. Requires an admin bearer token.
Request Body (all fields optional):
Response (200): Updated flag state (same shape as GET /api/feature-flags).
Example:
GET /api/cv/status · PATCH /api/cv/generate — CV Generation (feature-flagged)
Generate a CV PDF on demand.
Feature flag required
Only available when cv-generation is enabled in Azure App Configuration. When disabled, PATCH /api/cv/generate returns 404. Currently: enabled in both dev and prod.
Endpoints:
GET /api/cv/status— returns{"enabled": true/false}PATCH /api/cv/generate— triggers Bun publishpipe build, returns the resulting PDF
Request Body (PATCH /api/cv/generate):
lang(optional):"english"(default) or"nederlands"
Response: PDF file (application/pdf) on success. JSON error body on failure.
Why PATCH?
The PATCH method without a path parameter is used so that UIGen's view-hint classifier renders this as an action button rather than a form, which matches expected UX in the admin portal.
Strapi CMS API¶
The CMS exposes a standard Strapi v5 REST API at the /cms/ path prefix (proxied through nginx on the frontend).
CMS API base URLs:
| Environment | Base URL |
|---|---|
| Local | http://localhost:1337 |
| Dev | https://cms.dev.sven-relijveld.com |
| Prod | https://cms.sven-relijveld.com |
GET /cms/api/project-pages — List all project-page entries
Return all project-page entries from Strapi.
Response (200): Strapi collection response with data array and meta.
{
"data": [
{
"id": 1,
"documentId": "...",
"slug": "genai-framework",
"title": "Scalable GenAI Framework"
}
],
"meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 6 } }
}
Example:
GET /cms/api/project-pages?filters[slug][$eq]={slug}&populate=* — Get project page by slug
Fetch a single project-page entry by slug, with all relations and media populated.
Query Parameters:
filters[slug][$eq](required): the project slug (e.g.genai-framework)populate=*: populate all relations and media fields
Response (200): Strapi collection response with a single-item data array.
Error Response (200 with empty data): Strapi returns an empty data: [] array when no entry matches the filter.
Example:
GET /cms/api/hero-section?populate=* — Get hero section singleton
Fetch the hero section singleton with all media populated.
Query Parameters:
populate=*: populate all relations and media fields
Response (200): Strapi single-type response with a data object.
Example:
GET /cms/api/about-section?populate=* — Get about section singleton
Fetch the about section singleton with all media populated.
Query Parameters:
populate=*: populate all relations and media fields
Response (200): Strapi single-type response with a data object.
Example:
Public read permissions
All four endpoints above use Strapi public permissions — no authentication header is required. The Strapi admin UI at /admin requires Strapi admin credentials.
Email Behavior¶
When a contact form is submitted successfully:
- Notification email sent to the portfolio owner — contains all form details in a professional HTML template.
- Confirmation email sent to the form submitter — acknowledges receipt and includes the owner's branding.
Rate Limiting¶
Rate limiting is implemented via slowapi, keyed by client IP address. Exceeding a limit returns 429 Too Many Requests:
| Endpoint | Limit |
|---|---|
POST /api/contact |
5 / minute |
GET /api/download-cv |
10 / minute |
GET /api/projects, GET /api/projects/{id} |
60 / minute |
GET /api/experience, GET /api/experience/{index} |
60 / minute |
GET /api/skills |
60 / minute |
GET /api/awards |
60 / minute |
GET /api/certifications |
60 / minute |
GET /api/uigen-token |
no limit |
GET /api/feature-flags |
no limit |
PATCH /api/feature-flags |
no limit |
GET /api/cv/status |
no limit |
PATCH /api/cv/generate |
2 / minute |
CORS¶
CORS is configured to allow requests from all origins ("*") in the current build. This is intentional for local development; a stricter origin list (http://localhost:5173, https://www.sven-relijveld.com) is planned for production hardening.
Error Codes¶
| Code | Description |
|---|---|
| 200 | Success |
| 400 | Bad Request (validation error) |
| 401 | Unauthorized (missing or invalid bearer token) |
| 404 | Not Found (e.g. CV file missing, project slug unknown) |
| 422 | Unprocessable Entity (invalid data shape) |
| 429 | Too Many Requests |
| 500 | Internal Server Error |
Testing¶
Using curl¶
# Health check
curl http://localhost:8000/api/health
# Contact form
curl -X POST http://localhost:8000/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com","message":"Test message"}'
# Admin endpoint (requires token)
curl -X POST http://localhost:8000/api/projects \
-H "Authorization: Bearer <api-admin-token>" \
-H "Content-Type: application/json" \
-d '{"title":"New Project", ...}'
Using Python¶
import requests
# Health check
response = requests.get("http://localhost:8000/api/health")
print(response.json())
# Contact form
data = {
"name": "Test User",
"email": "test@example.com",
"message": "This is a test message",
"company": "Test Company"
}
response = requests.post("http://localhost:8000/api/contact", json=data)
print(response.json())
See Also¶
Page history
| Field | Value |
|---|---|
| Last updated | 2026-06-02 |
Changelog
| Date | PRs | Summary |
|---|---|---|
| 2026-03-02 | #43 | Clarify that frontend uses relative /api/* paths (no hardcoded host); update CORS and production URLs to www.sven-relijveld.com Outdated CORS origin https://portfolio.onedna.nl and production URL https://api.portfolio.onedna.nl |
| 2026-03-04 | #56, #59 | Add GET /api/download-cv endpoint; add name min-length and message max-length constraints to contact form fields; add 404 and 429 to error codes table |
| 2026-03-20 | #85 | Add projects, experience, and skills endpoints; document implemented rate limits via slowapi Placeholder rate limiting section that said no rate limiting was implemented |
| 2026-03-20 | #87 | Update Base URL section: production URL is now www.sven-relijveld.com (via nginx proxy); raw Container Apps URL demoted to a note Raw Container Apps URL listed as the primary production URL |
| 2026-03-22 | #89 | Rename health endpoint to GET /api/health (accessible via nginx); GET /health retained for infra probes but excluded from OpenAPI schema and docs; add dev.sven-relijveld.com to Base URL section GET /health as the primary public health endpoint |
| 2026-04-21 | #183, #184, #207, #247 | Add Admin API, UIGen Token, Feature Flags, and CV Generation endpoint sections; add new endpoints to rate limiting table |
| 2026-04-21 | Replace hardcoded URLs with subdomain references; expand authentication section with callout blocks for admin and UIGen auth flows Direct Container Apps URLs in Base URL section | |
| 2026-05-03 | #358, #362 | Remove /admin/ prefix from all admin write endpoints; add ?top=N query param to experience; add awards, certifications, and PATCH /api/feature-flags endpoints; add portfolioContentApi to feature-flags response; update CV generate method from GET to PATCH; update rate limiting table; update testing curl examples Stale /api/admin/* prefix throughout; GET /api/cv/generate (now PATCH) |
| 2026-06-02 | #484, #485, #486 | Add CMS base URLs and Strapi REST API endpoint section |