LangerThuisPlus API

Authentication foundation for public, registered-user, and service-client principals. Tokens are opaque bearer credentials and must not be placed in URLs.

Production base URL https://api.langerthuisinhuis.nl/v1
Development base URL https://api-dev.langerthuisinhuis.nl/v1
Bearer auth Authorization: Bearer ltp_at_...
Request body Send JSON with Content-Type: application/json unless an endpoint states otherwise.
Machine-readable spec /openapi.yaml

How Authentication Works

Native registered app
  • Call POST /v1/account/register or POST /v1/account/login with only an email address.
  • Confirm email plus the 6-digit code from the email or universal link.
  • Store only the current refresh_token securely and use access_token for API calls.
Browser or in-app browser
  • Cookie-authenticated unsafe requests require X-CSRF-Token.
  • CORS allows configured first-party origins with credentials.
External backend
  • Use issued service-client credentials.
  • Exchange client_id and client_secret at /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

CredentialValidityDetails
Access token15 minutesReturned by email-code confirmation, refresh, and service-client token exchange. Use as Authorization: Bearer <token>.
Refresh token idle validity30 daysRegistered sessions must refresh within this window. Each successful refresh rotates the token and extends idle validity.
Refresh token absolute validity180 daysRefresh rotation cannot extend beyond this session family limit. The response exposes refresh_expires_at for the absolute expiry.
Email login code15 minutesSix 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.0

Response

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_example

Response

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_token

Response

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=800
Example 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_request
  • 404 address_not_found
  • 405 method_not_allowed
  • 429 rate_limited
  • 503 address_lookup_unavailable
  • 503 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.0
Example 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_request
  • 426 update_policy.mode=force
  • 500 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/health
Example 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/csrf
Example 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_request
  • 401 invalid_grant
  • 422 invalid_request
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_request
  • 400 invalid_json
  • 422 invalid_request
  • 500 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_request
  • 400 invalid_json
  • 404 not_found
  • 500 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_request
  • 401 invalid_grant
  • 422 invalid_request
  • 500 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_request
  • 400 invalid_json
  • 404 account_not_found
  • 422 invalid_request
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 409 invalid_account
  • 415 unsupported_media_type
  • 422 invalid_request
  • 429 rate_limited
  • 500 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/resolve
Example response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "example": true
}

Error codes

  • 400 invalid_request
  • 400 invalid_json
  • 404 not_found
  • 500 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/claim
Example response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "example": true
}

Error codes

  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 409 invalid_account
  • 415 unsupported_media_type
  • 422 invalid_request
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 409 invalid_account
  • 415 unsupported_media_type
  • 422 invalid_avatar
  • 429 rate_limited
  • 503 avatar_storage_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 409 invalid_account
  • 415 unsupported_media_type
  • 422 invalid_avatar
  • 429 rate_limited
  • 503 avatar_storage_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 avatar_not_found
  • 503 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 409 invalid_account
  • 415 unsupported_media_type
  • 422 invalid_request
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 403 insufficient_scope
  • 404 not_found
  • 415 unsupported_media_type
  • 422 invalid_request
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_request
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 409 invalid_account
  • 422 invalid_request
  • 503 marketing_optin_unavailable
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 503 push_registration_unavailable
  • 500 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-store

Error codes

  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 503 push_registration_unavailable
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 500 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_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 500 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_cursor
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 500 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_cursor
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 500 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_cursor
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 419 csrf_failed
  • 422 invalid_request
  • 429 rate_limited
  • 500 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_request
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 409 conflict
  • 415 unsupported_media_type
  • 429 rate_limited
  • 500 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_request
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 409 conflict
  • 415 unsupported_media_type
  • 429 rate_limited
  • 500 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_request
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 409 conflict
  • 415 unsupported_media_type
  • 429 rate_limited
  • 500 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_locale
  • 400 invalid_timezone
  • 400 invalid_week_start
  • 401 unauthenticated
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 422 unsupported_locale
  • 422 unsupported_parameters
  • 429 rate_limited
  • 500 server_error
  • 503 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_example
Example 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_request
  • 401 invalid_credentials
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 insufficient_scope
  • 404 not_found
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 insufficient_scope
  • 404 not_found
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 not_found
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 insufficient_scope
  • 404 not_found
  • 415 unsupported_media_type
  • 422 validation_failed
  • 429 rate_limited
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 insufficient_scope
  • 404 not_found
  • 415 unsupported_media_type
  • 422 validation_failed
  • 429 rate_limited
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 insufficient_scope
  • 404 not_found
  • 500 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_request
  • 401 unauthorized
  • 401 invalid_token
  • 403 insufficient_scope
  • 404 not_found
  • 500 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/catalog
Example response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "example": true
}

Error codes

  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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/json
Example 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_query
  • 400 invalid_period
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 403 insufficient_scope
  • 429 rate_limited
  • 500 server_error
  • 503 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-snapshots
Example response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "example": true
}

Error codes

  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden
  • 419 csrf_failed
  • 500 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_request
  • 400 invalid_cursor
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 429 rate_limited
  • 500 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_request
  • 400 invalid_json
  • 401 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 413 payload_too_large
  • 415 unsupported_media_type
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 404 question_not_found
  • 409 stale_revision
  • 409 assessment_completed
  • 409 assessment_not_completed
  • 422 missing_revision
  • 422 invalid_answer
  • 422 hidden_question
  • 422 assessment_incomplete
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 404 question_not_found
  • 409 stale_revision
  • 409 assessment_completed
  • 409 assessment_not_completed
  • 422 missing_revision
  • 422 invalid_answer
  • 422 hidden_question
  • 422 assessment_incomplete
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 404 question_not_found
  • 409 stale_revision
  • 409 assessment_completed
  • 409 assessment_not_completed
  • 422 missing_revision
  • 422 invalid_answer
  • 422 hidden_question
  • 422 assessment_incomplete
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 404 question_not_found
  • 409 stale_revision
  • 409 assessment_completed
  • 409 assessment_not_completed
  • 422 missing_revision
  • 422 invalid_answer
  • 422 hidden_question
  • 422 assessment_incomplete
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 404 question_not_found
  • 409 stale_revision
  • 409 assessment_completed
  • 409 assessment_not_completed
  • 422 missing_revision
  • 422 invalid_answer
  • 422 hidden_question
  • 422 assessment_incomplete
  • 429 rate_limited
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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/agreement
Example response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "example": true
}

Error codes

  • 401 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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/accept
Example response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "example": true
}

Error codes

  • 401 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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.jpg
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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 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 unauthorized
  • 401 invalid_token
  • 402 subscription_required
  • 403 forbidden_principal
  • 403 insufficient_scope
  • 404 assessment_not_found
  • 409 scan_already_submitted
  • 409 new_photos_required
  • 409 scan_failed
  • 409 scan_not_completed
  • 422 invalid_room_type
  • 422 missing_photos
  • 422 too_many_photos
  • 422 invalid_image
  • 422 unsupported_image_type
  • 429 rate_limited
  • 503 storage_unavailable
  • 503 provider_unavailable
  • 500 server_error