{"status":"ok","service":"desidating-api","message":"Everything is up and running. Full documentation of every endpoint below — payloads, allowed values, and responses. Prefer it readable? Open /docs in a browser.","node":"v24.18.0","postgres":"18.4","baseUrl":"http://149.210.205.134:3000","authentication":"No headers or cookies. Every authenticated endpoint expects the username (an email address) and the sessionId INSIDE the JSON body. The sessionId is issued by desi-user-registration (as 'session_id') and desi-app-login (as 'sessionId') and is currently the MD5 hex of today's UTC date (YYYY-MM-DD) — so it changes daily and you must log in again to refresh it. A wrong or stale sessionId returns HTTP 401 {success:false, message:'Ongeldige sessie.'}.","demoAccount":{"username":"priya@desidating.nl","password":"Holi2026!","loginNote":"Login expects the MD5 of the password: md5('Holi2026!') = 6e2ce6ca45a28a25ff793065db66a06d. All 10 seeded demo users share this password."},"endpoints":[{"method":"GET","path":"/webhook/fb748ca5-ee65-4c0c-a601-db9b1c303054","name":"Cities","description":"List of cities for the registration dropdown. Unauthenticated.","auth":"None.","request":"No payload (plain GET).","response":{"[]":"Bare JSON array of city objects.","city":"string — city name, e.g. 'Amsterdam'.","country":"string — 'Nederland' | 'België' | 'Suriname'."},"responseExample":[{"city":"Amsterdam","country":"Nederland"},{"city":"Paramaribo","country":"Suriname"}]},{"method":"POST","path":"/webhook/pre-registration","name":"Pre-registration","description":"Waiting-list signup form (before the app is open): first name, last name, email and city. No account or session involved. Signing up twice with the same email is harmless — the form always responds friendly.","auth":"None.","request":{"name":"string, required — first name (the key 'first_name' is also accepted).","last_name":"string, required — last name.","email":"string, required — email address; stored lowercase, must be unique.","city":"string, optional — city."},"requestExample":{"name":"Anita","last_name":"Ramdin","email":"anita@example.com","city":"Utrecht"},"response":{"success":"boolean.","message":"string — 'Bedankt voor je aanmelding, <naam>! …' or 'Je staat al op de lijst — …' for a duplicate email."},"responseExample":{"success":true,"message":"Bedankt voor je aanmelding, Anita! We laten je weten zodra je aan de slag kunt."},"errors":["HTTP 400 {success:false, message:'Vul je naam, achternaam en e-mailadres in.'} — missing required fields.","HTTP 400 {success:false, message:'Dit lijkt geen geldig e-mailadres. …'} — invalid email format."],"notes":"Rows land in the pre_registrations table (first_name, last_name, email, city, created_at). Manage the list with get-pre-registrations / delete-pre-registration (admin only)."},{"method":"POST","path":"/webhook/heartbeat","name":"Presence heartbeat","description":"Marks the user as active right now (sets users.last_active). The app posts this once on open and then every 60 seconds while it is in the foreground; it stops when the app is backgrounded, so 'online' means genuinely present. A user counts as online while their last heartbeat is under 2 minutes old.","auth":"Body fields 'username' + 'sessionId' must match the user row.","request":"No extra fields.","response":{"success":"boolean — always true when the session is valid."},"errors":["HTTP 401 {success:false, message:'Ongeldige sessie.'}"],"notes":"get-conversations' opponent_profile exposes the derived is_online flag (false when that user disabled 'show online status', which also nulls last_active). The admin dashboard's DAU/MAU counters read the same last_active column and become more accurate with heartbeats."},{"method":"GET","path":"/webhook/verify-email","name":"Verify e-mail address","description":"Landing page for the button in the verification e-mail that registration sends automatically (via SendGrid). Flips users.admin_email_verified and shows a friendly Dutch confirmation page. The token is single-use.","auth":"None — the unguessable token in ?token= is the proof.","request":"Query string: token (UUID from the e-mail link).","response":"text/html — success page, or an 'invalid/expired link' page (400/404).","notes":"Verification is informational: the app never blocks unverified users. Sender falls back to the verified SendGrid identity until desidating.online's DNS is set up."},{"method":"POST","path":"/webhook/forgot-password","name":"Forgot password","description":"Sends a password-reset e-mail (SendGrid) with a 1-hour tokenized link to GET /webhook/reset-password. Always answers success so account existence never leaks; rate-limited to once per 10 minutes per account.","auth":"None.","request":{"username":"string — the account's e-mail address."},"response":{"success":"always true.","message":"friendly Dutch message."}},{"method":"GET","path":"/webhook/reset-password","name":"Password reset page","description":"Landing page for the reset e-mail button: a small form (new password + repeat) that posts back to POST /webhook/reset-password. Invalid/expired tokens get a friendly error page.","auth":"None — the unguessable ?token= is the proof (valid 1 hour, single use).","request":"Query string: token.","response":"text/html."},{"method":"POST","path":"/webhook/reset-password","name":"Set new password","description":"Completes the reset: stores md5(password) and invalidates the token.","auth":"None — body token is the proof.","request":{"token":"string — from the reset link.","password":"string — plaintext, min. 6 characters."},"response":{"success":"boolean.","message":"string."},"errors":["HTTP 400 — password too short.","HTTP 404 — token invalid/expired."]},{"method":"POST","path":"/webhook/resend-verification","name":"Resend verification e-mail","description":"Send the verification e-mail again for the logged-in user (max once per 10 minutes).","auth":"Body fields 'username' + 'sessionId' must match the user row.","request":"No extra fields.","response":{"success":"boolean — false when already verified or rate-limited (message explains).","message":"string."},"errors":["HTTP 502 {success:false, message} — SendGrid rejected the send."]},{"method":"POST","path":"/webhook/get-pre-registrations","name":"Pre-registration list (admin)","description":"The full pre-registration waiting list, in signup order. TEMPORARILY without authentication (on request); will become admin-only again later.","auth":"None — TEMPORARY. Will require an is_admin session again later.","request":"No payload required (an empty JSON object {} is fine).","requestExample":{},"response":{"[]":"Bare JSON array, oldest signup first.","id":"integer — row id (use it for delete-pre-registration).","first_name":"string.","last_name":"string.","email":"string — stored lowercase.","city":"string — '' when not given.","created_at":"ISO timestamp."},"responseExample":[{"id":4,"first_name":"Sherwin","last_name":"Soleymanpoor","email":"shervin001@gmail.com","city":"Achtkarspelen","created_at":"2026-07-16T08:11:00.290Z"}],"errors":[]},{"method":"POST","path":"/webhook/delete-pre-registration","name":"Delete pre-registration (admin)","description":"Delete one pre-registration by its row id (the 'id' from get-pre-registrations). TEMPORARILY without authentication (on request); will become admin-only again later.","auth":"None — TEMPORARY. Will require an is_admin session again later.","request":{"id":"integer, required — the row id to delete."},"requestExample":{"id":4},"response":{"success":"boolean.","message":"string — confirmation including the deleted email address."},"responseExample":{"success":true,"message":"Pre-registratie 4 (shervin001@gmail.com) verwijderd."},"errors":["HTTP 400 — 'id' missing or not an integer.","HTTP 404 — no pre-registration with this id."]},{"method":"POST","path":"/webhook/desi-user-registration","name":"Register","description":"Create a new account. Returns a session id immediately, so the user is logged in right after registering.","auth":"None (this call creates the session).","request":{"username":"string, required — an email address, e.g. 'anita@example.com'. Unique (case-insensitive); it is the login identifier for every other call.","password":"string, required — the RAW PLAINTEXT password, minimum 6 characters. The server stores its MD5; login later sends the MD5.","full_name":"string, required — display name, e.g. 'Anita Ramdin'.","city":"string, optional — one of the values from the Cities endpoint.","country":"string, optional — the country belonging to that city."},"requestExample":{"username":"anita@example.com","password":"MijnGeheim123!","full_name":"Anita Ramdin","city":"Utrecht","country":"Nederland"},"response":{"success":"boolean — true when the account was created.","message":"string — Dutch, user-facing. 'Registratie geslaagd!' or the failure reason.","session_id":"string — 32-char MD5 session id (snake_case key on THIS endpoint only; login uses camelCase 'sessionId'). Empty string on failure."},"responseExample":{"success":true,"message":"Registratie geslaagd!","session_id":"9818a9650a9c06f607f24d8ba097bbc5"},"errors":["HTTP 200 {success:false, message:'Deze gebruikersnaam is al in gebruik.'} — duplicate email.","HTTP 200 {success:false, message:'Vul gebruikersnaam, wachtwoord en volledige naam in.'} — missing required fields."]},{"method":"POST","path":"/webhook/desi-app-login","name":"Login","description":"Authenticate and receive a fresh session id.","auth":"None (credentials in the body).","request":{"username":"string, required — the registered email address.","password":"string, required — lowercase MD5 hex (32 chars) of the raw password, NOT the plaintext. Example: md5('Holi2026!') = 6e2ce6ca45a28a25ff793065db66a06d."},"requestExample":{"username":"priya@desidating.nl","password":"6e2ce6ca45a28a25ff793065db66a06d"},"response":{"success":"boolean — true on valid credentials.","message":"string — 'Inloggen geslaagd!' or the failure reason.","sessionId":"string — 32-char MD5 session id (camelCase key here). Send this on every authenticated call.","userId":"integer — your numeric user id; used as 'profileId' when fetching your own profile. null on failure.","pictureUploadCompleted":"boolean — true once profile photos were uploaded; the app uses this to route to the swipe screen (true) or the photo-upload screen (false)."},"responseExample":{"success":true,"message":"Inloggen geslaagd!","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","userId":1,"pictureUploadCompleted":true},"errors":["HTTP 200 {success:false, message:'Inloggen mislukt. Controleer je gegevens.', sessionId:'', userId:null, pictureUploadCompleted:false} — unknown user or wrong password."]},{"method":"POST","path":"/webhook/get-user-profile","name":"Get profile","description":"Fetch the full profile of any user: your own (profileId = your userId) or someone else's (e.g. tapping a swipe card).","auth":"Body fields 'Username' (your email) + 'SessionId' (from login/registration) must match the user row. Note the capitalized key names on this endpoint.","request":{"Username":"string, required — your email (capitalized key!).","SessionId":"string, required — your session id (capitalized key!).","profileId":"integer, required — the user id whose profile you want (lowercase key)."},"requestExample":{"Username":"priya@desidating.nl","SessionId":"9818a9650a9c06f607f24d8ba097bbc5","profileId":1},"response":{"(shape)":"One bare JSON object (never wrapped in an array). ~45 fields; unset text fields are ''.","id / userId":"integer — the profile's user id (same value twice).","username":"string — the email, ONLY filled when it is your own profile; '' for other users (privacy).","full_name":"string.","gender":"string — 'Man' | 'Vrouw' | 'Non-binair' | 'Zeg ik liever niet' | ''.","date_of_birth":"string — 'yyyy-MM-dd' (the app computes age from it) or ''.","city / country / nationality / raised_in":"strings.","bio":"string — free text, max 500 chars.","background":"string or null — 'Hindustaans' | 'Javaans' | 'Gemengd' | 'Creools' | 'Marron' | 'Anders' | free text; null when never set.","sub_background":"string — 'Punjabi' | 'Gujarati' | 'Bengali' | 'Tamil' | 'Sindhi' | 'Bhojpuri' | 'Anders' | ''.","caste / gotram":"strings — free text.","religion":"string — e.g. 'Hindoe', 'Sikh', or free text.","religion_importance":"string — 'Zeer belangrijk' | 'Enigszins belangrijk' | 'Niet belangrijk' | ''.","sect":"string — 'Shaivitisch' | 'Vaishnavitisch' | 'Sunni' | \"Shi'a\" | 'Protestant' | 'Katholiek' | 'Anders' | ''.","languages":"string — JSON-encoded array, e.g. '[\"Nederlands\",\"Hindi\"]'. Parse it as JSON.","intention":"string — 'Huwelijk' | 'Serieus' | 'Casual daten' | 'Vriendschap' | 'Weet ik nog niet' | ''.","education_level":"string — 'Middelbare school' | 'MBO' | 'HBO/Bachelor' | 'WO Bachelor' | 'HBO/Master' | 'WO/Master' | 'PhD/Doctoraat' | 'Anders' | ''.","field_of_study / occupation / employer":"strings.","height_cm":"integer or null — height in centimeters.","body_type":"string — 'Slank' | 'Atletisch' | 'Gemiddeld' | 'Mollig' | 'Gezet' | ''.","diet":"string — 'Vegetarisch' | 'Veganistisch' | 'Halal' | 'Niet-vegetarisch' | 'Geen voorkeur' | ''.","smoking":"string — 'Nee' | 'Sociaal' | 'Ja' | ''.","drinking":"string — 'Nee' | 'Zelden' | 'Sociaal' | 'Ja' | ''.","exercise":"string — 'Dagelijks' | 'Meerdere keren per week' | 'Soms' | 'Zelden' | 'Nooit' | ''.","pets":"string — 'Hond' | 'Kat' | 'Vogel' | 'Vis' | 'Geen' | 'Meerdere' | ''.","sleep_schedule":"string — 'Ochtendmens' | 'Avondmens' | 'Wisselend' | ''.","social_style":"string — 'Introvert' | 'Extrovert' | 'Ambivert' | ''.","love_language":"string — JSON-encoded array of: 'Kwaliteitstijd', 'Cadeaus', 'Fysieke aanraking', 'Woorden van bevestiging', 'Daden van dienstverlening'.","life_goals":"string — free text, max 500 chars.","wants_children":"string — 'Ja' | 'Nee' | 'Misschien' | 'Heb ik al' | ''.","marital_status":"string — 'Nooit getrouwd' | 'Single' | 'Gescheiden' | 'Weduwe/weduwnaar' | ''.","family_values":"integer 0–5 (default 3) — rendered as hearts in the app.","interests":"string — JSON-encoded array, e.g. '[\"Koken\",\"Yoga\"]'; free-text entries allowed.","parents_approval_important":"string — 'Ja' | 'Nee' | 'Enigszins' | ''.","willing_to_relocate / open_to_long_distance":"string — 'Ja' | 'Nee' | 'Misschien' | ''.","living_situation":"string — 'Alleen' | 'Met familie' | 'Met huisgenoten' | 'Met partner' | 'Anders' | ''.","is_verified":"boolean.","profile_completion_pct":"integer 0–100, computed server-side.","subscription_tier":"string — 'free' | 'premium' | 'premium_plus'.","picture1 / picture2 / picture3":"string — bare photo filename ('' when empty). Load via GET /webhook/get-picture?file=<filename>."},"errors":["HTTP 401 — invalid Username/SessionId pair.","HTTP 400 — profileId missing or not an integer.","HTTP 404 — no user with that id."]},{"method":"POST","path":"/webhook/update-profile","name":"Update profile","description":"Save your own profile (the edit screen sends all fields every time; JSON null clears a field). Allowed values per field are the same as documented on Get profile.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username / sessionId":"strings, required — auth (lowercase keys here).","userId":"integer — your own user id (informational; the session decides whose profile is updated).","full_name":"string.","gender, body_type, education_level, field_of_study, occupation, employer":"strings or null — see Get profile for the allowed values.","background, sub_background, caste, gotram, religion, religion_importance, sect, nationality, raised_in":"strings or null.","height_cm":"integer or null — centimeters, e.g. 172.","bio":"string — max 500 chars.","languages / love_language / interests":"string — JSON-ENCODED array string, e.g. \"[\\\"Nederlands\\\",\\\"Hindi\\\"]\" (a real JSON array is also accepted).","intention":"string or null — see Get profile.","marital_status / wants_children":"strings or null — see Get profile.","family_values":"integer 0–5 (values outside the range are clamped; null becomes 3).","parents_approval_important / willing_to_relocate / open_to_long_distance":"strings or null — 'Ja' | 'Nee' | 'Misschien'/'Enigszins'.","living_situation / diet / smoking / drinking / exercise / pets / sleep_schedule / social_style":"strings or null — see Get profile.","life_goals":"string — max 500 chars.","city":"string.","date_of_birth":"string — 'yyyy-MM-dd' (an ISO timestamp also works)."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","userId":1,"full_name":"Priya Sharma","bio":"Nieuwe bio!","height_cm":166,"family_values":4,"languages":"[\"Nederlands\",\"Hindi\"]","interests":"[\"Koken\",\"Yoga\"]","date_of_birth":"1997-03-14","…":"all other fields from the list above"},"response":{"success":"boolean.","message":"string — 'Profiel bijgewerkt.'"},"responseExample":{"success":true,"message":"Profiel bijgewerkt."},"errors":["HTTP 401 — invalid session."],"notes":"Country and pictures can NOT be changed here (pictures go through the upload endpoint)."},{"method":"POST","path":"/webhook/desi-upload-profile-pictures","name":"Upload photos","description":"Upload 1–3 profile photos as base64 JPEG strings. Also flips pictureUploadCompleted to true, so the next login routes to the swipe screen.","auth":"Body fields 'Username' (your email) + 'SessionId' (from login/registration) must match the user row. Note the capitalized key names on this endpoint.","request":{"Username":"string, required — your email (capitalized key).","SessionId":"string, required (capitalized key).","FileExtension":"string — always 'jpg' (photos are JPEG).","Picture1":"string — base64-encoded JPEG bytes of the main photo, max ~2 MB decoded. '' means no photo in this slot.","Picture2":"string — base64 JPEG or ''.","Picture3":"string — base64 JPEG or ''."},"requestExample":{"Username":"anita@example.com","SessionId":"9818a9650a9c06f607f24d8ba097bbc5","FileExtension":"jpg","Picture1":"/9j/4AAQSkZJRg… (base64)","Picture2":"","Picture3":""},"response":{"success":"boolean.","message":"string — \"Foto's opgeslagen.\""},"responseExample":{"success":true,"message":"Foto's opgeslagen."},"errors":["HTTP 401 — invalid session.","HTTP 400 — no photos in the payload, or a photo larger than 8 MB."],"notes":"At least one non-empty picture is required. Slots you leave as '' keep their previous photo."},{"method":"POST","path":"/webhook/get-swipe-profiles","name":"Swipe feed","description":"The card deck for the swipe screen: a batch of max 10 RANDOM profiles (your own profile and existing matches are excluded). Fresh, never-swiped profiles are served before repeats; the order is random on every call. When fresh profiles run out, batches refill with profiles you swiped before, flagged AlreadySwiped=true — so the feed is never empty. Lazy-loading pattern: when the user reaches card ~8 of 10, call this endpoint again with the ids of the cards still in the deck as ExcludeIds; the next batch of 10 then contains no duplicates.","auth":"Body fields 'Username' (your email) + 'SessionId' (from login/registration) must match the user row. Note the capitalized key names on this endpoint.","request":{"Username":"string, required (capitalized key).","SessionId":"string, required (capitalized key).","ExcludeIds":"array of integers, optional — user ids still visible in the local deck (max 50); these are left out of the returned batch. Omit on the first load."},"requestExample":{"Username":"priya@desidating.nl","SessionId":"9818a9650a9c06f607f24d8ba097bbc5","ExcludeIds":[6,9]},"response":{"[]":"Bare JSON array of profile cards. Note the CAPITALIZED keys.","id":"integer — the user id; send it as 'swipedUserId' to set-swipe or 'profileId' to get-user-profile.","FullName":"string.","DateOfBirth":"string — 'yyyy-MM-dd' (the app computes the age).","City":"string.","Picture1 / Picture2 / Picture3":"string — photo filename or ''.","Latitude / Longitude":"number or null — GPS coordinates (not set for the seed users).","AlreadySwiped":"boolean — false for a profile you never swiped on; true when this card is a repeat of someone you already liked/passed (swiping again simply overwrites your previous swipe)."},"responseExample":[{"id":6,"FullName":"Vikram Singh","DateOfBirth":"1995-04-05","City":"Eindhoven","Picture1":"","Picture2":"","Picture3":"","Latitude":null,"Longitude":null,"AlreadySwiped":false}],"errors":["HTTP 401 — invalid session."],"notes":"Legacy alias: POST /webhook/953bb5fd-bc34-4436-9658-4534bf17100c (the old n8n webhook id) still points to this same endpoint, so the current app build keeps working. Switch the app to /webhook/get-swipe-profiles when convenient. The current Flutter build prefetches when 2 cards remain but does NOT send ExcludeIds yet — add it there to avoid the last cards reappearing in the next batch. SPEEDDATE ROOMS: while the user is in an active speeddate room (joined via /webhook/join-room), the feed is automatically restricted to fellow participants of that room — the incognito filter does NOT apply inside a room (everyone is physically at the same event); leaving the room (or the admin closing it) restores the normal feed. While the room is NOT yet released (waiting room), this endpoint returns [] — the deck stays empty until the organisation presses Vrijgeven in the admin panel."},{"method":"POST","path":"/webhook/set-swipe","name":"Swipe (like / pass)","description":"Record a swipe on another user. A right-swipe sends status 'liked', a left-swipe sends 'unliked'. When both users have liked each other, the response status is 'matched' and a conversation is created (the app shows the confetti overlay). Swiping the same user again just updates your previous swipe — likes are not doubled.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email (lowercase key on THIS endpoint).","sessionId":"string, required (lowercase key).","swipedUserId":"integer, required — the 'id' from a swipe-feed card.","status":"string, required — 'liked' (right swipe) | 'unliked' (left swipe/pass).","isSuperlike":"boolean, optional — true makes this like a SUPERLIKE. Omitted = false. Free members get ONE superlike per 7 days (like the boost); Premium members superlike without limit. If the weekly one was already used the swipe still counts as a normal like and the response says when the next superlike is available."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","swipedUserId":6,"status":"liked","isSuperlike":false},"response":{"status":"string — 'matched' (mutual like — conversation created!) | 'pending' (you liked, they have not yet) | 'unliked' (you passed). The app only reads this field.","is_superlike":"boolean — whether YOUR like on this person is (now) a superlike. An unlike clears it; a later normal re-like keeps an earlier superlike.","next_superlike_available_at":"ISO timestamp — only present when a superlike was involved: when you may superlike again.","superlike_message":"string — only present when you requested a superlike during the cooldown: human-readable explanation that this swipe counted as a normal like and from when the next superlike is available.","id":"integer — internal row id.","match_id":"string (uuid) or null — only set when matched.","user1_id":"integer — the user who liked first.","user2_id":"integer — the user who completed the match (or the target while pending).","user1_liked_at / user2_liked_at":"ISO timestamp or null.","matched_at":"ISO timestamp or null — when the mutual like happened.","conversation_id":"string (uuid) or null — set when matched; use it with get-messages / send-message.","createdAt / updatedAt":"ISO timestamps."},"responseExample":{"id":2,"match_id":"4eedf61e-5e41-4797-aa45-6ef6b71b03a5","user1_id":6,"user2_id":1,"user1_liked_at":"2026-07-12T23:07:46.961Z","user2_liked_at":"2026-07-13T23:10:07.304Z","matched_at":"2026-07-13T23:10:07.304Z","status":"matched","conversation_id":"9149ed8e-467c-4faf-af49-dd1292b4072d","createdAt":"2026-07-13T23:10:07.304Z","updatedAt":"2026-07-13T23:10:07.304Z"},"errors":["SUPERLIKE ALREADY USED (not an HTTP error!): HTTP 200 with the swipe saved as a NORMAL like — is_superlike:false, plus superlike_message:'Je hebt je wekelijkse superlike al gebruikt — deze swipe telt als gewone like. Je kunt weer een superlike geven vanaf donderdag 23 juli om 09:02.' and next_superlike_available_at (ISO). Check superlike_message to show a toast. Free members only — Premium never hits this.","DAILY LIKE LIMIT (free members): HTTP 403 {success:false, limit_reached:true, premium_required:true, likes_used:25, likes_limit:25, message:'Je dagelijkse 25 likes zijn op! Morgen krijg je nieuwe — of word Premium en swipe onbeperkt.'} — the free daily like budget (25 per Amsterdam day) is used up; the swipe is NOT saved. Show the upgrade prompt. Premium members are never limited, passes ('unliked') never count, and re-liking someone you already liked today does not hit the limit.","HTTP 401 — invalid session.","HTTP 400 — swipedUserId missing/not an integer, swiping yourself, or status not 'liked'/'unliked'.","HTTP 404 — swipedUserId does not exist."],"notes":"Free members get 25 likes per day (reset at midnight Europe/Amsterdam; configurable server-side via FREE_DAILY_LIKES) and 1 superlike per 7 days; Premium members like and superlike without limits. Track the budget with likes_used_today / likes_limit from get-profile-stats."},{"method":"POST","path":"/webhook/get-conversations","name":"Match list / conversations","description":"All your matches with the opponent's full profile and the latest chat message, newest activity first.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required (lowercase key).","sessionId":"string, required (lowercase key)."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"[]":"Bare JSON array (empty = no matches yet).","conversation_id":"string (uuid) — pass to get-messages / send-message.","opponent_id":"integer — the other user's id.","last_message_preview":"string — text of the newest message, '' if no messages yet.","last_message_at":"ISO timestamp — newest message time, or the match time if no messages.","superliked_by_me":"boolean — YOU superliked this person.","superliked_by_opponent":"boolean — this person superliked YOU (show it in the conversation screen in a special color: 'heeft jou een superlike gegeven').","superliked_by_ids":"array of integers — the user ids of who superliked (empty, one, or both).","opponent_profile":"object — the opponent's full profile: same fields as Get profile (username always '' for privacy) plus 'last_active' (ISO timestamp)."},"responseExample":[{"conversation_id":"e4bb0449-26b0-4ad5-bf42-90ffa7d73492","opponent_id":2,"last_message_preview":"Respect! Zullen we binnenkort een chai doen in de stad?","last_message_at":"2026-07-13T18:07:46.980Z","opponent_profile":{"id":2,"userId":2,"full_name":"Arjun Persaud","city":"Rotterdam","date_of_birth":"1994-08-22","last_active":"2026-07-13T10:22:01.000Z","is_verified":true,"subscription_tier":"free","…":"all other profile fields"}}],"errors":["HTTP 401 — invalid session."]},{"method":"POST","path":"/webhook/get-messages","name":"Chat history","description":"All messages of one conversation, oldest first. Fetching also marks the opponent's messages as read.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row. You must be a participant of the conversation.","request":{"username":"string, required (lowercase key).","sessionId":"string, required (lowercase key).","conversation_id":"string (uuid), required — from get-conversations or a 'matched' set-swipe response (snake_case key!)."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","conversation_id":"e4bb0449-26b0-4ad5-bf42-90ffa7d73492"},"response":{"[]":"Bare JSON array, oldest message first.","message_id":"string (uuid) — unique message id.","id":"integer — numeric message id.","conversation_id":"string (uuid).","sender_id":"integer — compare with your own userId to know if the message is yours (sent) or theirs (received).","content":"string — the message text.","message_type":"string — always 'text' for now.","is_read":"boolean — read receipt.","created_at":"ISO timestamp."},"responseExample":[{"message_id":"1c9b0449-26b0-4ad5-bf42-90ffa7d73411","id":1,"conversation_id":"e4bb0449-26b0-4ad5-bf42-90ffa7d73492","sender_id":2,"content":"Hoi Priya! Leuk dat we een match zijn 😄","message_type":"text","is_read":true,"created_at":"2026-07-12T00:07:46.980Z"}],"errors":["HTTP 401 — invalid session.","HTTP 404 — conversation does not exist or you are not a participant."]},{"method":"POST","path":"/webhook/send-message","name":"Send message (new)","description":"Persist a chat message. NEW endpoint — the current Flutter build only stores sent messages locally; wire the chat send to this to make messages real.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row. You must be a participant of the conversation.","request":{"username":"string, required (lowercase key).","sessionId":"string, required (lowercase key).","conversation_id":"string (uuid), required.","content":"string, required — the message text (non-empty)."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","conversation_id":"e4bb0449-26b0-4ad5-bf42-90ffa7d73492","content":"Ja leuk! Zaterdag?"},"response":"The stored message in the same shape as one get-messages item (message_id, id, conversation_id, sender_id, content, message_type, is_read, created_at).","errors":["HTTP 401 — invalid session.","HTTP 404 — conversation not found / not a participant.","HTTP 400 — empty content."]},{"method":"GET","path":"/webhook/get-picture?file=<filename>","name":"Get picture","description":"Serves a profile photo by filename (the values of picture1/2/3 / Picture1/2/3 in profile responses). Returns raw image bytes. Unauthenticated, like the app's image loaders expect.","auth":"None.","request":{"file":"query parameter — the bare filename, e.g. 'u11_p1_1783984236309.jpg'. Path characters are rejected."},"response":"Raw image bytes with an image/* Content-Type. Photos not stored locally are transparently proxied from the legacy Google Cloud Storage bucket (desi_dating_images).","errors":["HTTP 400 — invalid filename.","HTTP 404 — no such photo."]},{"method":"POST","path":"/webhook/unmatch","name":"Unmatch","description":"End a match from inside the conversation. The session identifies WHO unmatches; the conversation_id identifies which match ends. The conversation disappears for BOTH users: it is no longer listed by get-conversations and get-messages/send-message return 404.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row. You must be a participant of the conversation.","request":{"username":"string, required — your email (the person doing the unmatch).","sessionId":"string, required.","conversation_id":"string (uuid), required — e.g. '787e8c10-cdf7-42b1-8b39-052b5e24a8c1', from get-conversations or a 'matched' set-swipe response."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","conversation_id":"787e8c10-cdf7-42b1-8b39-052b5e24a8c1"},"response":{"success":"boolean.","message":"string — 'Match verwijderd.'","conversation_id":"string (uuid) — the conversation that was ended.","opponent_id":"integer — the user you unmatched.","status":"string — 'unmatched'."},"responseExample":{"success":true,"message":"Match verwijderd.","conversation_id":"787e8c10-cdf7-42b1-8b39-052b5e24a8c1","opponent_id":2,"status":"unmatched"},"errors":["HTTP 401 — invalid session.","HTTP 400 — conversation_id missing or not a UUID.","HTTP 404 — conversation not found, not a participant, or already unmatched."],"notes":"The server records who ended it (unmatched_by). Messages stay stored: if the two users like each other again later, set-swipe revives the match INCLUDING the old chat history. Both users reappear in each other's swipe feed after an unmatch. The app's current unmatch dialog only closes the screen locally — wire it to this endpoint."},{"method":"POST","path":"/webhook/send-chat-image","name":"Send a photo in a chat","description":"Send a photo as a chat message. The image travels as base64 in the JSON body (same as the profile-photo upload) but is stored as a FILE in uploads/; only the filename lands in messages.content, with message_type 'image'. Keeping binary data out of the database keeps rows small, get-messages fast and dumps a sane size.","auth":"Body fields 'username' + 'sessionId' must match the user row, and the user must be part of the conversation.","request":{"conversation_id":"string, required — the conversation UUID.","image":"string, required — base64 of the image bytes, WITHOUT a 'data:' prefix. Max 8 MB decoded.","file_extension":"string, optional — jpg (default) | jpeg | png | webp | gif."},"response":"The created message, same shape as get-messages: {message_id, id, conversation_id, sender_id, content (the stored filename), message_type:'image', is_read, created_at}.","errors":["HTTP 400 {success:false, message} — no image, unsupported type, or larger than 8 MB.","HTTP 404 {success:false, message:'Gesprek niet gevonden.'} — not your conversation."],"notes":"Render it with GET /webhook/get-picture?file=<content>. get-conversations shows '📷 Foto' as last_message_preview for these messages, and the admin report transcript exposes a ready-made imageUrl."},{"method":"POST","path":"/webhook/report-user","name":"Report user","description":"Report another user (e.g. the 'Gebruiker rapporteren' dialog in the chat). The report is stored server-side for review; the reported user is NOT notified and nothing changes for them automatically.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email (the reporter).","sessionId":"string, required.","conversation_id":"string (uuid), required unless you pass reported_user_id — the server derives the reported user from it: you are known via username+sessionId, so the OTHER participant of the conversation is the one being reported. Also stored with the report so the review can find the chat history.","reported_user_id":"integer, optional (may be null) — the user id being reported. Omit it in the chat screen and just send conversation_id.","reason":"string, optional — free text up to 2000 chars, e.g. 'Ongepaste berichten'.","notes":"Duplicate-safe: while an earlier report from the same reporter about the same person is still open, a repeat report only increments reports.report_count (and keeps the newest reason) instead of creating another row — a double tap in the app cannot spam the moderation queue. The app's report dialog tells the user that confirming shares the conversation with the moderators (see /api/admin/reports/:id/messages)."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","conversation_id":"787e8c10-cdf7-42b1-8b39-052b5e24a8c1","reported_user_id":null,"reason":"Ongepaste berichten"},"response":{"success":"boolean.","message":"string — 'Rapport ontvangen. Ons team bekijkt deze melding.'","reported_user_id":"integer — the user that was reported (handy when the server derived it from the conversation)."},"responseExample":{"success":true,"message":"Rapport ontvangen. Ons team bekijkt deze melding.","reported_user_id":2},"errors":["HTTP 401 — invalid session.","HTTP 400 — neither reported_user_id nor a valid conversation_id given, or trying to report yourself.","HTTP 404 — conversation not found / not a participant, or reported user does not exist."],"notes":"Reports land in the 'reports' table (reporter_id, reported_user_id, conversation_id, reason, created_at). Reporting does NOT unmatch — call /webhook/unmatch separately if the user also wants that."},{"method":"POST","path":"/webhook/get-profile-stats","name":"Profile statistics","description":"Statistics of the logged-in user: how often your profile was viewed, how many likes you received, and your total number of matches. A 'view' is counted every time ANOTHER user opens your profile via get-user-profile (viewing your own profile does not count).","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"profile_views":"integer — total number of times other users opened your profile.","likes_received":"integer — number of users whose most recent swipe on you is a like (an unlike afterwards removes it again).","matches_total":"integer — total number of matches you have had.","is_premium":"boolean — whether you are an active Premium member.","likes_used_today":"integer — likes you spent today (Amsterdam day, resets at midnight).","likes_limit":"integer or null — the free daily like budget (25), or null for Premium members (unlimited).","boost_active":"boolean — whether a profile boost is currently running.","boost_active_until":"ISO timestamp or null — when the running boost ends.","next_boost_available_at":"ISO timestamp or null — when you may boost again (null = you can boost right now).","superlike_available":"boolean — whether your weekly superlike is available right now.","next_superlike_available_at":"ISO timestamp or null — when you may superlike again (null = available now)."},"responseExample":{"profile_views":12,"likes_received":4,"matches_total":2,"is_premium":false,"likes_used_today":3,"likes_limit":25,"boost_active":false,"boost_active_until":null,"next_boost_available_at":null,"superlike_available":true,"next_superlike_available_at":null},"errors":["HTTP 401 — invalid session."],"notes":"The profile screen in the app currently shows hardcoded zeros for these stats — wire it to this endpoint to make them real. For Premium members the cooldowns do not apply: next_boost_available_at is always null, superlike_available is always true and next_superlike_available_at is always null (likes_limit is null as well)."},{"method":"POST","path":"/webhook/boost-profile","name":"Boost profile","description":"Boost your profile: for 24 hours you get ±50% higher chance to appear (and rank higher) in other users' swipe feeds. Free members can do this ONCE PER WEEK (cooldown tracked server-side per account); PREMIUM members boost without limit — the weekly cooldown does not apply, a new boost simply restarts the 24-hour window.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"success":"boolean — true when the boost was activated; false when the weekly boost was already used.","message":"string — confirmation or when the next boost is available.","boost_active":"boolean — whether a boost is running right now.","boost_active_until":"ISO timestamp or null — end of the running boost.","next_boost_available_at":"ISO timestamp or null — from when you may boost again (null = available now)."},"responseExample":{"success":true,"message":"Boost geactiveerd! Je profiel wordt de komende 24 uur extra vaak getoond — tot donderdag 16 juli om 16:00.","boost_active":true,"boost_active_until":"2026-07-16T14:00:00.000Z","next_boost_available_at":"2026-07-22T14:00:00.000Z"},"errors":["HTTP 200 {success:false, message:'Je hebt je wekelijkse boost al gebruikt. Je kunt weer boosten vanaf woensdag 22 juli om 16:00. Premium-leden boosten onbeperkt!'} — cooldown still running (free members only; Premium never hits this).","HTTP 401 — invalid session."],"notes":"Mechanics: the swipe feed uses weighted random sampling; a boosted profile gets weight 1.5 instead of 1.0 (~50% more likely to be picked/ranked high). Boost status also appears in get-profile-stats, so the app can show a countdown (for Premium next_boost_available_at is always null there)."},{"method":"POST","path":"/webhook/get-user-settings","name":"Get settings","description":"Current settings of the logged-in user — use this to prefill the settings screen.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"app_language":"string — 'nl' | 'en' | 'sar' | 'jvn' | 'srn' | 'hi' | 'ur' (default 'nl').","notify_new_match":"boolean — push on new match (default true).","notify_new_message":"boolean — push on new chat message (default true).","notify_profile_liked":"boolean — push when someone likes you (default false).","notify_verification":"boolean — push on verification updates (default true).","notify_email_match":"boolean — email for matches & messages (default true).","notify_email_promo":"boolean — email promotions & news (default false).","show_online_status":"boolean — show your activity status to others (default true). When false, your 'last_active' is hidden in other users' match lists.","incognito_mode":"boolean — when true you only appear in the swipe feed of people you already liked (default false)."},"responseExample":{"app_language":"nl","notify_new_match":true,"notify_new_message":true,"notify_profile_liked":false,"notify_verification":true,"notify_email_match":true,"notify_email_promo":false,"show_online_status":true,"incognito_mode":false},"errors":["HTTP 401 — invalid session."]},{"method":"POST","path":"/webhook/update-user-settings","name":"Save settings","description":"Save notification preferences, app language and privacy toggles in one call. PARTIAL updates: only the keys you include are changed, so sending a single toggle also works.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required.","app_language":"string, optional — 'nl' | 'en' | 'sar' | 'jvn' | 'srn' | 'hi' | 'ur'.","notify_new_match / notify_new_message / notify_profile_liked / notify_verification":"boolean, optional — push notification toggles.","notify_email_match / notify_email_promo":"boolean, optional — email toggles.","show_online_status":"boolean, optional — show active status to others.","incognito_mode":"boolean, optional — only visible to people who already liked you. Switching it ON is a PREMIUM feature (403 for free members); switching it OFF is always allowed."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","app_language":"nl","notify_new_match":true,"notify_new_message":true,"notify_profile_liked":false,"notify_verification":true,"notify_email_match":true,"notify_email_promo":false,"show_online_status":true,"incognito_mode":false},"response":{"success":"boolean.","message":"string — 'Instellingen opgeslagen.'"},"responseExample":{"success":true,"message":"Instellingen opgeslagen."},"errors":["HTTP 401 {success:false, message:'Ongeldige sessie.'} — invalid session.","HTTP 403 {success:false, premium_required:true, message:'Incognito-modus is een Premium-functie. Word Premium om onzichtbaar te swipen!'} — a free member sent incognito_mode:true; nothing is saved. Setting it to false is always allowed.","HTTP 400 — unknown app_language, a toggle that is not true/false, or an empty payload."]},{"method":"POST","path":"/webhook/change-password","name":"Change password","description":"Change the password of the logged-in user ('Wachtwoord wijzigen'). Both passwords are sent as MD5 hex, matching the login convention.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required.","current_password":"string, required — MD5 hex (32 chars) of the CURRENT password.","new_password":"string, required — MD5 hex (32 chars) of the NEW password."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","current_password":"6e2ce6ca45a28a25ff793065db66a06d","new_password":"5f4dcc3b5aa765d61d8327deb882cf99"},"response":{"success":"boolean.","message":"string — 'Wachtwoord gewijzigd.' | 'Huidig wachtwoord is onjuist.'"},"responseExample":{"success":true,"message":"Wachtwoord gewijzigd."},"errors":["HTTP 200 {success:false, message:'Huidig wachtwoord is onjuist.'} — wrong current password (login-style: status stays 200).","HTTP 400 — a password that is not 32-char MD5 hex.","HTTP 401 — invalid session."],"notes":"After a successful change, the old password no longer works at login; the current session id stays valid until it rotates."},{"method":"POST","path":"/webhook/delete-account","name":"Delete account","description":"PERMANENTLY delete the logged-in user's account ('Account verwijderen'): the profile, all swipes, all matches and their entire chat conversations, and the uploaded photo files. This cannot be undone.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"success":"boolean — true when the account is gone.","message":"string — 'Account verwijderd.'"},"responseExample":{"success":true,"message":"Account verwijderd."},"errors":["HTTP 401 {success:false, message:'Ongeldige sessie.'} — invalid session (nothing is deleted)."],"notes":"Deletion also removes the user from other people's match lists and deletes both sides of every conversation. Logging in afterwards returns 'Inloggen mislukt.'"},{"method":"POST","path":"/webhook/create-premium-checkout","name":"Premium · Start checkout","description":"Start the Premium upgrade: creates a Stripe Checkout session for the monthly Premium subscription and returns the payment URL — the app opens it in the browser. After paying, Stripe redirects the browser to GET /webhook/premium-return, which verifies the payment and activates premium immediately.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"success":"boolean — true when a checkout session was created.","url":"string — the Stripe Checkout URL; open it in the browser (external, not a WebView with restrictions).","message":"string — 'Open de betaalpagina om Premium te activeren.'","price_eur":"number — the monthly price in euros, e.g. 9.99."},"responseExample":{"success":true,"url":"https://checkout.stripe.com/c/pay/cs_test_…","message":"Open de betaalpagina om Premium te activeren.","price_eur":9.99},"errors":["HTTP 200 {success:false, message:'Je bent al Premium-lid!', already_premium:true} — the account already has an active premium membership.","HTTP 401 — invalid session.","HTTP 503 {success:false, message:'Betalingen zijn nog niet geconfigureerd.'} — STRIPE_SECRET_KEY not configured on the server.","HTTP 502 {success:false, message} — Stripe rejected the request (message contains the Stripe error).","HTTP 500 {success:false, message} — unexpected server error."],"notes":"Premium unlocks: unlimited swipes (no daily like limit), see who liked you, unlimited super likes, unlimited boosts, incognito mode, and undo swipe. No Stripe webhooks are used: activation happens on the return redirect, and renewals/cancellations are picked up lazily by get-premium-status plus an hourly server sweep."},{"method":"GET","path":"/webhook/premium-return?session_id=<id>","name":"Premium · Checkout return page","description":"Stripe redirects the BROWSER here after checkout (the app never calls this itself). The checkout session is verified server-side against Stripe; on a paid session the user from its metadata gets premium activated on the spot, and a friendly Dutch HTML page tells them to go back to the app.","auth":"None — the unguessable session_id is the proof; it is verified server-side against Stripe.","request":{"session_id":"query parameter — the Stripe Checkout session id; Stripe fills it in via the {CHECKOUT_SESSION_ID} placeholder in the success URL.","canceled":"query parameter — set to 1 on the cancel URL; shows the 'Betaling geannuleerd' page without contacting Stripe."},"response":"An HTML page (text/html), NOT JSON: 'Welkom bij Premium!' on success (including the paid-through date when known), 'Betaling geannuleerd' when the user backed out, or 'Betaling nog niet afgerond' when the session exists but is not paid.","errors":["HTTP 400 — session_id missing (HTML error page 'Ongeldige betaallink.').","HTTP 500 — Stripe could not be reached / verification failed (HTML error page; the user is told to refresh their premium status in the app).","HTTP 200 with the 'Betaling nog niet afgerond' page — the checkout session is not (yet) paid; premium is NOT activated."],"notes":"The redirect base URL prefers the HTTPS tunnel (tunnel-url.txt) so Stripe accepts it. If activation raced the redirect, POST /webhook/get-premium-status re-syncs with Stripe and fixes the state."},{"method":"POST","path":"/webhook/get-premium-status","name":"Premium · Status","description":"Current premium state of the logged-in user — use it for the premium screen and to refresh after returning from checkout. Lazily re-syncs with Stripe when the stored paid-through moment has passed or is unknown: a renewal extends premium_until, a cancellation lapses the account back to free.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"is_premium":"boolean — whether premium is active right now.","premium_until":"ISO timestamp or null — end of the current paid period (extends on renewal). null while unknown (filled in on the next Stripe sync) or when not premium.","cancel_at_period_end":"boolean — true when the subscription was cancelled but premium still runs until premium_until.","price_eur":"number — the monthly price in euros, e.g. 9.99.","benefits":"array of strings — the Dutch benefit lines for the upgrade screen: 'Onbeperkt swipen', 'Zie wie jou leuk vindt', 'Onbeperkt super likes', 'Onbeperkt profiel boosts', 'Incognito-modus', 'Swipes ongedaan maken'."},"responseExample":{"is_premium":true,"premium_until":"2026-09-30T14:00:00.000Z","cancel_at_period_end":false,"price_eur":9.99,"benefits":["Onbeperkt swipen","Zie wie jou leuk vindt","Onbeperkt super likes","Onbeperkt profiel boosts","Incognito-modus","Swipes ongedaan maken"]},"errors":["HTTP 401 — invalid session."],"notes":"An hourly server sweep also re-checks users whose paid-through moment passed, so premium lapses even when the app never calls this."},{"method":"POST","path":"/webhook/cancel-premium","name":"Premium · Cancel","description":"Cancel the premium subscription at the end of the paid period: premium benefits stay active until premium_until, after that nothing is charged anymore and the account falls back to free. Nothing is refunded.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"success":"boolean.","message":"string — 'Je lidmaatschap is opgezegd. Je houdt Premium t/m <datum>; daarna wordt er niets meer afgeschreven.'","premium_until":"ISO timestamp or null — until when premium stays active.","cancel_at_period_end":"boolean — always true on success."},"responseExample":{"success":true,"message":"Je lidmaatschap is opgezegd. Je houdt Premium t/m woensdag 30 september om 16:00; daarna wordt er niets meer afgeschreven.","premium_until":"2026-09-30T14:00:00.000Z","cancel_at_period_end":true},"errors":["HTTP 200 {success:false, message:'Je hebt geen actief Premium-lidmaatschap.'} — not premium, or no Stripe subscription on file.","HTTP 401 — invalid session.","HTTP 502 {success:false, message} — Stripe rejected the cancellation.","HTTP 500 {success:false, message} — unexpected server error."]},{"method":"POST","path":"/webhook/get-likes-received","name":"Premium · Who liked me","description":"The users whose most recent swipe on you is a like and with whom you have no match yet, newest like first (max 100). Premium members see the full liker cards; free members only get the COUNT plus a teaser message — the likers array stays empty as the upgrade hook.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"is_premium":"boolean — whether the likers are visible to you.","count":"integer — number of pending likers (also filled for free members).","likers":"array — EMPTY for free members. For premium: {userId (integer), name (string), age (integer), city (string), picture1 (photo filename or ''), is_verified (boolean), is_superlike (boolean — they SUPERliked you), liked_at (ISO timestamp)}.","message":"string — only present for free members: 'Nog geen nieuwe likes — blijf swipen!' or '<n> mensen vinden jou leuk. Word Premium om te zien wie!'."},"responseExample":{"is_premium":true,"count":2,"likers":[{"userId":6,"name":"Vikram Singh","age":31,"city":"Eindhoven","picture1":"","is_verified":true,"is_superlike":false,"liked_at":"2026-08-29T18:12:41.000Z"}]},"errors":["HTTP 401 — invalid session."],"notes":"Someone who unliked you afterwards disappears from the list, and matched users are excluded (they live in get-conversations). Load photos via GET /webhook/get-picture?file=<picture1>."},{"method":"POST","path":"/webhook/undo-swipe","name":"Premium · Undo swipe","description":"Undo your MOST RECENT swipe (like or pass) — the swipe row is deleted, so the profile comes back in your deck. PREMIUM ONLY. A like that already produced a match cannot be undone here: that path is unmatch (which both users see).","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"success":"boolean.","message":"string — 'Je like voor <naam> is ongedaan gemaakt — het profiel komt terug in je stapel.' (or '…pass op <naam>…').","undone":"object — {userId (integer), name (string), action ('liked' | 'unliked' — what the undone swipe was), was_superlike (boolean)}."},"responseExample":{"success":true,"message":"Je like voor Vikram Singh is ongedaan gemaakt — het profiel komt terug in je stapel.","undone":{"userId":6,"name":"Vikram Singh","action":"liked","was_superlike":false}},"errors":["HTTP 403 {success:false, premium_required:true, message:'Swipes ongedaan maken is een Premium-functie. Word Premium om je laatste swipe terug te draaien!'} — free member.","HTTP 200 {success:false, message:'Er is geen swipe om ongedaan te maken.'} — no swipes on record.","HTTP 200 {success:false, message:'Deze swipe leidde tot een match met <naam> — die kun je niet ongedaan maken. Unmatchen kan via de chat.'} — the last swipe completed a match.","HTTP 401 — invalid session."],"notes":"The undone profile reappears in the swipe feed as a fresh card (AlreadySwiped=false). Undoing a superlike does NOT reset the weekly superlike cooldown for free members."},{"method":"POST","path":"/webhook/join-room","name":"Speeddate · Join room","description":"Join a speeddate event (the digital version of DesiDating's physical speeddate evenings) with the 4-digit code from the organisation. While you are in the room, the swipe feed (get-swipe-profiles) only shows fellow participants of the same event, until you leave or the admin closes the room. FRESH START: joining wipes the swipe history (both directions) between you and every current room member you are NOT matched with — people can look very different in real life, so at the physical event everyone deserves a second chance. Matches are kept (flagged matched in get-room-status members) and stay out of the room deck.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required.","code":"string, required — the 4-digit event code, e.g. '4821' (surrounding whitespace is trimmed)."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5","code":"4821"},"response":{"success":"boolean.","message":"string — 'Welkom bij <naam>! Op Ontdekken zie je nu alleen de deelnemers van dit event.'","room":"object — {name (string — event/location name), code (string — the 4-digit code), participants (integer — current participant count, including yourself), released (boolean — false while the organisation has not started the event yet; joining is allowed in both phases)}."},"responseExample":{"success":true,"message":"Welkom bij Restaurant Rangoli, Den Haag! Op Ontdekken zie je nu alleen de deelnemers van dit event.","room":{"name":"Restaurant Rangoli, Den Haag","code":"4821","participants":7}},"errors":["HTTP 401 — invalid session.","HTTP 400 {success:false, message:'Vul de 4-cijferige code van het event in.'} — code missing or not exactly 4 digits.","HTTP 404 {success:false, message:'Geen actief event gevonden met deze code. Controleer de code bij de organisatie.'} — no ACTIVE room with this code (closed rooms do not match)."],"notes":"Joining while already in another room simply moves you to the new one — no need to leave first. Re-joining the room you are already in is harmless: it does NOT wipe again (double-taps and network retries cannot destroy swipes made during the event). Room membership has no timer; it ends via leave-room or when the admin closes/deletes the room."},{"method":"POST","path":"/webhook/leave-room","name":"Speeddate · Leave room","description":"Leave the speeddate event you are currently in. The swipe feed immediately falls back to the normal (everyone) deck.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"success":"boolean — false (with HTTP 200) when you were not in a room to begin with.","message":"string — 'Je hebt het event verlaten — op Ontdekken zie je nu weer iedereen.'"},"responseExample":{"success":true,"message":"Je hebt het event verlaten — op Ontdekken zie je nu weer iedereen."},"errors":["HTTP 200 {success:false, message:'Je zit momenteel niet in een speeddate-event.'} — not in a room (nothing to leave; not an HTTP error).","HTTP 401 — invalid session."]},{"method":"POST","path":"/webhook/get-room-status","name":"Speeddate · Room status","description":"Whether the logged-in user is currently in an active speeddate room, and if so which one — use it to show/refresh the event banner in the app.","auth":"Body fields 'username' (your email) + 'sessionId' (from login/registration) must match the user row.","request":{"username":"string, required — your email.","sessionId":"string, required."},"requestExample":{"username":"priya@desidating.nl","sessionId":"9818a9650a9c06f607f24d8ba097bbc5"},"response":{"in_room":"boolean — true while you are in an ACTIVE room.","room":"object or null — {name, code, participants, released (boolean — false while the room is still a waiting room; the organisation flips it via the admin panel's Vrijgeven button)}, null when in_room is false.","members":"Array<{id (integer), name, age (integer|null), city, picture1 (string — filename for /webhook/get-picture?file=, '' when none), matched (boolean — you and this member already matched; shown as a Match label in the room list, and such members deliberately never appear in the room's swipe deck)}> — everyone currently in the room (yourself included), join order. Only present while in_room is true. Drives the waiting-room list and the 'new participants joined' detection."},"responseExample":{"in_room":true,"room":{"name":"Restaurant Rangoli, Den Haag","code":"4821","participants":7,"released":false},"members":[{"id":5,"name":"Sita Bhagwandin","age":28,"city":"Amsterdam","picture1":"u5_p1_1784075455796.jpg","matched":false}]},"errors":["HTTP 401 — invalid session."],"notes":"When the admin closed or deleted your room, this call lazily clears the stale membership and returns {in_room:false, room:null} — so polling it is also how the app notices the event ended. The app polls this every ~10s while on the Speeddate tab or in a room: it is how the waiting room fills up live, how release is detected (released flips to true — reload the deck), and how late joiners are noticed (participants grows — fetch more cards)."},{"method":"POST","path":"/api/admin/auth/login","name":"Admin · Login","description":"Login for the desiadmin panel (integration point marked in its LoginForm). Checks admin_users and returns a session token. NOTE: the other /api/admin/* endpoints do not require this token yet — auth wiring comes with the planned auth redesign.","auth":"None (this call creates the admin session).","request":{"email":"string, required — admin email, e.g. 'admin@desidating.nl'.","password":"string, required — RAW PLAINTEXT password (the server compares its MD5)."},"requestExample":{"email":"admin@desidating.nl","password":"Admin2026!"},"response":{"success":"boolean.","message":"string — 'Ingelogd als Super Admin'.","token":"string — random UUID, stored on admin_users.session_token.","admin":"object — {id, email, fullName, role}."},"errors":["HTTP 401 {success:false, message:'Ongeldig e-mailadres of wachtwoord'} — unknown email or wrong password."],"notes":"Seeded account: admin@desidating.nl / Admin2026! (role super_admin)."},{"method":"GET","path":"/api/admin/users","name":"Admin · List users","description":"Paginated, filterable user list for the admin panel's user-management screen, including moderation state and match/report counts.","auth":"None yet (admin panel).","request":{"page":"query, number — page number, default 1.","limit":"query, number — items per page, default 10, max 100.","search":"query, string — matches name, email (username) or city, case-insensitive.","status":"query, string — active | pending | suspended | banned (users.admin_status).","gender":"query, string — exact match, e.g. 'Vrouw' | 'Man'.","background":"query, string — exact match, e.g. 'Hindustaans'.","religion":"query, string — exact match, e.g. 'Hindoe'.","verified":"query, string — verified | unverified | pending | rejected (users.admin_verification_status).","period":"query, string — 7d | 30d | 90d | 365d | all — filters on registration date."},"response":{"data":"User[] — {id:'user-<n>', name, age, gender, background, city, country, status, verificationStatus, subscriptionTier, joinedDate:'YYYY-MM-DD', lastActive:'YYYY-MM-DD', matchCount, reportCount, avatar (initials), email, religion, languages[], bio, lookingFor, occupation, height ('178 cm'), education, religionImportance, diet, smoking, drinking, wantsChildren, maritalStatus, familyValues, interests[]}.","meta":"{total, page, limit, totalPages, filters:{backgrounds[], religions[], genders[] (distinct values from the DB), statuses[], verificationStatuses[], periods[]}}."},"notes":"Sorted newest registration first. matchCount counts current 'matched' matches; reportCount counts reports filed against the user."},{"method":"DELETE","path":"/api/admin/users/:id","name":"Admin · Delete user","description":"PERMANENTLY delete a user from the admin panel: profile, swipes, matches, both sides of every conversation, and uploaded photo files. Logged in admin_actions.","auth":"None yet (admin panel).","request":"Path id accepts 'user-42' (as returned by GET /api/users) or a bare '42'. No body.","response":{"success":"boolean.","message":"string — 'Gebruiker <id> is verwijderd'.","deletedId":"string — the id exactly as passed in the path."},"errors":["HTTP 404 {success:false, message:'Gebruiker niet gevonden'}."]},{"method":"PATCH","path":"/api/admin/users/:id/verify","name":"Admin · Approve verification","description":"Approve a user's profile verification: sets users.admin_verification_status='verified' (and the app's is_verified flag), stamps admin_verified_at, stores the note. Logged in admin_actions.","auth":"None yet (admin panel).","request":{"note":"string, optional — admin note, e.g. 'Identiteitsbewijs goedgekeurd'."},"requestExample":{"note":"Goedgekeurd door admin"},"response":{"success":"boolean.","message":"string — 'Verificatie van gebruiker <id> is goedgekeurd.' (+ ' Notitie: …' when a note was sent).","userId":"string — the id as passed.","newVerificationStatus":"string — always 'verified'."},"errors":["HTTP 404 {success:false, message:'Gebruiker niet gevonden'}."]},{"method":"PATCH","path":"/api/admin/users/:id/suspend","name":"Admin · Suspend user","description":"Suspend a user: sets users.admin_status='suspended', stamps admin_suspended_at, stores the reason. Logged in admin_actions. Currently administrative only — the app does not yet block suspended users from logging in.","auth":"None yet (admin panel).","request":{"reason":"string, optional — default 'Geen reden opgegeven'."},"requestExample":{"reason":"Ongepast gedrag"},"response":{"success":"boolean.","message":"string — 'Gebruiker <id> is geschorst. Reden: <reason>'.","userId":"string — the id as passed.","newStatus":"string — always 'suspended'."},"errors":["HTTP 404 {success:false, message:'Gebruiker niet gevonden'}."]},{"method":"PATCH","path":"/api/admin/users/:id/unsuspend","name":"Admin · Unsuspend user","description":"Lift a suspension: sets users.admin_status='active' and clears admin_suspended_at / admin_suspend_reason. Logged in admin_actions.","auth":"None yet (admin panel).","request":"No payload required (an empty JSON object {} is fine).","response":{"success":"boolean.","message":"string — 'Schorsing van gebruiker <id> is opgeheven'.","userId":"string — the id as passed.","newStatus":"string — always 'active'."},"errors":["HTTP 404 {success:false, message:'Gebruiker niet gevonden'}."]},{"method":"GET","path":"/api/admin/profiles","name":"Admin · Profiles","description":"Full profile cards for the admin panel's match/profile-monitoring screen, including photo URLs served by this API.","auth":"None yet (admin panel).","request":{"search":"query, string — matches name, city or background, case-insensitive.","verificationStatus":"query, string — verified | pending | rejected | unverified."},"response":{"data":"Profile[] — like Admin · List users plus {intention, photos:[{url, alt}]}; photos point at /webhook/get-picture on this API (absolute URLs built from the caller's host, so they stay https through the tunnel).","meta":"{total, verified, pending, rejected} — counted over the filtered result set."}},{"method":"GET","path":"/api/admin/matches","name":"Admin · Matches","description":"All current matches with chat activity for the monitoring screen.","auth":"None yet (admin panel).","request":{"chatActive":"query, 'true' | 'false', optional — a chat is active when a message was sent in the last 24 hours.","search":"query, string — matches either user's name, case-insensitive."},"response":{"data":"Match[] — {id:'match-<n>', user1, user2, avatar1, avatar2, matchedAt:'YYYY-MM-DD HH:MM' (Europe/Amsterdam), chatActive, messageCount, background1, background2, duration ('2u 28m' / '3d 16u' since matching)}.","meta":"{total, active, inactive} — counted over the filtered result set."},"notes":"Only status='matched' matches are listed; unmatched pairs are excluded."},{"method":"GET","path":"/api/admin/rooms","name":"Admin · Speeddate rooms","description":"All speeddate rooms (events) with a live participant count, active rooms first, then newest first.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Room[] — {id:'room-<n>', name, code (4-digit string), status:'active'|'closed', participants (live count), createdAt:'YYYY-MM-DD HH:MM' (Europe/Amsterdam), closedAt (same format or null)}.","meta":"{total, active}."},"responseExample":{"data":[{"id":"room-3","name":"Restaurant Rangoli, Den Haag","code":"4821","status":"active","participants":7,"createdAt":"2026-08-30 19:00","closedAt":null}],"meta":{"total":3,"active":1}}},{"method":"POST","path":"/api/admin/rooms","name":"Admin · Create room","description":"Create a speeddate event: send only the name/location; the server generates a random 4-digit code that is unique among ACTIVE rooms. Share that code with the guests — they join via POST /webhook/join-room in the app.","auth":"None yet (admin panel).","request":{"name":"string, required — event name / location, e.g. 'Restaurant Rangoli, Den Haag'."},"requestExample":{"name":"Restaurant Rangoli, Den Haag"},"response":{"success":"boolean.","message":"string — \"Event '<naam>' aangemaakt — deelnemers doen mee met code <code>\".","room":"object — the new room in the same shape as GET /api/admin/rooms (status 'active', participants 0)."},"responseExample":{"success":true,"message":"Event 'Restaurant Rangoli, Den Haag' aangemaakt — deelnemers doen mee met code 4821","room":{"id":"room-3","name":"Restaurant Rangoli, Den Haag","code":"4821","status":"active","participants":0,"createdAt":"2026-08-30 19:00","closedAt":null}},"errors":["HTTP 400 {success:false, message:'Vul een naam/locatie voor het event in'} — name missing or blank.","HTTP 500 {success:false, message:'Kon geen vrije code genereren — sluit oude events'} — no free 4-digit code found (only plausible with thousands of active rooms)."],"notes":"Codes are only unique among ACTIVE rooms — a closed room's code can be reused by a new event."},{"method":"GET","path":"/api/admin/rooms/:id","name":"Admin · Room detail","description":"One room plus its current participant list (in join order) for the event-monitoring screen.","auth":"None yet (admin panel).","request":"Path id accepts 'room-3' (as returned by GET /api/admin/rooms) or a bare '3'. No body.","response":{"room":"object — same shape as one GET /api/admin/rooms item (includes released (boolean), releasedAt ('YYYY-MM-DD HH:MM' Europe/Amsterdam or null), and matches (integer — matched pairs among current members)).","participants":"Array<{id:'user-<n>', name, age (integer, from date_of_birth), city, subscriptionTier ('free'|'premium'|'premium_plus'), joinedAt:'HH:MM' (Europe/Amsterdam), matchCount (integer — matches with FELLOW CURRENT room members; sort descending for the venue leaderboard)}> — join order, oldest first.","matches":"object — {total (matched pairs among current members), duringEvent (of those, matched after the room's release — 'happened tonight'), pairs: Array<{names:[string,string], ids:[integer,integer] (same order as names, for linking to the user profile), matchedAt:'HH:MM' (Europe/Amsterdam), matchedDate:'YYYY-MM-DD', duringEvent:boolean}> (newest first; matchedDate lets the panel show the date on pre-existing matches, where a bare time would read as tonight)}. The list card's `matches` field carries the same total."},"responseExample":{"room":{"id":"room-3","name":"Restaurant Rangoli, Den Haag","code":"4821","status":"active","participants":1,"createdAt":"2026-08-30 19:00","closedAt":null},"participants":[{"id":"user-1","name":"Priya Sharma","age":29,"city":"Amsterdam","subscriptionTier":"free","joinedAt":"19:04"}]},"errors":["HTTP 404 {success:false, message:'Event niet gevonden'}."]},{"method":"POST","path":"/api/admin/rooms/:id/release","name":"Admin · Room vrijgeven","description":"Start the event: until release the room is a waiting room — participants join with the code and see each other on the Speeddate tab, but the swipe deck stays empty. Releasing flips room.released to true; the app notices within ~10s via its get-room-status polling and loads the deck. Release also wipes the swipe history between every pair of current members that is not matched (fresh start at the physical event — join-room does the same for people arriving later). One-way while the room is active; closing and reopening the room puts it back in the waiting phase.","auth":"None yet (admin panel).","request":"Path id accepts 'room-3' or a bare '3'. No body.","response":{"success":"boolean.","message":"string — \"Event '<naam>' is vrijgegeven — de deelnemers kunnen nu speeddaten!\"","room":"object — same shape as one GET /api/admin/rooms item (released now true, releasedAt set)."},"errors":["HTTP 404 {success:false, message:'Event niet gevonden'}","HTTP 409 {success:false, message:'Dit event is gesloten — heropen het eerst'}","HTTP 409 {success:false, message:'Dit event is al vrijgegeven'}"]},{"method":"PATCH","path":"/api/admin/rooms/:id","name":"Admin · Update / close room","description":"Rename a room and/or change its status. status 'closed' ends the event: ALL participants are kicked back to the normal swipe feed immediately and closed_at is stamped. status 'active' reopens a closed room — only when no other active room has claimed its code in the meantime.","auth":"None yet (admin panel).","request":{"name":"string, optional — new event name (empty/missing keeps the current name).","status":"string, optional — 'closed' (end the event) | 'active' (reopen). Omit to only rename."},"requestExample":{"status":"closed"},"response":{"success":"boolean.","message":"string — \"Event '<naam>' is gesloten — alle deelnemers zien weer de normale app\" | \"Event '<naam>' is heropend met code <code>\" | \"Event '<naam>' bijgewerkt\" (rename only).","room":"object — the updated room, same shape as GET /api/admin/rooms."},"responseExample":{"success":true,"message":"Event 'Restaurant Rangoli, Den Haag' is gesloten — alle deelnemers zien weer de normale app","room":{"id":"room-3","name":"Restaurant Rangoli, Den Haag","code":"4821","status":"closed","participants":0,"createdAt":"2026-08-30 19:00","closedAt":"2026-08-30 22:15"}},"errors":["HTTP 404 {success:false, message:'Event niet gevonden'}.","HTTP 409 {success:false, message:'Code <code> is inmiddels in gebruik door een ander actief event — maak een nieuw event aan'} — reopening refused because the code was reused."],"notes":"Reopening does NOT restore the previous participants — everyone re-joins with the code. Closing an already-closed room (or activating an active one) just applies the rename."},{"method":"DELETE","path":"/api/admin/rooms/:id","name":"Admin · Delete room","description":"Delete a room entirely (any status). Current participants are released back to the normal swipe feed first, then the row is removed — unlike closing, the event also disappears from the admin list/history.","auth":"None yet (admin panel).","request":"Path id accepts 'room-3' or a bare '3'. No body.","response":{"success":"boolean.","message":"string — \"Event '<naam>' verwijderd\"."},"responseExample":{"success":true,"message":"Event 'Restaurant Rangoli, Den Haag' verwijderd"},"errors":["HTTP 404 {success:false, message:'Event niet gevonden'}."]},{"method":"GET","path":"/api/admin/stats/overview","name":"Admin · Stats overview","description":"Key platform metrics for the dashboard's bento grid, computed live.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"totalUsers":"number.","dau":"number — users active in the last 24h (last_active).","mau":"number — users active in the last 30 days.","mutualMatches":"number — matches ever made (matched_at set, incl. later unmatched).","genderRatio":"{female, male} — integer percentages of users with gender Vrouw/Man.","verifiedProfiles":"number — admin_verification_status='verified'.","verificationRate":"number — verified / total * 100, 1 decimal.","weeklyNewUsers":"number — registrations in the last 7 days.","weeklyNewMatches":"number — matches in the last 7 days.","dauTrend":"number — % vs the 24h before (mauTrend/matchesTrend/verifiedTrend analogous: vs the previous window; 100 when the previous window was 0)."}},{"method":"GET","path":"/api/admin/stats/trends?period=7d|30d|90d","name":"Admin · Registration trends","description":"Time series of registrations, verifications and matches for the dashboard chart. Buckets end today (Europe/Amsterdam): 7d = 7 daily points, 30d = 10 x 3 days, 90d = 13 weekly points.","auth":"None yet (admin panel).","request":{"period":"query, string — 7d | 30d | 90d, default 30d (unknown values fall back to 30d)."},"response":{"period":"string — echoed as requested.","data":"Array<{dag:'<day> <maand>' e.g. '9 jun' (bucket start), aanmeldingen, geverifieerd, matches}>."}},{"method":"GET","path":"/api/admin/stats/age-distribution","name":"Admin · Age distribution","description":"Age brackets (18–22 … 48+) computed from date_of_birth.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"peakBracket":"string — bracket with the highest count.","data":"Array<{leeftijd, aantal, pct (1 decimal, of users with a known birth date)}>."}},{"method":"GET","path":"/api/admin/stats/faith-distribution","name":"Admin · Faith distribution","description":"Users grouped into the dashboard's five faith categories (Hindoeïsme, Islam, Christendom, Geen / Seculier, Anders) by keyword-matching the free-form religion field.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{name, value, pct ('39.2%' string), color (hex, for the chart)}>."}},{"method":"GET","path":"/api/admin/stats/cultural-background","name":"Admin · Cultural background","description":"Users grouped into Hindustaans / Javaans / Gemengd / Anders from the background field (non-matching values, e.g. Punjabi or Bengali, count as Anders).","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{name, value, pct ('46.1%' string), color (hex)}>."}},{"method":"GET","path":"/api/admin/stats/conversion-funnel","name":"Admin · Conversion funnel","description":"Signup-to-conversation funnel: Aangemeld → E-mail geverifieerd → Profiel compleet → Eerste swipe → 3e swipe → Eerste match → Eerste bericht → 3e bericht. Every step carries BOTH readings: 'count' is the funnel proper (cleared this step and every earlier one, so it only narrows) and 'total' is the milestone on its own (did this at all, regardless of earlier steps). 'Profiel compleet' means 70% or more of the app's own completeness measure — the 20 COMPLETION_FIELDS in profile-shape.ts, photo included.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{id:'funnel-…', label, count (cumulative), pct, total (independent), totalPct, color (Tailwind class)}>. Percentages are of the signup total, 1 decimal.","meta":"{users, profileThresholdPct}."},"notes":"'E-mail geverifieerd' counts users who clicked the link in the verification e-mail (see /webhook/verify-email); users from before that flow keep their backfilled value. Because e-mail verification is optional in the app, the gap between 'count' and 'total' on the later steps shows people who are active without ever having verified."},{"method":"GET","path":"/api/admin/users/export","name":"Admin · Users CSV export","description":"CSV download of the user list; accepts the same filters as GET /api/admin/users (search, status, gender, background, religion, verified, period). Semicolon-separated, UTF-8 with BOM.","auth":"None yet (admin panel).","request":"Query string filters only.","response":"text/csv attachment 'gebruikers-export.csv'."},{"method":"POST","path":"/api/admin/users/bulk-suspend","name":"Admin · Bulk suspend","description":"Suspend several users at once.","auth":"None yet (admin panel).","request":{"ids":"Array of 'user-<n>' ids (bare ints also accepted).","reason":"string, optional."},"response":{"success":"boolean.","message":"string.","suspendedCount":"integer."}},{"method":"POST","path":"/api/admin/users/bulk-delete","name":"Admin · Bulk delete","description":"Permanently delete several users (photos, chats, matches included).","auth":"None yet (admin panel).","request":{"ids":"Array of 'user-<n>' ids."},"response":{"success":"boolean.","message":"string.","deletedCount":"integer."}},{"method":"POST","path":"/api/admin/users/bulk-email","name":"Admin · Bulk e-mail","description":"Send an ad-hoc e-mail (via SendGrid) to a selection of users.","auth":"None yet (admin panel).","request":{"ids":"Array of 'user-<n>' ids.","subject":"string, required.","bodyHtml":"string, required — HTML fragment; wrapped in the branded template."},"response":{"success":"boolean.","message":"string — includes the sender used.","sentCount":"integer."},"errors":["HTTP 502 {success:false, message} — SendGrid rejected the send (e.g. plan out of credits)."]},{"method":"GET","path":"/api/admin/reports","name":"Admin · Reports list","description":"User reports (from the app's report-user flow) with moderation status.","auth":"None yet (admin panel).","request":"Optional ?status=open|resolved|dismissed.","response":{"data":"Array<{id:'report-<n>', reporter, reporterId:'user-<n>', reported, reportedId, reportedStatus, reason, status, createdAt 'YYYY-MM-DD HH:mm' Amsterdam, reportCount (repeat reports folded into one case), hasConversation, messageCount}>.","meta":"{total, open, resolved, dismissed, resolvedThisWeek}."}},{"method":"GET","path":"/api/admin/reports/:id/messages","name":"Admin · Report chat transcript","description":"The full chat history behind a report, so a moderator can judge the case. Users consent to this when they confirm the report in the app (the dialog states the conversation is shared with the moderators); cancelling shares nothing.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"report":"{id:'report-<n>', reporter, reporterId, reported, reportedId, reason, status, reportCount}.","messages":"Array<{id, senderId:'user-<n>', senderName, fromReported (true when the reported user wrote it), content, type, sentAt 'YYYY-MM-DD HH:mm' Amsterdam}>, oldest first.","meta":"{total, hasConversation} — hasConversation is false when the report carries no conversation (then messages is empty)."},"errors":["HTTP 404 {success:false, message:'Melding niet gevonden'}."]},{"method":"PATCH","path":"/api/admin/reports/:id","name":"Admin · Update report status","description":"Set a report's moderation status; resolved/dismissed stamp admin_resolved_at.","auth":"None yet (admin panel).","request":{"status":"'open' | 'resolved' | 'dismissed'."},"response":{"success":"boolean.","message":"string.","reportId":"string.","newStatus":"string."}},{"method":"GET","path":"/api/admin/activity","name":"Admin · Activity feed","description":"Recent events across the platform (signups, matches, reports, campaigns, admin actions), newest first, for the dashboard's activity feed.","auth":"None yet (admin panel).","request":"Optional ?limit=8 (max 100).","response":{"data":"Array<{type:'signup'|'match'|'report'|'campaign'|'admin:<action>', title, detail, time (relative Dutch, e.g. '5 min geleden'), at (ISO), entityId}>."}},{"method":"GET","path":"/api/admin/nav-counts","name":"Admin · Sidebar badges","description":"Counters for the admin sidebar badges.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"pendingVerifications":"integer.","openReports":"integer."}},{"method":"POST","path":"/api/admin/auth/logout","name":"Admin · Logout","description":"Invalidates the admin session token issued at login.","auth":"None yet (admin panel).","request":{"token":"string, optional — the token returned by /api/admin/auth/login."},"response":{"success":"boolean.","message":"'Uitgelogd'."}},{"method":"GET","path":"/api/admin/content/translations","name":"Content · App texts","description":"Every user-visible text of the Flutter app (extracted from its source), per screen, in 7 languages (nl/en/sar/jvn/srn/hi/ur). Only 'nl' is seeded; other languages are filled in by admins.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{id:'tr-<n>', key, screen, category (Dutch label), description, nl, en, sar, jvn, srn, hi, ur}>.","meta":"{total, categories, languages: [{code, label, complete (0-100)}]}."}},{"method":"POST","path":"/api/admin/content/translations","name":"Content · Create text key","description":"Add a new text key (dot notation, e.g. 'swipe.button.like').","auth":"None yet (admin panel).","request":{"key":"string, required — dot notation, unique.","category":"string, optional — screen name or Dutch category label; default 'shared'.","description":"string, optional.","nl":"string, optional (idem en/sar/jvn/srn/hi/ur)."},"response":{"success":"boolean.","message":"string.","translation":"The created row."},"errors":["HTTP 400 — invalid key.","HTTP 409 — key already exists."]},{"method":"PATCH","path":"/api/admin/content/translations/:id","name":"Content · Update text","description":"Update the language values and/or description of a text key.","auth":"None yet (admin panel).","request":{"nl":"string, optional (idem en/sar/jvn/srn/hi/ur, description)."},"response":{"success":"boolean.","message":"string.","translation":"The updated row."},"errors":["HTTP 404 {success:false, message:'Vertaling niet gevonden'}."]},{"method":"DELETE","path":"/api/admin/content/translations/:id","name":"Content · Delete text key","description":"Remove a text key.","auth":"None yet (admin panel).","request":"No payload.","response":{"success":"boolean.","message":"string."}},{"method":"GET","path":"/api/admin/content/pages","name":"Content · Pages","description":"Long-form app documents (privacybeleid, voorwaarden, ...), extracted from the app's legal screen.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{id:'page-<n>', slug, title, lastUpdated 'YYYY-MM-DD', content}>."}},{"method":"PATCH","path":"/api/admin/content/pages/:id","name":"Content · Update page","description":"Edit a page's title and/or content.","auth":"None yet (admin panel).","request":{"title":"string, optional.","content":"string, optional."},"response":{"success":"boolean.","message":"string.","page":"The updated page."},"errors":["HTTP 404 {success:false, message:'Pagina niet gevonden'}."]},{"method":"GET","path":"/webhook/get-app-texts","name":"App texts (for the Flutter app)","description":"Read-only feed of all managed texts + pages for the Flutter app. With ?lang=nl|en|sar|jvn|srn|hi|ur returns a flat {key: text} map in that language, falling back to Dutch for untranslated keys; without lang returns all languages nested per key.","auth":"None.","request":"Optional ?lang=<code>.","response":{"texts":"{'<key>': string} (with ?lang) or {'<key>': {nl, en, ...}}.","pages":"{'<slug>': {title, content}}."},"notes":"The Flutter app still uses its hardcoded strings; pointing it at this endpoint is the follow-up step that makes admin edits visible in the app."},{"method":"GET","path":"/api/admin/campaigns","name":"E-mail · Campaign list","description":"All e-mail campaigns, newest first. For sent campaigns the delivery/open/click numbers come live from SendGrid (category 'campaign-<id>', cached 5 min).","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{id:'camp-<n>', name, subject, bodyHtml, segment, status:'draft'|'scheduled'|'sending'|'sent'|'failed', sentAt?, scheduledAt? ('YYYY-MM-DD HH:mm' Amsterdam), recipients, fromEmail?, error?, openRate?, clickRate?, unsubscribes?, delivered?, bounces?}>.","meta":"{total, sent, scheduled, draft, failed, statsError?}."}},{"method":"POST","path":"/api/admin/campaigns","name":"E-mail · Create / send campaign","description":"Create a campaign as draft, send it immediately, or schedule it. Recipients come from the chosen segment; '[Voornaam]' in subject/body is replaced per recipient. Sender: info@desidating.online once the domain is verified, otherwise the verified fallback sender.","auth":"None yet (admin panel).","request":{"name":"string.","subject":"string.","bodyHtml":"string — HTML fragment from the editor.","segment":"string — one of the segment names from GET /api/admin/email/segments.","action":"'draft' (default) | 'send' | 'schedule'.","scheduledAt":"ISO timestamp, required for action 'schedule' (must be in the future; sent by the in-process scheduler, checked every minute)."},"response":{"success":"boolean.","message":"string.","campaign":"The campaign object."},"errors":["HTTP 502 {success:false, message, campaign} — the send failed (campaign.status 'failed', reason in campaign.error)."]},{"method":"PATCH","path":"/api/admin/campaigns/:id","name":"E-mail · Update campaign","description":"Edit a draft/scheduled/failed campaign (sent ones are immutable). scheduledAt: string re-schedules, null converts back to draft.","auth":"None yet (admin panel).","request":{"name":"string, optional.","subject":"string, optional.","bodyHtml":"string, optional.","segment":"string, optional.","scheduledAt":"ISO string | null, optional."},"response":{"success":"boolean.","message":"string.","campaign":"The updated campaign."}},{"method":"DELETE","path":"/api/admin/campaigns/:id","name":"E-mail · Delete campaign","description":"Remove a campaign row (any status).","auth":"None yet (admin panel).","request":"No payload.","response":{"success":"boolean.","message":"string."}},{"method":"POST","path":"/api/admin/campaigns/:id/duplicate","name":"E-mail · Duplicate campaign","description":"Copy a campaign as a new draft ('<naam> (kopie)').","auth":"None yet (admin panel).","request":"No payload.","response":{"success":"boolean.","message":"string.","campaign":"The new draft."}},{"method":"POST","path":"/api/admin/campaigns/:id/send","name":"E-mail · Send campaign now","description":"Send a draft/scheduled/failed campaign immediately.","auth":"None yet (admin panel).","request":"No payload.","response":{"success":"boolean.","message":"string.","campaign":"The campaign object."},"errors":["HTTP 409 — already sent.","HTTP 502 {success:false, message, campaign} — send failed."]},{"method":"GET","path":"/api/admin/campaigns/:id/report","name":"E-mail · Campaign report","description":"Per-campaign totals plus a per-day series from SendGrid since the send date.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"campaign":"The campaign object incl. openRate/clickRate/delivered/bounces/unsubscribes.","daily":"Array<{date, requests, delivered, uniqueOpens, uniqueClicks, opens, clicks, bounces, spamReports, unsubscribes}>."}},{"method":"GET","path":"/api/admin/email/segments","name":"E-mail · Segments","description":"The available audience segments with a live recipient count per segment.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"data":"Array<{name, count}>."}},{"method":"GET","path":"/api/admin/email/stats","name":"E-mail · Account stats","description":"SendGrid account-wide e-mail metrics over the trailing 90 days (feeds the page's summary tiles).","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"periodDays":"90.","totalSent":"integer — requests.","delivered":"integer.","openRate":"number — unique opens / delivered × 100, 1 decimal.","clickRate":"number.","unsubscribeRate":"number.","bounces":"integer.","spamReports":"integer.","unsubscribes":"integer."},"errors":["HTTP 503 — SENDGRID_API_KEY not configured."]},{"method":"POST","path":"/api/admin/email/test","name":"E-mail · Test mail","description":"Send a single test e-mail to any address (category 'test', branded template).","auth":"None yet (admin panel).","request":{"to":"string, required — recipient.","subject":"string, optional.","bodyHtml":"string, optional."},"response":{"success":"boolean.","message":"string — includes the sender used."},"errors":["HTTP 502 {success:false, message} — SendGrid rejected the send."]},{"method":"GET","path":"/api/admin/email/sender-status","name":"E-mail · Sender status","description":"Whether info@desidating.online may be used as sender (single-sender verification or authenticated domain), which sender is active right now, and the desidating.online domain-authentication state incl. DNS records.","auth":"None yet (admin panel).","request":"No payload (plain GET).","response":{"preferred":"{email, name, usable (boolean), via}.","active":"{email, name} — the sender campaigns will use right now.","verifiedSenders":"Array<{email, verified}>.","domain":"{name, registered, valid, id, dns: Array<{type, host, data, valid}>}."}},{"method":"POST","path":"/api/admin/email/verify-domain","name":"E-mail · Verify domain","description":"Registers desidating.online for SendGrid domain authentication (idempotent) and runs validation. Returns the three CNAME records to add at the domain registrar; once they resolve, validation succeeds and info@desidating.online becomes usable — no mailbox needed.","auth":"None yet (admin panel).","request":"No payload.","response":{"success":"boolean.","message":"string.","domain":"Same shape as sender-status.domain."}}],"time":"2026-10-13T09:19:57.416Z"}