LangerThuisPlus API
Authentication foundation for public, registered-user, and service-client principals. Tokens are opaque bearer credentials and must not be placed in URLs.
https://api.langerthuisinhuis.nl/v1
https://api-dev.langerthuisinhuis.nl/v1
Authorization: Bearer ltp_at_...
Content-Type: application/json unless an endpoint states otherwise.
How Authentication Works
- Call
POST /v1/account/registerorPOST /v1/account/loginwith only an email address. - Confirm
emailplus the 6-digit code from the email or universal link. - Store only the current
refresh_tokensecurely and useaccess_tokenfor API calls.
- Cookie-authenticated unsafe requests require
X-CSRF-Token. - CORS allows configured first-party origins with credentials.
- Use issued service-client credentials.
- Exchange
client_idandclient_secretat/v1/oauth/token. - Fixed service API keys can be sent directly as
Authorization: Bearer ltp_svc_key_.... - Never ship service secrets in frontend or mobile code.
Token Lifetimes
| Credential | Validity | Details |
|---|---|---|
| Access token | 15 minutes | Returned by email-code confirmation, refresh, and service-client token exchange. Use as Authorization: Bearer <token>. |
| Refresh token idle validity | 30 days | Registered sessions must refresh within this window. Each successful refresh rotates the token and extends idle validity. |
| Refresh token absolute validity | 180 days | Refresh rotation cannot extend beyond this session family limit. The response exposes refresh_expires_at for the absolute expiry. |
| Email login code | 15 minutes | Six digits and consumed once. Register and login return verification_required until this code is confirmed. |
Examples
Bootstrap app configThe app calls this unversioned endpoint before normal /v1 API calls. Query params override X-App-* headers. Force updates return HTTP 426 with the same JSON shape.
Request
GET /app/config?platform=ios&build=38&version=1.2.0Response
HTTP/1.1 426 Upgrade Required
Cache-Control: public, max-age=60
{
"schema_version": 1,
"update_policy": {
"mode": "force",
"latest_version": "1.4.0",
"latest_build": 42,
"minimum_supported_build": 40,
"soft_update_build": 41,
"store_url": "https://apps.apple.com/app/example",
"title": "Update vereist",
"message": "Werk de app bij om verder te gaan."
},
"maintenance": {
"enabled": false,
"title": null,
"message": null
},
"contact": {
"form_identifier": "contact"
},
"intro_screen": {
"title": "Welkom bij Langer Thuis in Huis",
"text": "Ontdek stap voor stap hoe de app u ondersteunt.",
"video_url": "https://media.example.nl/app/intro.mp4"
},
"features": {}
}Use an access tokenRegistered and service tokens are bearer tokens. Never send them in URLs.
Request
GET /v1/me
Authorization: Bearer ltp_at_example...Response
HTTP/1.1 200 OK
{
"principal": {
"principal_type": "registered_user",
"credential_type": "access_token",
"user_id": 42,
"uuid": "7fbff4a8-6dbd-4d52-b65d-672401ad7a21",
"scopes": [
"content:read",
"profile:read:own",
"profile:write:own",
"profile:avatar:read",
"account:read:own",
"account:write:own",
"session:read:own",
"session:revoke:own",
"assessment:read:own",
"assessment:write:own",
"exercise:progress:read:own",
"exercise:progress:write:own",
"progress:read:own",
"feed:events:write:own",
"notification:read:own",
"notification:write:own",
"community:read",
"community:write",
"community:chat:read",
"community:chat:write"
]
},
"account": {
"user_id": 42,
"email": "[email protected]",
"display_name": "Pim",
"email_verified": true,
"email_verified_at": "2026-07-06T12:00:00+00:00",
"signed_up": "2026-06-15T09:30:00+00:00",
"marketing_opt_in": {
"stable_identifier_type": "profile_uuid",
"stable_identifier": "7fbff4a8-6dbd-4d52-b65d-672401ad7a21",
"subscription_status": "subscribed",
"has_consent": true,
"can_prompt": false,
"source": "app",
"event_type": "marketing_opt_in",
"consented_at": "2026-07-06T12:30:00+00:00",
"unsubscribed_at": null,
"optin_url": "app://onboarding/newsletter"
}
},
"profile": {
"display_name": "Pim",
"avatar": null,
"birth_date": "1958-04-12",
"interests": ["walking", "healthy_living"],
"nutrition": {
"goals": ["eat_healthier"],
"diet_types": [],
"food_avoidances": ["nuts"],
"food_avoidances_other": null,
"eating_preferences": ["simple_meals"],
"meals_per_day": 3
},
"exercise": {
"goals": ["improve_balance"],
"frequency": "twice_weekly",
"preferences": ["at_home"],
"home_equipment": [],
"level": "beginner"
},
"answered_fields": [
"exercise.frequency",
"exercise.goals",
"exercise.home_equipment",
"exercise.level",
"exercise.preferences",
"nutrition.diet_types",
"nutrition.eating_preferences",
"nutrition.food_avoidances",
"nutrition.goals",
"nutrition.meals_per_day",
"personal.birth_date",
"personal.display_name",
"personal.interests"
],
"completion": {
"personal_information": {"complete": true, "answered": 3, "total": 3},
"recipe_preferences": {"complete": true, "answered": 5, "total": 5},
"exercise_preferences": {"complete": true, "answered": 5, "total": 5},
"overall": true
},
"revision": 4,
"updated_at": "2026-07-17T12:00:00+00:00"
}
}Refresh and rotate a sessionRefresh tokens rotate every time. Replace the stored refresh token with the new value immediately. If an old refresh token is reused, the whole family is revoked.
Request
POST /v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "ltp_rt_current..."
}Response
HTTP/1.1 200 OK
{
"access_token": "ltp_at_new...",
"refresh_token": "ltp_rt_new...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_expires_at": "2026-12-30T12:00:00+00:00",
"principal_type": "registered_user",
"user_id": 42,
"uuid": "7fbff4a8-6dbd-4d52-b65d-672401ad7a21",
"scopes": [
"content:read",
"profile:read:own",
"profile:write:own",
"profile:avatar:read"
]
}Request an email login codeRegister and login are passwordless. Login sends a 6-digit code or magic link for an existing account and returns account_not_found when the email is not registered.
Request
POST /v1/account/login
Content-Type: application/json
{
"email": "[email protected]",
"client_type": "native_app",
"device_id": "expo-installation-id",
"platform": "android",
"app_version": "1.0.0"
}Response
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"ok": true,
"status": "verification_required",
"email": "[email protected]",
"delivery": "email_sent_if_account_exists",
"verification_expires_at": "2026-07-06T12:15:00+00:00",
"expires_in": 900
}Confirm the 6-digit codeThe app confirms the code from the email or universal link. Tokens are returned only in the JSON response body, never in URLs.
Request
POST /v1/account/email-verification/confirm
Content-Type: application/json
{
"email": "[email protected]",
"verification_code": "123456",
"client_type": "native_app",
"device_id": "expo-installation-id"
}Response
HTTP/1.1 200 OK
{
"access_token": "ltp_at_example...",
"refresh_token": "ltp_rt_example...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_expires_at": "2026-12-30T12:00:00+00:00",
"principal_type": "registered_user",
"user_id": 42,
"uuid": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb",
"scopes": [
"content:read",
"profile:read:own",
"profile:write:own",
"profile:avatar:read",
"account:read:own",
"account:write:own",
"session:read:own",
"session:revoke:own",
"assessment:read:own",
"assessment:write:own",
"exercise:progress:read:own",
"exercise:progress:write:own",
"progress:read:own",
"feed:events:write:own",
"notification:read:own",
"notification:write:own",
"community:read",
"community:write",
"community:chat:read",
"community:chat:write"
]
}Service-client token exchangeUse issued client_id and client_secret credentials. Service secrets belong only on backend systems, never in the mobile app or browser frontend.
Request
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=partner_x&client_secret=ltp_svc_secret_exampleResponse
HTTP/1.1 200 OK
{
"access_token": "ltp_svc_at_example...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "service:auth content:read"
}Fixed service API keySome service clients can use a fixed API key directly as a bearer token. Treat it like a backend-only secret, give it only the scopes it needs, and rotate it periodically.
Request
GET /v1/me
Authorization: Bearer ltp_svc_key_example...Response
HTTP/1.1 200 OK
{
"principal": {
"principal_type": "service_client",
"credential_type": "fixed_api_key",
"service_client_id": 12,
"scopes": [
"content:read"
]
}
}Browser session CSRFNative app calls should prefer Authorization headers. Browser cookie-authenticated unsafe requests must include X-CSRF-Token.
Request
GET /v1/session/csrf
PATCH /v1/me
X-CSRF-Token: 83f7...
Content-Type: application/json
{
"display_name": "Pim",
"birth_date": "1958-04-12",
"interests": ["walking", "healthy_living"],
"nutrition": {
"goals": ["eat_healthier"],
"diet_types": [],
"food_avoidances": ["nuts"],
"food_avoidances_other": null,
"eating_preferences": ["simple_meals"],
"meals_per_day": 3
},
"exercise": {
"goals": ["improve_balance"],
"frequency": "twice_weekly",
"preferences": ["at_home"],
"home_equipment": [],
"level": "beginner"
}
}Response
HTTP/1.1 200 OK
{
"account": {
"user_id": 42,
"email": "[email protected]",
"display_name": "Pim"
}
}Start and answer fall-risk assessmentFall-risk assessment endpoints are registered-user only. The API derives UUID from the token, returns visible questions, and rejects stale autosaves.
Request
POST /v1/assessments/fall-risk/start
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{}
PUT /v1/assessments/fall-risk/asmt_7Kv9uV9K0pQf4N2x/answers/q1
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"value": true,
"revision": 1
}Response
HTTP/1.1 200 OK
{
"assessment": {
"id": "asmt_7Kv9uV9K0pQf4N2x",
"type": "fall_risk",
"version": 1,
"status": "in_progress",
"revision": 2
},
"state": {
"complete_allowed": false,
"next_question_key": "q2",
"result_level": null,
"result_final": false
},
"answers": {
"q1": true
}
}Submit a home scan and poll statusHome-scan endpoints are registered-user only. The existing Start action sends the agreement version shown beside its button, so acceptance and scan start happen together. A clearly wrong room returns needs_new_photos so the app can re-upload on the same scan; invalid completed analysis remains a separate terminal provider failure.
Request
POST /v1/assessments/home-scan/start
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"room_type": "bathroom",
"source": "native_app",
"agreement_version_code": "home-scan-quality-v2"
}
POST /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/photos
Authorization: Bearer ltp_at_registered...
Content-Type: multipart/form-data
photos[]=bathroom-1.jpg
photos[]=bathroom-2.jpg
POST /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/submit
Authorization: Bearer ltp_at_registered...
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x
Authorization: Bearer ltp_at_registered...Response
HTTP/1.1 200 OK
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"status": "queued",
"revision": 3,
"photo_count": 2,
"issue_count": 0,
"max_photos": 2,
"entitlement": {
"kind": "free_room",
"reserved": true,
"consumed": false
}
}
}
HTTP/1.1 200 OK
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "failed",
"revision": 4,
"photo_count": 2,
"issue_count": 0,
"max_photos": 2,
"entitlement": {
"kind": "free_room",
"reserved": false,
"consumed": false
},
"provider": {
"name": "openai_home_scan",
"status": "failed",
"request_id": "resp_0abb82639e46263d",
"attempts": 1,
"error": {
"code": "invalid_analysis_result",
"message": "Home scan analysis result was invalid.",
"retryable": false
}
},
"completed_at": null,
"failed_at": "2026-07-24T15:47:49+00:00"
}
}Replace photos that do not match the selected roomA clear mismatch is an actionable scan state, not a provider failure. The reservation is released, the provider remains completed, and a replacement upload clears the action before a corrected resubmission.
Request
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x
Authorization: Bearer ltp_at_registered...
POST /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/photos
Authorization: Bearer ltp_at_registered...
Content-Type: multipart/form-data
photos[]=bathroom-corrected-1.jpg
photos[]=bathroom-corrected-2.jpg
POST /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/submit
Authorization: Bearer ltp_at_registered...Response
HTTP/1.1 200 OK
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "needs_new_photos",
"photo_count": 2,
"issue_count": 0,
"entitlement": {"kind": "free_room", "reserved": false, "consumed": false},
"provider": {
"name": "openai_home_scan",
"status": "completed",
"request_id": "resp_example",
"attempts": 1,
"error": null
},
"action_required": {
"type": "reupload_photos",
"code": "room_mismatch",
"message": "De foto's lijken niet bij de gekozen ruimte Badkamer te passen. Upload nieuwe foto's van deze ruimte.",
"expected_room_type": "bathroom",
"detected_room_type": "living_room",
"mismatched_photo_slots": [1],
"photos_url": "/v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/photos"
}
}
}
HTTP/1.1 200 OK
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"status": "photos_uploaded",
"action_required": null
}
}Register an installation and enable Community pushThe app retains one installation UUID in SecureStore. Token registration and topic consent are separate: joining a group never enables Community push by itself.
Request
PUT /v1/me/push-installations/a61c7ceb-a071-4d51-a09a-e3cc99882280
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"provider": "expo",
"platform": "ios",
"push_token": "ExpoPushToken[example]",
"permission_status": "granted",
"app_version": "1.0.0",
"locale": "nl-NL",
"timezone": "Europe/Amsterdam"
}
PATCH /v1/me/notification-preferences/community.chat
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"push_enabled": true
}Response
HTTP/1.1 200 OK
Cache-Control: no-store
{
"topic": {
"code": "community.chat",
"label": "Communityberichten",
"description": "Ontvang meldingen over nieuwe berichten in uw groepen.",
"available": true,
"default_push_enabled": false,
"push_enabled": true,
"value_source": "user"
}
}Subscribe to a home-scan result and read the inboxOne push intent covers ready, failed, and needs-new-photos outcomes. A correction notification uses home_scan.needs_new_photos and opens the photo-capture screen.
Request
PUT /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/notification-subscription
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"push_enabled": true
}
GET /v1/me/notifications?limit=25
Authorization: Bearer ltp_at_registered...Response
HTTP/1.1 200 OK
Cache-Control: no-store
{
"items": [
{
"id": "ntf_example",
"schema_version": 1,
"type": "home_scan.ready",
"topic": "assessments.home_scan",
"title": "Uw woningcheck is klaar",
"body": "Bekijk de resultaten in de app.",
"presentation": {
"category": "assessment",
"icon": "home-scan",
"tone": "info",
"thumbnail_url": null
},
"action": {
"schema_version": 1,
"kind": "resource",
"resource_type": "home_scan_result",
"resource_id": "hscan_7Kv9uV9K0pQf4N2x",
"context": {}
},
"read": false,
"opened": false,
"created_at": "2026-07-24T11:00:00+00:00",
"expires_at": "2026-10-22T11:00:00+00:00"
}
],
"pagination": {"next_cursor": null, "has_more": false},
"unread_count": 1
}Cache a recipe or exercise catalogueRecipe and exercise responses are private for 60 seconds and carry a weak ETag derived from deterministic JSON. Send that ETag with If-None-Match; an unchanged response returns 304 with no body.
Request
GET /v1/recipes?locale=nl-NL&page=1&per_page=25
Authorization: Bearer ltp_at_registered...
If-None-Match: W/"sha256-digest"Response
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"Error response shapeAuthentication errors are generic except where a first-party flow needs an explicit branch. Login returns account_not_found when the email is not registered.
Request
GET /v1/me
Authorization: Bearer expired_or_invalid_tokenResponse
HTTP/1.1 401 Unauthorized
{
"error": {
"code": "invalid_token",
"message": "Authentication failed."
}
}Endpoint Reference
GET/v1/address/lookupPublic
Exact Dutch postcode and house-number lookup through PDOK. Returns address variants for selection. Default limits: 30/minute and 300/hour per verified IP; cached results count.
Request format
GET /v1/address/lookup?postcode=1118BN&house_number=800Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"addresses": [{
"postcode": "1118BN",
"house_number": 800,
"house_letter": "",
"house_number_addition": "",
"street": "Schiphol Boulevard",
"city": "Schiphol",
"formatted_address": "Schiphol Boulevard 800, 1118 BN Schiphol"
}],
"requires_selection": false
}Error codes
400 invalid_request404 address_not_found405 method_not_allowed429 rate_limited503 address_lookup_unavailable503 address_lookup_busy
GET/app/configPublic
Returns unversioned mobile app bootstrap config, contact form identifier, intro-screen content, maintenance state, feature flags, and update policy.
Request format
GET /app/config?platform=ios&build=38&version=1.2.0
Equivalent headers when query params are absent:
X-App-Platform: ios
X-App-Build: 38
X-App-Version: 1.2.0Example response
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json
{
"schema_version": 1,
"update_policy": {
"mode": "soft",
"latest_version": "1.4.0",
"latest_build": 42,
"minimum_supported_build": 40,
"soft_update_build": 41,
"store_url": "https://apps.apple.com/app/example",
"title": "Update beschikbaar",
"message": "Werk de app bij wanneer het uitkomt."
},
"maintenance": {
"enabled": false,
"title": null,
"message": null
},
"contact": {
"form_identifier": "contact"
},
"intro_screen": {
"title": "Welkom bij Langer Thuis in Huis",
"text": "Ontdek stap voor stap hoe de app u ondersteunt.",
"video_url": "https://media.example.nl/app/intro.mp4"
},
"features": {
"home_scan_enabled": true
}
}Error codes
400 invalid_request426 update_policy.mode=force500 server_error
GET/v1/healthPublic
Checks API availability and freshness of the top-level queue runner. Returns 503 when the runner is missing, failed, stale, or unavailable.
Request format
GET /v1/healthExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"service": "ltihweb-api",
"path": "/v1/health",
"time": "2026-07-22T12:00:00+00:00",
"queue": {
"ok": true,
"status": "succeeded",
"last_started_at": "2026-07-22T11:59:01+00:00",
"last_heartbeat_at": "2026-07-22T11:59:08+00:00",
"last_completed_at": "2026-07-22T11:59:08+00:00",
"last_success_at": "2026-07-22T11:59:08+00:00",
"age_seconds": 52,
"stale_after_seconds": 300,
"duration_ms": 7342,
"schedules_run": 1,
"required_lanes": 11,
"failed_lanes": 0
},
"mobile_notifications": {
"available": true,
"delivery_enabled": true,
"outbox": {
"counts": {"processed": 18},
"oldest_open_age_seconds": null
},
"deliveries": {
"counts": {"receipt_ok": 12},
"oldest_open_age_seconds": null
},
"community_aggregation": {
"counts": {"processed": 7},
"oldest_open_age_seconds": null,
"due_count": 0,
"oldest_overdue_age_seconds": null,
"last_successful_finalization_at": "2026-07-22T11:59:04Z"
},
"community_source": {
"cursor_id": 412,
"max_id": 412,
"lag": 0,
"cursor_updated_at": "2026-07-22T11:59:05Z",
"cursor_age_seconds": 3
},
"provider": {
"last_successful_send_at": "2026-07-22T11:58:02Z",
"last_successful_receipt_at": "2026-07-22T11:59:03Z",
"awaiting_receipt_count": 0,
"oldest_awaiting_receipt_age_seconds": null
},
"installations_by_key_version": {"v1": 8},
"revoked_installations_by_reason": {"device_not_registered": 2}
}
}Error codes
500 server_error
GET/v1/session/csrfPublic browser session
Returns a CSRF token for unsafe browser cookie-authenticated requests.
Request format
GET /v1/session/csrfExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"token": "83f7..."
}Error codes
500 server_error
POST/v1/auth/refreshRefresh token
Rotates a registered refresh token. Reusing an old token revokes the token family.
Request format
POST /v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "ltp_rt_current..."
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "ltp_at_example...",
"refresh_token": "ltp_rt_example...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_expires_at": "2026-12-30T12:00:00+00:00",
"principal_type": "registered_user",
"user_id": 42,
"uuid": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb",
"scopes": [
"content:read",
"profile:read:own",
"profile:write:own",
"profile:avatar:read",
"account:read:own",
"account:write:own",
"session:read:own",
"session:revoke:own",
"assessment:read:own",
"assessment:write:own",
"exercise:progress:read:own",
"exercise:progress:write:own",
"progress:read:own",
"feed:events:write:own",
"notification:read:own",
"notification:write:own",
"community:read",
"community:write",
"community:chat:read",
"community:chat:write"
]
}Error codes
400 invalid_request401 invalid_grant422 invalid_request500 server_error
POST/v1/auth/logoutAuthenticated or refresh token
Revokes the current session or the session for a submitted refresh token.
Request format
POST /v1/auth/logout
Authorization: Bearer ltp_at_example...
Content-Type: application/json
{
"refresh_token": "ltp_rt_current..."
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/auth/logout-allRegistered
Revokes all sessions for the current registered account.
Request format
POST /v1/auth/logout-all
Authorization: Bearer ltp_at_example...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/account/registerPublic
Creates or accepts a registered account request and sends a 6-digit email code.
Request format
POST /v1/account/register
Content-Type: application/json
{
"email": "[email protected]",
"display_name": "Pim",
"client_type": "native_app",
"device_id": "expo-installation-id"
}Example response
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"ok": true,
"status": "verification_required",
"email": "[email protected]",
"delivery": "email_sent_if_account_exists",
"verification_expires_at": "2026-07-06T12:15:00+00:00",
"expires_in": 900
}Error codes
400 invalid_request400 invalid_json422 invalid_request500 server_error
POST/v1/account/email-verification/resendPublic
Requests a fresh 15-minute email code for an active account. Always returns ok to avoid account enumeration.
Request format
POST /v1/account/email-verification/resend
Content-Type: application/json
{
"email": "[email protected]"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true
}Error codes
400 invalid_request400 invalid_json404 not_found500 server_error
POST/v1/account/email-verification/confirmEmail + 6-digit code
Consumes a one-time email code, marks the email verified, and returns registered app tokens.
Request format
POST /v1/account/email-verification/confirm
Content-Type: application/json
{
"email": "[email protected]",
"verification_code": "123456",
"client_type": "native_app",
"device_id": "expo-installation-id",
"platform": "ios",
"app_version": "1.0.0"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "ltp_at_example...",
"refresh_token": "ltp_rt_example...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_expires_at": "2026-12-30T12:00:00+00:00",
"principal_type": "registered_user",
"user_id": 42,
"uuid": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb",
"scopes": [
"content:read",
"profile:read:own",
"profile:write:own",
"profile:avatar:read",
"account:read:own",
"account:write:own",
"session:read:own",
"session:revoke:own",
"assessment:read:own",
"assessment:write:own",
"exercise:progress:read:own",
"exercise:progress:write:own",
"progress:read:own",
"feed:events:write:own",
"notification:read:own",
"notification:write:own",
"community:read",
"community:write",
"community:chat:read",
"community:chat:write"
]
}Error codes
400 invalid_request401 invalid_grant422 invalid_request500 server_error
POST/v1/account/loginPublic
Accepts an existing active account email and sends a 6-digit magic-login code. Unknown emails return account_not_found.
Request format
POST /v1/account/login
Content-Type: application/json
{
"email": "[email protected]",
"client_type": "native_app",
"device_id": "expo-installation-id",
"platform": "android",
"app_version": "1.0.0"
}Example response
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"ok": true,
"status": "verification_required",
"email": "[email protected]",
"delivery": "email_sent_if_account_exists",
"verification_expires_at": "2026-07-06T12:15:00+00:00",
"expires_in": 900
}Error codes
400 invalid_request400 invalid_json404 account_not_found422 invalid_request500 server_error
GET/v1/meRegistered + profile:read:own or service
Resolves the current principal. Registered responses include account state, marketing consent, owner-only profile preferences, completion progress, and unfinished claimed referral references.
Request format
GET /v1/me
Authorization: Bearer ltp_at_example...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"principal": {
"principal_type": "registered_user",
"credential_type": "access_token",
"user_id": 42,
"uuid": "7fbff4a8-6dbd-4d52-b65d-672401ad7a21",
"scopes": [
"content:read",
"profile:read:own",
"profile:write:own",
"profile:avatar:read",
"account:read:own",
"account:write:own"
]
},
"account": {
"user_id": 42,
"email": "[email protected]",
"display_name": "Pim",
"email_verified": true,
"email_verified_at": "2026-07-06T12:00:00+00:00",
"signed_up": "2026-06-15T09:30:00+00:00",
"marketing_opt_in": {
"stable_identifier_type": "profile_uuid",
"stable_identifier": "7fbff4a8-6dbd-4d52-b65d-672401ad7a21",
"subscription_status": "subscribed",
"has_consent": true,
"can_prompt": false,
"source": "app",
"event_type": "marketing_opt_in",
"consented_at": "2026-07-06T12:30:00+00:00",
"unsubscribed_at": null,
"optin_url": "app://onboarding/newsletter"
}
},
"profile": {
"display_name": "Pim",
"avatar": null,
"birth_date": "1958-04-12",
"interests": ["walking", "healthy_living"],
"nutrition": {
"goals": ["eat_healthier"],
"diet_types": [],
"food_avoidances": ["nuts"],
"food_avoidances_other": null,
"eating_preferences": ["simple_meals"],
"meals_per_day": 3
},
"exercise": {
"goals": ["improve_balance"],
"frequency": "twice_weekly",
"preferences": ["at_home"],
"home_equipment": [],
"level": "beginner"
},
"answered_fields": [
"exercise.frequency",
"exercise.goals",
"exercise.home_equipment",
"exercise.level",
"exercise.preferences",
"nutrition.diet_types",
"nutrition.eating_preferences",
"nutrition.food_avoidances",
"nutrition.goals",
"nutrition.meals_per_day",
"personal.birth_date",
"personal.display_name",
"personal.interests"
],
"completion": {
"personal_information": {"complete": true, "answered": 3, "total": 3},
"recipe_preferences": {"complete": true, "answered": 5, "total": 5},
"exercise_preferences": {"complete": true, "answered": 5, "total": 5},
"overall": true
},
"revision": 3,
"updated_at": "2026-07-17T12:00:00+00:00"
}
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope409 invalid_account415 unsupported_media_type422 invalid_request429 rate_limited500 server_error
POST/v1/referral-invitations/resolvePublic; verified account for patient confirmation context
Acceptance-only personal invitation preview. Token in JSON body; no claim or login side effect. Public response contains only safe partner/practice information.
Request format
POST /v1/referral-invitations/resolveExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
400 invalid_request400 invalid_json404 not_found500 server_error
POST/v1/referral-invitations/claimActive verified registered + profile:read:own + profile:write:own
Explicitly links invitation to authenticated account regardless of login email. Scoped patient link is atomic and cannot be transferred to another account.
Request format
POST /v1/referral-invitations/claimExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/me/referrals/{referral_id}Active verified registered + profile:read:own
Owner-only referral and partner context. Invitation checkpoint only: clinical_status is not_implemented; no result or consent is fabricated.
Request format
GET /v1/me/referrals/{referral_id}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
PATCH/v1/meRegistered + profile:read:own + profile:write:own
Partially autosaves personal, nutrition, and exercise profile fields. Omitted fields remain unchanged. Browser-cookie use requires X-CSRF-Token.
Request format
PATCH /v1/me
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"display_name": "Pim",
"birth_date": "1958-04-12",
"interests": ["walking", "healthy_living"],
"nutrition": {
"goals": ["eat_healthier"],
"diet_types": [],
"food_avoidances": ["nuts"],
"food_avoidances_other": null,
"eating_preferences": ["simple_meals"],
"meals_per_day": 3
},
"exercise": {
"goals": ["improve_balance"],
"frequency": "twice_weekly",
"preferences": ["at_home"],
"home_equipment": [],
"level": "beginner"
}
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"principal": {
"principal_type": "registered_user",
"credential_type": "access_token",
"user_id": 42,
"uuid": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb"
},
"account": {
"user_id": 42,
"email": "[email protected]",
"display_name": "Pim",
"signed_up": "2026-06-15T09:30:00+00:00",
"marketing_opt_in": {
"stable_identifier_type": "profile_uuid",
"stable_identifier": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb",
"subscription_status": "subscribed",
"has_consent": true,
"can_prompt": false,
"source": "app",
"event_type": "marketing_opt_in",
"consented_at": "2026-07-06T12:30:00+00:00",
"unsubscribed_at": null,
"optin_url": "app://onboarding/newsletter"
}
},
"profile": {
"display_name": "Pim",
"avatar": null,
"birth_date": "1958-04-12",
"interests": ["walking", "healthy_living"],
"nutrition": {
"goals": ["eat_healthier"],
"diet_types": [],
"food_avoidances": ["nuts"],
"food_avoidances_other": null,
"eating_preferences": ["simple_meals"],
"meals_per_day": 3
},
"exercise": {
"goals": ["improve_balance"],
"frequency": "twice_weekly",
"preferences": ["at_home"],
"home_equipment": [],
"level": "beginner"
},
"answered_fields": [
"exercise.frequency",
"exercise.goals",
"exercise.home_equipment",
"exercise.level",
"exercise.preferences",
"nutrition.diet_types",
"nutrition.eating_preferences",
"nutrition.food_avoidances",
"nutrition.goals",
"nutrition.meals_per_day",
"personal.birth_date",
"personal.display_name",
"personal.interests"
],
"completion": {
"personal_information": {"complete": true, "answered": 3, "total": 3},
"recipe_preferences": {"complete": true, "answered": 5, "total": 5},
"exercise_preferences": {"complete": true, "answered": 5, "total": 5},
"overall": true
},
"revision": 4,
"updated_at": "2026-07-17T12:00:00+00:00"
}
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope409 invalid_account415 unsupported_media_type422 invalid_request429 rate_limited500 server_error
POST/v1/me/avatarRegistered + profile:read:own + profile:write:own
Uploads or replaces the owner avatar with one sanitized 512px private rendition. The submitted original is not retained.
Request format
POST /v1/me/avatar
Authorization: Bearer ltp_at_registered...
Content-Type: multipart/form-data
[email protected]Example response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
{
"principal": {
"principal_type": "registered_user",
"user_id": 42,
"uuid": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb"
},
"account": {
"user_id": 42,
"email": "[email protected]",
"display_name": "Pim"
},
"profile": {
"display_name": "Pim",
"avatar": {
"id": "avt_W7r7R3iNnF4XqM2b8zP6yK",
"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK",
"mime_type": "image/webp",
"width": 512,
"height": 512
},
"birth_date": "1958-04-12",
"interests": ["walking"],
"revision": 5,
"updated_at": "2026-07-17T12:05:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope409 invalid_account415 unsupported_media_type422 invalid_avatar429 rate_limited503 avatar_storage_unavailable500 server_error
DELETE/v1/me/avatarRegistered + profile:read:own + profile:write:own
Revokes and removes the authenticated user current private avatar.
Request format
DELETE /v1/me/avatar
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
{
"principal": {
"principal_type": "registered_user",
"user_id": 42,
"uuid": "41e1bb17-8a2a-47a8-862f-a5ddaf5f39cb"
},
"account": {
"user_id": 42,
"email": "[email protected]",
"display_name": "Pim"
},
"profile": {
"display_name": "Pim",
"avatar": {
"id": "avt_W7r7R3iNnF4XqM2b8zP6yK",
"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK",
"mime_type": "image/webp",
"width": 512,
"height": 512
},
"birth_date": "1958-04-12",
"interests": ["walking"],
"revision": 5,
"updated_at": "2026-07-17T12:05:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope409 invalid_account415 unsupported_media_type422 invalid_avatar429 rate_limited503 avatar_storage_unavailable500 server_error
GET/v1/avatars/{avatar_public_id}Active verified registered + profile:avatar:read
Serves sanitized avatar bytes by opaque ID to authenticated registered users, including future community members. Public files, UUIDs, user IDs, and storage paths are never exposed.
Request format
GET /v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK
Authorization: Bearer ltp_at_registered...
If-None-Match: "previous-sha256-digest"Example response
HTTP/1.1 200 OK
Content-Type: image/webp
Cache-Control: private, max-age=86400
ETag: "sha256-digest"
X-Content-Type-Options: nosniff
<private sanitized avatar bytes>Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 avatar_not_found503 avatar_storage_unavailable
POST/v1/me/account-deletion-verificationActive verified registered + account:write:own
Emails a single-use 15-minute code for permanent account deletion. Admin and editor accounts are rejected.
Request format
POST /v1/me/account-deletion-verification
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"ok": true,
"status": "verification_required",
"delivery": "email_sent",
"expires_at": "2026-07-30T12:15:00+00:00"
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
DELETE/v1/meActive verified registered + account:write:own + verification code
Immediately disables the account, revokes sessions and push credentials, logs out the caller, and queues irreversible erasure.
Request format
DELETE /v1/me
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"verification_code": "123456"
}Example response
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"ok": true,
"status": "deletion_queued",
"deletion_request_id": "adr_0123456789abcdef0123456789abcdef"
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope409 invalid_account415 unsupported_media_type422 invalid_request429 rate_limited500 server_error
GET/v1/interestsActive verified registered + community:read
Returns the ordered active Dutch interest catalogue shared by profile, groups, and external activities.
Request format
GET /v1/interests?locale=nl-NL
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"id": "08f72b61-3b5f-4c68-b69d-1e9f6ae5d928",
"code": "walking",
"locale": "nl-NL",
"label": "Wandelen",
"description": "Samen wandelen en buiten actief zijn.",
"display_order": 10
}
]
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/configActive verified registered + community:read
Returns current Community rules, polling timing, fixed reactions, chat limits, discovery radius, and discover-all actions.
Request format
GET /v1/community/config
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"enabled": true,
"locale": "nl-NL",
"rules": {
"version": "2026-01",
"locale": "nl-NL",
"title": "Communityregels",
"body": "Behandel elkaar met respect.",
"url": "https://langerthuisinhuis.nl/community-voorwaarden/",
"published_at": "2026-07-01T08:00:00+00:00"
},
"reactions": ["like", "love", "support", "celebrate", "laugh"],
"chat": {
"transport": "polling",
"poll_interval_ms": 2000,
"idle_poll_interval_ms": 10000,
"background_polling": false,
"message_max_characters": 2000,
"edit_window_seconds": 900,
"retention_days": 395,
"links": "plain_text",
"attachments": false
},
"discovery": {
"nearby_radius_km": 25,
"maps_supported": false,
"actions": {
"discover_all_groups": "/v1/community/groups",
"discover_all_events": "/v1/community/events"
}
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/rules/acceptActive verified registered + community:write
Accepts the exact current rules version for the authenticated user and UUID.
Request format
POST /v1/community/rules/accept
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"version": "2026-01"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"profile": {
"can_browse": true,
"can_participate": true,
"missing_requirements": [],
"has_location": true,
"rules": {
"version": "2026-01",
"title": "Communityregels",
"accepted": true,
"accepted_at": "2026-07-20T10:15:00+00:00"
}
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/communityActive verified registered + community:read
Returns capability state, top five relevant groups, top five nearby relevant external activities, highlights, and explicit discover-all actions.
Request format
GET /v1/community
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"profile": {
"can_browse": true,
"can_participate": true,
"missing_requirements": [],
"has_location": true,
"rules": {
"version": "2026-01",
"title": "Communityregels",
"accepted": true,
"accepted_at": "2026-07-20T10:15:00+00:00"
}
},
"interests": [
{
"id": "08f72b61-3b5f-4c68-b69d-1e9f6ae5d928",
"code": "walking",
"locale": "nl-NL",
"label": "Wandelen",
"description": "Samen wandelen en buiten actief zijn.",
"display_order": 10
}
],
"groups": {
"items": [
{
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": {"url": "/uploads/community/walking-group.webp"},
"member_count": 18,
"member_preview": [
{"name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "joined_at": "2026-07-01T09:00:00+00:00"}
],
"membership": {"joined": true, "role": "member", "state": "active", "participation_blocked": false},
"published_at": "2026-07-01T09:00:00+00:00"
}
],
"action": {
"type": "discover_all_groups",
"label": "Ontdek alle groepen",
"endpoint": "/v1/community/groups"
}
},
"events": {
"state": "ready",
"radius_km": 25,
"items": [
{
"id": "7f613e41-b91f-4f89-a1ef-2398f945c1c7",
"title": "Wandeling door Amelisweerd",
"summary": "Een rustige groepswandeling.",
"image": {"url": "/uploads/community/amelisweerd.webp"},
"interest": {"code": "walking", "label": "Wandelen"},
"schedule": {"starts_at": "2026-08-02T08:30:00+00:00", "ends_at": "2026-08-02T10:30:00+00:00", "timezone": "Europe/Amsterdam", "recurrence": null},
"venue": {"name": "Theehuis Rhijnauwen", "address": "Rhijnauwenselaan 16, Bunnik", "distance_km": 6.4},
"provider": "Utrecht Natuurlijk",
"age_guidance": {"minimum": 55, "maximum": null},
"action": {"type": "external_enrollment", "label": "Bekijk activiteit", "url": "https://example.org/activiteiten/amelisweerd"}
}
],
"action_discover_all": {
"type": "discover_all_events",
"label": "Ontdek alle activiteiten",
"endpoint": "/v1/community/events"
}
},
"highlights": {"content": [], "exercises": []}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/groupsActive verified registered + community:read
Searches and filters public groups by free query, interest, joined/all membership, and recommendation ordering.
Request format
GET /v1/community/groups?q=wandelen&interest=walking&membership=joined&recommendation=true&page=1&per_page=20
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": {"url": "/uploads/community/walking-group.webp"},
"member_count": 18,
"member_preview": [
{"name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "joined_at": "2026-07-01T09:00:00+00:00"}
],
"membership": {"joined": true, "role": "member", "state": "active", "participation_blocked": false},
"published_at": "2026-07-01T09:00:00+00:00"
}
],
"filters": {"q": "wandelen", "interest": "walking", "membership": "joined", "recommendation": true},
"pagination": {"page": 1, "per_page": 20, "total": 1, "total_pages": 1}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groupsActive verified registered + community:write + eligible profile
Immediately publishes a public group and atomically makes the creator its organizer.
Request format
POST /v1/community/groups
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"title": "Samen wandelen in Utrecht",
"description": "Een openbare groep voor rustige wandelingen in en rond Utrecht.",
"interest": "walking"
}Example response
HTTP/1.1 201 Created
Content-Type: application/json
{
"group": {
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Een openbare groep voor rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": null,
"member_count": 1,
"member_preview": [
{"name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "joined_at": "2026-07-20T10:20:00+00:00"}
],
"membership": {"joined": true, "role": "organizer", "state": "active", "participation_blocked": false},
"published_at": "2026-07-20T10:20:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/groups/{group_id}Active verified registered + community:read
Returns public group detail with member count and privacy-safe preview.
Request format
GET /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"group": {
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Een openbare groep voor rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": {"url": "/uploads/community/walking-group.webp"},
"member_count": 18,
"member_preview": [
{"name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "joined_at": "2026-07-01T09:00:00+00:00"}
],
"membership": {"joined": true, "role": "member", "state": "active", "participation_blocked": false},
"published_at": "2026-07-01T09:00:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groups/{group_id}/coverOrganizer + community:write + eligible profile
Uploads a JPEG, PNG, or WebP cover up to 5 MB through the media pipeline.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/cover
Authorization: Bearer ltp_at_registered...
Content-Type: multipart/form-data
[email protected]Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"group": {
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Een openbare groep voor rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": {"url": "/uploads/community/walking-group.webp"},
"member_count": 18,
"member_preview": [],
"membership": {"joined": true, "role": "organizer", "state": "active", "participation_blocked": false},
"published_at": "2026-07-01T09:00:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groups/{group_id}/joinActive verified registered + community:write + eligible profile
Idempotently joins a public group unless the membership is banned.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/join
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"group": {
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Een openbare groep voor rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": {"url": "/uploads/community/walking-group.webp"},
"member_count": 19,
"member_preview": [],
"membership": {"joined": true, "role": "member", "state": "active", "participation_blocked": false},
"published_at": "2026-07-01T09:00:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groups/{group_id}/leaveActive member + community:write + eligible profile
Idempotently leaves a group while preserving membership history.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/leave
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"group": {
"id": "5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474",
"title": "Samen wandelen in Utrecht",
"description": "Een openbare groep voor rustige wandelingen in en rond Utrecht.",
"interest": {"code": "walking", "label": "Wandelen"},
"cover": {"url": "/uploads/community/walking-group.webp"},
"member_count": 18,
"member_preview": [],
"membership": {"joined": false, "role": null, "state": "left", "participation_blocked": false},
"published_at": "2026-07-01T09:00:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/groups/{group_id}/membersActive member + community:read
Returns paginated name/avatar/group-role/application-admin projections without private profile attributes.
Request format
GET /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/members?page=1&per_page=30
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"name": "Pim",
"avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"},
"role": "organizer",
"is_admin": true,
"joined_at": "2026-07-01T09:00:00+00:00"
}
],
"pagination": {"page": 1, "per_page": 30, "total": 18, "total_pages": 1}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groups/{group_id}/members/{member_id}/blockActive non-admin organizer + community:write
Blocks an eligible ordinary member by opaque membership ID. Platform admins use the admin UI. Uses the report reason catalogue; other requires private context. Optional source_message_id is validated and snapshotted without creating a report.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/members/2f99c7b7-2656-4da2-a87f-7c91e5d34780/block
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"reason": "harassment",
"context": "Herhaald gedrag in de groepschat.",
"source_message_id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"member": {
"id": "2f99c7b7-2656-4da2-a87f-7c91e5d34780",
"state": "banned",
"actions": {"can_block": false, "can_unblock": true}
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groups/{group_id}/members/{member_id}/unblockActive non-admin organizer + community:write
Idempotently removes a group block, leaving the person outside the group and free to rejoin voluntarily. Platform admins use the admin UI.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/members/2f99c7b7-2656-4da2-a87f-7c91e5d34780/unblock
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"member": {
"id": "2f99c7b7-2656-4da2-a87f-7c91e5d34780",
"state": "left",
"actions": {"can_block": false, "can_unblock": false}
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/eventsActive verified registered + community:read
Searches upcoming external activities by free query, interest, date, and optional owner-location distance. Supports sort=date (default), distance, or date_distance (calendar day first, then nearest within the day). Recurring series return one ongoing/next occurrence within the date range, with schedule.recurrence metadata and occurrence-based ordering.
Request format
GET /v1/community/events?q=wandelen&interest=walking&date_from=2026-07-20&date_to=2026-08-31&sort=date_distance&page=1&per_page=20
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"id": "7f613e41-b91f-4f89-a1ef-2398f945c1c7",
"title": "Wandeling door Amelisweerd",
"summary": "Een rustige groepswandeling.",
"image": {"url": "/uploads/community/amelisweerd.webp"},
"interest": {"code": "walking", "label": "Wandelen"},
"schedule": {"starts_at": "2026-08-02T08:30:00+00:00", "ends_at": "2026-08-02T10:30:00+00:00", "timezone": "Europe/Amsterdam", "recurrence": null},
"venue": {"name": "Theehuis Rhijnauwen", "address": "Rhijnauwenselaan 16, Bunnik", "distance_km": 6.4},
"provider": "Utrecht Natuurlijk",
"age_guidance": {"minimum": 55, "maximum": null},
"action": {"type": "external_enrollment", "label": "Bekijk activiteit", "url": "https://example.org/activiteiten/amelisweerd"}
}
],
"filters": {"q": "wandelen", "interest": "walking", "date_from": "2026-07-20", "date_to": "2026-08-31", "max_distance_km": null, "sort": "date_distance"},
"pagination": {"page": 1, "per_page": 20, "total": 1, "total_pages": 1}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/events/{event_id}Active verified registered + community:read
Returns one external activity and enrollment action without coordinates. Recurring series include the ongoing/next occurrence and schedule.recurrence metadata; expired series return 404.
Request format
GET /v1/community/events/7f613e41-b91f-4f89-a1ef-2398f945c1c7
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"event": {
"id": "7f613e41-b91f-4f89-a1ef-2398f945c1c7",
"title": "Wandeling door Amelisweerd",
"summary": "Een rustige groepswandeling.",
"image": {"url": "/uploads/community/amelisweerd.webp"},
"interest": {"code": "walking", "label": "Wandelen"},
"schedule": {"starts_at": "2026-08-02T08:30:00+00:00", "ends_at": "2026-08-02T10:30:00+00:00", "timezone": "Europe/Amsterdam", "recurrence": null},
"venue": {"name": "Theehuis Rhijnauwen", "address": "Rhijnauwenselaan 16, Bunnik", "distance_km": 6.4},
"provider": "Utrecht Natuurlijk",
"age_guidance": {"minimum": 55, "maximum": null},
"action": {"type": "external_enrollment", "label": "Bekijk activiteit", "url": "https://example.org/activiteiten/amelisweerd"},
"body": "We wandelen ongeveer vijf kilometer in een rustig tempo."
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/groups/{group_id}/messagesActive member + community:chat:read
Returns durable cursor-based group history plus the separate changes_cursor used to start polling.
Request format
GET /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages?limit=50&cursor=djE6MTIz
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"body": "Wie wandelt er donderdag mee?",
"deleted": false,
"edited": false,
"author": {"user_id": 42, "name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "is_admin": true},
"reply_to": null,
"reactions": [{"code": "support", "count": 2, "reacted_by_me": true}],
"created_at": "2026-07-20T10:30:00+00:00",
"edited_at": null,
"deleted_at": null
}
],
"next_cursor": "djE6MTIz",
"has_more": true,
"changes_cursor": "cG9sbDp2MTo0Nw",
"retention_days": 395,
"read_state": {
"through_message_id": null,
"read_at": null
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/community/groups/{group_id}/messages/changesActive member + community:chat:read
Returns only retained creates, edits, tombstones, and reaction changes after a required changes cursor.
Request format
GET /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages/changes?cursor=cG9sbDp2MTo0Nw&limit=100 HTTP/1.1
Host: api-dev.langerthuisinhuis.nl
Authorization: Bearer ltp_at_registered...
Accept: application/json
No request body.Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"events": [
{
"id": "b8d2ab83-693c-4261-b13e-d5999a267e14",
"type": "message.updated",
"message": {
"id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"body": "Wie wandelt er donderdagmiddag mee?",
"deleted": false,
"edited": true,
"author": {"user_id": 42, "name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "is_admin": true},
"reply_to": null,
"reactions": [{"code": "support", "count": 2, "reacted_by_me": true}],
"created_at": "2026-07-20T10:30:00+00:00",
"edited_at": "2026-07-20T10:35:00+00:00",
"deleted_at": null
}
}
],
"next_cursor": "cG9sbDp2MTo0OA",
"has_more": false,
"poll_after_ms": 2000
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
PUT/v1/community/groups/{group_id}/readActive member + community:chat:write
Monotonically marks messages through one visibly rendered message as read. This can suppress pending Community notifications; fetching history alone never marks messages read.
Request format
PUT /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/read
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"through_message_id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
{
"read_state": {
"through_message_id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"read_at": "2026-07-20T10:31:00+00:00",
"advanced": true
}
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden403 insufficient_scope404 not_found415 unsupported_media_type422 invalid_request429 rate_limited500 server_error
POST/v1/community/groups/{group_id}/messagesActive member + community:chat:write + eligible profile
Persists an idempotent basic-text/emoji message or one-level reply, rejects links and rich/media markup, and appends an incremental polling event.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"client_message_id": "ios-019f7f06-f874-78c2-9f9a-ecec1668b215",
"body": "Wie wandelt er donderdag mee?",
"reply_to_message_id": null
}Example response
HTTP/1.1 201 Created
Content-Type: application/json
{
"message": {
"id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"body": "Wie wandelt er donderdag mee?",
"deleted": false,
"edited": false,
"author": {"user_id": 42, "name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "is_admin": true},
"reply_to": null,
"reactions": [],
"created_at": "2026-07-20T10:30:00+00:00",
"edited_at": null,
"deleted_at": null
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
PATCH/v1/community/groups/{group_id}/messages/{message_id}Message owner + community:chat:write + eligible profile
Edits an owned message within 15 minutes under the same basic-text/emoji content policy.
Request format
PATCH /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages/9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"body": "Wie wandelt er donderdagmiddag mee?"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": {
"id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"body": "Wie wandelt er donderdagmiddag mee?",
"deleted": false,
"edited": true,
"author": {"user_id": 42, "name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "is_admin": true},
"reply_to": null,
"reactions": [],
"created_at": "2026-07-20T10:30:00+00:00",
"edited_at": "2026-07-20T10:35:00+00:00",
"deleted_at": null
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
DELETE/v1/community/groups/{group_id}/messages/{message_id}Message owner + community:chat:write + eligible profile
Tombstones an owned message within 15 minutes.
Request format
DELETE /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages/9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": {
"id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"body": null,
"deleted": true,
"edited": false,
"author": {"user_id": 42, "name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "is_admin": true},
"reply_to": null,
"reactions": [],
"created_at": "2026-07-20T10:30:00+00:00",
"edited_at": null,
"deleted_at": "2026-07-20T10:36:00+00:00"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/community/groups/{group_id}/messages/{message_id}/reportsActive member + community:chat:write
Idempotently records one moderation report per reporter and queues a privacy-safe Telegram review alert.
Request format
POST /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages/9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce/reports
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"reason": "harassment",
"context": "Dit bericht is op meerdere groepsleden gericht."
}Example response
HTTP/1.1 202 Accepted
Content-Type: application/json
Cache-Control: private, no-store
{
"report": {
"id": "3ae62a8d-0b6c-4f38-9388-f07e6384d99e",
"status": "received"
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
PUT/DELETE/v1/community/groups/{group_id}/messages/{message_id}/reactions/{reaction}Active member + community:chat:write + eligible profile
Idempotently adds or removes a supported fixed reaction.
Request format
PUT /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages/9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce/reactions/support
Authorization: Bearer ltp_at_registered...
DELETE /v1/community/groups/5d9e5a6c-89d4-4e0c-a4e0-7c32772c6474/messages/9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce/reactions/support
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": {
"id": "9a3bc4f1-1328-48a6-bcba-0f3f14e1a4ce",
"body": "Wie wandelt er donderdag mee?",
"deleted": false,
"edited": false,
"author": {"user_id": 42, "name": "Pim", "avatar": {"url": "/v1/avatars/avt_W7r7R3iNnF4XqM2b8zP6yK"}, "role": "organizer", "is_admin": true},
"reply_to": null,
"reactions": [{"code": "support", "count": 2, "reacted_by_me": true}],
"created_at": "2026-07-20T10:30:00+00:00",
"edited_at": null,
"deleted_at": null
}
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/me/marketing/opt-inRegistered + profile:write:own
Records explicit app marketing opt-in for the authenticated profile.
Request format
POST /v1/me/marketing/opt-in
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"marketing_opt_in": true,
"optin_url": "app://onboarding/newsletter"
}Example response
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"status": "accepted",
"uuid": "7fbff4a8-6dbd-4d52-b65d-672401ad7a21",
"email": "[email protected]",
"marketing_opt_in": true
}Error codes
400 invalid_request400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope409 invalid_account422 invalid_request503 marketing_optin_unavailable500 server_error
PUT/v1/me/push-installations/{installation_id}Active verified registered + notification:write:own
Registers or refreshes one Expo installation. Token material is encrypted at rest and never returned. Push eligibility requires an active native-app session whose device_id matches the installation UUID. Granted registration reconciles outstanding resource notifications without calling Expo inline.
Request format
PUT /v1/me/push-installations/a61c7ceb-a071-4d51-a09a-e3cc99882280
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"provider": "expo",
"platform": "ios",
"push_token": "ExpoPushToken[example]",
"permission_status": "granted",
"app_version": "1.0.0",
"locale": "nl-NL",
"timezone": "Europe/Amsterdam"
}Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"installation": {
"id": "inst_example",
"installation_id": "a61c7ceb-a071-4d51-a09a-e3cc99882280",
"provider": "expo",
"platform": "ios",
"permission_status": "granted",
"push_eligible": true,
"app_version": "1.0.0",
"locale": "nl-NL",
"timezone": "Europe/Amsterdam",
"last_seen_at": "2026-07-24T11:00:00+00:00"
}
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope419 csrf_failed422 invalid_request429 rate_limited503 push_registration_unavailable500 server_error
DELETE/v1/me/push-installations/{installation_id}Active verified registered + notification:write:own
Idempotently revokes one owned installation, clears encrypted token material, and cancels pending deliveries.
Request format
DELETE /v1/me/push-installations/a61c7ceb-a071-4d51-a09a-e3cc99882280
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 204 No Content
Cache-Control: no-storeError codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope419 csrf_failed422 invalid_request429 rate_limited503 push_registration_unavailable500 server_error
GET/v1/me/notification-preferencesActive verified registered + notification:read:own
Lists server-managed notification topics with resolved default or explicit user push state.
Request format
GET /v1/me/notification-preferences
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"topics": [
{
"code": "assessments.home_scan",
"label": "Woningcheck",
"description": "Ontvang een melding wanneer uw woningcheck klaar is.",
"available": true,
"default_push_enabled": true,
"push_enabled": true,
"value_source": "default"
},
{
"code": "community.chat",
"label": "Communityberichten",
"description": "Ontvang meldingen over nieuwe berichten in uw groepen.",
"available": true,
"default_push_enabled": false,
"push_enabled": false,
"value_source": "default"
}
]
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found419 csrf_failed422 invalid_request429 rate_limited500 server_error
PATCH/v1/me/notification-preferences/{topic_code}Active verified registered + notification:write:own
Sets one explicit topic preference. Enabling succeeds even when no installation is currently eligible.
Request format
PATCH /v1/me/notification-preferences/community.chat
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"push_enabled": true
}Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"topic": {
"code": "community.chat",
"label": "Communityberichten",
"description": "Ontvang meldingen over nieuwe berichten in uw groepen.",
"available": true,
"default_push_enabled": false,
"push_enabled": true,
"value_source": "user"
}
}Error codes
400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found419 csrf_failed422 invalid_request429 rate_limited500 server_error
GET/v1/me/notificationsActive verified registered + notification:read:own
Returns the authenticated user inbox with signed cursor pagination, current unread count, and unexpired immutable notification snapshots.
Request format
GET /v1/me/notifications?limit=25&cursor=ltp_nc_opaque-signed-cursor
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"items": [
{
"id": "ntf_example",
"schema_version": 1,
"type": "home_scan.ready",
"topic": "assessments.home_scan",
"title": "Uw woningcheck is klaar",
"body": "Bekijk de resultaten in de app.",
"presentation": {
"category": "assessment",
"icon": "home-scan",
"tone": "info",
"thumbnail_url": null
},
"action": {
"schema_version": 1,
"kind": "resource",
"resource_type": "home_scan_result",
"resource_id": "hscan_7Kv9uV9K0pQf4N2x",
"context": {}
},
"read": false,
"opened": false,
"created_at": "2026-07-24T11:00:00+00:00",
"expires_at": "2026-10-22T11:00:00+00:00"
}
],
"pagination": {
"next_cursor": null,
"has_more": false
},
"unread_count": 1
}Error codes
400 invalid_cursor400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found419 csrf_failed422 invalid_request429 rate_limited500 server_error
PATCH/v1/me/notifications/{notification_id}Active verified registered + notification:write:own
Idempotently marks one owned notification read or opened. Open implies read and timestamps never move backward.
Request format
PATCH /v1/me/notifications/ntf_example
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"read": true,
"opened": true
}Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"notification": {
"id": "ntf_example",
"schema_version": 1,
"type": "home_scan.ready",
"topic": "assessments.home_scan",
"title": "Uw woningcheck is klaar",
"body": "Bekijk de resultaten in de app.",
"presentation": {"category": "assessment", "icon": "home-scan", "tone": "info", "thumbnail_url": null},
"action": {"schema_version": 1, "kind": "resource", "resource_type": "home_scan_result", "resource_id": "hscan_example", "context": {}},
"read": true,
"opened": true,
"created_at": "2026-07-24T11:00:00+00:00",
"expires_at": "2026-10-22T11:00:00+00:00"
},
"unread_count": 0
}Error codes
400 invalid_cursor400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found419 csrf_failed422 invalid_request429 rate_limited500 server_error
POST/v1/me/notifications/read-allActive verified registered + notification:write:own
Marks every currently visible owned notification read and returns the updated count.
Request format
POST /v1/me/notifications/read-all
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"updated_count": 4,
"unread_count": 0
}Error codes
400 invalid_cursor400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found419 csrf_failed422 invalid_request429 rate_limited500 server_error
GET/v1/me/exercisesRegistered + exercise:progress:read:own
Lists the authenticated user exercise progress summaries. Supports status and locale filters.
Request format
GET /v1/me/exercises?status=in_progress&locale=nl-NL
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
{
"data": [
{
"exercise": {
"id": "00000000-0000-4000-8000-000000000101",
"code": "sit_to_stand",
"locale": "nl-NL",
"slug": "stoel-opstaan",
"title": "Stoel opstaan"
},
"progress": {
"status": "in_progress",
"progress_percent": 40,
"mood_score": null,
"mood_recorded_at": null,
"last_position_seconds": 120,
"duration_seconds": 300,
"first_started_at": "2026-07-13T11:58:00+00:00",
"last_event_at": "2026-07-13T12:00:00+00:00",
"last_event_type": "progress",
"completed_at": null,
"completion_count": 0
}
}
]
}Error codes
400 invalid_request400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found409 conflict415 unsupported_media_type429 rate_limited500 server_error
GET/v1/me/exercises/{exercise_id_or_code}Registered + exercise:progress:read:own
Returns one personal exercise progress summary or not_started for an existing published exercise.
Request format
GET /v1/me/exercises/sit_to_stand?locale=nl-NL
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
{
"exercise": {
"id": "00000000-0000-4000-8000-000000000101",
"code": "sit_to_stand",
"locale": "nl-NL",
"slug": "stoel-opstaan",
"title": "Stoel opstaan"
},
"progress": {
"status": "not_started",
"progress_percent": 0,
"mood_score": null,
"mood_recorded_at": null,
"last_position_seconds": null,
"duration_seconds": null,
"first_started_at": null,
"last_event_at": null,
"last_event_type": null,
"completed_at": null,
"completion_count": 0
}
}Error codes
400 invalid_request400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found409 conflict415 unsupported_media_type429 rate_limited500 server_error
POST/v1/me/exercises/captureRegistered + exercise:progress:write:own
Captures one idempotent user exercise progress event and updates the per-exercise summary.
Request format
POST /v1/me/exercises/capture
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"exercise_id": "00000000-0000-4000-8000-000000000101",
"client_event_id": "7c6f8d3a-6f8b-4d8c-9c4a-2e53f8a2e5a1",
"event_type": "progress",
"position_seconds": 120,
"duration_seconds": 300,
"progress_percent": 40,
"occurred_at": "2026-07-13T12:00:00Z"
}
{
"exercise_code": "sit_to_stand",
"client_event_id": "mood-7c6f8d3a-6f8b-4d8c",
"event_type": "mood_recorded",
"mood_score": 4,
"occurred_at": "2026-07-13T12:02:00Z"
}Example response
HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
{
"event": {
"id": "6c1f8ad7-4a31-4c20-8f11-f39d03d65a7d",
"client_event_id": "7c6f8d3a-6f8b-4d8c-9c4a-2e53f8a2e5a1",
"event_type": "progress",
"idempotent_replay": false
},
"exercise": {
"id": "00000000-0000-4000-8000-000000000101",
"code": "sit_to_stand",
"locale": "nl-NL",
"slug": "stoel-opstaan",
"title": "Stoel opstaan"
},
"progress": {
"status": "in_progress",
"progress_percent": 40,
"mood_score": null,
"mood_recorded_at": null,
"last_position_seconds": 120,
"duration_seconds": 300,
"first_started_at": "2026-07-13T11:58:00+00:00",
"last_event_at": "2026-07-13T12:00:00+00:00",
"last_event_type": "progress",
"completed_at": null,
"completion_count": 0
}
}Error codes
400 invalid_request400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found409 conflict415 unsupported_media_type429 rate_limited500 server_error
GET/v1/me/progressActive verified registered + progress:read:own
Returns the versioned Voortgang aggregate for assessments, latest-per-room home scans, and timezone-aware exercise activity.
Request format
GET /v1/me/progress?locale=nl-NL&timezone=Europe%2FAmsterdam&week_start=2026-07-27
Authorization: Bearer ltp_at_registered...
If-None-Match: W/"previous-sha256-digest"Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
Content-Type: application/json
{
"schema_version": 1,
"generated_at": "2026-07-27T09:30:00Z",
"locale": "nl-NL",
"timezone": "Europe/Amsterdam",
"overview": {"completed": 4, "total": 10, "percentage": 40},
"findings": {"high": 1, "medium": 2, "low": 3, "unclassified": 0, "total": 6},
"activity": {
"week_start": "2026-07-27",
"week_end": "2026-08-02",
"active_days": 1,
"current_streak": 4,
"longest_streak": 8,
"days": [
{"date": "2026-07-27", "completed": true, "completion_count": 2},
{"date": "2026-07-28", "completed": false, "completion_count": 0},
{"date": "2026-07-29", "completed": false, "completion_count": 0},
{"date": "2026-07-30", "completed": false, "completion_count": 0},
{"date": "2026-07-31", "completed": false, "completion_count": 0},
{"date": "2026-08-01", "completed": false, "completion_count": 0},
{"date": "2026-08-02", "completed": false, "completion_count": 0}
]
},
"completed_checks": [
{
"id": "hscan_bathroom_123",
"type": "home_scan",
"title": "Badkamer",
"status": "completed",
"completed_at": "2026-07-25T13:20:00Z",
"finding_count": 3,
"image": {"url": null, "alt": "Badkamer"},
"action": {"type": "home_scan_result", "resource_id": "hscan_bathroom_123"}
}
],
"home_scan": {
"completed_rooms": 3,
"total_rooms": 9,
"next_room": {"room_type": "kitchen", "label": "Keuken"},
"latest_completed_assessment_id": "hscan_bathroom_123"
}
}Error codes
400 invalid_locale400 invalid_timezone400 invalid_week_start401 unauthenticated401 invalid_token403 forbidden_principal403 insufficient_scope422 unsupported_locale422 unsupported_parameters429 rate_limited500 server_error503 progress_unavailable
GET/v1/me/sessionsRegistered
Lists registered user sessions for account/device management.
Request format
GET /v1/me/sessions
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"sessions": [
{
"id": 123,
"client_type": "native_app",
"device_id": "expo-installation-id",
"device_name": "Pim's iPhone",
"platform": "ios",
"app_version": "1.0.0",
"last_seen_at": "2026-07-03 12:00:00",
"expires_at": "2026-12-30 12:00:00",
"revoked_at": null,
"created_at": "2026-07-03 12:00:00"
}
]
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
DELETE/v1/me/sessions/{id}Registered
Revokes one registered user session owned by the current user.
Request format
DELETE /v1/me/sessions/123
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
POST/v1/oauth/tokenService client secret
OAuth2-style client_credentials exchange for service clients.
Request format
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=partner_x&client_secret=ltp_svc_secret_exampleExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "ltp_svc_at_example...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "service:auth content:read"
}Error codes
400 invalid_request401 invalid_credentials500 server_error
GET/v1/contentGuest, registered, or service + content:read
Lists published content with limited fields. The homepage, hidden/internal-search pages, and Advertorial-tagged pages are always excluded. Supports pagination, parent/root, navigation-only, leaf-only, tag, taxonomy, type, locale, nav sorting, and prioritized money-page ranking.
Request format
GET /v1/content?parent_id=0&type=page&sort=nav
Authorization: Bearer ltp_at_or_ltp_svc_at...
GET /v1/content?in_navigation=1&leaf_only=1&type=page
Authorization: Bearer ltp_at_or_ltp_svc_at...
GET /v1/content?page=1&per_page=25&locale=nl-NL&prioritized=1&tag=badkamer&taxonomy=category
Authorization: Bearer ltp_at_or_ltp_svc_at...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Content-Type: application/json
{
"data": [
{
"id": 123,
"type": "page",
"locale": "nl-NL",
"parent_id": 45,
"title": "Veilig douchen",
"path": "/badkamer/veilig-douchen/",
"image": {
"url": "/uploads/veilig-douchen.webp",
"alt": "Senior in veilige badkamer"
},
"tags": [
{
"taxonomy": "category",
"slug": "badkamer",
"name": "Badkamer"
}
],
"published_at": "2026-07-01T10:30:00+00:00",
"updated_at": "2026-07-02T08:15:00+00:00"
}
],
"pagination": {
"page": 1,
"per_page": 25,
"total": 132,
"total_pages": 6
}
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 insufficient_scope404 not_found500 server_error
GET/v1/content/{id}Guest, registered, or service + content:read
Returns one published content item with normalized blocks, parent metadata, direct children, and referenced published forms. Offers list blocks include product_eans alongside product_ids for offer rendering. Hidden/internal-search and Advertorial-tagged pages return not_found.
Request format
GET /v1/content/123
Authorization: Bearer ltp_at_or_ltp_svc_at...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Content-Type: application/json
{
"content": {
"id": 123,
"type": "page",
"locale": "nl-NL",
"parent": {
"id": 45,
"title": "Badkamer",
"path": "/badkamer/"
},
"title": "Veilig douchen",
"path": "/badkamer/veilig-douchen/",
"meta_title": "Veilig douchen",
"meta_description": "Praktische tips voor een veilige badkamer.",
"image": {
"url": "/uploads/veilig-douchen.webp",
"alt": "Senior in veilige badkamer"
},
"tags": [
{
"taxonomy": "category",
"slug": "badkamer",
"name": "Badkamer"
}
],
"published_at": "2026-07-01T10:30:00+00:00",
"updated_at": "2026-07-02T08:15:00+00:00",
"children": [
{
"id": 124,
"title": "Antislipmatten",
"path": "/badkamer/antislipmatten/"
}
],
"blocks": [
{
"type": "paragraph",
"data": {
"text": "Gebruik antislipmateriaal en voldoende steunpunten."
}
},
{
"type": "form_embed",
"data": {
"code": "advies-aanvraag",
"variant": "regular"
}
}
]
},
"included": {
"forms": [
{
"code": "advies-aanvraag",
"name": "Adviesaanvraag",
"description": null,
"api_available": true,
"submit_url": "/v1/forms/advies-aanvraag",
"schema_url": "/v1/forms/advies-aanvraag",
"fields": []
}
]
}
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 insufficient_scope404 not_found500 server_error
GET/v1/recipesActive verified registered user + content:read
Lists published recipe summaries. Supports locale, category, search, featured, and strict page/per_page filters. Responses use private 60-second caching and weak ETags.
Request format
GET /v1/recipes?locale=nl-NL&category=avondeten&q=soep&featured=1&page=1&per_page=25
Authorization: Bearer ltp_at_registered...
If-None-Match: W/"previous-sha256-digest"Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
Content-Type: application/json
{
"data": [
{
"id": "stable-recipe-public-id",
"locale": "nl-NL",
"slug": "groentesoep",
"title": "Groentesoep",
"summary": "Een lichte soep vol groenten.",
"hero_image": { "url": "/uploads/groentesoep.webp", "alt": "Kom groentesoep" },
"author": {
"id": "stable-author-public-id",
"name": "Voedingspraktijk Voorbeeld",
"avatar": { "url": "/uploads/auteur.webp", "alt": "Voedingspraktijk Voorbeeld" }
},
"base_servings": 4,
"yield_text": "4 kommen",
"timing": { "preparation_minutes": 15, "cooking_minutes": 30, "total_minutes": 45 },
"difficulty": "easy",
"featured": true,
"categories": [{ "taxonomy": "meal-type", "slug": "avondeten", "name": "Avondeten" }],
"published_at": "2026-07-13T10:00:00+00:00",
"updated_at": "2026-07-13T11:00:00+00:00"
}
],
"pagination": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 }
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope500 server_error
GET/v1/recipes/{slug}Active verified registered user + content:read
Returns one published recipe with author, ingredients, ordered steps, optional nutrition, and SEO. Draft, archived, wrong-locale, and unknown recipes return not_found.
Request format
GET /v1/recipes/groentesoep?locale=nl-NL
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
Content-Type: application/json
{
"recipe": {
"id": "stable-recipe-public-id",
"locale": "nl-NL",
"slug": "groentesoep",
"title": "Groentesoep",
"summary": "Een lichte soep vol groenten.",
"hero_image": { "url": "/uploads/groentesoep.webp", "alt": "Kom groentesoep" },
"author": {
"id": "stable-author-public-id",
"name": "Voedingspraktijk Voorbeeld",
"biography": "Externe redactionele auteur.",
"avatar": { "url": "/uploads/auteur.webp", "alt": "Voedingspraktijk Voorbeeld" }
},
"base_servings": 4,
"yield_text": "4 kommen",
"timing": { "preparation_minutes": 15, "cooking_minutes": 30, "total_minutes": 45 },
"difficulty": "easy",
"featured": true,
"categories": [{ "taxonomy": "meal-type", "slug": "avondeten", "name": "Avondeten" }],
"published_at": "2026-07-13T10:00:00+00:00",
"updated_at": "2026-07-13T11:00:00+00:00",
"ingredient_groups": [
{
"title": "Voor de soep",
"ingredients": [
{
"name": "Wortel",
"quantity_min": "1.5000",
"quantity_max": "2.0000",
"unit": "stuks",
"display_text": null,
"note": "in plakjes",
"scalable": true
}
]
}
],
"steps": [
{
"number": 1,
"title": "Snijd de groenten",
"description": "Snijd alle groenten in gelijke stukken.",
"tip": null,
"timer_seconds": null,
"image": null
}
],
"nutrition": {
"basis": "per_serving",
"facts": [
{ "key": "energy", "label": "Energie", "amount": "185.0000", "unit": "kcal", "display_value": null }
]
},
"seo": { "title": "Groentesoep recept", "description": "Maak eenvoudige groentesoep." }
}
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found500 server_error
GET/v1/exercisesActive verified registered user + content:read
Lists published exercise summaries. Supports locale, category, difficulty, presentation mode, and strict page/per_page filters. Responses use private 60-second caching and weak ETags.
Request format
GET /v1/exercises?locale=nl-NL&category=balans&difficulty=easy&presentation_mode=video&page=1&per_page=25
Authorization: Bearer ltp_at_registered...
If-None-Match: W/"previous-sha256-digest"Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
Content-Type: application/json
{
"data": [
{
"id": "stable-exercise-public-id",
"code": "sit_to_stand",
"locale": "nl-NL",
"slug": "stoel-opstaan",
"title": "Stoel opstaan",
"summary": "Train beenkracht door rustig op te staan.",
"goal": "Sterkere benen en stabieler opstaan.",
"thumbnail": { "url": "/uploads/stoel-opstaan.webp", "alt": "Oefening stoel opstaan" },
"categories": [{ "taxonomy": "exercise-type", "slug": "balans", "name": "Balans" }],
"difficulty": "easy",
"duration_minutes": 5,
"equipment": "Een stabiele stoel",
"presentation_mode": "video",
"published_at": "2026-07-13T10:00:00+00:00",
"updated_at": "2026-07-13T11:00:00+00:00"
}
],
"pagination": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 }
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope500 server_error
GET/v1/exercises/{slug}Active verified registered user + content:read
Returns one published video or image-step exercise with safety, review, media, ordered steps, and SEO. Draft, archived, wrong-locale, and unknown exercises return not_found.
Request format
GET /v1/exercises/stoel-opstaan?locale=nl-NL
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
Content-Type: application/json
{
"exercise": {
"id": "stable-exercise-public-id",
"code": "sit_to_stand",
"locale": "nl-NL",
"slug": "stoel-opstaan",
"title": "Stoel opstaan",
"summary": "Train beenkracht door rustig op te staan.",
"goal": "Sterkere benen en stabieler opstaan.",
"thumbnail": { "url": "/uploads/stoel-opstaan.webp", "alt": "Oefening stoel opstaan" },
"categories": [{ "taxonomy": "exercise-type", "slug": "balans", "name": "Balans" }],
"difficulty": "easy",
"duration_minutes": 5,
"equipment": "Een stabiele stoel",
"presentation_mode": "video",
"published_at": "2026-07-13T10:00:00+00:00",
"updated_at": "2026-07-13T11:00:00+00:00",
"safety_reminder": "Stop bij pijn of duizeligheid.",
"review": { "approved_at": "2026-07-12T09:00:00+00:00", "valid": true },
"media": {
"type": "video",
"video_url": "https://media.example.nl/stoel-opstaan.mp4",
"poster": { "url": "/uploads/stoel-opstaan.webp", "alt": "Startpositie stoel opstaan" },
"captions_url": "https://media.example.nl/stoel-opstaan.vtt",
"transcript": null
},
"steps": [
{ "number": 1, "title": null, "description": "Ga vooraan op de stoel zitten.", "image": null }
],
"seo": { "title": "Stoel opstaan", "description": "Train veilig thuis." }
}
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 not_found500 server_error
GET/v1/forms/{code}Guest, registered, or service + content:read
Returns the safe published form schema used by content blocks.
Request format
GET /v1/forms/advies-aanvraag?locale=nl-NL
Authorization: Bearer ltp_at_or_ltp_svc_at...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Content-Type: application/json
{
"form": {
"code": "advies-aanvraag",
"name": "Adviesaanvraag",
"description": null,
"api_available": true,
"submit_url": "/v1/forms/advies-aanvraag",
"schema_url": "/v1/forms/advies-aanvraag",
"fields": [
{
"name": "email",
"type": "email",
"label": "E-mailadres",
"required": true,
"placeholder": null,
"help_text": null,
"options": [],
"label_display": "top",
"field_width": "full"
},
{
"name": "partner_contact_consent",
"type": "checkbox",
"label": "De partner van Langer Thuis in Huis mag naar aanleiding van deze aanvraag contact met mij opnemen.",
"required": true,
"placeholder": null,
"help_text": null,
"options": [],
"label_display": "top",
"field_width": "full"
}
]
}
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 insufficient_scope404 not_found415 unsupported_media_type422 validation_failed429 rate_limited500 server_error
POST/v1/forms/{code}Guest/registered + content:read or service + forms:submit
Submits a published form and returns accepted status.
Request format
POST /v1/forms/advies-aanvraag
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"source": "mobile_app",
"content_id": 123,
"external_reference": "screen-instance-123",
"fields": {
"name": "Pim",
"email": "[email protected]",
"message": "Ik wil graag advies.",
"marketing_consent": true,
"partner_contact_consent": true
}
}Example response
HTTP/1.1 201 Created
Content-Type: application/json
{
"ok": true,
"submission_id": 456,
"form_code": "advies-aanvraag",
"status": "accepted"
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 insufficient_scope404 not_found415 unsupported_media_type422 validation_failed429 rate_limited500 server_error
GET/v1/productsGuest, registered, or service + content:read
Lists active/published products with discovery fields only. No offers, prices, affiliate URLs, or availability are included.
Request format
GET /v1/products?page=1&per_page=25&category=badkamer&brand=voorbeeldmerk&q=douchekruk
Authorization: Bearer ltp_at_or_ltp_svc_at...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Content-Type: application/json
{
"data": [
{
"ean": "8712345678901",
"title": "Douchekruk verstelbaar",
"slug": "douchekruk-verstelbaar",
"brand": "Voorbeeldmerk",
"image": {
"url": "https://cdn.example.test/douchekruk.jpg",
"alt": "Douchekruk verstelbaar"
},
"rating": {
"value": 4.4,
"count": 38
},
"latest_price": {
"amount": 49.95,
"currency": "EUR"
},
"current_offer": {
"partner": "thh",
"partner_name": "Thuishulpmiddelen.nl",
"price": {"amount": 49.95, "currency": "EUR"},
"shipping_cost": {"amount": 0, "currency": "EUR"},
"availability": 2,
"condition": "new",
"affiliate_url": "https://langerthuisinhuis.nl/go/eyJ0b2tlbiI6ImV4YW1wbGU",
"open_behavior": "external_browser",
"last_seen_at": "2026-07-02T08:10:00+00:00"
},
"tags": [
{
"slug": "badkamer",
"name": "Badkamer"
}
],
"updated_at": "2026-07-02T08:15:00+00:00"
}
],
"pagination": {
"page": 1,
"per_page": 25,
"total": 260,
"total_pages": 11
}
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 insufficient_scope404 not_found500 server_error
GET/v1/products/{ean}Guest, registered, or service + content:read
Returns product detail by EAN with public-safe offers and internal affiliate redirect URLs.
Request format
GET /v1/products/8712345678901
Authorization: Bearer ltp_at_or_ltp_svc_at...Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
Content-Type: application/json
{
"product": {
"ean": "8712345678901",
"title": "Douchekruk verstelbaar",
"slug": "douchekruk-verstelbaar",
"description": "Veilige, verstelbare douchekruk.",
"brand": "Voorbeeldmerk",
"image": {
"url": "https://cdn.example.test/douchekruk.jpg",
"alt": "Douchekruk verstelbaar"
},
"rating": {
"value": 4.4,
"count": 38
},
"latest_price": {
"amount": 49.95,
"currency": "EUR"
},
"current_offer": {
"partner": "thh",
"partner_name": "Thuishulpmiddelen.nl",
"price": {"amount": 49.95, "currency": "EUR"},
"shipping_cost": {"amount": 0, "currency": "EUR"},
"availability": 2,
"condition": null,
"affiliate_url": "https://langerthuisinhuis.nl/go/eyJ0b2tlbiI6ImV4YW1wbGU",
"open_behavior": "external_browser",
"last_seen_at": null
},
"categories": [
{
"slug": "badkamer",
"name": "Badkamer"
}
],
"updated_at": "2026-07-02T08:15:00+00:00"
},
"offers": [
{
"partner": "bol",
"partner_name": "Bol.com",
"price": {
"amount": 49.95,
"currency": "EUR"
},
"shipping_cost": {
"amount": 0,
"currency": "EUR"
},
"availability": 2,
"condition": "new",
"affiliate_url": "https://langerthuisinhuis.nl/go/eyJ0b2tlbiI6ImV4YW1wbGU",
"open_behavior": "external_browser",
"last_seen_at": "2026-07-02T08:10:00+00:00"
}
]
}Error codes
400 invalid_request401 unauthorized401 invalid_token403 insufficient_scope404 not_found500 server_error
GET/v1/publisher/catalogPublisher-bound service + publisher:catalog:read
Returns one complete non-paginated live projection of the committed LTIH catalog. Optional include groups are content, specifications, reviews, and offers.
Request format
GET /v1/publisher/catalogExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/publisher/catalog/{ean}Publisher-bound service + publisher:catalog:read
Returns the current committed LTIH publisher catalog projection for one checksum-valid EAN without calling a merchant.
Request format
GET /v1/publisher/catalog/{ean}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/reporting/revenueFixed API key + revenue:read
Returns complete PII-free daily commerce revenue, refund, purchase-cost, margin, and coverage aggregates for a bounded date range.
Request format
GET /v1/reporting/revenue?from=2026-08-01&to_exclusive=2026-09-01
Authorization: Bearer ltp_svc_key_example...
Accept: application/jsonExample response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
Vary: Authorization
{
"schema_version": "1",
"source": "commerce",
"timezone": "Europe/Amsterdam",
"period": {
"from": "2026-08-01",
"to_exclusive": "2026-09-01"
},
"complete": true,
"generated_at": "2026-09-01T06:00:00Z",
"totals": [
{
"currency": "EUR",
"paid_order_count": 2,
"sold_quantity": 2,
"refunded_order_count": 1,
"unrefunded_return_order_count": 0,
"gross_product_revenue_ex_vat": "150.00",
"refund_ex_vat": "20.00",
"net_product_revenue_ex_vat": "130.00",
"costed_revenue_ex_vat": "130.00",
"purchase_cost_ex_vat": "70.00",
"gross_margin_ex_vat": "60.00",
"gross_margin_percentage": "46.15",
"missing_cost_revenue_ex_vat": "0.00",
"missing_cost_quantity": 0
}
],
"days": [
{
"date": "2026-08-15",
"currency": "EUR",
"paid_order_count": 2,
"sold_quantity": 2,
"refunded_order_count": 1,
"unrefunded_return_order_count": 0,
"gross_product_revenue_ex_vat": "150.00",
"refund_ex_vat": "20.00",
"net_product_revenue_ex_vat": "130.00",
"costed_revenue_ex_vat": "130.00",
"purchase_cost_ex_vat": "70.00",
"gross_margin_ex_vat": "60.00",
"gross_margin_percentage": "46.15",
"missing_cost_revenue_ex_vat": "0.00",
"missing_cost_quantity": 0
}
]
}Error codes
400 invalid_query400 invalid_period401 unauthorized401 invalid_token403 forbidden403 insufficient_scope429 rate_limited500 server_error503 invalid_financial_source
POST/v1/integrations/thh/order-snapshotsTHH-bound service + affiliate:conversions:write
Accepts an idempotent, complete, privacy-minimized THH order outcome snapshot for click reconciliation and internal economics.
Request format
POST /v1/integrations/thh/order-snapshotsExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token403 forbidden419 csrf_failed500 server_error
GET/v1/me/feed/homeActive verified registered user + content:read
Returns a cursor-paginated mixed home feed. Every user receives the same locale snapshot for six hours.
Request format
GET /v1/me/feed/home?locale=nl-NL&limit=20
Authorization: Bearer ltp_at_registered...
GET /v1/me/feed/home?cursor=ltp_fc_opaque-signed-cursor
Authorization: Bearer ltp_at_registered...
If-None-Match: W/"previous-sha256-digest"Example response
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
Content-Type: application/json
{
"data": [
{
"feed_item_id": "fi_01J2RECIPE0123456789",
"rank": 1,
"type": "recipe",
"resource_id": "stable-recipe-public-id",
"label": "Recept",
"title": "Gezonde groentesoep",
"summary": "Een eenvoudige maaltijd voor vier personen.",
"image": {
"url": "/uploads/groentesoep.webp",
"alt": "Kom groentesoep"
},
"badges": ["45 min", "Avondeten"],
"attributes": {
"difficulty": "easy",
"total_minutes": 45,
"featured": true,
"categories": [
{"taxonomy": "meal-type", "slug": "avondeten", "name": "Avondeten"}
]
},
"action": {
"type": "api_detail",
"path": "/v1/recipes/groentesoep",
"open_behavior": "in_app"
}
}
],
"feed": {
"id": "feed_01J2ABCDEF0123456789",
"strategy": "mixed_v1",
"generated_at": "2026-07-14T06:00:00+00:00",
"expires_at": "2026-07-14T12:00:00+00:00"
},
"pagination": {
"limit": 20,
"next_cursor": "ltp_fc_opaque-signed-cursor",
"has_more": true
}
}Error codes
400 invalid_request400 invalid_cursor401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope429 rate_limited500 server_error
POST/v1/me/feed/eventsActive verified registered user + feed:events:write:own
Captures batches of idempotent impression, open, and dismiss events for the authenticated UUID.
Request format
POST /v1/me/feed/events
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
X-App-Version: 1.4.0
X-Platform: ios
{
"events": [
{
"client_event_id": "feed-event-01J2ABCDEF",
"feed_id": "feed_01J2ABCDEF0123456789",
"feed_item_id": "fi_01J2RECIPE0123456789",
"event_type": "impression",
"occurred_at": "2026-07-14T10:03:00Z"
}
]
}Example response
HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
{
"accepted": 1,
"duplicates": 0
}Error codes
400 invalid_request400 invalid_json401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope413 payload_too_large415 unsupported_media_type429 rate_limited500 server_error
GET/v1/assessments/fall-riskRegistered + assessment:read:own
Returns the current fall-risk assessment state, latest completed result, or not_started state.
Request format
GET /v1/assessments/fall-risk
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "asmt_7Kv9uV9K0pQf4N2x",
"type": "fall_risk",
"version": 1,
"status": "in_progress",
"revision": 2,
"started_at": "2026-07-06T10:30:00+00:00",
"updated_at": "2026-07-06T10:31:18+00:00",
"completed_at": null
},
"state": {
"complete_allowed": false,
"next_question_key": "q2",
"result_level": null,
"result_final": false,
"progress": {
"answered_visible": 1,
"required_visible": 3,
"visible_total": 3
}
},
"questions": [
{
"key": "q1",
"type": "boolean",
"required": true,
"visible": true,
"title": "Bent u in de afgelopen 12 maanden gevallen?",
"help_title": null,
"help_body": null,
"options": [
{ "value": true, "label": "Ja" },
{ "value": false, "label": "Nee" }
],
"answer": true
}
],
"answers": {
"q1": true
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 assessment_not_found404 question_not_found409 stale_revision409 assessment_completed409 assessment_not_completed422 missing_revision422 invalid_answer422 hidden_question422 assessment_incomplete429 rate_limited500 server_error
POST/v1/assessments/fall-risk/startRegistered + assessment:write:own
Starts a fall-risk assessment or returns the existing in-progress assessment.
Request format
POST /v1/assessments/fall-risk/start
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "asmt_7Kv9uV9K0pQf4N2x",
"type": "fall_risk",
"version": 1,
"status": "in_progress",
"revision": 2,
"started_at": "2026-07-06T10:30:00+00:00",
"updated_at": "2026-07-06T10:31:18+00:00",
"completed_at": null
},
"state": {
"complete_allowed": false,
"next_question_key": "q2",
"result_level": null,
"result_final": false,
"progress": {
"answered_visible": 1,
"required_visible": 3,
"visible_total": 3
}
},
"questions": [
{
"key": "q1",
"type": "boolean",
"required": true,
"visible": true,
"title": "Bent u in de afgelopen 12 maanden gevallen?",
"help_title": null,
"help_body": null,
"options": [
{ "value": true, "label": "Ja" },
{ "value": false, "label": "Nee" }
],
"answer": true
}
],
"answers": {
"q1": true
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 assessment_not_found404 question_not_found409 stale_revision409 assessment_completed409 assessment_not_completed422 missing_revision422 invalid_answer422 hidden_question422 assessment_incomplete429 rate_limited500 server_error
PUT/v1/assessments/fall-risk/{assessment_id}/answers/{question_key}Registered + assessment:write:own
Autosaves one boolean answer with revision protection and returns updated assessment state.
Request format
PUT /v1/assessments/fall-risk/asmt_7Kv9uV9K0pQf4N2x/answers/q1
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"value": true,
"revision": 1
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "asmt_7Kv9uV9K0pQf4N2x",
"type": "fall_risk",
"version": 1,
"status": "in_progress",
"revision": 2,
"started_at": "2026-07-06T10:30:00+00:00",
"updated_at": "2026-07-06T10:31:18+00:00",
"completed_at": null
},
"state": {
"complete_allowed": false,
"next_question_key": "q2",
"result_level": null,
"result_final": false,
"progress": {
"answered_visible": 1,
"required_visible": 3,
"visible_total": 3
}
},
"questions": [
{
"key": "q1",
"type": "boolean",
"required": true,
"visible": true,
"title": "Bent u in de afgelopen 12 maanden gevallen?",
"help_title": null,
"help_body": null,
"options": [
{ "value": true, "label": "Ja" },
{ "value": false, "label": "Nee" }
],
"answer": true
}
],
"answers": {
"q1": true
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 assessment_not_found404 question_not_found409 stale_revision409 assessment_completed409 assessment_not_completed422 missing_revision422 invalid_answer422 hidden_question422 assessment_incomplete429 rate_limited500 server_error
POST/v1/assessments/fall-risk/{assessment_id}/completeRegistered + assessment:write:own
Completes the assessment when all visible required questions are answered and freezes the result.
Request format
POST /v1/assessments/fall-risk/asmt_7Kv9uV9K0pQf4N2x/complete
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"revision": 8
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "asmt_7Kv9uV9K0pQf4N2x",
"type": "fall_risk",
"version": 1,
"status": "completed",
"revision": 9,
"started_at": "2026-07-06T10:30:00+00:00",
"updated_at": "2026-07-06T10:36:45+00:00",
"completed_at": "2026-07-06T10:36:45+00:00"
},
"result": {
"level": "high",
"final": true,
"scoring_version": 1
},
"answers": {
"q1": true,
"q2": false,
"q3": false,
"q4": false,
"q5": true,
"q6": false,
"q7": false,
"q8": false
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 assessment_not_found404 question_not_found409 stale_revision409 assessment_completed409 assessment_not_completed422 missing_revision422 invalid_answer422 hidden_question422 assessment_incomplete429 rate_limited500 server_error
GET/v1/assessments/fall-risk/{assessment_id}/resultRegistered + assessment:read:own
Returns the final low, medium, or high result for a completed assessment.
Request format
GET /v1/assessments/fall-risk/asmt_7Kv9uV9K0pQf4N2x/result
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "asmt_7Kv9uV9K0pQf4N2x",
"type": "fall_risk",
"version": 1,
"status": "completed",
"revision": 9,
"started_at": "2026-07-06T10:30:00+00:00",
"updated_at": "2026-07-06T10:36:45+00:00",
"completed_at": "2026-07-06T10:36:45+00:00"
},
"result": {
"level": "high",
"final": true,
"scoring_version": 1
},
"answers": {
"q1": true,
"q2": false,
"q3": false,
"q4": false,
"q5": true,
"q6": false,
"q7": false,
"q8": false
}
}Error codes
401 unauthorized401 invalid_token403 forbidden_principal403 insufficient_scope404 assessment_not_found404 question_not_found409 stale_revision409 assessment_completed409 assessment_not_completed422 missing_revision422 invalid_answer422 hidden_question422 assessment_incomplete429 rate_limited500 server_error
GET/v1/assessments/home-scanRegistered + assessment:read:own
Returns home-scan room eligibility, each room's latest and latest-completed owned scan, the backward-compatible global summaries, and any wrong-room action required.
Request format
GET /v1/assessments/home-scan
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"eligibility": {
"subscription": {
"active": false,
"checked_at": "2026-07-06T10:30:00+00:00"
},
"free_room_policy": {
"enabled": true,
"limit_per_room": 1
},
"free_bathroom_scan": {
"available": false,
"reserved": false,
"consumed": true
},
"max_photos_per_scan": 2
},
"rooms": [
{
"room_type": "bathroom",
"label": "Badkamer",
"allowed": false,
"entitlement": "free_room",
"locked_reason": "subscription_required",
"latest_scan": {
"id": "hscan_bathroom",
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "completed",
"completed_at": "2026-07-24T12:00:00+00:00",
"issue_count": 2
},
"latest_completed_scan": {
"id": "hscan_bathroom",
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "completed",
"completed_at": "2026-07-24T12:00:00+00:00",
"issue_count": 2
}
},
{
"room_type": "bedroom",
"label": "Slaapkamer",
"allowed": false,
"entitlement": "free_room",
"locked_reason": "subscription_required",
"latest_scan": {
"id": "hscan_bedroom",
"room_type": "bedroom",
"room_label": "Slaapkamer",
"status": "completed",
"completed_at": "2026-07-24T13:00:00+00:00",
"issue_count": 1
},
"latest_completed_scan": {
"id": "hscan_bedroom",
"room_type": "bedroom",
"room_label": "Slaapkamer",
"status": "completed",
"completed_at": "2026-07-24T13:00:00+00:00",
"issue_count": 1
}
}
],
"current_scan": null,
"latest_completed_scan": {
"id": "hscan_bedroom",
"room_type": "bedroom",
"room_label": "Slaapkamer",
"status": "completed",
"completed_at": "2026-07-24T13:00:00+00:00",
"issue_count": 1
}
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
GET/v1/assessments/home-scan/agreementRegistered + assessment:read:own
Returns the current combined Home Scan quality notice and whether this exact version has already been accepted.
Request format
GET /v1/assessments/home-scan/agreementExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
POST/v1/assessments/home-scan/agreement/acceptRegistered + assessment:write:own
Deprecated compatibility endpoint that idempotently accepts the exact current Home Scan notice version once.
Request format
POST /v1/assessments/home-scan/agreement/acceptExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"example": true
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
POST/v1/assessments/home-scan/startRegistered + assessment:write:own
Starts or resumes the room scan. Legacy v1 requests may omit the agreement version without recording quality acceptance. Explicit displayed versions are validated and captured only for new scans.
Request format
POST /v1/assessments/home-scan/start
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"room_type": "bathroom",
"source": "native_app",
"agreement_version_code": "home-scan-quality-v2"
}Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "photos_uploaded",
"revision": 2,
"photo_count": 2,
"issue_count": 0,
"max_photos": 2,
"entitlement": {
"kind": "free_room",
"reserved": false,
"consumed": false
}
},
"photos": [
{
"id": "hphoto_2MRuKcx9Qw",
"slot": 1,
"mime_type": "image/jpeg",
"width": 1600,
"height": 1200,
"size_bytes": 284102,
"uploaded_at": "2026-07-06T10:33:00+00:00"
}
]
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
POST/v1/assessments/home-scan/{assessment_id}/photosRegistered + assessment:write:own
Uploads or replaces one or two private source photos; replacement after a wrong-room result retains the assessment id.
Request format
POST /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/photos
Authorization: Bearer ltp_at_registered...
Content-Type: multipart/form-data
photos[]=bathroom-1.jpg
photos[]=bathroom-2.jpgExample response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "photos_uploaded",
"revision": 2,
"photo_count": 2,
"issue_count": 0,
"max_photos": 2,
"entitlement": {
"kind": "free_room",
"reserved": false,
"consumed": false
}
},
"photos": [
{
"id": "hphoto_2MRuKcx9Qw",
"slot": 1,
"mime_type": "image/jpeg",
"width": 1600,
"height": 1200,
"size_bytes": 284102,
"uploaded_at": "2026-07-06T10:33:00+00:00"
}
]
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
POST/v1/assessments/home-scan/{assessment_id}/submitRegistered + assessment:write:own
Checks entitlement, starts analysis, and returns processing state. Repeated submit is idempotent; rejected photos require replacement first.
Request format
POST /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/submit
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "processing",
"revision": 3,
"photo_count": 2,
"issue_count": 0,
"max_photos": 2,
"entitlement": {
"kind": "free_room",
"reserved": true,
"consumed": false
},
"submitted_at": "2026-07-06T10:34:00+00:00",
"completed_at": null
},
"photos": [
{
"id": "hphoto_2MRuKcx9Qw",
"slot": 1,
"mime_type": "image/jpeg",
"width": 1600,
"height": 1200,
"size_bytes": 284102,
"uploaded_at": "2026-07-06T10:33:00+00:00"
}
]
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
PUT/v1/assessments/home-scan/{assessment_id}/notification-subscriptionActive verified registered + assessment read/write + notification:write:own
Stores idempotent push intent for one owned home scan, covering ready, failed, and needs-new-photos outcomes without calling Expo inline.
Request format
PUT /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/notification-subscription
Authorization: Bearer ltp_at_registered...
Content-Type: application/json
{
"push_enabled": true
}Example response
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"assessment_id": "hscan_7Kv9uV9K0pQf4N2x",
"assessment_status": "processing",
"subscription": {
"push_enabled": true,
"topic_push_enabled": true,
"effective_push_enabled": true,
"blocked_reason": "no_eligible_installation",
"consumed": false
},
"notification_id": null
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
GET/v1/assessments/home-scan/{assessment_id}Registered + assessment:read:own
Returns async status while the app polls after submit. Wrong-room photos return needs_new_photos with action_required; invalid output becomes terminal failed.
Request format
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "needs_new_photos",
"revision": 4,
"photo_count": 2,
"issue_count": 0,
"max_photos": 2,
"entitlement": {
"kind": "free_room",
"reserved": false,
"consumed": false
},
"provider": {
"name": "openai_home_scan",
"status": "completed",
"request_id": "resp_example",
"attempts": 1,
"error": null
},
"action_required": {
"type": "reupload_photos",
"code": "room_mismatch",
"message": "De foto's lijken niet bij de gekozen ruimte Badkamer te passen. Upload nieuwe foto's van deze ruimte.",
"expected_room_type": "bathroom",
"detected_room_type": "living_room",
"mismatched_photo_slots": [1],
"photos_url": "/v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/photos"
},
"submitted_at": "2026-07-24T15:47:00+00:00",
"completed_at": null,
"failed_at": null
},
"photos": [
{"id": "hphoto_example_1", "slot": 1},
{"id": "hphoto_example_2", "slot": 2}
]
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
GET/v1/assessments/home-scan/{assessment_id}/resultRegistered + assessment:read:own
Returns one marked-image descriptor per analyzed source photo, every issue in risk order, the backward-compatible first-image URL and highest-risk issue, stable indicator keys, and backend-resolved advice tags.
Request format
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/result
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: application/json
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"version": 1,
"room_type": "bathroom",
"room_label": "Badkamer",
"status": "completed",
"revision": 5,
"photo_count": 2,
"issue_count": 2,
"completed_at": "2026-07-06T10:35:18+00:00"
},
"result": {
"marked_image_url": "/v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/result/image",
"marked_images": [
{
"photo_id": "hphoto_example_1",
"slot": 1,
"url": "/v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/result/images/hphoto_example_1",
"mime_type": "image/jpeg",
"width": 1200,
"height": 1600,
"size_bytes": 245000,
"marker_count": 2,
"has_markers": true
},
{
"photo_id": "hphoto_example_2",
"slot": 2,
"url": "/v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/result/images/hphoto_example_2",
"mime_type": "image/jpeg",
"width": 1200,
"height": 1600,
"size_bytes": 231000,
"marker_count": 0,
"has_markers": false
}
],
"recommendations_url": "/v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/recommendations",
"issue_count": 2,
"advice_tags": [
{"slug": "antislip-natte-ruimte", "name": "Antislip in natte ruimte"},
{"slug": "beugels-onder-de-douche", "name": "Beugels onder de douche"}
],
"issues": [
{
"id": "hissue_6Yp2x",
"indicator_key": "wet_area_antislip",
"risk_level": "high",
"risk_score": 90,
"title": "Glad oppervlak bij de douche of wastafel",
"description": "De analyse markeert een plek waar uitglijden of struikelen extra waarschijnlijk kan zijn.",
"recommendation": "Controleer deze plek en verwijder losse of gladde materialen waar mogelijk.",
"advice_tags": [
{"slug": "antislip-natte-ruimte", "name": "Antislip in natte ruimte"}
]
},
{
"id": "hissue_9Km4q",
"indicator_key": "shower_support",
"risk_level": "medium",
"risk_score": 61,
"title": "Onvoldoende steun bij de douche",
"description": "Er is geen duidelijk steunpunt zichtbaar.",
"recommendation": "Overweeg een geschikte wandbeugel.",
"advice_tags": [
{"slug": "beugels-onder-de-douche", "name": "Beugels onder de douche"}
]
}
],
"highest_risk_issue": {
"id": "hissue_6Yp2x",
"indicator_key": "wet_area_antislip",
"risk_level": "high",
"risk_score": 90,
"title": "Glad oppervlak bij de douche of wastafel",
"description": "De analyse markeert een plek waar uitglijden of struikelen extra waarschijnlijk kan zijn.",
"recommendation": "Controleer deze plek en verwijder losse of gladde materialen waar mogelijk.",
"advice_tags": [
{"slug": "antislip-natte-ruimte", "name": "Antislip in natte ruimte"}
]
}
}
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
GET/v1/assessments/home-scan/{assessment_id}/recommendationsActive verified registered + assessment:read:own + content:read
Returns current content, recipe, exercise, and in-stock product recommendations matched to stored scan advice tags. Use type for independent pagination; complete responses support private 60-second ETags.
Request format
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/recommendations
Authorization: Bearer ltp_at_registered...
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/recommendations?type=product&page=2&per_page=10&locale=nl-NL
Authorization: Bearer ltp_at_registered...
If-None-Match: W/"previous-sha256-digest"Example response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
ETag: W/"sha256-digest"
{
"assessment": {
"id": "hscan_7Kv9uV9K0pQf4N2x",
"type": "home_scan",
"revision": 5
},
"recommendation_context": {
"strategy": "home_scan_advice_tags_v1",
"advice_tags": [
{"slug": "drempelvrij", "name": "Drempelvrij", "max_risk": "high"},
{"slug": "bewegen", "name": "Bewegen", "max_risk": "medium"},
{"slug": "voeding", "name": "Voeding", "max_risk": "low"}
]
},
"sections": [
{
"type": "content",
"status": "ready",
"data": [
{
"id": 209,
"type": "post",
"locale": "nl-NL",
"parent_id": null,
"title": "Drempels veilig verwijderen",
"path": "/advies/drempels-veilig-verwijderen/",
"image": {"url": "/uploads/drempels.webp", "alt": "Drempelvrije doorgang"},
"tags": [{"taxonomy": "category", "slug": "wonen", "name": "Wonen"}],
"published_at": "2026-07-15T09:00:00+00:00",
"updated_at": "2026-07-15T09:00:00+00:00",
"matched_advice_tags": [{"slug": "drempelvrij", "name": "Drempelvrij"}],
"action": {"type": "api_detail", "path": "/v1/content/209", "open_behavior": "in_app"}
}
],
"total": 1,
"pagination": {"page": 1, "per_page": 5, "total_pages": 1, "has_more": false, "next_url": null}
},
{
"type": "recipe",
"status": "ready",
"data": [
{
"id": "26000000-0000-4000-8000-000000000101",
"locale": "nl-NL",
"slug": "gezonde-ontbijtkom",
"title": "Gezonde ontbijtkom",
"summary": "Een eenvoudig ontbijt.",
"hero_image": {"url": "/uploads/ontbijtkom.webp", "alt": "Ontbijtkom"},
"author": {"id": "28000000-0000-4000-8000-000000000101", "name": "Langer Thuis in Huis", "avatar": null},
"base_servings": 2,
"yield_text": "2 kommen",
"timing": {"preparation_minutes": 10, "cooking_minutes": 0, "total_minutes": 10},
"difficulty": "easy",
"featured": false,
"categories": [{"taxonomy": "advice-tags", "slug": "voeding", "name": "Voeding"}],
"published_at": "2026-07-14T09:00:00+00:00",
"updated_at": "2026-07-15T09:00:00+00:00",
"matched_advice_tags": [{"slug": "voeding", "name": "Voeding"}],
"action": {"type": "api_detail", "path": "/v1/recipes/gezonde-ontbijtkom", "open_behavior": "in_app"}
}
],
"total": 1,
"pagination": {"page": 1, "per_page": 5, "total_pages": 1, "has_more": false, "next_url": null}
},
{
"type": "exercise",
"status": "ready",
"data": [
{
"id": "27000000-0000-4000-8000-000000000101",
"code": "sit_to_stand",
"locale": "nl-NL",
"slug": "stoel-opstaan",
"title": "Stoel opstaan",
"summary": "Train rustig de beenkracht.",
"goal": "Sterker en stabieler opstaan.",
"thumbnail": {"url": "/uploads/stoel-opstaan.webp", "alt": "Opstaan uit een stoel"},
"categories": [{"taxonomy": "advice-tags", "slug": "bewegen", "name": "Bewegen"}],
"difficulty": "easy",
"duration_minutes": 5,
"equipment": "Gebruik een stabiele stoel.",
"presentation_mode": "image_steps",
"published_at": "2026-07-14T08:00:00+00:00",
"updated_at": "2026-07-15T08:00:00+00:00",
"matched_advice_tags": [{"slug": "bewegen", "name": "Bewegen"}],
"action": {"type": "api_detail", "path": "/v1/exercises/stoel-opstaan", "open_behavior": "in_app"}
}
],
"total": 1,
"pagination": {"page": 1, "per_page": 5, "total_pages": 1, "has_more": false, "next_url": null}
},
{
"type": "product",
"status": "ready",
"data": [
{
"ean": "8712345678901",
"title": "Rubberen drempelhulp",
"slug": "rubberen-drempelhulp",
"brand": "Voorbeeld",
"image": {"url": "/uploads/drempelhulp.jpg", "alt": "Rubberen drempelhulp"},
"rating": {"value": 4.5, "count": 28},
"tags": [{"slug": "drempelhulpen", "name": "Drempelhulpen"}],
"updated_at": "2026-07-15T08:00:00+00:00",
"matched_advice_tags": [{"slug": "drempelvrij", "name": "Drempelvrij"}],
"action": {"type": "api_detail", "path": "/v1/products/8712345678901", "open_behavior": "in_app"}
}
],
"total": 1,
"pagination": {"page": 1, "per_page": 5, "total_pages": 1, "has_more": false, "next_url": null}
}
],
"as_of": "2026-07-16T10:30:00Z"
}Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
GET/v1/assessments/home-scan/{assessment_id}/result/imageRegistered + assessment:read:own
Serves the first private marked result image as a backward-compatible alias.
Request format
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/result/image
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: image/jpeg
<private marked image bytes>Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error
GET/v1/assessments/home-scan/{assessment_id}/result/images/{photo_id}Registered + assessment:read:own
Serves the private marked JPEG associated with one source photo after ownership, completion, and membership checks.
Request format
GET /v1/assessments/home-scan/hscan_7Kv9uV9K0pQf4N2x/result/images/hphoto_example_1
Authorization: Bearer ltp_at_registered...Example response
HTTP/1.1 200 OK
Content-Type: image/jpeg
<private marked image bytes for the selected source photo>Error codes
401 unauthorized401 invalid_token402 subscription_required403 forbidden_principal403 insufficient_scope404 assessment_not_found409 scan_already_submitted409 new_photos_required409 scan_failed409 scan_not_completed422 invalid_room_type422 missing_photos422 too_many_photos422 invalid_image422 unsupported_image_type429 rate_limited503 storage_unavailable503 provider_unavailable500 server_error