Skip to content

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):

{
  "status": "healthy",
  "message": "Portfolio API is running"
}

Example:

curl http://localhost:8000/api/health
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 address
  • message (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):

{
  "detail": "An error occurred while processing your request"
}

Example:

curl -X POST http://localhost:8000/api/contact \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "message": "Hello, I would like to discuss a project.",
    "company": "Example Corp"
  }'
GET /api/download-cv — CV Download

Download a CV PDF by language.

Query Parameters:

  • lang (optional): english (default) or nederlands

Response: PDF file (application/pdf) as a file download.

Error Response (404):

{ "detail": "CV file not found" }

Example:

curl -O -J http://localhost:8000/api/download-cv?lang=english
curl -O -J http://localhost:8000/api/download-cv?lang=nederlands
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:

curl http://localhost:8000/api/projects
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):

{ "detail": "Project 'unknown' not found" }

Example:

curl http://localhost:8000/api/projects/genai-framework
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=4 for 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:

curl http://localhost:8000/api/experience
curl http://localhost:8000/api/experience?top=4
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):

{ "detail": "Experience entry at index 99 not found" }

Example:

curl http://localhost:8000/api/experience/0   # Witteveen+Bos (current)
curl http://localhost:8000/api/experience/12  # University of Twente (oldest)
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:

curl http://localhost:8000/api/skills
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:

curl http://localhost:8000/api/awards
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:

curl http://localhost:8000/api/certifications
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 project
  • DELETE /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 entry
  • DELETE /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 category
  • PUT /api/skills/categories/{category_id} — replace a skill category
  • PATCH /api/skills/categories/{category_id} — partial update
  • DELETE /api/skills/categories/{category_id} — delete a category (204, cascades to skills)
  • POST /api/skills/categories/{category_id}/skills — add a skill to a category
  • PUT /api/skills/{skill_id} — replace a skill
  • PATCH /api/skills/{skill_id} — partial update a skill
  • DELETE /api/skills/{skill_id} — delete a skill (204)

Error Responses:

  • 401 — missing or invalid bearer token
  • 404 — resource not found
  • 503 — 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):

{ "token": "<token>", "role": "guest|admin" }

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):

{ "cvGeneration": true, "portfolioContentApi": true }
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):

{ "cvGeneration": true, "portfolioContentApi": false }

Response (200): Updated flag state (same shape as GET /api/feature-flags).

Example:

curl -X PATCH http://localhost:8000/api/feature-flags \
  -H "Authorization: Bearer <api-admin-token>" \
  -H "Content-Type: application/json" \
  -d '{"portfolioContentApi": true}'
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": "english" }
  • 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:

curl https://cms.dev.sven-relijveld.com/cms/api/project-pages
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:

curl "https://cms.dev.sven-relijveld.com/cms/api/project-pages?filters[slug][\$eq]=genai-framework&populate=*"
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.

{
  "data": {
    "id": 1,
    "documentId": "...",
    "headline": "...",
    "subheadline": "..."
  },
  "meta": {}
}

Example:

curl "https://cms.dev.sven-relijveld.com/cms/api/hero-section?populate=*"
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:

curl "https://cms.dev.sven-relijveld.com/cms/api/about-section?populate=*"

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:

  1. Notification email sent to the portfolio owner — contains all form details in a professional HTML template.
  2. 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:

json
{ "detail": "Rate limit exceeded" }
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

bash
# 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

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