Changelog

Backend changes shipped to production, most recent first. Entries flagged Payload Change require attention from the mobile app team.

Daily question answers lock once your partner answers

Validation Change
2026-10-28

PUT /api/daily-questions/answers/{answer} now refuses the edit once a partner has answered the same question on the same day. That is the moment both answers are revealed, so a revealed answer stays as the partner saw it. Before the partner answers, and for personal answers with no relationship, editing works as before. POST /api/daily-questions/answers gets the same check, because posting again for a question already answered today replaces that answer.

Changed responses

  • 422 with error_code: answer_locked and a message on errors.answer_text when the answer is locked.
  • Posting again for a question already answered today returns the same 422 answer_locked once a partner has answered it. The check runs before the daily limit, so a locked answer never shows the paywall.
  • The 200 reply is unchanged.

Mobile: hide Edit in QuestionSegment.tsx when both_answered is true; that is exactly when the answer is locked, so the second person to answer never sees it. Full schemas at /docs/api.

error_code is optional on some error responses

Docs Only
2026-10-28

These responses share a status code with errors that carry no error_code: a plain validation failure (422) or an unknown id in the path (404). The spec marked the field as always present; it now marks it optional. No response body changes.

Changed documentation

  • POST /api/journal-journeys/{journalJourney}/progress: the 422.
  • GET /api/quizzes/{quiz}/results: the 404.
  • POST /api/lessons/{lesson}/steps/{step}/respond, /skip and GET .../results: the 404.

Mobile: regenerated client types mark error_code as optional on these responses; read it with a fallback. Full schemas at /docs/api.

The per-question relationships endpoint is removed

Payload Change
2026-10-27

GET /api/daily-questions/today/relationships is gone. It answered "who has answered this question today, per relationship" for one question at a time, and only for today's pair. The same facts now come with the pair itself, for both questions at once and for any date the pair is requested for, as relationships[] on GET /api/daily-question/pair (see the entry below).

Removed

  • GET /api/daily-questions/today/relationships now returns 404.
  • Its reply shape (relationship_id, label, your_answer_status, partner_answer_status, both_answered, partner_has_answered) leaves the spec.

Mobile: no screen calls it; the partner picker already reads GET /api/daily-questions/answers. Drop the unused useTodayRelationships hook and its TodayRelationship type when regenerating the client types, and the comments in Picker.tsx and lib/timezone.ts that explain why it was avoided. Full schemas at /docs/api.

Partner answer states on the daily question pair

Payload Change
2026-10-27

GET /api/daily-question/pair now carries relationships: one entry per confirmed relationship, in the user's partner order, saying who has answered each of the day's two questions. The Today screen uses it for the indicator over each partner's character ("You answered", "You both answered", "Lisa is waiting").

New fields

  • relationships[].relationship_id: matches the ids from GET /api/relationships.
  • relationships[].partner: the partner as a PublicUserResource (id, name, first_name, display_name, profile_picture, cutie_profile). Use display_name for "Lisa is waiting".
  • relationships[].question_a and relationships[].question_b: each {state, you_answered, partner_answered} for that question.
  • state is one of nobody_answered, you_answered, partner_waiting (the partner answered and you have not) or both_answered.
  • Only answers to the base question filed under the requested date count. Follow-up answers, reactions and replies do not. An empty list means no confirmed relationship.

Mobile: nothing breaks; has_answer and every other field are unchanged. To light up the Today indicator, read relationships[], match relationship_id to the linked partners, pick question_a or question_b for the page being shown, and refetch the pair after answering and on the daily_questions.partner_waiting and daily_questions.reveal_ready pushes. Full schemas at /docs/api.

Break-up packs are free for everyone

Payload Change
2026-10-26

Every signed-in user can now list, open and answer the break-up reflection packs, whatever their subscription, so free users can reach every pack, including the safety pack.

Changed behaviour

  • GET /api/breakup-packs, GET /api/breakup-packs/{id} and POST /api/breakup-pack-responses no longer return the 403 subscription_required paywall with feature: breakup_packs.

Changed fields

  • features.breakup_packs on GET /api/users/me/subscription and POST /api/subscription/start-trial is now always true. It stays in the payload, so the client types do not change.

Mobile: nothing to do. The app team has already removed the PLUS tag from the Discover break-up row, and access needs no release: the row already follows features.breakup_packs, so current builds open the packs for free users now that this is live. Full schemas at /docs/api.

Solo quizzes no longer alert partners

2026-10-26

Onboarding and assessment quizzes are solo: they are about the person taking them, and there is no partner comparison. Partners are no longer told when one is finished. Until now every linked partner got "completed a quiz, see how they answered!", which opened a comparison those quizzes do not have.

Changed behaviour

  • quiz.partner_completed is no longer sent, as a push or an in-app notification, when the quiz finished has quiz_type onboarding or assessment. Compatibility and custom quizzes are unchanged.
  • A solo quiz no longer counts towards the partner's one-per-24-hours limit on that notification, so a compatibility quiz finished later the same day still reaches them.

Mobile: nothing to do.

One moment at a time

Payload Change
2026-10-26

Someone with several partners can come back to several waiting moments. Only one pops up; the rest stay in the notifications list as moments and play when tapped.

Changed behaviour

  • Calling POST /api/notifications/{id}/moment-shown for any moment stops every other moment waiting at that time from popping up: they leave pending_moments_count and the pending moments list, but stay in the list, unread, with is_moment. Moments that arrive later wait as normal. Any the person had already deleted from the inbox are deleted at that point.

Changed payloads

  • Every push's data now carries notification_id: the id of the same notification in GET /api/notifications.

Mobile: pop up one card per sitting. A tapped push, or a push arriving while the app is open, whose data has is_moment shows that card, marked with its notification_id. Otherwise, when pending_moments_count is above zero, show the first of GET /api/notifications?filter[pending_moment]=true&per_page=1 (the most recent). Never start a second card while one is showing or a push is opening one. Tapping any notification with is_moment in the list plays its card.

Compatibility quiz reveal moments

Payload Change
2026-10-26

When a partner finishes a compatibility quiz the other partner already finished, the one who was waiting gets a new moment notification, so it shows up in pending_moments_count and the pending moments list, even with push switched off. The one who just finished sees the moment straight from the reply to their final answer, like the daily question's save reply. Free accounts get both; the comparison on the results screen stays premium.

New notification and reply field

  • quiz.compatibility_reveal_ready, always is_moment: true, with partner_user_id, quiz_id and quiz_title; the partner who just finished is the actor. Sent to the partner who finished first, in place of quiz.partner_completed for that quiz, every time (no 24-hour limit).
  • The reply to the final answer of a compatibility quiz (POST /api/quizzes/{quiz}/next) carries partners_who_finished_first: the partners who had already finished it, as the same public user shape as a notification's actor. Present only on the reply that completes a compatibility quiz (an empty list when nobody finished first).

Changed behaviour

  • Whoever finishes a compatibility quiz second gets no notification: the reply to their final answer shows them the moment.
  • quiz.partner_responded is no longer sent, for any quiz type: it reached the partner who had just finished the quiz. quiz.partner_completed is no longer sent to a partner who has already finished that compatibility quiz; otherwise unchanged.

Mobile: route quiz.compatibility_reveal_ready to the compatibility results for quiz_id with partner_user_id. After the final answer of a compatibility quiz, if the reply's partners_who_finished_first lists anyone, show the card for the first of them straight away, as with both_answered on the daily question.

Daily question reveal moments

Payload Change
2026-10-26

When the second partner's answer completes the daily question, the partner who was waiting gets their daily_questions.reveal_ready notification as a moment, so it shows up in pending_moments_count and the pending moments list, even with push switched off.

Changed payloads

  • daily_questions.reveal_ready data gains question_text (the question) and is_moment: true, on every one, since it now only goes to the partner who was waiting. The card's partner comes from the existing actor and its date from the existing served_on_date.
  • is_moment is only ever sent as true, never false. On Android pushes every data value arrives as a string, so it reads "1" there: treat the key being present as the flag.

Changed behaviour

  • The partner whose answer completes the round no longer gets daily_questions.reveal_ready or daily_questions.followup_reveal_ready, as a push or in the inbox. The partner who was waiting still gets theirs.
  • daily_questions.followup_reveal_ready is not a moment: the follow-up reveal lands in the inbox only, so there is one card a day. It still goes only to the partner who was waiting.
  • daily_questions.partner_waiting and daily_questions.followup_partner_waiting are unchanged.

Mobile: after saving a daily answer, both_answered: true in the reply means this answer completed the round: show the card straight from the reply, no notification needed. The partner who was waiting gets theirs from the push, an inbox tap or pending_moments_count, as set out in the Moment notifications entry below.

Moment notifications

Payload Change
2026-10-25

A moment notification is a notification whose data.is_moment is true: a full-screen "match" reveal waiting for its recipient. Whether its card has been shown is tracked separately from whether it has been read, so opening the inbox or marking everything read never makes a waiting reveal disappear. Daily question reveals and compatibility quiz reveals are the moments sent so far (see the two entries above).

Added fields

  • pending_moments_count on GET /api/notifications/unread-count: how many moment notifications, however old, still have their card waiting to be shown. A moment read in the inbox still counts until its card is shown.
  • filter[pending_moment]=true on GET /api/notifications returns only those waiting moment notifications, in the usual notification shape.
  • POST /api/notifications/{id}/moment-shown marks a moment's card as shown, on every device, and marks the notification read. Calling it again does nothing; a notification that is not a moment returns 422 with error_code: not_a_moment; someone else's notification returns 403, as marking it read does.

Changed behaviour

  • DELETE /api/notifications/{id} and DELETE /api/notifications (clear all) never lose a moment whose card is still waiting: it leaves the list and the unread count as usual, but stays in pending_moments_count and the pending filter. Calling moment-shown for it then deletes it, so a repeat call for that one returns 404, which can be ignored. No app change is needed: keep calling delete and clear all as today.

Mobile: whenever the unread count is fetched, if pending_moments_count is above zero, fetch GET /api/notifications?filter[pending_moment]=true and show each card, then call moment-shown when it closes or Reveal is tapped. Tapping any notification whose data.is_moment is true shows its card.

Assessment quizzes return a growth tip per leaning trait

Payload Change
2026-10-22

On Assessment quizzes, each pillar now gets a growth tip for every trait scoring 2 or more in either direction, instead of one tip from the strongest trait. Right-pole tips come first, strongest to weakest, then left-pole tips, strongest to weakest. Traits under 2 get no tip, except that a pillar where no trait reaches 2 still gets one: the tip of its strongest trait for the side it leans. Compatibility, onboarding and custom quizzes are unchanged: at most one tip per pillar. The quiz import CSV is unchanged. Assessment tips skip the LLM polish pass and come back exactly as written in the growth tip cells; the summary is still polished.

Added fields

  • pillars[].growth_tips on GET /api/quizzes/{quiz}/results lists each growth tip as its own string, in display order. It is null under the same rules as growth_tip.

Changed fields

  • pillars[].growth_tip on Assessment quizzes holds all of the pillar's tips joined with a blank line, so a client that has not switched to growth_tips still shows every tip in one block. On other quiz types it is unchanged.

Mobile: render growth_tips as separate items. When quiz_type is assessment, label the block "What to do about it" instead of "Growth Tip". Results already stored keep their single tip until they are regenerated.

Card packs filter by theme

Payload Change
2026-10-22

Card pack themes are now secondary tags on top of a pack's category, for example Check-Ins. A pack keeps its one category, and its themes are extra labels the app can filter by as their own group. Themes switched off in Nova no longer reach the app at all.

Added endpoint

  • GET /api/card-pack-themes lists the active card pack themes in the Nova sort order, each with id, name and slug, for the theme chips on the card pack filter. It works like GET /api/course-themes, and a theme activated or deactivated in Nova shows there straight away. meta.available_themes on GET /api/card-packs holds the same list and is kept.

Added filter

  • filter[theme]=check-ins,date-night on GET /api/card-packs takes theme slugs from GET /api/card-pack-themes and returns packs with any of them. It follows the filter[category] rules: an unknown or switched-off slug matches nothing, only such slugs return an empty list, an empty value applies no filter, and the array form returns 422. With both filters, a pack must match both.

Changed fields

  • themes on every card pack (GET /api/card-packs items and GET /api/card-packs/{id}) now lists only active themes, in the Nova sort order. The shape of each theme is unchanged.
  • The API docs now describe the whole GET /api/card-packs reply, paged and per_page=0. Every meta field has its real type: current_page, last_page, per_page and total are integers, from and to are integers or null, and links.prev and links.next are strings or null. Before, the paged reply was documented as a bare string and total as a string. The JSON itself is unchanged; regenerate client types to pick this up.
  • The older ?theme= parameter (one name or slug) now matches active themes only. Prefer filter[theme].
  • The tag on card pack items in GET /api/featured-content is now the first active theme, in the Nova sort order, so a switched-off theme never shows there.

Guided journeys use the journal categories

Payload Change
2026-10-19

Guided journeys now carry one of the journal categories from GET /api/journal-prompt-categories (Shadow Work, Dating, After a Fight and so on), the same list single journal prompts use. This replaces the content category journeys carried since 2026-10-05. Existing journeys are categorised at release; until a journey is tagged its category is null.

