Changelog

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

New break-up reflection pack endpoints

2026-09-04

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

  • GET /api/breakup-packs — published pack list: id, title, tag, question_count, accent_color, icon_url, sort_order. tag is the chip label shown on the pack tile.
  • GET /api/breakup-packs/{id} — the full pack: description, ordered questions with lettered options and per-option reflection_text, plus closing_reflection and closing_celebration. closing_reflection is markdown (LF line endings) and may contain {scheme}://breakup-packs/{id} deep links.
  • POST /api/breakup-pack-responses — records which option the user tapped. Idempotent upsert per (user, question): 201 on the first answer, 200 when a re-answer overwrites it. Clients must not add duplicate guards (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.

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

Payload Change
2026-09-03

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

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

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

2026-09-03

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

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

Quiz insights can be assembled from pre-authored fragments

2026-09-02

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

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

Featured content surfaces documented as optional, together_for always present

Payload Change
2026-09-01

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

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

Lesson reflection steps no longer require a title

Payload Change
2026-09-01

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

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

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

Payload Change
2026-09-01

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

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

Spec corrections: types now match what the API actually returns

2026-09-01

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

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

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

Payload Change
2026-08-31

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

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

Milestone lists now speak in occurrences

Payload Change
2026-08-31

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

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

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

Payload Change
2026-08-31

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

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

Badge fixes: two badges that could never be earned

2026-08-31

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

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

Trial expiry notifications fixed and sent at a friendly local hour

2026-08-31

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

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

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

Payload Change
2026-08-30

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

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

Milestones no longer appear on the wrong partner's calendar

Payload Change
2026-08-27

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

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

Notification email channels corrected to match the settings spec

2026-08-27

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

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

Reveal-ready notification now names the responding partner

Payload Change
2026-08-26

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

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

Deleting an in-use activity tag no longer fails

Payload Change
2026-08-25

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

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

Milestones can now recur on a schedule

Payload Change
2026-08-25

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

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

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

2026-08-17

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

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

Relationships can now carry a real "together since" date

Payload Change
2026-08-17

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

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

The Today screen's suggestions now come from the API

2026-08-16

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

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

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

The daily question now follows the user's timezone

2026-08-15

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

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

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

Notifications name their sender and carry an actor

Payload Change
2026-08-14

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

  • GET /api/notifications gains a nullable actor object — id, name, first_name, display_name, profile_picture, cutie_profile — identifying the user whose action caused the notification.
  • Titles and bodies now fall back from first_name to name, which is the name most users actually have. The same value is exposed as a new display_name field on every public user object, including poll authors and audio question askers. It is an empty string for the small number of users who have never given a name at all — the server does not substitute a stand-in phrase, since the right wording differs between a partner and a stranger.
  • Every notification with a sender now carries actor_user_id in its data payload. The existing per-event keys (partner_id, partner_user_id, initiator_id, requester_user_id, recipient_user_id, creator_user_id, initiator_user_id, actor_user_id) are all unchanged, so existing clients keep working — but actor replaces the need to probe them.
  • Value change: the existing partner_name and initiator_name keys in data carried the raw first_name, which was null for most senders. They now carry the same first_namename 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_namename fallback as everywhere else, so it prefers a first name where one is set, and is an empty string rather than null when no name exists. This makes display_name mean the same thing everywhere it appears in the API.

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

Quizzes can be retaken by editing existing answers

Payload Change
2026-08-14

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

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

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

The user's last_active_at field is now populated

2026-08-13

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

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

Device token registration is now rate-limited

2026-08-13

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

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

Partner invite codes are now case-insensitive

2026-08-10

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

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

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

Per-partner profile sharing settings are now enforced

Payload Change
2026-08-10

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

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

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

Daily question pair empty state returns 200 null instead of 404

Payload Change
2026-08-10

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

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

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

Premium gate added to quiz /next

2026-08-01

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

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

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

Quiz /start now resumes in-progress attempts

Payload Change
2026-08-01

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

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

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

Compare quiz endpoint fixes

2026-07-30

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

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

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

Daily question follow-up availability tweaks

2026-07-29

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

No payload change. Mobile: no changes required.

Follow-up questions per pair + per-question limits

Payload Change
2026-07-27

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

Removed fields

  • followup_question
  • followup_available
  • followup_answered

Added fields

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

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

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

Payload Change
2026-07-27

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

Added fields

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

Behavior notes

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

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

Journal entry titles are optional

Validation Change
2026-07-27

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

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

Nova: tone field is a dropdown

2026-07-27

Internal admin change in Nova only.

No payload change. Mobile: no changes required.