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
(deliberate divergence from poll votes, which reject duplicates).
-
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.