Changed fields

  • category on every journey payload (GET /api/journal-journeys items, GET /api/journal-journeys/{id}, POST /api/journal-journeys/{id}/progress and every run card's journey) is now a journal category: id, slug, title, description. It has no icon_url; journal categories have no icons. It is the same object a journal prompt carries as category.
  • Featured journal_journey items on GET /api/featured-content now send category: null. The card's tag (the question count) is unchanged.

Changed filter

  • filter[category]=shadow-work,dating on GET /api/journal-journeys now takes journal category slugs, with the same rules as before: an unknown slug is ignored, only unknown slugs return an empty list, an empty value applies no filter, and the array form returns 422.

Removed

  • icon_urls.journal_journey on GET /api/content-categories. No journey tint was ever uploaded, so it was always null.

Mobile: build the journey picker chips from GET /api/journal-prompt-categories instead of GET /api/content-categories, sending each chip's slug as filter[category], and regenerate the client types. Current builds send content category slugs, which now match nothing, so their category chips show an empty list until the update ships; Suggestions and search are unaffected. Full schemas at /docs/api.

Discover prompt bubbles and tool rows are banner groups

Payload Change
2026-10-15

No field changed; this is a switch for the app, following the 2026-10-13 intro bubbles entry. The Polls and Audio questions prompt bubbles, the new poll form bubble and the six tool rows on the Discover home screen are now written in the admin, one banner group each, with today's wording already in place.

On GET /api/announcements?filter[group]=<slug> (the intro bubbles call)

  • Prompt bubbles: polls-intro, audio-questions-intro. title is the tag, body the text; render every item in order, with the app's button once below them.
  • New poll form: polls-new-intro. Read body only; the tag stays today's date from the app (title just names the banner in the admin).
  • Tool rows: daily-questions-tool, journal-tool, milestones-tool, relationship-tracker-tool, how-to-tool, breaking-up-tool. Use the first item: title is the row name, body the line under it.

Mobile: keep the i18n copy as the loading and empty fallback, as with the intro bubbles. Buttons, icons, routes, the PLUS tag and the break-up paywall stay in the app.

Push notifications carry event_key, and the push data bag is documented

Payload Change
2026-10-15

A push is built from the same notification as the in-app row returned by GET /api/notifications. Its data now carries event_key (for example daily_questions.reveal_ready) beside type (daily_questions_reveal_ready), which was the only event marker a push had until now. Purely additive: type and every other key are unchanged.

What a push carries

  • Android (FCM): notification.title, notification.body and notification.image. data is a flat string map holding event_key, type, every key of the in-app row's data, and action_url. FCM only accepts strings, so integers arrive as strings and arrays as JSON text: question_id is "364" and partner_ids is "[56]".
  • iOS (APNs): aps.alert.title, aps.alert.body, aps.sound default, aps.badge 1 and aps.mutable-content 1. Then every key of the in-app row's data as a top-level custom key with its JSON type kept, plus event_key, action_url and image_url.

Worked example: a real daily_questions.reveal_ready sent on 2026-10-14 for question 364, where partner 56 ("Amy") responded.

In-app row

{
  "title": "Amy responded",
  "body": "Amy responded. Tap to see their answer!",
  "action_url": "app://daily-questions/364/reveal",
  "event_key": "daily_questions.reveal_ready",
  "priority": "high",
  "data": {
    "type": "daily_questions_reveal_ready",
    "actor_user_id": 56,
    "question_id": 364,
    "partner_ids": [56],
    "partner_id": 56,
    "partner_name": "Amy",
    "served_on_date": "2026-10-14"
  }
}

Android, the FCM v1 message

{
  "message": {
    "token": "<device token>",
    "notification": {
      "title": "Amy responded",
      "body": "Amy responded. Tap to see their answer!",
      "image": "https://cutieapp.dev/images/icon-color-09.png"
    },
    "data": {
      "event_key": "daily_questions.reveal_ready",
      "type": "daily_questions_reveal_ready",
      "actor_user_id": "56",
      "question_id": "364",
      "partner_ids": "[56]",
      "partner_id": "56",
      "partner_name": "Amy",
      "served_on_date": "2026-10-14",
      "action_url": "app://daily-questions/364/reveal"
    }
  }
}

iOS, the APNs payload

{
  "aps": {
    "alert": { "title": "Amy responded", "body": "Amy responded. Tap to see their answer!" },
    "sound": "default",
    "badge": 1,
    "mutable-content": 1
  },
  "action_url": "app://daily-questions/364/reveal",
  "image_url": "https://cutieapp.dev/images/icon-color-09.png",
  "event_key": "daily_questions.reveal_ready",
  "type": "daily_questions_reveal_ready",
  "actor_user_id": 56,
  "question_id": 364,
  "partner_ids": [56],
  "partner_id": 56,
  "partner_name": "Amy",
  "served_on_date": "2026-10-14"
}

Routing a push tap: read event_key (fall back to type for pushes sent before this change), question_id and partner_id. Prefer partner_id over partner_ids on the push path because of the Android string rule, and compare ids as strings. served_on_date is present when known, as the 2026-09-21 entry below describes.

"Everyone answered!": the plural reveal copy fires only when the recipient has two or more other participants in one relationship. Relationships are created with exactly two people and every production relationship has two, so this copy cannot fire today; it stays for a possible multi-person relationship. A member linked to two partners gets one "X responded" reveal per relationship. This qualifies the "Everyone answered!" bullet in the 2026-09-11 partner_id entry below.

Mobile: nothing to change. A push handler that gates on data.event_key now sees it; type and every other key are unchanged.

Every member-facing day now follows the member's timezone

Validation Change
2026-10-15

The app sends X-Timezone on every request and most features already used it to decide what "today" means for a member. A handful still used the server's UTC day, so a journal entry written in the evening in Los Angeles was filed under tomorrow, a member in Sydney could not save one for their own morning, anniversaries left the upcoming list at UTC midnight, and some reminders arrived in the middle of the night. Every remaining feature now uses the member's own calendar and clock. No field was added, removed or renamed. Members whose device has never sent the header behave exactly as before.

Changed behaviour

  • POST /api/journal-entries and the lesson reflection step: the default date is today in the member's timezone, and the "not in the future" rule allows the member's local today. A reflection with a future date now fails with "The date cannot be in the future." The journal streak in meta.streak updates the moment an entry is saved or deleted.
  • GET /api/calendar/day: all four available_actions flags compare against the viewer's local today, exactly matching what each write endpoint accepts.
  • PATCH /api/relationship-tracker/entries/{entry} rejects a future entry_date with 422, as create already did.
  • GET /api/milestones and the shared-with-me list: scope=upcoming, scope=past and next_occurrence_date split on the viewer's local today.
  • The 14-day check-in badge is judged on the member's today, and the shared-answers badge counts the calendar month the answer was filed under.
  • The check-in reminder fires at the chosen hour on the member's clock and counts days, weekdays and months on their calendar, at most once per local day. The email verification reminder and the inactivity nudge arrive at 10:00 and 11:00 in the member's timezone instead of a fixed UTC hour.
  • "Your trial expires in N days" counts whole calendar days in the member's timezone instead of 24-hour blocks.
  • together_for without a together_since counts from the day the couple linked on the viewer's calendar; date_of_birth on PATCH /api/users/me must be before the member's local today.

This replaces the "deliberate asymmetry" note in the calendar day detail entry of 2026-09-15 below: the tracker and journal flags no longer compare against server today.

Mobile: nothing to change. Keep sending X-Timezone on every request.

Relationship tracker stats refresh at once and include today

2026-10-15

Insights and comparison answers were cached until midnight UTC and nothing cleared them when a check-in changed, so "Last 7 days" and "Last 30 days" could disagree about the same day until the next morning. Every window also stopped at yesterday, and "today" was the server's UTC day for everyone. No field was added, removed or renamed.

Changed behaviour on the relationship tracker endpoints

  • Creating, editing or deleting an entry (including a change to its activity tags only) now clears every cached answer for that member and partner in both directions. The next request to insights/time-series, by-activity, by-hormonal-status, did-you-know or partners/{partner}/comparison is recomputed, for the partner too.
  • summary.average_satisfaction and summary.average_mood on the time-series now average only the entries that carry that value. A mood-only check-in no longer drags the satisfaction average down, and the other way round. Under monthly aggregation summary.check_in_days_count now counts check-in days rather than months with a check-in; daily counts are unchanged.
  • Every window now ends today instead of yesterday, as did-you-know already did: range.to is today's date, a check-in saved this morning counts straight away, and "this week" on a Monday is that Monday alone. weekday_averages therefore includes today's weekday, whose face can change during the day as the entry is edited.
  • "Today" is now the member's own day, from the X-Timezone header the app already sends: window boundaries, the streak's days_this_month, and the default entry_date when none is sent. Cached answers expire at the member's local midnight. A member who has never sent a timezone keeps UTC.
  • POST /api/relationship-tracker/entries now accepts an entry_date equal to the member's local today. East of UTC that date was rejected as "in the future" until UTC midnight; it is accepted now.

Mobile: one line, please. invalidateEntries() in src/api/hooks/useTrackerEntries.ts invalidates the insights queries after an entry mutation but not TRACKER_COMPARISON_KEY, so the Average score card refreshes on remount or period change rather than straight after a save. Adding that key to the invalidation makes it instant. Nothing else needs to change; the Daily averages card already reads weekday_averages.

Quiz results answer 202 until insight text is ready

2026-10-13

The first results request after a quiz was completed, or after a retake changed an answer, wrote the pillar insight text inside the request, which took 10 to 14 seconds. That text is now written in the background like the scores, and the endpoint answers quickly in every state. No field was added or changed.

Changed behaviour on GET /api/quizzes/{quiz}/results

  • While the insight text is being written, the endpoint answers 202 with results_processing, the same response used while scores are calculated. Expect it after a completion and after a retake changes an answer, usually for 10 to 15 seconds.
  • With partner_user_id, a missing shared insight set also answers results_processing. partner_results_processing still means only that the partner's scores are being calculated.
  • Members without premium never wait for insight text, since their results carry none. They get 200 as soon as the scores exist.
  • This replaces the note in the 2026-08-14 retake entry that the first results call after an edit is slower.

Mobile: polling already retries every 202, so nothing else needs to change. The 45 second timeout override on this endpoint can be removed.

Email verification reminders stop after three

2026-10-13

The "Verify your email" reminder repeated every two days for as long as the address stayed unverified. It is now sent on day 2, day 7 and day 30 after sign-up, then stops. Members already reminded continue the sequence from where they are. No field was added or changed.

Mobile: nothing to change.

Verify your email reminders open My Account

Payload Change
2026-10-13

The "Verify your email" reminder linked to a verify-email screen the app does not have, so the row could not be opened. It now links to My Account, which shows the verification status and the "Resend verification email" link.

Changed field

  • action_url on account.email_verification_reminder, in GET /api/notifications and in the push payload, is now /profile/settings/account. It was a verify-email scheme link.

Mobile: nothing to change. The notification router already opens a bare in-app path from action_url. Reminders stored before this change keep the old link and stay unopenable.

The featured card pack skips packs you have completed

2026-10-13

The card pack on the Today selection (the Suggestions card on Discover) was the same for everyone on a given day, so a member who had finished it was told to open it again. The entry is now never a pack the viewer has completed. No field was added or changed.

Changed behaviour on GET /api/featured-content

  • The card_pack entry is the next featured pack the viewer has not completed, so two viewers can receive different packs on the same day. A viewer who has completed nothing sees exactly what they saw before.
  • Once a viewer has completed every featured pack, their rotation continues through every active pack they have not completed, one per day in the admin sort order, with the same fields as a featured pack.
  • A viewer who has completed every active pack receives no card_pack entry. That is the existing absent-surface rule: no placeholder and no null entry.
  • Nothing featured still means no card_pack entry, whatever the viewer has completed. Courses, quizzes and game packs are unchanged and may still be ones the viewer has completed.

Mobile: nothing to change. The Suggestions card already pages through whatever types come back.

Discover intro bubbles are banner groups

Payload Change
2026-10-13

No field changed; this is a switch for the app. The INTRO bubble at the top of the Courses, Quizzes, Card Packs, Games and "I'm thinking about breaking up" lists is now written in the admin, one banner group per screen, with today's wording already in place. Until the app reads them, the built-in strings still show.

On GET /api/announcements?filter[group]=<slug> (the Did You Know? call)

  • Groups: courses-intro, quizzes-intro, card-packs-intro, games-intro, breaking-up-intro.
  • title is the tag label (today "INTRO"); body is the text.
  • A group may hold more than one banner: render every item returned, in order, rather than the first.

Mobile: on each of the five screens replace the i18n intro tag and body with the group's banners (same hook as Did You Know?), keeping the i18n copy as the loading and empty fallback. Games keeps its NEW INVITE override.

Quiz results no longer carry placeholder text

Payload Change
2026-10-12

A pillar with nothing to say used to come back with "Placeholder growth tip for ..." (or the summary and compatibility equivalents), which the app displayed. Those strings are gone: the field is null instead.

On QuizPillarResource (results and the results feed)

  • growth_tip is null whenever every trait in the pillar scored neutral (no lean to work on), as well as when insights are locked.
  • summary and insights are null when the pillar has no authored text for the user's scores. Types are unchanged.

Mobile: both results screens already hide the insight and growth tip boxes when the value is null; nothing to change.

Game sessions carry partner scores and a tiered couple-score message

Payload Change
2026-10-12

The game results screen can now show who knew whom better ("Jeffy knows Amy 77%", "Amy knows Jeffy 63%") and a headline and sentence that fit the couple's score, instead of the one fixed "You two are psychic!" line. The scoring itself is unchanged.

New fields on GameSessionResource, in GET /api/game-sessions/{gameSession} and GET /api/game-sessions

  • your_accuracy_percent: how well the caller knows their partner (the caller's guesses, as rated by the partner), 0 to 100. Computed live while the session is in progress and stored when it completes.
  • partner_accuracy_percent: how well the partner knows the caller, the same way round. Both are resolved for whoever is calling, like partner and your_next_action, so the app never matches user ids.
  • accuracy_response: { title, body } for the 10% tier the stored accuracy_percent falls in, using the pack's own wording for that tier when it has one. Null while the session is pending or in progress.

accuracy_percent is computed from the raw points and is not the mean of the two rounded directional values. The tier wording is edited in Nova under Content > Game Packs > Game Score Tiers; a pack's Score Tier Overrides tab replaces it for one tier on that pack.

Mobile: replace the hardcoded "You two are psychic!" line with accuracy_response.title and accuracy_response.body once your_next_action is view_results, and show the two directional percentages next to accuracy_percent. Regenerate the client types. Full schemas at /docs/api.

Up next cards carry an item count

Payload Change
2026-10-09

The Up next cards on the quiz Resources tab can now show how much is inside each item ("15 CARDS", "5 MODULES"), counted exactly as on the Discover featured cards, so the two always match.

New fields

  • item_count on each related_resources item of QuizResultsResource, in GET /api/quizzes/{quiz}/results and GET /api/lessons/{lesson}/steps/{step}/results: active cards for a card pack, published lessons for a course, questions for a game pack or quiz, the same as item_count on GET /api/featured-content. Null for videos and podcasts.

Mobile: fill the Up next card footer from item_count, picking the unit from type (cards, modules, questions) as on Discover. The chip reads category.title, which every card already carries. Regenerate the client types. Full schemas at /docs/api.

Quiz results carry the Resources tab highlight card and useful links

Payload Change
2026-10-09

The quiz Resources tab can now show its purple highlight card and its numbered useful links. Both are set per quiz in Nova from shared libraries, so one card or link can be reused across quizzes.

New fields

  • highlight_card on QuizResultsResource: {tag, message, button_url, email, phone}, or null when the quiz has no card. message is always present; the others are each null when not set.
  • useful_links on QuizResultsResource: [{title, url}] in the quiz's order, empty when there are none.
  • Both appear on GET /api/quizzes/{quiz}/results, GET /api/lessons/{lesson}/steps/{step}/results and each item of GET /api/users/me/quiz-results.

Mobile: replace quiz-resources.fixtures.ts with these fields. Map highlight_card: tag to the chip, message to the body, button_url to the Visit button (keep "Visit" as its text), and email and phone to the contact rows. Hide the card when it is null and the Useful links block when the list is empty. Regenerate the client types. Full schemas at /docs/api.

Accepted-invite notifications carry the relationship id

Payload Change
2026-10-09

Tapping "accepted your invite!" opens the new relationship's Manage sheet. The notification now says which relationship that is, so the app no longer has to find it by the partner's id.

New fields

  • data.relationship_id on relationship.invite_accepted, in GET /api/notifications and in the push payload: the id of the relationship the acceptance created.

Mobile: open the Manage sheet by data.relationship_id. Notifications stored before this change do not have it, so keep the lookup by partner id as the fallback. Regenerate the client types. Full schemas at /docs/api.

Notifications never leave a gap where a name goes

Payload Change
2026-10-09

A person who never gave a name (people who join through a shared link skip the name step) used to leave a gap in every notification that names them, such as " wants to connect with you". The copy now uses a stand-in instead.

Changed values

  • title and body on every notification that names a person: a person with no name reads as "your partner" ("Your partner accepted your invite!", "You're now connected with your partner."), or "someone" on relationship.invite_received ("Someone wants to connect with you"), since the sender is not a partner yet.
  • A name that opens the copy now starts with a capital letter, so a first name saved as "kam" reads "Kam accepted your invite!". Names in the middle of a sentence are unchanged.
  • data.partner_name and data.initiator_name are unchanged and are still an empty string for a person with no name.

Mobile: new notifications no longer start with a space, so the leading-space name repair in the notifications list stops applying to them. Notifications stored before this change keep their original text.

Subscription lists the break-up packs feature

Payload Change
2026-10-09

The break-up pack endpoints are premium-only, and the subscription now says so up front, so the app no longer has to guess access from the subscription status.

New fields

  • features.breakup_packs on GET /api/users/me/subscription and POST /api/subscription/start-trial: true exactly when GET /api/breakup-packs and GET /api/breakup-packs/{id} return 200 rather than the 403 paywall (a premium subscription, or a trial that has not ended).

Mobile: gate the Discover break-up row on features.breakup_packs instead of status and is_expired, and keep treating a 403 as authoritative. Regenerate the client types. Full schemas at /docs/api.

Featured content items carry an item count

Payload Change
2026-10-09

The Discover suggestion card shows how big each item is, for example "15 CARDS". Featured content now sends that number, so the footer label no longer has to stay blank.

New fields

  • item_count on every GET /api/featured-content item, a nullable integer counted the same way as each content type's own endpoint: active cards for a card pack (as card_count), published lessons for a course (as stats.lessons_count), questions for a game pack or quiz (as question_count), prompts for a guided journey, and null for a journal prompt. Pick the unit (CARDS, MODULES, QUESTIONS) from type.

Break-up packs carry a content category

Payload Change
2026-10-09

Break-up reflection packs are now tagged with one of the content categories, set in Nova like card packs, so the app no longer needs its own per-pack category map for the pack icon, the "CATEGORY • REFLECTION" line and the filter, and the list can be filtered by category on the server.

New fields

  • category on GET /api/breakup-packs items and GET /api/breakup-packs/{id}: the same nullable object card packs carry (id, slug, title, description, icon_url), with icon_url in the new break-up pack tint. Null while a pack is untagged.
  • icon_urls.breakup_pack on GET /api/content-categories, the break-up pack tint of each category. It is null until the tint is uploaded, so keep the bundled picture as the fallback.

New filter

  • filter[category]=vulnerable,hard-stuff on GET /api/breakup-packs returns packs in any of the categories, with the same rules as the card pack, quiz, game pack, course and journey lists: an unknown slug is ignored, only unknown slugs return an empty list, an empty value applies no filter, and the array form (filter[category][]=...) returns 422.

Unchanged

  • The pack's own icon_url stays: optional artwork uploaded for that one pack. The category pictogram the pack list draws is category.icon_url.

Mobile: filtering the pack list on the device still works; send filter[category] to let the server do it instead. Regenerate the client types. Full schemas at /docs/api.

New poll notifications name the poll

2026-10-08

Several polls going live close together sent identical "New poll is live!" notifications that read as copies. Each notification now shows its poll's question, and admins choose whether a poll is announced at all.

Changed behaviour

  • The poll.live notification body is now the poll question followed by "Tap to vote.", for example "Morning sex or night sex? Tap to vote." The title, event key, data.poll_id and the {scheme}://polls/{id} deep link are unchanged.
  • A poll sends poll.live only the first time it goes live, and only when "Notify users when published" is ticked in Nova (on by default). Changing the publish date of a live poll no longer sends it again.

Reflections can link to websites, phone numbers and texts

Payload Change
2026-10-08

Breakup pack reflections can now point people to a website, a phone number or a text message, for example a support hotline at the end of a safety pack.

Changed payloads

  • closing_reflection and each option's reflection_text on GET /api/breakup-packs/{id} may contain https://, tel: and sms: links as well as pack links, so the app can open them with the operating system (browser, dialer, Messages) and keep routing {scheme}://breakup-packs/{id} inside the app. The response shape is unchanged.

Option reflections can link to other packs

Payload Change
2026-10-08

The reflection shown after a user taps an answer in a breakup pack can now carry the same pack links as the closing reflection.

Changed payloads

  • reflection_text on each option in GET /api/breakup-packs/{id} is markdown (LF line endings) and may contain {scheme}://breakup-packs/{id} deep links, so the app can render it with the same markdown renderer and link handler as closing_reflection. The response shape is unchanged.

Unlinked partners can invite each other again

2026-10-07

A couple who unlinked, or someone whose invite was declined, could not send a new invite to the same person: the request failed with a server error. Old answered invites are now kept as history and no longer block a new one.

Changed behaviour

  • POST /api/relationship-invites and POST /api/partner-invite-links/redeem now succeed for a pair who were partners before or whose earlier invite was declined, instead of returning 500 {"message": "Server Error"}.
  • A pending invite in either direction, or an existing partnership, still returns the same 422.

How to guides keep the Free Space note

Payload Change
2026-10-07

The Free Space card at the top of every How to guide had nowhere to be stored, so its text was lost on Save. The guide now carries it as one note per guide, and a new endpoint saves it.

New fields

  • free_text (string or null, always present) on HowToAnswerResource: GET /api/how-to/categories/{category}/my-answer and GET /api/how-to/answers/{answer}, so partners see it when the guide is shared.

New endpoint

  • PUT /api/how-to/categories/{category}/my-answer with {"free_text": "..."} saves the note at once (it is not queued like prompt answers) and creates the guide if the user has not started it.
  • null or an empty string clears the note. The key is required, so a body without it returns 422 and leaves the note alone. Up to 65,535 characters.
  • Returns 200 with the whole guide, the same shape as the GET, on the first save and every save after.

Changed behaviour

  • Global search (GET /api/search) also matches the Free Space note. A guide still appears once, whether the words are in its Free Space note, its prompt answers or both.

Changed documentation

  • The not-started reply of GET /api/how-to/categories/{category}/my-answer is now documented as {"data": null} instead of an untyped object. The reply itself is unchanged.

Mobile: regenerate the client types (npm run gen:api). On Save, send the Free Space card with the new PUT, and fill the card from free_text when the guide loads. The as unknown as { data: HowToAnswer | null } casts on my-answer can go. Full schemas at /docs/api.

Social sign-in reports server failures as server errors

2026-10-07

Social sign-in answered 401 "Invalid or expired provider token." for any failure, including ones on our side. It now does so only when the provider rejects the token.

Changed behaviour

  • POST /api/auth/social/{provider} returns 500 {"message": "Server Error"} when the provider accepted the token but signing in failed on the server (for example while creating the account). Previously this returned 401.
  • A token the provider rejects, or a provider that cannot be reached, still returns 401 with the same message.

Invite errors no longer show database details

2026-10-07

Sending an invite by code could return the database's own error text as the message when saving the invite failed. That text is now logged on the server only, and the app gets the same generic error as every other endpoint.

Changed behaviour

  • POST /api/relationship-invites now returns 500 {"message": "Server Error"} for an unexpected failure, instead of a 422 whose errors.codes carried the raw database message.
  • The rule failures (invalid or expired code, only your own code, already partners, invite already pending) still return 422 with the same message and errors.codes text.

Poll votes can be changed

Payload Change
2026-10-06

A user can now change their answer on an open poll. Voting again moves their single vote to the newly selected option and the tallies update with it; they still count once per poll.

Changed behaviour

  • POST /api/polls/{poll}/vote on an active poll the user already voted on now returns 200 with the refreshed poll (user_selected_option_id, total_votes and each option's votes_count / percentage) instead of 409 "Duplicate vote". The request and response shape are unchanged, and a first vote still returns 200.
  • On POST /api/polls/{poll}/vote, completed and archived polls still reject votes with 422, so Discover answers are final once a poll closes.
  • The app can drop its user_has_voted guard on the vote action and let the user tap a different option.
  • Poll steps inside course lessons work the same way: responding to a poll step again (POST /api/lessons/{lesson}/steps/{step}/respond) moves the vote instead of silently keeping the first answer. Lesson poll steps accept answers even when the poll has closed, so a lesson can always be completed.

Guided journeys use content categories; the level is removed

Payload Change
2026-10-05

Guided journeys now carry one of the content categories (Deep Dive, Vulnerable, Mushy Stuff, Spicy, Unhinged, Early Days, Hard Stuff, The Talk), the same as card packs, quizzes, game packs and courses. Nova requires a category on every journey and the existing journeys are categorised at release; until a journey is tagged its category is null. The category replaces the Basic / Deeper level, which is removed.

New fields

  • category on GET /api/journal-journeys items, GET /api/journal-journeys/{id} and POST /api/journal-journeys/{id}/progress: the same nullable object card packs carry (id, slug, title, description, icon_url), with icon_url already in the journey tint. Null until a journey is tagged.
  • journey.category on every run card: GET /api/journal-journey-runs, GET /api/journal-journey-runs/shared-with-me, GET /api/journal-journey-runs/{id} and the calendar day's journal_journey_runs.
  • category on featured journal_journey items on GET /api/featured-content, like the other content types.
  • icon_urls.journal_journey on GET /api/content-categories, the journey tint of each category. It is null until the tint is uploaded.

New filter

  • filter[category]=vulnerable,early-days on GET /api/journal-journeys returns journeys in any of the categories, with the same rules as the card pack, quiz, game pack and course lists: an unknown slug is ignored, only unknown slugs return an empty list, an empty value applies no filter, and the array form (filter[category][]=...) returns 422.

Removed (breaking)

  • level is gone from every journey payload: the catalogue, the journey state, progress and every run card.
  • filter[level] on GET /api/journal-journeys now returns 400, like any unknown filter.

Mobile: replace the Basic and Deeper chips on the journey picker with category chips built from GET /api/content-categories (the chip icon is icon_urls.journal_journey) that send filter[category], and show category.title where the level tag was. Keep the bundled icon by slug while icon_url is null. Regenerate the client types. Current builds read level, so this API change ships with that app release. Full schemas at /docs/api.

The Today journey card sends the journey's title

Payload Change
2026-10-05

Guided journeys are shown by their title, not their first question. Every journey payload already carried the title; the featured card was the one place that sent the first question in its place.

Changed payloads

  • Featured journal_journey items on GET /api/featured-content send the journey's own title in title, like every other type. Since 2026-09-29 it was the journey's first prompt. description (the journey's description) and tag ("3 questions") are unchanged, and the response shape is the same.

Where the journey title lives

  • title on GET /api/journal-journeys (the picker) and GET /api/journal-journeys/{id}; the questions are in prompts[].title.
  • journey.title on every run card: GET /api/journal-journey-runs, GET /api/journal-journey-runs/{id} and the calendar day's journal_journey_runs.

Mobile: the Today card keeps rendering title and now shows the journey's title with no change. Head the picker and run cards with the journey title above instead of the current prompt. The featured cache re-keys itself on deploy. Full schemas at /docs/api.

Journey contract fixes

Payload Change
2026-10-02

Small corrections to how guided journeys are typed, described and listed. Nothing a user writes changes; the generated client types get simpler and an empty run after Start over is no longer sent where it is not wanted.

Changed payloads

  • user_run.completed_at on GET /api/journal-journeys is a date-time in the same shape as every other journey timestamp (2026-09-28T05:19:24.000000Z, format: date-time). Before, it was sent as 2026-09-28T05:19:24+00:00 with no format.
  • The month marker's journey_runs[].percent on GET /api/calendar/month-overview is typed as a plain integer. Its values are unchanged.
  • The user_ids description on POST /api/journal-entries/{id}/share and POST /api/journal-journey-runs/{id}/share is corrected: the partners to share with, never yourself.
  • The calendar's journal_entries (day) and marker entries (month) descriptions now state that journey answers appear in their journey card instead, as they have since the partner journey release.

Changed behaviour

  • A run with no answer or skip (the fresh run after restart: true, or one whose prompts were all removed) is no longer listed on GET /api/calendar/day, the month marker or GET /api/journal-journey-runs until its first answer or skip. GET /api/journal-journeys/{id}, progress and the catalogue's user_run still report it at 0%, because the next answer lands there. A day whose only run is such a run therefore has has_entry false on the month marker. The run lists hold runs in progress or completed with at least one prompt answered or skipped, and runs started over with at least one answer kept.
  • Already fixed by the journey sharing release: a re-share lists each user in exactly one of newly_shared, restored or already_shared.

Mobile: regenerate the types for percent and completed_at. The client-side empty-run filter can stay or go; it now only matters for user_run, which reports the fresh run on purpose. Full schemas at /docs/api.

A partner's shared journeys arrive as journey cards

Payload Change
2026-10-02

A guided journey a partner shared now reaches the viewer the same way their own journeys do: as one journey card, holding only the answers shared with them, instead of loose journal entries. A journey counts as shared with the viewer as soon as one of its answers is, so answers shared one at a time group into a card too.

Added endpoint

  • GET /api/journal-journey-runs/shared-with-me: journeys your confirmed partners shared with you, as the same JournalJourneyRunResource card (is_mine false, only the answers shared with you, progress counted from those), newest calendar day first. The same from, to, filter[status], filter[search], sort, page and per_page as GET /api/journal-journey-runs, plus filter[shared_by][]. The search matches the journey title, its prompts and the answers shared with you, never an answer you cannot read. Not premium gated.

Changed payloads

  • With partner_id, journal_journey_runs on GET /api/calendar/day carries that partner's journeys shared with you next to your own, in the order they were started, told apart by is_mine. Each partner card holds only the answers shared with you.
  • With partner_id, the month marker's journey_runs on GET /api/calendar/month-overview lists those journeys too (is_mine false), with a percent counted only from the answers shared with you.
  • A partner's journey answers no longer appear in journal_entries on the calendar day, in the month marker's entries, or in GET /api/journal-entries/shared-with-me; they are read inside their journey card. Without partner_id nothing changes. meta.sharers on the shared entry list still includes a partner whose only shares are journey answers; filtering that list by them returns no entries, and their journeys are in the new run list.

Mobile: split journal_journey_runs on is_mine: a partner's card shows SHARED BY and opens read-only, with no Delete, Share or Edit (Edit would start the viewer's own run of that journey). Read a partner's journeys from the new list and drop the client-side regrouping of loose journey answers. Ship this in the same release as the API change, because today every journal_journey_runs item is drawn as the viewer's own. Full schemas at /docs/api.

Journey sharing: partners see only what is shared, and a share covers the whole journey

Payload Change
2026-10-02

A partner reading a shared guided journey now sees only the answers shared with them. Sharing a journey is now recorded on the journey itself, so answers written later reach the same partners without sharing again, the journal auto-share setting applies to journeys, and the partner is told once with a single journey notification.

Changed payloads

  • GET /api/journal-journey-runs/{id} for a partner: a prompt whose answer is not shared with them, and a skipped prompt, has status null and journal_entry null, so the card never reveals that a hidden answer exists. progress counts only the answers shared with them (skipped is 0), and shared_with lists the recipients of the answers they can see. Before, a partner received every answer in full once any one of them was shared. The owner's view is unchanged.
  • POST /api/journal-journey-runs/{id}/share lists each user in exactly one of newly_shared, restored or already_shared, by where they stood with the journey before the request. total_active_shares is the number of partners the journey is shared with.

Changed behaviour

  • Sharing a journey covers the whole journey. Every answer written after the share, including a skipped prompt answered later, is shared with the same partners straight away, whether or not the user taps Finish. Edited answers keep their shares. Sharing a journey before anything is written still counts.
  • Journeys shared before this release keep exactly the answers that were shared; they start covering later answers the next time they are shared. Old shares are never turned into whole-journey shares, because a past share of one answer cannot be told apart from a share of the journey.
  • Unsharing a single answer (DELETE /api/journal-entries/{id}/shares/{user}) hides only that answer, on the journey card and everywhere else; later answers are still shared. Sharing the journey again shares every answer in it again, including one that was unshared on its own.
  • DELETE /api/journal-journey-runs/{id}/shares/{user} stops sharing the journey: its answers are unshared and answers written afterwards stay private.
  • A new journey starts shared with the user's auto-share partners when auto_share.enabled is on (PATCH /api/journal/settings), current partners only. This includes the fresh journey after restart: true; otherwise Start over begins an unshared journey.
  • One notification per partner per journey: journal.journey_shared ("shared a guided journey with you", data journal_journey_run_id, deep link cutie-app://journal/journey-runs/{id}). It is sent as soon as an answer is shared with the partner (straight away when the journey has answers, otherwise with the first answer), and later answers send nothing. Stopping and sharing again notifies once more. Journey answers no longer send journal.shared; sharing a single answer by hand still does.

Mobile: remove the re-share at Finish; it is no longer needed, and it re-shares an answer the user unshared on its own. Draw a partner's journey card from the response as it is; a null status looks the same as an unanswered prompt. journal.journey_shared has the journal category, which the app already routes to the calendar. Full schemas at /docs/api.

Daily averages come from the API

2026-10-02

The relationship tracker's daily time-series now works out the Daily averages card itself. Each weekday combines its average mood and satisfaction into one score out of 5, so the face on each day reflects both, not mood alone.

Added field

  • weekday_averages on GET /api/relationship-tracker/insights/time-series when aggregation is daily. Monthly responses do not carry it.
  • Always seven rows, Monday first, each with weekday, average_mood (1 to 5), average_satisfaction (1 to 10), combined_score (out of 5) and mood, the face to show.
  • The combined score halves the satisfaction average onto the mood scale and averages it with the mood average. A weekday with only one of the two scores that one. mood is the combined score rounded to the nearest whole rating. A weekday with no entries returns null for all four values.
  • series and summary are unchanged.

Mobile: draw each Daily averages tile from weekday_averages[i].mood and stop averaging series points on the device. Regenerate the client types to pick up the new optional field. Full schemas at /docs/api.

Partner order

2026-10-02

Users can arrange the order their partners appear in. The order is each user's own: saving it never changes what their partners see. Every partner list follows it, so the profile cards and the Today pages now agree.

Added endpoint

  • PUT /api/relationships/partners/order with {"partner_user_ids": [12, 7, 31]}: the user's confirmed partners, first to last. It replaces the saved order and returns the reordered partner list, the same payload as GET /api/relationships/partners. Partners left out are listed after the placed ones. An id that is not a confirmed partner, or a partner listed twice, is a 422.

Changed ordering

  • These lists now return partners in the user's saved order: GET /api/relationships/partners, GET /api/relationships, GET /api/users/me/sharing, partners on GET /api/relationship-tracker/settings, and GET /api/daily-questions/today/relationships.
  • Partners the user has not placed are listed after the placed ones, oldest relationship first. Before any order is saved, that is the whole list; it used to follow user id or relationship id.

Mobile: after a drag on the profile Relationships cards, send the full list as shown to PUT /api/relationships/partners/order and render the response. Keep rendering every partner list in API order; no client sort is needed. Full schemas at /docs/api.

Journey runs list and a standalone-only journal list

Payload Change
2026-09-30

The journal list shows a guided journey as one card per run, not as one entry per answer. The user's journeys now have their own paginated list, the same card the calendar day detail renders, and the flat journal list holds only entries written outside a journey. The Today card for a featured journey carries its first prompt in title, the way every other featured card carries its body line.

Added endpoint

  • GET /api/journal-journey-runs: the user's own journey runs as cards, newest calendar day first, paginated (page, per_page 1 to 100, default 10). Each item is the same JournalJourneyRunResource card as journal_journey_runs on the calendar day detail. By default it lists the runs the calendar shows: in progress, completed, or started over with at least one answer kept. Narrow with from and to (Y-m-d, on the run's calendar day), filter[status] (in_progress, completed, abandoned) and filter[search] (journey title, prompt wording, answer text). Sort with sort: entry_date, created_at (prefix - for descending). Not premium gated: runs are the user's own data, like journal entries.

Changed payloads

  • GET /api/journal-entries no longer lists entries written inside a guided journey; they render inside their run's card. As a result meta.total counts standalone entries only, while meta.streak still counts every entry the user wrote, inside or outside a journey. journey_run_id remains on GET /api/journal-entries/{id}. GET /api/journal-entries/shared-with-me is unchanged: a partner's journey answers still arrive there as individual entries.
  • GET /api/journal-entries honours per_page (1 to 100, default 10). It was validated but ignored before; every page held ten entries.
  • Featured journal_journey items on GET /api/featured-content now send the journey's first prompt in title and the journey's own description in description. Before, the two were the journey name and the first prompt. tag is still the question count.

Mobile: build the journal list from GET /api/journal-journey-runs (one card per run) beside GET /api/journal-entries; the Today card keeps rendering title as its body line with no journey-specific branch. If the profile statistics should count journeys, the run list's meta.total gives that number. Full schemas at /docs/api.

Card packs, game packs and courses show who has completed them

Payload Change
2026-09-29

The Completed by row now works on card packs, game packs and courses as well as quizzes. Each item carries completed_by in the same shape as on quizzes, so the app can draw the row without a request per card.

Added field

  • CardPackResource.completed_by on GET /api/card-packs (paginated and per_page=0) and GET /api/card-packs/{id}.
  • GamePackResource.completed_by on GET /api/game-packs and GET /api/game-packs/favorites. Always present; it does not need include_favorite or include_history.
  • CourseResource.completed_by on GET /api/courses, GET /api/courses/{id} and GET /api/courses/enrolled.
  • Each is an array of {id, name, first_name, display_name, profile_picture, cutie_profile}: the user first (when they have completed it), then confirmed partners in the order they finished. A partner can appear while the user has not completed it. An empty array when nobody has.

What counts as completed

  • Card packs: every active card viewed, the same completion behind is_completed.
  • Game packs: a finished session of the pack, as initiator or partner. A partner who finished it with someone else is listed; the other player is only listed when they are the user or another confirmed partner.
  • Courses: the enrollment is completed (progress.completed_at set). Unenrolling clears it, so the user drops off.
  • Partners who hide activity in profile sharing are left out, as the activity section covers these content types. On game packs, a partner who finished the pack with the user is still listed, since the user already sees them in played_with.

Mobile: hide the Completed by row when completed_by is empty and draw each avatar from cutie_profile, falling back to profile_picture, as on quizzes. Please regenerate the client types. No other fields changed.

Daily questions carry a reason

Payload Change
2026-09-29

Every question in the daily pair now carries the “why this matters” text the user can open from the question card. Reasons are authored once in Nova and shared across questions: a question shows its own reason when it has one, otherwise the default reason of its tone.

Added field

  • reason on every question returned by GET /api/daily-question/pair: question_a, question_b, followup_a.question and followup_b.question.
  • An object {id, title, body} or null when the question has no reason and its tone has no default.
  • title is the button label under the question and the heading of the sheet it opens, without trailing punctuation. body is the sheet text.

Mobile: show the reason affordance only when reason is not null. The same reason object is returned on many questions, so do not treat reason.id as unique per question. Full schemas at /docs/api.

Quizzes show who has completed them

Payload Change
2026-09-25

Quiz cards on the Quizzes screen now have a Completed by row with the avatars of the user and their partners who have finished the quiz. Each quiz now carries that list, so the app can draw the row without a request per card.

Added field

  • completed_by on every quiz returned by GET /api/quizzes and GET /api/quizzes/{id}: an array of {id, name, first_name, display_name, profile_picture, cutie_profile}.
  • Lists the user (when they have completed the quiz) first, then confirmed partners in the order they finished. A partner can appear while the user is still in progress.
  • Only the latest attempt counts: a user who restarts a quiz drops off until they finish it again. Partners who hide their quiz results in profile sharing are left out, matching the results screens.
  • An empty array when nobody has completed the quiz. Quizzes embedded in course steps always return an empty array.

Mobile: hide the Completed by row when completed_by is empty. Draw each avatar from cutie_profile, falling back to profile_picture. No changes required elsewhere.

Guided journal journeys

Payload Change
2026-09-28

A journey is an authored sequence of two to five journal prompts a person works through in one sitting for a chosen calendar day. Every answer is a normal journal entry, so search, streaks, editing, sharing and attachments keep working on it. Single journal entries are unchanged.

Added endpoints

  • GET /api/journal-journeys — the published catalogue, enveloped {data, links, meta}, recommended first. Filters filter[level]=basic|deeper, filter[recommended]=1 and filter[search]; optional date (default: the viewer’s local today). Each JournalJourneySummaryResource carries its ordered prompts, real-boolean is_premium / is_locked, and user_run: null when nothing was started that day, else the run’s status and progress.
  • GET /api/journal-journeys/{id}?date= — the viewer’s state for a day as JournalJourneyStateResource: run (nullable), resume_prompt_id, progress {answered, skipped, total, percent}, is_all_done, and prompts[] with status answered | skipped | null and the full journal entry. Read only; never creates a run.
  • POST /api/journal-journeys/{id}/progress — the whole flow in one call, always 200 with the same state. prompt_id + text saves an answer (or updates the one already written for that prompt), with optional attachments[] in the same shape as the journal entry store (sent again for the same prompt, the list replaces the entry’s attachments; omitted, they are untouched); prompt_id alone logs a skip; finish: true skips whatever is left; restart: true abandons the open run and begins again, keeping its entries; an empty body reports the state without creating a run. The run completes on its own when every prompt has an answer or a skip. 422 with error_code prompt_not_in_journey or no_answers_yet.
  • GET, DELETE /api/journal-journey-runs/{id}, POST .../share and DELETE .../shares/{userId} — the finished journey as one card (JournalJourneyRunResource), deleted with its entries, or shared and unshared with partners entry by entry. Not paywalled: they are the user’s own data.

Changed payloads

  • JournalEntryResource gains journey_run_id (nullable integer) everywhere an entry is returned.
  • GET /api/calendar/day gains journal_journey_runs (one card per run); the viewer’s own run entries leave journal_entries. GET /api/calendar/month-overview’s journal marker gains journey_runs ({id, is_mine, status, percent}) and its has_entry is true when a day has either.
  • GET /api/featured-content can return a seventh card, type journal_journey, with the first prompt as description and “N questions” as tag.
  • Premium: journeys carry is_premium per item; the catalogue is open with lock flags, and show and progress return the standard 403 subscription_required with feature journal_journeys.
  • Entries inside journal_journey_runs cards carry their full text on every surface, including GET /api/calendar/day; the flat journal_entries list keeps its 200-character preview.
  • journal_prompt_id on POST and PUT /api/journal-entries accepts standalone prompts only (422 for a journey prompt); an entry written as a journey answer keeps the prompt it was written for (422 on a change).
  • total_active_shares on POST /api/journal-entries/{id}/share and POST /api/journal-journey-runs/{id}/share now counts every user with an active share after the request, not only the users named in it.
  • GET /api/journal-journeys honours per_page (1 to 100, default 10).

Mobile: the next arrow must call progress with the prompt id and no text so the skip is logged; the app owns position while inside a journey and uses resume_prompt_id only when reopening; user_run null means not started. Full schemas at /docs/api.

API reference grouped by feature

Docs Only
2026-09-28

The sidebar at /docs/api now lists one section per product feature (Daily Questions, Relationship Tracker, Journal, Courses, Games, and so on) instead of one section per backend controller. Endpoints such as reactions, replies, insights and settings sit under the feature they belong to, and the sections are alphabetical.

Nothing to change in the app

  • Only the OpenAPI tags changed. Paths, methods, operation IDs, request and response schemas, status codes and auth are identical.
  • Client types generated with openapi-typescript are byte-for-byte the same, since it ignores tags.

Category filter accepts several categories

Validation Change
2026-09-23

The app’s category chips are multi-select, but filter[category] applied only the first slug of a comma-separated value, so sending a selection silently returned the wrong items. Every slug now applies, and an item matches if it is in any of them.

Changed parameter

  • filter[category]=vulnerable,spicy on /api/card-packs, /api/quizzes, /api/game-packs and /api/courses returns items in either category. Order does not matter, and spaces after commas are allowed.
  • An unknown slug in the list is ignored and the others still apply, so a stale slug in a saved filter does not blank the screen. A value with only unknown slugs still returns an empty list, as before.
  • An empty value (filter[category]=) now applies no filter; previously it returned no results.
  • Comma-separated is the only form. The array form (filter[category][]=vulnerable&filter[category][]=spicy) returns 422 rather than being half applied.
  • Response shapes are unchanged. This replaces the “Single slug only” note in the content categories entry below.

Mobile: send the ticked category chips as one comma-separated filter[category] value, and leave the parameter out, or empty, when none are ticked. Client-side category filtering can be dropped once this is live.

Content category icons are served as PNG

Payload Change
2026-09-23

Category icons were SVG, which React Native’s Image cannot draw, so iOS and Android fell back to the PNGs bundled in the app and never showed updated artwork. Each icon is also pixel art drawn as about 935 rectangles (about 76 KB), too heavy to render through an SVG library across a Discover list. Every icon is now a PNG at 448×496 (2× the 224×248 design size), about 2 KB, with a transparent background and the same tint per content type as before.

Changed fields (format only; names and shape unchanged)

  • ContentCategorySummaryResource.icon_url — the embedded category on card packs, quizzes, game packs and courses, and the featured content, related resource and feeling lucky cards built from them. Now a signed URL to a .png served as image/png.
  • ContentCategoryResource.icon_urls on GET /api/content-categories — all four tints (card_pack, quiz, game_pack, course), same change.
  • URLs are still signed for 10 minutes, and still null until a tint has been uploaded. This replaces the “Icons are SVG” note in the content categories entry below.

Mobile: render icon URLs with React Native’s Image on every platform; SvgUri is no longer needed for these. Keep the bundled PNG only as the fallback for a null URL or a failed load.

Custom activity tags can be renamed

Payload Change
2026-09-23

The relationship tracker’s Manage activities sheet edits the user’s own tags in place, and until now the API could only create and delete them. A new endpoint renames a custom tag without detaching it from the check-ins it is already on; those entries show the new label straight away.

Added endpoint

  • PATCH /api/activity-tags/{id} — body {label, icon?}, same rules as create: label required, up to 100 characters; icon optional, up to 10 characters. Leaving icon out keeps the current one; sending null clears it. Returns 200 with the updated ActivityTagResource.
  • ActivityTagResource.value is regenerated from the new label (for example custom_trail_running), so it changes on rename. id is stable.
  • 403 for admin tags and other users’ tags; 422 on label when the new name matches another existing tag. Saving a tag with its current label, or with a capitalization-only change, succeeds.

Mobile: the Manage activities Save sends one PATCH for each tag whose text changed. Key tags by id, not value, and show a 422 on the field it applies to. No changes required elsewhere.

Daily-question notifications carry the day the answer was filed under

Payload Change
2026-09-21

Daily-question notifications route by question id alone, which left the client to work out which day that question was served on. Deriving it from the notification timestamp is wrong in three situations: after UTC midnight, where the timestamp has rolled to the next date; when partners are in different timezones, since an answer is filed under the answerer’s local day; and on reactions and replies, which can land on an answer from weeks earlier, where the timestamp has no relation to the answer’s day. The day is now on the payload, so it does not have to be derived at all.

Added fields

  • data.served_on_date — the day the answer was filed under, as YYYY-MM-DD, on seven notification events: daily_questions.partner_waiting, daily_questions.reveal_ready, daily_questions.followup_partner_waiting, daily_questions.followup_reveal_ready, daily_questions.nudge, daily_questions.reaction_received and daily_questions.reply_received. Present in both the push data extras and the stored notification data returned by GET /api/notifications.
  • Omitted, not null, when unknown. Notifications queued before this shipped, and any already stored, carry no served_on_date key at all. FCM data values are strings, so a null would arrive as the literal "null"; the key is left out instead. Use a presence check, not a null check.
  • action_url is unchanged. Taps route off the data extras, so the day is carried there only. daily_questions.take_today_prompt is also unchanged: it names no question, and a reminder always concerns the recipient’s own current day.

Mobile: when opening the daily-entry thread from a notification, use data.served_on_date as the pair date rather than deriving one from the notification timestamp. Keep a fallback for notifications that predate this: format the timestamp in the device’s local timezone, not UTC. GET /api/daily-question/pair?date= already accepts the value and resolves it in the user’s timezone, so no endpoint changes are needed.

Content categories on card packs, quizzes, game packs and courses

Payload Change
2026-09-17

Every card pack, quiz, game pack and course now belongs to one content category (Deep Dive, Vulnerable, Mushy Stuff, Spicy, Unhinged, Early Days, Hard Stuff, The Talk), managed by the client in Nova. Each category has one pictogram in four tints, one per content type. The API returns the category on each item, lists the categories, and filters each list by category. Regenerate client types from the spec: every list parameter below is documented there.

  • category on content. CardPackResource, QuizResource (including quiz steps inside lessons), GamePackResource, CourseResource, featured content items, the related resource cards on quiz results, and the feeling lucky pick each carry category: {id, slug, title, description, icon_url} or null. icon_url is already the tint for that content type (blue on card packs, orange on quizzes, pink on game packs, yellow on courses); render it as is. Related video and podcast cards always have null. null also means not yet categorised; the backfill is happening in Nova and the field stops being null once it is done.
  • Icons are SVG. Every icon URL is a signed URL valid for 10 minutes to an SVG served as image/svg+xml. Render with SvgUri from react-native-svg; React Native's Image draws nothing for an SVG. null until the icon has been uploaded.
  • New endpoint GET /api/content-categories. Active categories in display order: {id, slug, title, description, icon_urls: {card_pack, quiz, game_pack, course}}. Use it for labels and filter chips; a screen reads the tint for the content it shows.
  • filter[category]=<slug> on the four lists (/api/card-packs, /api/quizzes, /api/game-packs, /api/courses). Same URL shape everywhere. An unknown slug returns an empty list, not an error. Single slug only; on a comma-separated value only the first slug applies.
  • Course list validation. GET /api/courses now validates its query string through a request class: page min 1, per_page 1 to 100, filter[search] max 255 characters, filter[recommended] one of true, false, 1, 0. Invalid values return 422 where they were silently ignored.
  • Unknown query parameters on quizzes, game packs and card packs. These three lists now read filter[...] through the same query builder as courses, so an unknown filter[...] key, any sort= and any include= value return 400 where they were ignored. Nothing the app sends today is affected.
  • Game packs: filter=recommended retired. Use filter[recommended]=true (as courses do). The old string form is ignored and returns every pack. meta.filter still echoes recommended or all, and filter[recommended] combines with filter[category]. include_favorite and include_history are unchanged.

New endpoint: calendar day detail

Payload Change
2026-09-15

GET /api/calendar/day?date=YYYY-MM-DD&partner_id= returns everything the day sheet shows when a day is tapped on the unified calendar — one request instead of four separate list calls, with all server-side rules already applied. Purely additive; partner_id semantics are identical to GET /api/calendar/month-overview.

  • The content blocks, in the design's display order: relationship_tracker_entry (the viewer's own) and partner_relationship_tracker_entry (the entry that partner shared), daily_questions with per-question answer state, milestones (recurrence-expanded, each carrying occurrence_date), and journal_entries (own plus partner-shared, is_mine on each, 200-character text preview).
  • Existing rules carry over unchanged: a partner's daily question answer text stays hidden until the viewer answers the same question (the partner_answered flag is always present), and tracker entries outside a free user's 7-day history window come back as locked stubs. answered_count / questions_count cover base questions only; follow-up questions are listed with is_follow_up true.
  • available_actions holds four booleans driving the "What would you like to add?" sheet, each mirroring the exact rule its write endpoint enforces — the app never re-implements them. can_add_relationship_tracker_entry (partner context, date not after server today, no own entry yet, inside the viewer's history window), can_add_daily_question_answer (partner context, the viewer's local today, a serving pair with a question the viewer can still store — base questions respect the daily answer limit, follow-ups need their base answered), can_add_milestone (always true), and can_add_journal_entry (date not after server today).
  • Note the deliberate asymmetry mirrored from the write endpoints: the tracker and journal flags compare the date against server today, while the daily question flag uses the viewer's local today — exactly matching which dates each store endpoint accepts.

Consistent is_mine flag; calendar includes partner-shared content

Payload Change
2026-09-15

Every entry the calendar renders now carries a viewer-centric is_mine boolean — true when the authenticated user authored the item, false when it was authored by their partner — and, with partner_id, the calendar includes content that partner shared with the viewer. All changes are additive.

  • is_mine added to journal entries (GET /api/journal-entries, /journal-entries/{id}, /journal-entries/shared-with-me), milestones (all milestone endpoints), relationship tracker entries (all tracker entry endpoints), and the calendar's tracker, milestone, and journal marker entries (daily question marker entries remain the viewer's own answer ids). Daily question answers already had it.
  • GET /api/calendar/month-overview with partner_id: journal markers include entries that partner shared with the viewer (revoked shares never appear), and the relationship_tracker marker's entries array now holds up to 2 entries — the viewer's own first, then the entry the partner shared with the viewer. The free-tier lock nulls mood/satisfaction on locked days regardless of who wrote the entry.
  • Solo mode (no partner_id) is unchanged: the viewer's own content only.

Unified calendar: month overview reworked (breaking)

Payload Change
2026-09-12

GET /api/calendar/month-overview is reworked as a breaking change for the unified calendar design (one swipeable page per partner). The app team should regenerate client types; old marker fields are gone, not deprecated.

  • Scoping moves to partner_id. An unknown user id fails validation with 422; an existing user who is not a confirmed partner (including your own id) returns 403. relationship_id is removed and now returns 422. No parameter means solo mode (journal and own-milestone markers only).
  • Every day marker now carries an entries array of id-bearing objects. The milestone marker's has_milestone/items become has_entry/entries; the tracker marker's is_shared_with_partner moves inside its entry object. The daily questions marker is unchanged (you_answered, partner_answered, participants) and additionally carries the viewer's answer ids in entries.
  • The relationship tracker marker's entry carries mood and satisfaction (drives the day emoji); journal entries carry their title and emoji.
  • New top-level streaks (current day streak + days in the requested month, free for all tiers) and relationship_tracker_history (explicit free-tier 7-day limit flag; is_locked: true marks days outside the viewer's window, and any entry on such a day has null mood/satisfaction but keeps its id). Both are null without partner context.
  • Each day gains is_today, computed in the user's timezone, so clients never do timezone math. The default month, the streaks grace period, and the free-tier history window (visible_from_date and is_locked) follow the user's timezone as well, so every date in the response agrees with the user's local calendar. The history window change also applies to the existing relationship tracker endpoints (entry is_locked and the free-tier entry-date validation), and streak grace periods follow the streak owner's timezone everywhere streaks are returned.

By-activity insights no longer prefix custom tag labels with "Custom"

2026-09-11

GET /api/relationship-tracker/insights/by-activity now returns each activity tag's stored display label in activities[].label, matching what the activity tag endpoints already return.

  • Previously the label was derived by title-casing the tag's value slug, so a custom tag like custom_date_night came back as "Custom Date Night". It now comes back as "Date Night".
  • Default tag labels and the response shape are unchanged - activities[].tag still carries the slug. No client changes needed.

Notification schema fully typed in the OpenAPI spec

2026-09-11

Documentation-only - nothing changes on the wire. The NotificationResource schema served at /docs/api is now precise; clients that generate types from the spec should regenerate.

  • data is no longer a free-form dictionary: every payload key across all notification events is enumerated with its type (question_id, partner_id, partner_ids, and so on). type is always present; each event carries its own subset of the optional keys.
  • action_url is now correctly nullable, and priority is an enum (high | normal | low).

Reveal-ready notification payloads now carry partner_id

Payload Change
2026-09-11

When a single partner responded, the daily_questions.reveal_ready and daily_questions.followup_reveal_ready notifications' data payloads now include that partner under the same keys every other daily-question notification uses.

  • data.partner_id (the responding partner's user id) and data.partner_name (their display name) are added when exactly one other partner responded - the same case where the copy names the partner ("Brett responded"). The existing partner_ids and actor_user_id keys are unchanged.
  • This lets clients that resolve the conversation to open from partner_id drop the notification tap directly into that partner's thread instead of the partner picker.
  • When two or more partners responded ("Everyone answered!") the payload is unchanged - there is no single conversation to open, so the partner picker remains the intended destination.
  • Purely additive - no existing field changes shape, so clients that ignore unknown keys need no work.

New break-up reflection pack endpoints

2026-09-04

The Discover → Breaking up content is now admin-authored and API-served. Three new endpoints, all inside auth:sanctum:

  • GET /api/breakup-packs — published pack list: id, title, tag, question_count, accent_color, icon_url, sort_order. tag is the chip label shown on the pack tile.
  • GET /api/breakup-packs/{id} — the full pack: description, ordered questions with lettered options and per-option reflection_text, plus closing_reflection and closing_celebration. closing_reflection is markdown (LF line endings) and may contain {scheme}://breakup-packs/{id} deep links.
  • POST /api/breakup-pack-responses — records which option the user tapped. Idempotent upsert per (user, question): 201 on the first answer, 200 when a re-answer overwrites it. Clients must not add duplicate guards.
  • The whole feature is premium: all three routes — including the list, unlike other Discover content — return 403 subscription_required with feature: breakup_packs for non-premium users, so free clients should hide or lock the entry point.

GET /api/users/me now includes the user's timezone

Payload Change
2026-09-03

The timezone read-back is no longer exclusive to GET /api/user: the profile endpoints now expose it too.

  • GET /api/users/me and PATCH /api/users/me responses gain a nullable timezone string: the IANA identifier the server currently holds for the user, or null (treated as UTC) if no client has ever sent the X-Timezone header for them.
  • The field is read-only here: it is set exclusively by the X-Timezone request header and cannot be written through PATCH /api/users/me.
  • Purely additive - no existing field changes shape, so clients that ignore unknown keys need no work.

The X-Timezone header and GET /api/user are now in the API reference

2026-09-03

The per-user timezone capture that shipped on 2026-08-15 was described only in this changelog; the OpenAPI reference at /docs/api never declared it. The generated spec now documents the full contract. No payload or behavior change: every response is byte-identical to before.

  • Every authenticated operation now lists the optional X-Timezone request header, including the IANA-identifier-only rule (legacy spellings such as Asia/Calcutta accepted), the rejection of numeric offsets such as -08:00, and the silent-ignore semantics for invalid values (never a 4xx).
  • GET /api/user now appears in the reference, documenting the nullable timezone string as the read-back for the identifier the server currently holds (null means the user is treated as UTC).
  • GET /api/users/me was not touched by this documentation pass; the separate entry above adds the timezone field to it.

Quiz insights can be assembled from pre-authored fragments

2026-09-02

A new server-side mechanism can generate quiz result content (pillar summaries, growth tips, and compatibility insights) from admin-authored copy fragments instead of having the LLM write it from scratch. No payload change: the shapes returned by GET /quizzes/{quiz}/results and GET /users/me/quiz-results are identical in both modes; only how the text is produced changes.

  • Each quiz selects its own mechanism through a new Insights Source field on the quiz's Nova edit screen: fragments (the default) or llm (the legacy behavior, where the LLM writes all insight content from the trait scores). The quiz detail screen shows a fragment-readiness indicator so authors can see how many of the quiz's traits have authored fragments. In fragments mode each trait score resolves to one pre-written fragment by its pole and strength band (neutral under 2; clear 2-5; strong 5-8; defining 8-10), the pillar's strongest trait contributes its growth tip, and the LLM only rewords the assembled draft under a strict no-new-claims prompt.
  • Fragments are authored in Nova directly on each trait's edit screen (band fragments, growth tips, and compatibility combos), with rotating opener sentences managed on each pillar. Content only appears for quizzes whose fragments have been authored; anything unauthored falls back to the existing placeholder copy.
  • Scoring is untouched: trait score calculation, normalization, and every score returned by the API are identical in both modes.
  • Admins can now regenerate a completed attempt's results from Nova: a "Regenerate Results" action on the attempt detail screen recalculates trait scores from the stored answers and rebuilds all insights with the quiz's configured insights source.

Featured content surfaces documented as optional, together_for always present

Payload Change
2026-09-01

Spec-vs-live alignment pass: the GET /featured-content contract now says what the endpoint has always done, and together_for is once again a key clients can rely on being present.

  • GET /featured-content docs now state that a surface with nothing currently featured is simply absent - no placeholder, no null entry - so the list holds anywhere from zero to six items and is never backfilled. Documentation only; the endpoint's behavior is unchanged. Clients should render only the types present and never assume a fixed count per surface.
  • together_for is now always present on GET /relationships / GET /relationships/{relationship} and GET /relationships/partners, as null until the relationship has a together_since or confirmation date. Previously the key was omitted entirely in that state, which had flipped it to optional in the spec. Its {years, months, days} shape is unchanged, and existing truthy guards in clients keep working.
  • Confirmed, no change needed: is_recommended on lesson-step quizzes has been in the spec since the featured-content release added it to quiz payloads.

Lesson reflection steps no longer require a title

Payload Change
2026-09-01

POST /lessons/{lesson}/steps/{step}/respond no longer requires title for journal_prompt steps, aligning it with POST /journal-entries, which made journal entry titles optional earlier. Both endpoints write the same journal entry rows and now share the same rule.

  • title is now typed string|null — it may be omitted or sent as an explicit null; either saves the reflection with no title. Previously a title-less request returned a 422 ("The title field is required for a reflection.").
  • text remains required for reflection steps.
  • Clients can drop any workaround that derived a placeholder title (e.g. from the note's first line) before submitting a reflection.

Quiz results survive retakes, search meta fixed, results list who completed

Payload Change
2026-09-01

Three behavior/payload fixes: quiz results no longer disappear while a retake is in progress, the search endpoint's pagination meta values are single integers again, and quiz results now say whose completed attempts they draw on.

  • GET /quizzes/{quiz}/results (and the quiz-step variant GET /lessons/{lesson}/steps/{step}/results) now always returns the most recently completed attempt. Starting a retake with restart: true previously made results 404 with no_results until the retake finished; the preserved attempt now keeps serving them. The same applies to a partner mid-retake, which previously read as partner_not_completed. 404 now strictly means "never completed".
  • GET /search's meta.current_page / meta.per_page / meta.total came back as two-element arrays (e.g. current_page: [1, 1]) because the controller duplicated keys the paginator already provides. They are now the plain integers the spec always promised, so app-side collapsing (metaNumber()) can be dropped.
  • Quiz results gain a completions array of {user, completed_at} - always the requesting user, plus the partner on compatibility results - so clients can show who has completed the quiz and when. Purely additive.

Spec corrections: types now match what the API actually returns

2026-09-01

Documentation-only fixes - no live payloads changed. The OpenAPI spec previously mistyped several fields; clients that added workarounds or hand-typed overrides for these can now rely on the spec directly.

  • PublicUserResource.first_name and UserResource.first_name are now typed string|null (null for users who never set one - display_name remains the safe always-a-string choice).
  • SubscriptionResource.features is now documented as the named boolean object it always was (relationship_tracker / audio_question_submission / advanced_compatibility_reports) instead of an unnamed boolean tuple, and is_expired / can_upgrade are typed boolean. The relationship_tracker flag's meaning is now spelled out: it grants unlimited tracker history and insights access - the tracker itself works on the free tier, limited to limits.relationship_tracker_history_days days. Do not gate the whole feature on it.
  • DailyQuestionPairResource.followup_a / followup_b are now documented as the nullable object {question, available, answered} they return live (previously an untyped nullable array).
  • RelationshipTrackerEntryResource.is_locked, RelationshipTrackerPartnerSettingsResource.auto_share_entries and .average_share_opt_in are typed boolean; .shared_entries is documented as the array of entry summaries ({id, entry_date, mood, satisfaction_score, activity_tags}) it always returned, instead of a string.
  • GET /daily-question/dates docs now state the list holds only the dates in the 7-day window that actually have a question pair - it can be shorter than 7 or empty, so match by date value rather than building a 7-cell strip by index.
  • Announcement deep links: the seeded announcements and Nova examples used the retired therapy-jeff:// scheme; they now use cutie-app://, and Nova now validates action_url against the app's registered scheme (or https) so unopenable links can't be saved again.

Daily question notifications: duplicates fixed, follow-ups get their own event keys

Payload Change
2026-08-31

When both partners answered a daily question within a few seconds of each other, each partner's queued job could independently conclude that everyone had answered and send its own round of daily_questions.reveal_ready - every recipient's inbox showed the same notification twice. Dispatch is now anchored to the answer rows themselves, so each notification is sent exactly once per participant per question per day. Follow-up questions also now announce themselves as follow-ups instead of reusing the main question's keys and copy.

  • daily_questions.reveal_ready and daily_questions.partner_waiting are now sent exactly once per participant per question per day. Duplicate rows already sitting in inboxes are untouched.
  • daily_questions.partner_waiting is intentionally not sent when everyone has already answered by the time its job runs - the reveal notice supersedes it, where previously the race dropped it silently.
  • Answers to follow-up questions now emit two new event keys with follow-up-specific copy: daily_questions.followup_partner_waiting and daily_questions.followup_reveal_ready, instead of reusing the main-question keys. This re-routes existing traffic: follow-up activity no longer produces partner_waiting / reveal_ready rows.
  • The new keys' data payload carries question_id as the main question's id - so taps land on the existing daily-entry thread - plus a new followup_question_id with the follow-up's own id, alongside the usual actor_user_id and partner_ids / partner_id.
  • No app changes are required: the new keys share the daily_questions prefix, so existing category-based routing, icons, and notification preferences apply as before.

Milestone lists now speak in occurrences

Payload Change
2026-08-31

GET /api/milestones and GET /api/relationships/{relationship}/milestones/shared-with-me previously matched recurring milestones against a date range but returned each one with its original date only, sorted scopes by that original date, and ignored the sort parameter entirely. All of that now operates on the occurrence each row actually represents. This supersedes the 2026-08-25 entry's guidance that a recurring milestone "is still returned once, with its original date" for ranged queries - that is no longer true.

  • Every list row now carries an occurrence_date saying which occurrence it represents; the series' date is unchanged. On single-milestone responses (show, create, update, share) occurrence_date is always null.
  • When both from and to are given, a recurring milestone returns one row per occurrence in that range - the same expansion GET /api/calendar/month-overview already does. meta.total counts occurrences, not series, for such queries.
  • With a one-sided range or no range, each series is returned once, dated at its nearest matching occurrence: scope=upcoming uses the next occurrence from today (ordered soonest first) and scope=past uses the most recent elapsed occurrence.
  • scope=upcoming no longer returns a recurring milestone whose recurrence_max_occurrences is exhausted - a series that will never occur again is not upcoming.
  • The sort parameter now works and accepts exactly date or -date, applied to each row's occurrence_date. Any other value (including the previously silently-ignored next_occurrence_date) now responds 422.

"Feeling lucky": one random course, quiz or game

Payload Change
2026-08-31

The Discover screen's "Feeling lucky" button is now server-driven. A new endpoint picks one random item across published courses, quizzes and game packs, and returns a deep link the app can navigate straight into.

  • New endpoint GET /api/feeling-lucky returns {type, id, title, description, is_premium, is_locked, deep_link}. type is one of course, quiz or game_pack - the same vocabulary as GET /api/featured-content.
  • deep_link carries the same {screen, params} shape as GET /api/search results: CourseDetail/course_id, QuizDetail/quiz_id or GamePackDetail/game_pack_id.
  • Free users only ever receive content they can open - premium items are excluded from their pool rather than returned locked, so is_locked is always false.
  • Content the user has already completed (finished course enrollment, completed quiz attempt, completed game session as either participant) is avoided. Once everything eligible is completed, the pick falls back to the full accessible pool so the button keeps working.
  • When the user's accessible pool is empty, the endpoint responds 404 with error_code: no_content_available.

Badge fixes: two badges that could never be earned

2026-08-31

Two badge evaluators had bugs that prevented their badges from ever being awarded. Both are fixed.

  • we_actually_talk (15 shared Daily Question answers in a calendar month) previously crashed at the moment of awarding. It is now awarded to both partners in the relationship, as originally intended.
  • actually_paying_attention (relationship tracker entries 14 days in a row) silently never triggered due to a date comparison bug. It is now earnable.
  • Re-evaluating an already-earned badge no longer resets its original earned_at date.

Trial expiry notifications fixed and sent at a friendly local hour

2026-08-31

Users whose trial or premium subscription is about to end are warned three days ahead, and expired subscriptions are downgraded promptly. Two bugs in this pipeline are fixed.

  • Registering through a partner invite link now grants the full 14-day extended trial: the account gets a real subscription_ends_at. Previously these accounts were marked as trial with no end date, which meant no premium access, no expiry warning, and no downgrade. Existing affected accounts were backfilled with a 14-day trial.
  • The expiring-soon warning is now sent at 10:00 in each user's own timezone (previously 08:00 UTC, which is 1:00 AM Pacific) and is sent exactly once per trial window instead of once per day for three days. Extending a subscription re-arms the warning for the new end date.
  • Expired subscriptions are now downgraded within the hour rather than at a fixed daily time.

Notifications can now be deleted - one at a time or all at once

Payload Change
2026-08-30

A notification now stays in the list until it is specifically deleted, whether read or unread. Reading a notification never removes it - the read state only drives the unread indicator. Two new endpoints back the app's per-notification "x" and the new "Clear all" action.

  • New DELETE /api/notifications/{id}: permanently deletes a single notification. Returns 404 for an unknown id and 403 for another user's notification.
  • New DELETE /api/notifications: permanently deletes all of the authenticated user's notifications (read and unread). Idempotent - succeeds even when the list is already empty.
  • GET /api/notifications is unchanged: it keeps returning every notification that has not been deleted, regardless of read state. Use the existing POST /api/notifications/read-all to clear the unread indicators when the list is opened.

Milestones no longer appear on the wrong partner's calendar

Payload Change
2026-08-27

For a user with multiple partners, milestones tied to one relationship were marking days on a different partner's calendar: the month-overview endpoint included all of the user's own milestones regardless of the requested relationship. Milestone results are now scoped to the relationship context - personal milestones (no relationship) plus that relationship's own and partner-shared milestones. Milestones tied to the user's other relationships are excluded.

  • GET /api/calendar/month-overview?relationship_id=X: markers.milestones no longer includes the user's own milestones tied to other relationships.
  • GET /api/milestones?relationship_id=X now also returns the user's personal milestones (previously it returned only milestones tied to relationship X, which made it impossible to build a per-partner list without hiding personal entries). Milestones tied to other relationships remain excluded.
  • GET /api/calendar/month-overview without relationship_id is now a "no partner selected" view: milestone markers cover every milestone the user created, with no partner-shared milestones and no relationship_tracker or daily_questions markers. Partner-scoped markers always require an explicit relationship_id (previously the API filled the missing parameter with one of the user's confirmed relationships).
  • Not a data leak: a milestone shared with one partner was never visible to another partner. The bug only surfaced the owner's own milestones in the wrong partner context of their own views.

Notification email channels corrected to match the settings spec

2026-08-27

Reconciled which events send email against the approved notification settings spec. Three corrections shipped:

  • Daily question nudges (daily_questions.nudge) no longer send email. They were emailing by default even though the spec marks email "not used" for this event; push and in-app notifications are unaffected.
  • Subscription expiring-soon and expired notifications (subscription.expiring_soon, subscription.expired) now send email as the spec requires, subject to the user's existing email and account/billing notification preferences.
  • Admin broadcast announcements now honor each recipient's email preference and the "Broadcasts" category toggle instead of emailing every targeted user regardless of their notification settings.

Reveal-ready notification now names the responding partner

Payload Change
2026-08-26

The daily-question "reveal ready" push notification always read "Everyone answered! All partners have responded." even in a standard two-person relationship, where "everyone" is really one specific named partner. When there is exactly one other partner, the notification now reads "{Name} responded. Tap to see their answer!" instead. The generic "Everyone answered!" copy is reserved for relationships with more than one other partner.

  • The reveal-ready notification's data payload now includes partner_ids, and includes actor_user_id when there is exactly one other partner.

Deleting an in-use activity tag no longer fails

Payload Change
2026-08-25

A custom activity tag attached to existing relationship tracker check-ins could not be deleted - the request failed with a 409 error. Deleting now always succeeds: the tag is removed from every check-in it's attached to, then permanently deleted. The check-in entries themselves are never affected, only their association with the deleted tag.

  • DELETE /api/activity-tags/{id} no longer returns 409 for a tag that's in use. It now returns 200 in every case where the requesting user is authorized to delete the tag.

Milestones can now recur on a schedule

Payload Change
2026-08-25

A milestone can now repeat on a schedule instead of only being a one-off dated entry (e.g. a yearly anniversary or a monthly milestone). There is still just one milestone row per series - occurrence dates are computed on the fly rather than stored as separate records, and editing or deleting a recurring milestone always applies to the whole series.

  • POST /api/milestones and PATCH /api/milestones/{id} now accept recurrence_frequency (daily, weekly, monthly, or yearly), recurrence_interval ("every N"), and an end condition - either recurrence_end_date or recurrence_max_occurrences, which are mutually exclusive. Omitting all of these keeps a milestone as a one-off, unchanged from before.
  • The milestone resource now includes a recurrence object (null for one-off milestones) and a computed next_occurrence_date.
  • GET /api/calendar/month-overview now expands recurring milestones so markers.milestones.has_milestone is correct for every occurrence, not just the milestone's original date. The scope=upcoming filter on GET /api/milestones now also includes recurring milestones that haven't ended yet, regardless of how long ago they were originally dated.
  • markers.milestones on GET /api/calendar/month-overview now also includes an items array (id, icon_key, title per milestone landing on that day) alongside has_milestone, so the app can render the right icon and open the milestone without a second, date-matching fetch - the marker resolves recurring occurrences to actual milestones itself.
  • The from/to date-range filters on GET /api/milestones (and the relationship's shared-with-me equivalent) also now include a recurring milestone whose recurrence could touch the requested range, even when its own original date falls outside it. The milestone is still returned once, with its original date - use recurrence and next_occurrence_date to know when it actually recurs.

Support staff can sign in to the app as a specific user

2026-08-17

Debugging an account-specific issue used to require knowing the user's password, which does not exist in retrievable form. An admin can now generate a one-time login code from the admin panel and sign in as that user through the app's normal login form. Nothing changes for the app: POST /api/login accepts the code in the password field and responds exactly as a normal login.

  • Codes are generated per user via the "Generate Impersonation Login Code" action on the App Users Nova resource. Each code is single use, expires after 15 minutes, and generating a new one replaces the old.
  • A login via code issues a token that expires after 24 hours (normal logins keep their 90-day tokens), and the user's activity tracking and timezone are left untouched while it is in use.
  • The whole feature is off unless the IMPERSONATION_ENABLED environment flag is set, and codes can never be generated for admin accounts.

Relationships can now carry a real "together since" date

Payload Change
2026-08-17

The partner card's "together for" badge could only show how long two users had been linked in the app, because the duration was derived from the moment the relationship was confirmed. Either partner can now enter the date the couple actually started dating, and the badge duration is derived from that date whenever one is set.

  • New endpoint PATCH /api/relationships/{relationship} partially updates the relationship's user-editable details. Send together_since as a Y-m-d date to set it, or null to clear it. Either confirmed participant may edit; the date cannot be in the future (checked against the user's own timezone).
  • together_since (string|null, Y-m-d) is now returned on GET /api/relationships, GET /api/relationships/{relationship} and each partner in GET /api/relationships/partners. Use it to prefill the date picker; null means no real date has been entered yet.
  • together_for keeps its {years, months, days} shape but is now derived from together_since when set, falling back to the app-link date otherwise. Formatting the badge (hiding zero parts, spelling out days under a month) remains the app's responsibility.
  • The duration no longer adds an extra day (previously "day 1" was the link day) and is measured against the user's local today, so an entered anniversary reads as whole years on the anniversary itself in every timezone. Expect app-link durations to read one day shorter than before.

The Today screen's suggestions now come from the API

2026-08-16

The reflective prompt, the card pack, the course, the game and the quiz on the Today screen were hardcoded in the app, so changing any of them needed a release. GET /api/featured-content now serves them from the admin panel, and the selection changes every day on its own.

  • The response is today's cards only, not the whole featured catalogue — six entries for a fully stocked day. Group them by type to fill each card, and render every entry of a type rather than only the first.
  • type is one of journal_prompt, card_pack, course, game_pack or quiz — the same tokens GET /api/search already returns. Each entry also carries id, title, description, tag, display_order, is_premium and is_locked.
  • The journal surface returns two entries, always from different categories. Journal prompts are the one special case in the payload: the prompt text arrives in title and description is null, because the prompt itself is the card's body line, while tag holds the category that heads the card.
  • For every other type, title is the item's own name — which is the line the card shows — and tag is that specific item's label: a card pack's or course's first theme, a quiz's type. Game packs have no per-item category yet, so their tag is null and the app should keep using its own label for that card.
  • No colours, gradients, backgrounds or imagery are returned. The API sends text and identifiers only; artwork stays in the app, keyed by type. The id is included so cards can deep link to a specific item later, even though they currently open the relevant list screen.
  • display_order is a zero-based position across the whole response, already sorted.
  • Premium items are returned marked is_locked: true for users without a subscription rather than being hidden, matching how card packs, courses and search results already behave. Only a title, one line of copy and a badge are sent — the gated content stays behind its own endpoints.
  • “Today” is the user's own local date, consistent with the daily question. Responses are cached, but an admin change to what is featured takes effect on the very next request.

Payload change, but no immediate action needed. GET /api/journal-prompts and GET /api/quizzes gain an is_recommended boolean, matching the field card packs, courses and game packs already return; nothing existing changes shape. Mobile: replace the hardcoded Today-screen suggestions with a single call to GET /api/featured-content, keeping routing local — journal prompts go to /journal-entry?prompt=<title> and everything else to its list screen.

The daily question now follows the user's timezone

2026-08-15

Every daily-question date was computed against server time (UTC), so a user in Pacific time lost the day's question at 5 PM local and a user in Tokyo lost it at 9 AM. Clients can now tell the API which timezone they are in, and the question stays available until that user's own midnight.

  • All authenticated requests accept a new optional X-Timezone request header carrying an IANA identifier, for example America/Los_Angeles. The API stores the most recent value it has seen for each user, so sending it on every request keeps travel and daylight-saving changes correct. Numeric offsets such as -08:00 are rejected — they cannot express daylight saving.
  • Send whatever the platform reports and no mapping is needed: TimeZone.current.identifier on iOS, ZoneId.systemDefault().id on Android, Intl.DateTimeFormat().resolvedOptions().timeZone on web. Legacy identifiers are accepted alongside their modern spellings, so Android's Asia/Calcutta and Europe/Kiev work exactly as iOS's Asia/Kolkata and Europe/Kyiv do.
  • Value change: GET /api/daily-question/pair and GET /api/daily-question/dates resolve “today” in the user's timezone rather than UTC. The same applies to GET /api/daily-questions/today/relationships, to the served_on_date recorded against a submitted answer, and to the daily answer limit, which now resets at the user's local midnight.
  • Value change: daily-question reminder notifications are sent at 09:00 in each user's own timezone instead of 09:00 UTC, and notification quiet hours are evaluated as local wall-clock times, so a 22:00–08:00 window now means 22:00–08:00 where the user actually is.
  • GET /api/user gains a nullable timezone string, so a client can read back the identifier the server currently holds for it. No other response changes shape — GET /api/users/me is unaffected.
  • Clients that send no X-Timezone header are treated as UTC, exactly as before, so this can be adopted per platform whenever each client is ready.

One question pair is published per calendar date and it is shared by everyone. Pairs therefore need to be scheduled at least a day ahead: users east of UTC reach a given date before the server does.

Notifications name their sender and carry an actor

Payload Change
2026-08-14

Notification copy interpolated the sender's first_name, a nullable column that is never set at registration, so notifications read " wants to play!" with a blank where the name belongs. Sender names are now resolved server-side, and the notifications endpoint says who each notification is from.

  • GET /api/notifications gains a nullable actor object — id, name, first_name, display_name, profile_picture, cutie_profile — identifying the user whose action caused the notification.
  • Titles and bodies now fall back from first_name to name, which is the name most users actually have. The same value is exposed as a new display_name field on every public user object, including poll authors and audio question askers. It is an empty string for the small number of users who have never given a name at all — the server does not substitute a stand-in phrase, since the right wording differs between a partner and a stranger.
  • Every notification with a sender now carries actor_user_id in its data payload. The existing per-event keys (partner_id, partner_user_id, initiator_id, requester_user_id, recipient_user_id, creator_user_id, initiator_user_id, actor_user_id) are all unchanged, so existing clients keep working — but actor replaces the need to probe them.
  • Value change: the existing partner_name and initiator_name keys in data carried the raw first_name, which was null for most senders. They now carry the same first_name → name fallback as the copy, so they hold a real name far more often, and are an empty string rather than null when no name exists. A client branching on if (partner_name) should still work, but the null case is now an empty string.
  • actor is permanently null for system-generated notifications that have no single sender, including daily_questions.reveal_ready, games.all_guesses_submitted and games.round_scored.
  • Value change: the author.display_name on the How-To “Shared with me” list (GET /api/how-to/shared-with-me) carried the sharer's full name. It now uses the same first_name → name fallback as everywhere else, so it prefers a first name where one is set, and is an empty string rather than null when no name exists. This makes display_name mean the same thing everywhere it appears in the API.

This applies to notifications created from this release onward. Notifications already in a user's inbox keep the copy they were sent with and return actor: null; the inbox fills in as new notifications arrive.

Quizzes can be retaken by editing existing answers

Payload Change
2026-08-14

A completed quiz can now be retaken by re-opening the existing attempt and editing its answers, instead of starting over and losing the previous results. No new endpoints were added — the existing start and next endpoints handle it.

  • POST /api/quizzes/{quiz}/start on a completed quiz without restart now returns 200 with the existing attempt re-opened for review, where it previously returned 201 with a new empty attempt. The response keeps status: completed and 100% progress, and current_question is the first question.
  • Questions now carry an answer field holding the previously submitted answer, in the same shape the client submits: the chosen option id (pick_one, multiple_choice, scenario), the chosen value (slider, likert), or the ordered array of option ids (ranking). It is null for an unanswered question, and is absent entirely while an attempt is in progress.
  • POST /api/quizzes/{quiz}/next now accepts completed attempts and serves their questions in order with answers embedded. current_question: null means the walk is finished.
  • Re-submitting a question that was already answered updates the stored answer in place; the previous “This question has already been answered” 422 is gone on in-progress attempts too.
  • Changing an answer recalculates trait scores immediately, so GET /api/quizzes/{quiz}/results is correct on the very next call. Insight text is regenerated by that results request, which therefore takes longer than usual right after a retake. Re-submitting an identical answer does nothing at all, and partners are not re-notified.
  • restart: true is unchanged: it discards an in-progress attempt and its answers, or keeps a completed attempt as history, and starts fresh with a 201.

Fresh and in-progress payloads are unchanged — existing quiz flows need no client work. Use /start without restart for the Retake button; sending restart: true will discard the user's answers.

The user's last_active_at field is now populated

2026-08-13

The last_active_at field returned by GET /api/user was always null due to a server-side bug. It now reflects the time of the user's most recent authenticated request, updated at most once per hour. Users who have not made a request since this fix deployed will continue to show null until their next session.

No shape change — the field already existed in the response; it simply carries a real timestamp now.

Device token registration is now rate-limited

2026-08-13

POST /api/device-tokens and DELETE /api/device-tokens/{id} are now rate-limited to 20 requests per minute per authenticated user. Excess calls receive a 429 with the standard Retry-After header. Realistic device rotation and sign-out flows are well under this ceiling.

No payload change — request and response shapes are identical. Added after a production incident where a client re-registered the same token in a tight loop; root cause is being addressed on the mobile side.

Partner invite codes are now case-insensitive

2026-08-10

POST /api/relationship-invites previously rejected invite codes typed in lowercase with a 422, even though the code was otherwise correct. Submitted codes are now uppercased server-side before validation and lookup, so abc123 and ABC123 both match the same invite.

  • Applies to every code in the codes array; codes are still generated and stored uppercase.
  • Validation error messages echo the uppercased form of the submitted code.
  • Partner invite link tokens (partner_invite_token) are unchanged and remain case-sensitive — they are opened from a link, never typed.

No payload change — request and response shapes are identical. Mobile may drop any client-side uppercase forcing on the invite code field if desired.

Per-partner profile sharing settings are now enforced

Payload Change
2026-08-10

The per-partner sharing sections saved via PATCH /api/users/me/sharing/partners/{partner} previously had no effect on served data. They are now enforced server-side on every endpoint that returns a partner's data.

  • Every embedded user summary (partners list, relationship resources, quiz partner embeds, daily question answers/replies/reactions, invites, shares, games) now applies the owner's settings toward the viewer: hiding about nulls bio and pronouns; hiding essentials nulls email. Keys are always present — hidden values come back as null. id, name, first_name, profile_picture, and cutie_profile are never masked.
  • GET /api/relationships/{relationship}/streaks: hiding statistics returns null for all three streaks, per the documented integer|null contract. The existing global streak-visibility preference still applies on top.
  • GET /api/users/{user}/badges: hiding badges returns 200 with an empty list — indistinguishable from having earned no badges.
  • Hidden results read as if the partner never completed the quiz: GET /api/quizzes/{id}/results?partner_user_id=… returns the existing 404 error_code: partner_not_completed, and in GET /api/users/me/quiz-results the item carries partner and partner_score null.
  • The partner cards on GET /api/users/me/sharing are themselves masked by each partner's settings toward the caller.
  • The relationships and activity sections have no serving endpoints yet; they will be enforced when such endpoints ship.

Payload change. Mobile: treat email, bio, and pronouns as nullable in every user summary. Hidden data is indistinguishable from data that doesn't exist, so no new error states to handle — every hidden section reuses that endpoint's existing empty/absent representation. Defaults are unchanged — users who never touched sharing settings serve identical payloads.

Daily question pair empty state returns 200 null instead of 404

Payload Change
2026-08-10

GET /api/daily-question/pair previously answered "no pair seeded for this date" with a 404 and a generic message, making an ordinary empty day indistinguishable from a real error. It now returns 200 with {"data": null}, matching how /daily-question/dates already handles empty state.

  • Applies to any requested date with no seeded pair — today (no date param) and explicit past or future dates alike.
  • The happy path is unchanged — a seeded date still returns the pair payload under data.

Payload change, but no immediate action needed. Mobile: the existing nullOn404 workaround already converts the old 404 to null, so behavior is identical either way. The workaround can be removed at leisure.

Premium gate added to quiz /next

2026-08-01

POST /api/quizzes/{id}/next is now behind the same premium:quizzes_full_library gate as /start, quiz detail, and results. It previously had no premium check, so free users could answer premium quizzes directly.

  • Free quizzes are unaffected — the gate only applies when the quiz itself is premium.
  • A free user calling /next on a premium quiz now gets a 403 with error_code: subscription_required, matching /start.

No payload change. Mobile: handle 403 from /next the same way as from /start (paywall). This can surface mid-quiz if a subscription lapses.

Quiz /start now resumes in-progress attempts

Payload Change
2026-08-01

POST /api/quizzes/{id}/start previously deleted any in-progress attempt (and all of its answers) and created a fresh one on every call, permanently wiping the user's progress. It now resumes instead.

  • When an in-progress attempt exists for the same quiz and relationship_id context, /start returns it with progress intact and current_question set to the next unanswered question.
  • New optional boolean body parameter restart: send {"restart": true} to explicitly discard the in-progress attempt and start over (the old behavior).
  • Status code is now 200 when resuming and 201 when a new attempt is created (previously always 201).
  • Completed attempts are unaffected — starting again after completion still preserves the old attempt and creates a new one.

Payload change. Mobile: accept both 200 and 201 from /start (the body shape is unchanged), and wire any "start over" action to restart: true. Calling /start to render the quiz screen is now safe for users mid-quiz.

Compare quiz endpoint fixes

2026-07-30

GET /api/quizzes/{id}/results?partner_user_id=… was 500-ing in two situations. Now returns 200 with a valid payload.

  • Malformed Claude compatibility/solo responses are now normalized instead of throwing a TypeError.
  • Users with a null first_name no longer break prompt building — falls back to name.

No payload change. Mobile: no changes required. Expect fewer 500s from the compare screen.

Daily question follow-up availability tweaks

2026-07-29

Backend logic refinements to when follow-up questions are surfaced. No public contract changes.

No payload change. Mobile: no changes required.

Follow-up questions per pair + per-question limits

Payload Change
2026-07-27

DailyQuestionPairResource now returns follow-ups per branch (A/B) instead of a single follow-up per pair.

Removed fields

  • followup_question
  • followup_available
  • followup_answered

Added fields

  • followup_a — { question, available, answered } or null
  • followup_b — { question, available, answered } or null

Mobile: update the daily-question screen to read the new per-branch shape. The "followup_question" object moved inside each branch's block as followup_x.question.

Relationship tracker opens to free tier (7-day window)

Payload Change
2026-07-27

Premium gating moved from the whole feature to a per-viewer history window. Free users now get the tracker with the last 7 days visible; older entries return as locked stubs.

Added fields

  • RelationshipTrackerEntryResource.is_locked — true when the entry is outside the viewer's window.
  • SubscriptionResource.limits.relationship_tracker_history_days — number of days visible (7 for free, null = unlimited for premium).

Behavior notes

  • Locked entries keep id and date; all content fields (mood, satisfaction, note, hormonal_status, tags, attachments) are null or empty.
  • Free users cannot create or move entries dated outside their 7-day window (validation now enforces this).
  • The five insights endpoints remain premium-only.

Mobile: render locked stubs with an upsell affordance; do not attempt to read content fields when is_locked is true. Use subscription.limits.relationship_tracker_history_days for messaging like "You can see the last 7 days."

Journal entry titles are optional

Validation Change
2026-07-27

title is no longer required when creating or updating a journal entry. Entries can now be emoji + text only, or explicitly clear an existing title by sending null.

Mobile: backwards-compatible — existing "title required" flows keep working. Opportunity: allow blank titles in the editor if you want to match the new backend behavior.

Nova: tone field is a dropdown

2026-07-27

Internal admin change in Nova only.

No payload change. Mobile: no changes required.