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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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 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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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 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.
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.
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 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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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).
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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."
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.