CatalyzeUp
sondely/migration

API Route Map

Every route the Python backend serves today, what it becomes in the TypeScript app, and what an agent needs from it. Read from the code on 5 November 2026: sondely/backend/app/main.py and every backend/app/api/*/routes.py. The design side follows TourCockpit's framework as it stands at commit f952d0eea (src/lib/api/openapi.ts, src/lib/api/errors.ts, src/lib/api/auth.ts, src/lib/assistant/refusal-legibility.ts). Sign-in itself is on Sign-in, Sessions and API Keys; the tables behind the routes are on Data Model.

Count

Router File Mounted at Routes
auth backend/app/api/auth/routes.py /api/v1/auth 17
geo backend/app/api/geo/routes.py /api/v1/geo 1
languages backend/app/api/languages/routes.py /api/v1/languages 1
users backend/app/api/users/routes.py /api/v1 1
surveys backend/app/api/surveys/routes.py /api/v1/surveys 21
organizations backend/app/api/organizations/routes.py /api/v1/organizations 15
sessions backend/app/api/sessions/routes.py /api/v1 9
dashboard backend/app/api/dashboard/routes.py /api/v1/dashboard 1
invitations backend/app/api/invitations/routes.py /api/v1/invitations 2
assignments backend/app/api/assignments/routes.py /api/v1 8
templates backend/app/api/templates/routes.py /api/v1/templates 3
impact backend/app/api/impact/routes.py /api/v1/surveys 3
notifications backend/app/api/notifications/routes.py /api/v1/notifications 5
api_keys backend/app/api/api_keys/routes.py /api/v1/organizations 3
app level backend/app/main.py / 5
Total 95

95 route decorators. Two of the app-level ones are exclusive (the SPA catch-all when a built frontend is present, the JSON root when not), so 94 answer at once. FastAPI also serves /api/v1/openapi.json, /docs and /redoc, which are generated, not written.

How to read the tables

Each router has two tables keyed by a route number: access (who may call it today and in the new design) and contract (request, response, writes, dry run, idempotency, errors).

Who may call it today names the FastAPI dependency and the role check:

Today Meaning
none No dependency. Anyone on the internet
optional get_current_user: a bearer is read if present, never required
signed in get_current_active_user: any valid API key, JWT or Redis session token (backend/app/utils/auth.py)
member of org get_org_member_or_deny (backend/app/utils/permissions.py): any role, respondents included
OWNER/ADMIN, OWNER require_org_role with that list
creator or ... get_survey_with_org_access: the survey's creator passes without any role check, even if no longer a member; everyone else goes through the role check

Three things hold for every signed-in route today and are not repeated per row: a missing or bad credential is 401 "Not authenticated"; an API key over 60 requests a minute is 429 "Rate limit exceeded" (backend/app/middleware/rate_limit.py, in memory, per replica); an API key acts as the user who created it, and the organization on the key is never checked.

Registry Rbac is the value the route gets in Sondely's copy of RouteDef.rbac. TourCockpit's type is "any" | "office" | "manager" | "owner" | "operator"; Sondely's becomes:

Rbac Guard (new, in src/lib/api/auth.ts) Who passes
public none Anyone. New value: TourCockpit has no public v1 routes, Sondely's respondent flow needs them
self withApiSelf Any signed-in person, acting on their own records only. Session only, never an API key
grant withApiGrant A respondent grant for this survey, or a signed-in person entitled to this survey
member withApiMember Any member of the organization that owns the resource, respondent included
admin withApiAdmin Owner or admin of that organization
owner withApiOwner Owner only
test withTestOnly Only when ENVIRONMENT=test; 404 otherwise, the way TourCockpit's api/auth/test-login answers 404 without E2E_AUTH_SECRET

An API key carries one organization and one scope: read (GET only), draft (create and edit drafts, never publish, delete or send), full. The Rbac cell adds the lowest key scope that may call the route, or "no key" where only a person may. A key on another organization's resource gets 404 NOT_FOUND, never 403, as TourCockpit does for cross-tenant ids.

TS path is "same" unless a reason is given: the bearer contract is kept at cutover so the Vite frontend does not change (Sign-in, Sessions and API Keys). The Next route folder was planned as server/src/app/api/v1/<path>/route.ts with one dynamic segment name per level ([surveyId], [orgId]), so the sessions and assignments routes that share /surveys/{survey_id} and /organizations/{org_id} prefixes live in the same folders as the survey and organization routes. As built on 7 November 2026 in CatalyzeUp/sondely-platform, the routes are registered in one route registry and served through src/lib/api/dispatch.ts from route files under src/app/api/v1/; the new app serves its own screens with a session cookie, while the bearer contract stays for the Vite app until the cutover.

The plan's folder rule: with one dynamic segment name per level ([surveyId], [orgId]), so the sessions and assignments routes that share /surveys/{survey_id} and /organizations/{org_id} prefixes live in the same folders as the survey and organization routes.

Dry run: ?dryRun=true runs the whole write in its transaction and rolls back (TourCockpit src/lib/api/request-transaction.ts). "yes, delivery" means the route sends something that cannot be taken back (an email, a model call) and answers its own dry run with what it would send, as TourCockpit's src/lib/api/delivery.ts does. Sign-in routes do not take dry run.

Idempotency key: Idempotency-Key header (TourCockpit src/lib/api/idempotency.ts). "yes" means a retry without it would do the thing twice. "optional" means the write is naturally idempotent but accepts a key. "no, rate limit" means a send that a person triggers and that is throttled instead.

Errors: today's status and detail text as written in the handler, then the code it becomes. The new envelope is {error: {code, message, details}}; the codes and their classes are in Error codes for Sondely.

auth

File: backend/app/api/auth/routes.py.

# Method Python path TS path Handler Today Registry Rbac
A1 POST /api/v1/auth/register same register none public, no key
A2 POST /api/v1/auth/login same login none public, no key
A3 GET /api/v1/auth/verify-email same verify_email none public, no key
A4 POST /api/v1/auth/resend-verification same resend_verification signed in self
A5 POST /api/v1/auth/forgot-password same forgot_password none public, no key
A6 POST /api/v1/auth/reset-password same reset_password none public, no key
A7 POST /api/v1/auth/request-otp same request_otp none public, no key
A8 POST /api/v1/auth/verify-otp same verify_otp none public, no key
A9 GET /api/v1/auth/google same google_login none public, no key
A10 GET /api/v1/auth/google/callback same (Google's registered redirect URI) google_callback none public, no key
A11 GET /api/v1/auth/apple same apple_login none public, no key
A12 POST /api/v1/auth/apple/callback same (Apple's registered redirect URI) apple_callback none public, no key
A13 POST /api/v1/auth/auto-enroll same path, new meaning: mints a respondent grant auto_enroll none: returns a full token for any existing account public, no key
A14 POST /api/v1/auth/upgrade same upgrade_account none: sets a password from an email alone grant (the grant names the user), no key
A15 POST /api/v1/auth/logout same logout signed in self
A16 GET /api/v1/auth/me same get_current_user_info signed in self
A17 GET /api/v1/auth/session same check_session signed in self
# Request Response Writes Dry run Idempotency Errors today, then code
A1 UserCreate: email, password (min 6), full_name, locale AuthTokenResponse: message, user, access_token, session_token, token_type yes: user, verification token, tenant membership by Host, session no no: the unique email makes a retry a 409 400 "Email already registered" to EMAIL_TAKEN; 422 Pydantic to VALIDATION_FAILED; new PASSWORD_TOO_WEAK, RATE_LIMITED
A2 UserLogin: email, password AuthTokenResponse yes: session no no 401 "Invalid email or password" to INVALID_CREDENTIALS; new RATE_LIMITED
A3 query token 307 to FRONTEND_URL/auth/signin?verified=true yes, on a GET no no 400 "Invalid or expired verification token" to TOKEN_INVALID; 400 "Verification token expired" to TOKEN_EXPIRED
A4 none MessageResponse yes: token, email no no, rate limit 401; new RATE_LIMITED
A5 {email} MessageResponse, same text whether the email exists or not yes: reset token, email no no, rate limit none today; new RATE_LIMITED
A6 {token, password} MessageResponse yes: password, token used no no 400 "Password must be at least 6 characters" to PASSWORD_TOO_WEAK; 400 "Invalid or expired reset token" to TOKEN_INVALID; 400 "Reset token expired" to TOKEN_EXPIRED
A7 {email} MessageResponse yes: code, email no no, rate limit 429 "Too many codes requested. Please try again later." to RATE_LIMITED
A8 {email, code, full_name?} AuthTokenResponse yes: user created or promoted, membership by Host, session no no 400 "Invalid or expired code", "Code expired" to OTP_EXPIRED; 400 "Invalid code" to OTP_INVALID
A9 query return_to 307 to Google yes: OAuth state no no 500 "Google OAuth not configured" to NOT_CONFIGURED
A10 query code, state 307 to {origin}/auth/google/callback?access_token=...&session_token=... yes: user, link, membership, session no no 400 "Invalid or expired OAuth state" to OAUTH_STATE_INVALID; 400 "Failed to exchange authorization code", "Failed to get user info from Google" to OAUTH_PROVIDER_FAILED
A11 query return_to 307 to Apple yes: OAuth state no no 500 "Apple Sign In not configured" to NOT_CONFIGURED
A12 form code, state, user 303 to {origin}/auth/apple/callback?access_token=...&session_token=... yes no no 400 "Missing Apple authorization code" to VALIDATION_FAILED; 400 state to OAUTH_STATE_INVALID; 400 "Failed to exchange Apple authorization code", "Apple did not return an id_token" to OAUTH_PROVIDER_FAILED; 400 "Apple did not provide an email" to OAUTH_EMAIL_MISSING
A13 {email, survey_id, organization_id}; survey_id is never read AutoEnrollResponse: user_id, token (4 hours, scope: respondent never checked), is_new yes: email-only user, RESPONDENT membership no optional: the new design returns the live grant on a retry 400 "Organization not found" to NOT_FOUND; new SIGN_IN_REQUIRED for an existing account, SURVEY_NOT_OPEN
A14 {email, password, full_name?}; email from the body AuthTokenResponse yes: password set no no 400 password length to PASSWORD_TOO_WEAK; 404 "User not found" (an enumeration oracle) to GRANT_OUT_OF_SCOPE; 400 "Account is already a full account" to ACCOUNT_ALREADY_FULL
A15 none MessageResponse no today (nothing is revoked); yes after: the session row is deleted no optional 401
A16 none UserMeResponse, including roles and is_admin no no no 401
A17 none SessionCheckResponse: valid, user_id, email no no no 401

geo, languages, users

Files: backend/app/api/geo/routes.py, backend/app/api/languages/routes.py, backend/app/api/users/routes.py.

# Method Python path TS path Handler Today Registry Rbac
G1 GET /api/v1/geo/detect same detect_geo none public
L1 GET /api/v1/languages same list_languages none public
U1 PATCH /api/v1/users/me same update_me signed in self
# Request Response Writes Dry run Idempotency Errors today, then code
G1 none; reads x-forwarded-for, cf-ipcountry raw dict: country_code, region, source no no no none; every lookup failure answers source: none
L1 none raw dict from services/languages.registry() no no no none
U1 UserLocaleUpdate: locale raw dict: locale yes yes optional 422 "Locale 'x' is not an enabled language" to LOCALE_NOT_ENABLED

surveys

File: backend/app/api/surveys/routes.py.

# Method Python path TS path Handler Today Registry Rbac
S1 POST /api/v1/surveys same create_survey OWNER/ADMIN of organization_id in the body admin, key draft
S2 GET /api/v1/surveys same get_surveys signed in; returns surveys the caller created plus all surveys of orgs where they are OWNER/ADMIN self across the caller's orgs; a key reads its own org, key read
S3 GET /api/v1/surveys/{survey_id} same get_survey creator, or member of org (respondents see drafts too) member, key read
S4 GET /api/v1/surveys/public/{public_id} same get_public_survey none public
S5 GET /api/v1/surveys/{survey_id}/preview same preview_survey creator, or OWNER/ADMIN admin, key read
S6 PUT /api/v1/surveys/{survey_id} same; publishing runs the validator update_survey creator, or OWNER/ADMIN admin, key draft; setting status: PUBLISHED needs key full
S7 DELETE /api/v1/surveys/{survey_id} same, becomes a soft delete delete_survey creator, or OWNER/ADMIN admin, key full
S8 POST /api/v1/surveys/{survey_id}/questions same create_question creator, or OWNER/ADMIN admin, key draft
S9 DELETE /api/v1/surveys/{survey_id}/questions/{question_id} same delete_question creator, or OWNER/ADMIN admin, key draft
S10 POST /api/v1/surveys/{survey_id}/respond same; accepts the id or the public id, as today submit_survey_response optional public or grant
S11 POST /api/v1/surveys/responses/{response_id}/claim same claim_response signed in; any unclaimed response, by id self, plus the claim token returned at submit
S12 GET /api/v1/surveys/{survey_id}/my-responses same get_my_survey_responses signed in; matched by user id or by email self or grant
S13 DELETE /api/v1/surveys/{survey_id}/my-responses/{response_id} same delete_my_survey_response signed in; owner by user id or email self
S14 GET /api/v1/surveys/{survey_id}/responses same get_survey_responses creator, or member (RESPONDENT sees own) member, key read
S15 GET /api/v1/surveys/{survey_id}/responses/export.csv same export_survey_responses_csv creator, or OWNER/ADMIN admin, key read
S16 DELETE /api/v1/surveys/{survey_id}/responses same, becomes a soft delete delete_all_responses creator (any role, through the creator rule), or OWNER owner, key full
S17 GET /api/v1/surveys/{survey_id}/compliance same get_survey_compliance creator, or OWNER/ADMIN admin, key read
S18 GET /api/v1/surveys/{survey_id}/respondent/{email}/history same get_respondent_history creator, or OWNER/ADMIN admin, key read
S19 POST /api/v1/surveys/{survey_id}/translate same translate_survey_endpoint creator, or OWNER/ADMIN admin, key draft
S20 PATCH /api/v1/surveys/{survey_id}/translations/{lang} same edit_translation creator, or OWNER/ADMIN admin, key draft; status: approved needs key full
S21 DELETE /api/v1/surveys/{survey_id}/translations/{lang} same delete_translation creator, or OWNER/ADMIN admin, key draft
# Request Response Writes Dry run Idempotency Errors today, then code
S1 SurveyCreate: organization_id, title, description, settings, questions[], recurrence, reminder_intervals, require_email, result_type, question_groups, template_id Survey with questions yes yes yes: a retried create makes a second survey 400 "Organization not found" to NOT_FOUND; 403 role to FORBIDDEN (or NOT_FOUND when not a member); 500 "Failed to create survey"; new INVALID with reasons
S2 query organization_id, skip, limit (max 1000) List[Survey], each with its questions no no no 500 "Failed to fetch surveys"
S3 none Survey no no no 404 "Survey not found"; 403 "Not a member of this organization" to NOT_FOUND; 500 "Failed to fetch survey"
S4 query lang Survey with translated text and the org's profile_config no no no 404 "Survey not found or not published" to SURVEY_NOT_OPEN
S5 none Survey, any status no no no 404; 403 to FORBIDDEN or NOT_FOUND; 500 "Failed to fetch survey preview"
S6 SurveyUpdate: any survey field, including status and is_active Survey yes yes optional 404; 403; 500 "Failed to update survey"; new INVALID on publish, SURVEY_HAS_RESPONSES when groups or scoring change under responses
S7 none raw dict: message yes: survey, questions and every response, hard delete yes optional 404; 403; 500 "Failed to delete survey"; new SURVEY_DELETED on a second call
S8 QuestionCreate: text, type, required, order, options, scoring_config, group_id, weight, rating_labels Question yes yes yes 409 "Cannot modify questions: survey has N responses. Delete all responses first." to SURVEY_HAS_RESPONSES; new INVALID
S9 none raw dict: message yes yes optional 409 to SURVEY_HAS_RESPONSES; 404 "Question not found" to NOT_FOUND
S10 SurveyResponseCreate: respondent_email (free text), responses (unchecked dictionary), score (used when nothing scores), geo, respondent_name, context SurveyResponse with score, breakdown, group results yes: response, assignment status yes: returns the score it would store yes: a retry submits twice 404 "Survey not found or not accepting responses" to SURVEY_NOT_OPEN; 422 "Email is required to take this survey", "Location is required...", "Name is required..." to PROFILE_FIELD_REQUIRED; 409 "You can retake this survey after YYYY-MM-DD" to COOLDOWN_ACTIVE; new ANSWER_INVALID
S11 none SurveyResponse yes: user id and email overwritten yes optional 404 "Response not found"; 409 "Response already belongs to an account" to RESPONSE_ALREADY_CLAIMED
S12 query limit (max 200) List[SurveyResponse] no no no 404 "Survey not found"
S13 none raw dict: message, id yes: hard delete yes optional 404 "Survey not found", "Response not found"; 403 "Not your response" to NOT_FOUND
S14 query skip, limit List[SurveyResponse] no no no 404; 403; 500 "Failed to fetch responses"
S15 query lang text/csv attachment no no no 404; 403
S16 none raw dict: message, deleted_count yes: every response, hard delete yes: returns the count optional 404; 403; 500 "Failed to delete responses"
S17 none List[ComplianceMember] no no no 404; 403; 500 "Failed to fetch compliance data"
S18 none; email in the path List[RespondentHistoryEntry] no no no 404; 403; 500 "Failed to fetch respondent history"
S19 TranslateRequest: target_languages, context raw dict: available_languages, default_language, languages (per-language status, failures inside a 200) yes: translations, plus a model call yes, delivery: languages and question count, no model call yes: a retry pays the model twice 400 "Unsupported language: x" to LANGUAGE_UNSUPPORTED; 400 "Survey has no questions to translate" to NOTHING_TO_TRANSLATE; 500 "Translation failed: {exception text}" to TRANSLATION_FAILED
S20 TranslationEditRequest: survey (free dict), questions (free dict by question id, never checked to belong to this survey), status raw dict: message, language, status yes yes optional 404 "No translation found for language: x" to NOT_FOUND; 500 "Failed to update translation"; new INVALID
S21 none raw dict: message, language yes yes optional 400 "Cannot remove the default language" to LANGUAGE_IS_DEFAULT; 404 to NOT_FOUND

organizations

File: backend/app/api/organizations/routes.py.

# Method Python path TS path Handler Today Registry Rbac
O1 POST /api/v1/organizations same create_organization signed in, any user self, no key
O2 GET /api/v1/organizations same get_my_organizations signed in self; a key reads its own org, key read
O3 GET /api/v1/organizations/by-host same; the host is resolved in Next middleware too get_organization_by_host none public
O4 GET /api/v1/organizations/{org_id} same get_organization member of org member, key read
O5 PUT /api/v1/organizations/{org_id} same update_organization OWNER/ADMIN admin, key full
O6 PUT /api/v1/organizations/{org_id}/custom-domain same set_organization_custom_domain OWNER owner, no key
O7 PUT /api/v1/organizations/{org_id}/profile-config same set_organization_profile_config OWNER/ADMIN admin, key full
O8 DELETE /api/v1/organizations/{org_id} same, becomes a soft delete delete_organization OWNER owner, no key
O9 GET /api/v1/organizations/{org_id}/members same get_organization_members member of org: respondents read every member's email admin, key read; a respondent gets FORBIDDEN
O10 GET /api/v1/organizations/{org_id}/members/export.csv same export_organization_members_csv OWNER/ADMIN admin, key read
O11 POST /api/v1/organizations/{org_id}/members/invite same path; email and role move from the query string to a JSON body (the contract suite pins both forms until the screen moves) invite_member OWNER/ADMIN admin, key full
O12 DELETE /api/v1/organizations/{org_id}/members/{member_id} same remove_member OWNER/ADMIN; an admin may remove another admin admin for respondents, owner for admins, no key
O13 PUT /api/v1/organizations/{org_id}/members/{member_id}/role same; role moves to the body like O11 update_member_role OWNER owner, no key
O14 GET /api/v1/organizations/{org_id}/invitations same get_organization_invitations member of org: respondents read pending invitation emails admin, key read
O15 DELETE /api/v1/organizations/{org_id}/invitations/{invitation_id} same cancel_invitation OWNER/ADMIN admin, key full
# Request Response Writes Dry run Idempotency Errors today, then code
O1 OrganizationCreate: name, description, settings; website, industry, size and custom_domain are accepted and silently dropped Organization yes: org, OWNER membership yes yes 400 "Organization name already exists" to ORG_NAME_TAKEN; dropped fields become UNKNOWN_FIELD or are stored
O2 query skip, limit List[Organization] no no no none
O3 Host header OrganizationByHost: id, name, description, custom_domain no no no 404 "No tenant for host" to NOT_FOUND (the frontend reads it as "no tenant")
O4 none Organization no no no 403 "Not a member of this organization" to NOT_FOUND; 404 "Organization not found"
O5 OrganizationUpdate: name, description, website, industry, size, settings Organization yes yes optional 403 to FORBIDDEN; 404 "Organization not found"
O6 {custom_domain} or null Organization yes yes optional 400 invalid domain text to DOMAIN_INVALID; 409 "Custom domain is already claimed by another organization" to DOMAIN_TAKEN; 404
O7 ProfileConfigUpdate: profile_config by field, enabled, required Organization yes yes optional 400 "Unsupported profile field: x" to PROFILE_FIELD_UNSUPPORTED; 404
O8 none 204 yes: org, members, invitations, hard delete; surveys, responses, assignments and keys are left behind (inferred: no delete on them) yes optional 403 to FORBIDDEN; new ORG_HAS_SURVEYS
O9 none List[OrgMemberResponse]: id, user {id, email, full_name}, role, joined_at no no no 403 to NOT_FOUND
O10 query lang text/csv attachment no no no 404 "Organization not found"
O11 query email, role (default RESPONDENT) InviteMemberResponse: message, type (direct, auto_accepted, invitation), token yes: membership or invitation, invitation email; in development it creates the user with the password testing123 yes, delivery: who would be added or emailed yes: a retry sends a second email 400 "Role must be RESPONDENT or ADMIN" to ROLE_NOT_ALLOWED; 400 "User is already a member" to ALREADY_MEMBER
O12 none 204 yes yes optional 404 "Member not found"; 400 "Cannot remove organization owner" to OWNER_PROTECTED
O13 query role UpdateRoleResponse: message, new_role yes yes optional 400 role to ROLE_NOT_ALLOWED; 404 "Member not found", also answered when the role is unchanged (modified_count == 0); an OWNER can be demoted, leaving no owner, to new LAST_OWNER
O14 none List[OrgInvitationResponse]; the model requires organization_id, which the handler never returns, so any non-empty list fails response validation as a 500 (inferred from code, not run) no no no 403 to NOT_FOUND
O15 none 204 yes yes optional 404 "Invitation not found"

sessions

File: backend/app/api/sessions/routes.py. None of these routes checks that the survey is published or that the caller belongs to its organization: any signed-in token starts a session on any survey id, drafts included.

# Method Python path TS path Handler Today Registry Rbac
SE1 POST /api/v1/surveys/{survey_id}/sessions/start same start_survey_session signed in grant
SE2 GET /api/v1/sessions/{session_id} same get_session signed in, own session grant (session of that survey) or self
SE3 PUT /api/v1/sessions/{session_id}/save same save_session_progress signed in, own session grant or self
SE4 POST /api/v1/sessions/{session_id}/complete same complete_session signed in, own session grant or self
SE5 POST /api/v1/sessions/{session_id}/abandon same abandon_session signed in, own session grant or self
SE6 GET /api/v1/surveys/{survey_id}/sessions same get_survey_sessions signed in, own sessions grant or self
SE7 GET /api/v1/users/me/sessions same get_my_sessions signed in self
SE8 GET /api/v1/surveys/{survey_id}/sessions/stats same get_session_stats signed in, own sessions grant or self
SE9 GET /api/v1/surveys/{survey_id}/can-attempt same check_can_attempt signed in grant or self
# Request Response Writes Dry run Idempotency Errors today, then code
SE1 none SurveySession; an in-progress session is returned instead of a new one yes yes optional: already returns the open session 404 "Survey not found"; 400 "You can retake this survey after YYYY-MM-DD" to COOLDOWN_ACTIVE; new SURVEY_NOT_OPEN
SE2 none SurveySession no no no 404 "Session not found"
SE3 SurveySessionUpdate: responses, progress_percentage, time_spent_seconds, current_question_index, status SurveySession yes yes optional 404; 400 "Cannot save to a completed or abandoned session" to SESSION_CLOSED; the progress branch does await db.questions.find(...), which the SafeCursor in backend/app/core/database.py cannot await, so a save with answers is a 500 (inferred from code, not run); new ANSWER_INVALID
SE4 free JSON dictionary of answers SurveySession yes: session, plus a survey_responses row with _id only, no id, no score yes yes: a retry would write a second response once the closed check is per request 404; 400 "Session is already completed or abandoned" to SESSION_CLOSED
SE5 none SurveySession yes yes optional 404; 400 to SESSION_CLOSED
SE6 none List[SurveySession] no no no awaits a cursor, a 500 (inferred)
SE7 query status_filter List[SurveySession] no no no awaits a cursor, a 500 (inferred)
SE8 none SessionStats no no no awaits a cursor, a 500 (inferred)
SE9 none raw dict: can_attempt, attempts_used, min_days_between_attempts, next_eligible_date, has_active_session, active_session_id, reason no no no 404 "Survey not found"

dashboard, invitations

Files: backend/app/api/dashboard/routes.py, backend/app/api/invitations/routes.py.

# Method Python path TS path Handler Today Registry Rbac
D1 GET /api/v1/dashboard/stats same get_dashboard_stats signed in self
I1 GET /api/v1/invitations/accept same validate_invitation none; the token is the credential public
I2 POST /api/v1/invitations/accept same accept_invitation signed in, email must match the invitation self, no key
# Request Response Writes Dry run Idempotency Errors today, then code
D1 none raw dict, one of two shapes: admin (stats, recent_surveys, organizations) when the caller is OWNER/ADMIN anywhere or has no membership at all, respondent (is_respondent, my_surveys, organizations) otherwise. AdminDashboardResponse and RespondentDashboardResponse are imported but not used as the response model no no no 401
I1 query token InvitationPreviewResponse: status, invitation {email, role, org_name, org_id} no no no 404 "Invitation not found" to NOT_FOUND; 400 "Invitation has already been accepted" to INVITATION_USED; 400 "Invitation has expired" to INVITATION_EXPIRED
I2 query token InvitationAcceptResponse: message, org_id yes: membership, invitation accepted yes optional: a second accept answers "already a member" the I1 errors; 403 "This invitation was sent to a different email address" to INVITATION_EMAIL_MISMATCH

assignments

File: backend/app/api/assignments/routes.py.

# Method Python path TS path Handler Today Registry Rbac
AS1 POST /api/v1/organizations/{org_id}/surveys/{survey_id}/assign same assign_members OWNER/ADMIN admin, key full
AS2 DELETE /api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id} same unassign_member OWNER/ADMIN admin, key full
AS3 GET /api/v1/organizations/{org_id}/surveys/{survey_id}/assignments same get_assignments member of org (RESPONDENT sees own) member, key read
AS4 GET /api/v1/me/assignments same get_my_assignments signed in self
AS5 GET /api/v1/organizations/{org_id}/overdue same get_overdue_assignments OWNER/ADMIN admin, key read
AS6 POST /api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id}/extend same extend_due_date OWNER/ADMIN admin, key full
AS7 POST /api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id}/waive same waive_assignment OWNER/ADMIN admin, key full
AS8 POST /api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id}/remind same send_manual_reminder OWNER/ADMIN admin, key full
# Request Response Writes Dry run Idempotency Errors today, then code
AS1 SurveyAssignmentCreate: member_ids (max 500) AssignResult: message, assigned_count, skipped_count, assignments yes yes optional: duplicates are skipped 404 "Survey not found in this organization" to NOT_FOUND; non-members and duplicates are skipped silently, which becomes a per-id reason in details
AS2 none 204 yes yes optional 404 "Assignment not found"
AS3 none List[SurveyAssignmentWithMember] no no no 403 to NOT_FOUND
AS4 none List[MyAssignment] no no no none
AS5 none List[OverdueAssignment]; the model requires due_date, member_email and member_name as strings and the handler passes the raw row without due_date, so any overdue row is a 500 (inferred from code, not run) no no no 403
AS6 ExtendRequest: days 1 to 365 ExtendResponse: message, new_due_date yes yes yes: each call adds the days again 404 "Assignment not found"
AS7 none WaiveResponse: message, status yes yes yes: on a recurring survey each call pushes the next due date again 404 "Assignment not found"
AS8 none MessageResponse yes: email, reminder count yes, delivery yes: a retry sends a second email 404 "Assignment not found", "User or survey not found"; new NOT_DELIVERED

templates, impact

Files: backend/app/api/templates/routes.py, backend/app/api/impact/routes.py (mounted under /api/v1/surveys).

# Method Python path TS path Handler Today Registry Rbac
T1 GET /api/v1/templates same list_templates none public
T2 GET /api/v1/templates/{template_id} same get_template none public
T3 POST /api/v1/templates/{template_id}/create-survey same create_survey_from_template OWNER/ADMIN of organization_id in the body admin, key draft
IM1 GET /api/v1/surveys/{survey_id}/impact same get_survey_impact OWNER/ADMIN of the survey's org (no creator rule here) admin, key read
IM2 GET /api/v1/surveys/{survey_id}/export.csv same; the name sits next to /responses/export.csv and means the impact report export_impact_csv OWNER/ADMIN admin, key read
IM3 GET /api/v1/surveys/{survey_id}/report.pdf same export_impact_pdf OWNER/ADMIN admin, key read
# Request Response Writes Dry run Idempotency Errors today, then code
T1 none list[SurveyTemplateResponse] no no no none
T2 none SurveyTemplateResponse no no no 404 "Template not found" to NOT_FOUND
T3 CreateFromTemplate: organization_id, title, description raw dict: the survey document plus its questions, no response model yes yes yes 404 "Template not found"; 400 "Organization not found" to NOT_FOUND; 403
IM1 none ImpactReport no no no 404 "Survey not found"; 403; a survey without organization_id raises a KeyError, a 500 (inferred)
IM2 query lang text/csv attachment no no no as IM1
IM3 query lang application/pdf attachment no no no as IM1

notifications

File: backend/app/api/notifications/routes.py.

# Method Python path TS path Handler Today Registry Rbac
N1 GET /api/v1/notifications/preferences same get_preferences signed in self
N2 PUT /api/v1/notifications/preferences same update_preferences signed in self
N3 POST /api/v1/notifications/preferences/mute-survey/{survey_id} same mute_survey signed in self
N4 POST /api/v1/notifications/preferences/unmute-survey/{survey_id} same unmute_survey signed in self
N5 GET /api/v1/notifications/unsubscribe same GET shows the confirmation; a new POST on the same path does the unsubscribe (one-click, RFC 8058), so a mail scanner that follows the link changes nothing unsubscribe none; the token is the credential public
# Request Response Writes Dry run Idempotency Errors today, then code
N1 none raw stored document, including unsubscribe_token and user_id yes, on a GET: defaults are created on first read no no none
N2 NotificationPreferences (a local class in the routes file, full replace including muted_survey_ids) raw stored document yes yes optional 422 Pydantic to VALIDATION_FAILED
N3 none; the survey id is never checked MessageResponse yes yes optional none; new NOT_FOUND for an unknown survey
N4 none MessageResponse yes yes optional none
N5 query token MessageResponse yes, on a GET no optional 404 "Invalid unsubscribe token" to TOKEN_INVALID

api_keys

File: backend/app/api/api_keys/routes.py (mounted under /api/v1/organizations). Keys are managed only by a signed-in person, never by another key.

# Method Python path TS path Handler Today Registry Rbac
K1 POST /api/v1/organizations/{org_id}/api-keys same create_api_key OWNER/ADMIN; also any API key whose creator is OWNER/ADMIN admin, no key
K2 GET /api/v1/organizations/{org_id}/api-keys same list_api_keys OWNER/ADMIN admin, no key
K3 DELETE /api/v1/organizations/{org_id}/api-keys/{key_id} same revoke_api_key OWNER/ADMIN admin, no key
# Request Response Writes Dry run Idempotency Errors today, then code
K1 ApiKeyCreate: name (1 to 100); new: scope, expiresAt ApiKeyCreatedResponse: id, name, key_hint, created_at, last_used_at, request_count, is_revoked, full_key (once) yes yes: never mints a key yes: a retry would mint a second live key 403
K2 none List[ApiKeyResponse] no no no 403
K3 none 204 yes: revoked_at yes optional 404 "API key not found"; 400 "Key already revoked" to KEY_ALREADY_REVOKED

app level

File: backend/app/main.py.

# Method Python path TS path Handler Today Registry Rbac
M1 GET /health same path kept for the container probe (TourCockpit's is /api/health); probe config inferred, check in catalyzeup-devops health_check none outside the registry
M2 GET /api/v1/dev/emails /api/v1/test/emails list_dev_emails none; registered only when ENVIRONMENT is development or test test
M3 DELETE /api/v1/dev/emails /api/v1/test/emails delete_dev_emails as M2 test
M4 GET /{full_path:path} Next routing. As built, the new app serves every screen itself and has no Vite fallback (Screens) serve_frontend none; only when backend/static exists outside the registry
M5 GET / Next landing page root none; only when backend/static is absent outside the registry
# Request Response Writes Dry run Idempotency Errors today, then code
M1 none raw dict: status, version, environment no no no none
M2 none list of intercepted emails from services/email.get_dev_emails() no no no none
M3 none raw dict: message yes no no none
M4 path a static file or index.html no no no none
M5 none raw dict: message, version, docs no no no none

M2 and M3 move under one test prefix so a single guard and a single test cover every test-only route. The contract suite runs against both backends, so the Python side gets the same /api/v1/test/* paths (see Testing and Bugs).

Error codes for Sondely

TourCockpit keeps one class per code in src/lib/assistant/refusal-legibility.ts, typed so a new code does not compile until it is classed: ask (the person fixes it by saying something), explain (a fact of the data, with an offer), denied (their access does not reach it), retry (the data moved; read again and prepare again). Sondely keeps the envelope, the typing and the four classes and replaces the 65 tour codes with these. Status mapping follows TourCockpit src/lib/api/errors.ts: 403 strictly means wrong role, and anything outside the caller's organization is 404.

Framework, copied from TourCockpit

Code Status Class Used for
UNAUTHORIZED 401 denied No credential, or a revoked or expired one
FORBIDDEN 403 denied Signed in, wrong role in this organization
NOT_FOUND 404 explain Missing, deleted from view, or in another organization. details.resource names what
VALIDATION_FAILED 400 ask Zod shape failure; details carries the flattened issues
UNKNOWN_FIELD 400 ask A field the route does not take (src/lib/api/strict-request.ts)
INVALID 422 ask A semantic rule; details.reason is one of the reasons below
CONFLICT 409 retry A unique violation the service did not name
RATE_LIMITED 429 retry With Retry-After
IDEMPOTENCY_KEY_REUSED 422 explain The key was used for a different request or caller
NOT_DELIVERED 422 explain An email the route had to send was refused by the mail service
NOT_READY 503 retry A dependency (database, mail) is not up
TIMED_OUT 504 explain, unsure The answer was lost; whether it happened is unknown
BATCH_REF_UNRESOLVED, BATCH_ITEM_STANDALONE, BATCH_ITEM_NOT_A_WRITE 400 ask POST /api/v1/batch, copied with src/lib/api/batch.ts

Access and sign-in

Code Status Class Today's cases
INVALID_CREDENTIALS 401 ask A2
EMAIL_TAKEN 409 explain A1; offer: sign in, or sign in with a code
PASSWORD_TOO_WEAK 422 ask A1, A6, A14; rules from TourCockpit src/lib/auth/password-policy.ts
TOKEN_INVALID 400 explain A3, A6, N5; offer: request a new link
TOKEN_EXPIRED 422 explain A3, A6; offer: request a new link
OTP_INVALID 422 ask A8
OTP_EXPIRED 422 explain A8; offer: send a new code
OAUTH_STATE_INVALID 400 retry A10, A12; again: start the sign-in again
OAUTH_PROVIDER_FAILED 502 retry A10, A12
OAUTH_EMAIL_MISSING 422 explain A12; offer: sign in with an email code
NOT_CONFIGURED 503 explain A9, A11
ACCOUNT_ALREADY_FULL 409 explain A14; offer: sign in
SIGN_IN_REQUIRED 409 ask New on A13: the email has an account, so ask for the code sent to it instead of minting a grant
GRANT_OUT_OF_SCOPE 403 denied New: a respondent grant used outside its survey, or on a non-respondent route
GRANT_EXPIRED 401 explain New; offer: open the survey link again
KEY_SCOPE_TOO_NARROW 403 denied New: a read key on a write, a draft key on publish, delete or send
KEY_ALREADY_REVOKED 409 explain K3
LOCALE_NOT_ENABLED 422 ask U1

Organizations and members

Code Status Class Today's cases
ORG_NAME_TAKEN 409 ask O1; ask for another name
DOMAIN_INVALID 422 ask O6
DOMAIN_TAKEN 409 explain O6
PROFILE_FIELD_UNSUPPORTED 422 ask O7; details list the supported fields
ALREADY_MEMBER 409 explain O11
ROLE_NOT_ALLOWED 422 ask O11, O13; roles that can be given are respondent and admin
OWNER_PROTECTED 409 explain O12
LAST_OWNER 409 explain New on O13 and O12: the change would leave no owner
ORG_HAS_SURVEYS 409 explain New on O8; offer: delete or move the surveys first
INVITATION_USED 409 explain I1, I2
INVITATION_EXPIRED 422 explain I1, I2; offer: ask the organization for a new invitation
INVITATION_EMAIL_MISMATCH 403 denied I2

Surveys, questions and translations

Code Status Class Today's cases
SURVEY_NOT_OPEN 404 explain S4, S10, A13, SE1: not published, inactive or deleted
SURVEY_HAS_RESPONSES 409 explain S8, S9, S6; offer: duplicate the survey, or an owner deletes the responses
SURVEY_DELETED 409 explain New on soft-deleted surveys; offer: restore
LANGUAGE_UNSUPPORTED 422 ask S19
LANGUAGE_IS_DEFAULT 409 explain S21
NOTHING_TO_TRANSLATE 422 explain S19; offer: add questions first
TRANSLATION_FAILED 502 retry S19, replacing the 500 that today returns the exception text

INVALID reasons for the survey validator, all new (today a survey is published with no checks): NO_QUESTIONS, CHOICE_WITHOUT_OPTIONS, SCORING_TYPE_MISMATCH (scoring type does not fit the question type), OPTION_WEIGHTS_MISMATCH, GROUP_UNKNOWN (a question points to a group the survey does not have), DUPLICATE_ORDER, RECURRENCE_INTERVAL_MISSING, TRANSLATION_SHAPE (a translation that changes ids or option counts, the rule services/translation.py already enforces), QUESTION_NOT_IN_SURVEY (S20 today writes to any question id). Each is judged: body when the request alone shows it, judged: record when only the stored survey does, as in TourCockpit.

Responses, sessions and assignments

Code Status Class Today's cases
PROFILE_FIELD_REQUIRED 422 ask S10: email, name or location; details.field names it
ANSWER_INVALID 422 ask New on S10, SE3, SE4: an answer that does not fit its question type or options
COOLDOWN_ACTIVE 409 explain S10, SE1; details.nextEligibleAt, offer that date
RESPONSE_ALREADY_CLAIMED 409 explain S11
SESSION_CLOSED 409 explain SE3, SE4, SE5; offer: start a new attempt

That is 15 framework codes (three of them for batch) plus 42 Sondely codes, and 9 INVALID reasons. INTERNAL (500) stays unclassed: it is a defect, logged with the trace id, never a refusal.

Frontend call sites that depend on response shapes

From frontend/src. Two clients exist side by side: axios (src/api/client.ts, src/utils/api.ts) and plain fetch with API_ENDPOINTS (src/config/api.ts). Both send Authorization: Bearer <access_token from localStorage>. The TypeScript backend must answer these shapes unchanged until each screen moves.

Route Call sites What the screen reads
A1, A2, A8, A14 contexts/AuthContext.tsx, pages/SignInPage.tsx, components/PasswordlessSignIn.tsx, pages/SurveyTakePage.tsx access_token, session_token, user.email; both tokens go to localStorage
A10, A12 pages/GoogleCallbackPage.tsx, pages/AppleCallbackPage.tsx access_token and session_token from the redirect's query string
A13 pages/SurveyTakePage.tsx token, kept in memory through setSessionOnlyToken in api/client.ts, then used for SE1 and the session calls
A16 contexts/AuthContext.tsx, App.tsx (AdminRoute) is_admin, roles, locale; is_admin gates every admin screen
U1 components/ui/LanguagePicker.tsx locale
D1 pages/DashboardPage.tsx is_respondent decides which of the two shapes it renders; stats, recent_surveys, organizations, my_surveys
S1, S3, S6, S8, S9 pages/SurveyBuilderPage.tsx, pages/CreateSurveyPage.tsx, pages/EditSurveyPage.tsx id, questions[].id. The builder saves by updating the survey (with status: PUBLISHED when publishing) and then deleting every question and creating it again, so question ids and their translations change on every save
S2 api/organizations.ts (getSurveys), pages/ImpactReportPage.tsx a plain array
S4 pages/SurveyTakePage.tsx id, organization_id, questions[], question_groups, profile_config.geo.enabled, original_options
S5 pages/AdminTestPage.tsx the survey
S10 pages/SurveyTakePage.tsx id (kept for S11), score and group results
S11 pages/SurveyTakePage.tsx, pages/GoogleCallbackPage.tsx, pages/AppleCallbackPage.tsx status only; the response id waits in localStorage across the OAuth round trip
S12, S13 components/results/MyResultsHistory.tsx the list
S14, S15 pages/SurveyResponsesPage.tsx, pages/SurveySharePage.tsx, pages/OrganizationDetailPage.tsx the list; a CSV download
S17, S18 pages/CompliancePage.tsx member_id, status, scores; history entries
S19, S20, S21 components/TranslationManager.tsx, components/TranslationReview.tsx languages, available_languages
O1 to O15 api/organizations.ts, used by pages/OrganizationDetailPage.tsx, components/organizations/*, components/CustomDomainSection.tsx, contexts/TenantContext.tsx O3: a 404 means "no tenant"; O11 and O13 send query parameters; invitation resend is cancel plus invite, done in the client
SE1 to SE9 api/sessions.ts, used by components/sessions/SessionManager.tsx, components/sessions/SessionHistory.tsx, pages/SurveyTakePage.tsx, pages/SurveySessionPage.tsx id, active_session_id, can_attempt
AS1 to AS8 api/assignments.ts, used by components/AssignmentSection.tsx, components/OverdueSection.tsx, pages/CompliancePage.tsx, pages/DashboardPage.tsx the lists
T1, T2, T3 pages/SurveyTemplatesPage.tsx the catalog; the created survey's id
IM1, IM2, IM3 pages/ImpactReportPage.tsx the report; downloads
N1, N2, N4 pages/NotificationSettingsPage.tsx the stored document, including unsubscribe_token
K1, K2, K3 api/api-keys.ts, components/ApiKeyTable.tsx full_key once, key_hint
G1 components/ProfileStep.tsx country_code, region
I1, I2 pages/AcceptInvitationPage.tsx invitation, org_id
every error 26 reads of .detail in 14 files, among them OrganizationDetail.tsx, SessionManager.tsx, SurveyTakePage.tsx, AcceptInvitationPage.tsx, TranslationManager.tsx detail as a string. The coded envelope breaks these, which is why the error format at cutover is an open decision on the migration overview

Client code that calls routes that do not exist: surveyApi.getSurveyQuestions in utils/api.ts (GET /surveys/{id}/questions) and API_ENDPOINTS.sessions.bySurvey (/sessions/survey/{id}). Neither has a caller outside its own file; both are deleted, not ported. On every 401 both clients clear localStorage and send the browser to /auth/signin, so the 30-minute token is felt as a sign-out mid-task.

Routes that return raw dicts

These have no response model, or a model that does not match what the handler returns. Each needs a Zod response schema in the registry, which is also what lets the contract test fail when a response drifts.

Route Today Schema to write
S7, S9, S13, S16, S21 {message} or {message, id}, {message, deleted_count}, {message, language} idResponseSchema style, plus counts
S19, S20 translation result dictionaries translationRunResponse, translationEditResponse
G1, L1, U1 dicts geoDetectResponse, languageRegistryResponse, localeResponse
SE9 response_model=dict canAttemptResponse
D1 two shapes, models imported and unused a discriminated union on is_respondent
T3 the survey document plus questions the same surveyResponse as S1
N1, N2 the stored document notificationPreferencesResponse, without internal fields
M1, M2, M3, M5 dicts healthResponse; test routes get schemas too, so the contract suite can rely on them
O14 model requires organization_id, not returned (500, inferred) the schema plus the field
AS5 model requires due_date, not returned (500, inferred) the schema with nextDueDate
SE4 writes a response row with _id only not a schema problem: the TS service writes one response through the same path as S10

New routes the agent layer needs

From Sondely as an Agent, step 1, and the Agent Layer page. All go into the registry, so each becomes a write tool or a read for Faro's Sondely copy and for MCP.

Method Path Rbac Request Response Purpose
POST /api/v1/surveys/validate admin, key draft a SurveyCreate body {valid, problems: [{reason, path, message}]} Check a survey before it exists. No write
POST /api/v1/surveys/{surveyId}/validate admin, key read none as above, plus what publish would refuse Check the stored survey
POST /api/v1/surveys/{surveyId}/publish admin, key full none surveyResponse Publish as its own step, refused with INVALID reasons. S6 with status: PUBLISHED runs the same check
PATCH /api/v1/surveys/{surveyId}/questions/{questionId} admin, key draft partial QuestionUpdate (the model already exists in backend/app/models/survey.py, no route uses it) questionResponse Edit in place, so ids and translations survive. Text-only edits under responses are a decision for the Business Logic page
PUT /api/v1/surveys/{surveyId}/questions/order admin, key draft {questionIds: [...]}, the full list the ordered questions Reorder; refused with INVALID when the list misses or adds an id
PATCH /api/v1/surveys/{surveyId}/groups/{groupId} admin, key draft name, color, description groupResponse Groups become a table, so an agent addresses them
PUT /api/v1/surveys/{surveyId}/groups/order admin, key draft {groupIds: [...]} the ordered groups Reorder groups
POST /api/v1/surveys/{surveyId}/restore admin, key full none surveyResponse Undo S7, now a soft delete
POST /api/v1/surveys/{surveyId}/responses/restore owner, key full none {restoredCount} Undo S16, now a soft delete
POST /api/v1/organizations/{orgId}/restore owner, no key none organizationResponse Undo O8, now a soft delete
GET /api/v1/organizations/{orgId}/audit owner, key read query: actor, resource, from, to, cursor paginated audit rows Who or which key changed what, from TourCockpit's Audit model
POST /api/v1/batch as each item items with refs per item results Copied from TourCockpit (src/lib/api/batch.ts): create a survey and its questions in one transaction, one dry run
GET /api/v1/openapi.json public none OpenAPI 3.1 Generated from the registry, as FastAPI's is today
POST /api/v1/test/reset test {scope?} {ok} Truncate every table. Both backends, for the contract suite
POST /api/v1/test/seed test a fixture name and overrides the created ids Seed a known org, users, surveys and responses. Both backends
GET /api/v1/test/read/{model}/{id} test none the stored row Read back what a write stored, for tests that today read Mongo directly. Both backends
GET, DELETE /api/v1/test/emails test none the intercepted emails M2 and M3, moved
POST /api/v1/test/sign-in test {email} a session The e2e sign-in seam, as TourCockpit's api/auth/test-login

The fake model mode (translation) and the fake geo lookup are environment switches read when ENVIRONMENT=test, not routes. A test route answers 404 in every other environment, and a unit test asserts that for each one, so a wrong environment variable in production cannot open them.