Owlmetry

Questionnaires

Structured multi-question surveys shown in-app on launch-count, foreground-count, or time-since-install triggers — answers feed into the dashboard with per-question analytics.

Questionnaires are short, structured surveys you build once on the server and present in-app via the Swift SDK. Where feedback is a single free-text message, a questionnaire walks the user through a sequence of typed questions — rating scales, NPS, multiple choice, free text — and rolls each answer up in dashboard analytics.

How It Works

  1. Create a questionnaire via the dashboard, CLI (owlmetry questionnaires create), or MCP. Pick an immutable slug like post-onboarding. The schema is JSON: up to 30 questions, mix of text, single_choice, multi_choice, rating (1–5), nps (0–10).
  2. In your iOS app, add a SwiftUI view modifier referencing the slug — e.g. .owlQuestionnaire(slug: "post-onboarding", trigger: .afterLaunches(3)).
  3. When the trigger fires, the SDK calls GET /v1/questionnaires/:slug to fetch the latest schema and check eligibility for the current user (not already responded, not globally dismissed). If the user has an unsubmitted draft from an earlier session, the response also includes in_progress: { response_id, answers } so the SDK can resume.
  4. If eligible, the SDK presents OwlQuestionnaireView in a sheet. Each Next tap saves the current answer set as a draft (POST /v1/questionnaires/:slug/responses with is_complete: false). When the user taps Submit on the last question, the final call goes out with is_complete: true, which flips submitted_at to non-null and fires the team notification exactly once.
  5. The response (draft or submitted) shows up under /dashboard/questionnaires/<id> — drafts carry a Draft badge with an N/M answered indicator; submitted rows carry Submitted. Per-question analytics include drafts by default so abandonment shows up as a natural drop-off curve.

Progressive responses

The SDK persists answers on every Next tap, not just on Submit. This means:

  • A user who answers Q1, Q2, and quits has their two answers saved as a draft with submitted_at = null. Analytics for Q1 and Q2 reflect those answers immediately; Q3+ does not.
  • The same user's next eligible launch resumes them on Q3 with Q1 and Q2 pre-filled (consent is skipped on resume — they've already opted in).
  • Going Back and changing an earlier answer overwrites the saved value; subsequent Next saves the new value into the same row.
  • The team notification (questionnaire.response_new) fires only on the final Submit, never on partial draft saves.
  • Abandoned drafts (submitted_at IS NULL and untouched > 90 days) are soft-deleted by the daily questionnaire_draft_cleanup job — long enough to let a user resume across a long gap, short enough to bound the orphan set.

Tip: put your most important questions first. Users who quit halfway still leave you with their high-signal answers.

Question types

V1 supports five question types, all configured in the JSON schema:

TypeAnswer shapeNotes
textstringmultiline: true renders a text editor, otherwise a single-line field
single_choiceoption id2–20 options per question; each option has its own short id + label
multi_choicearray of option idsSame option shape as single_choice; renders as toggles
ratingint 1–5Scale is fixed at 5 in V1
npsint 0–10Implicit "Not at all likely / Extremely likely" scale labels

Each question can be marked required: true (Submit stays disabled until answered) or optional.

Short vs long text

text questions default to a single-line TextField. Set multiline: true to render a tall TextEditor (roughly five lines, grows as the user types) — pair it with a longer placeholder when you want a paragraph-style answer:

{
  "id": "q_takeaway",
  "type": "text",
  "title": "One-line takeaway?",
  "required": false,
  "placeholder": "Optional"
}
{
  "id": "q_details",
  "type": "text",
  "title": "Anything else you'd like to share?",
  "required": false,
  "multiline": true,
  "placeholder": "Optional — tell us as much as you'd like"
}

Both variants share the same 4000-character cap on the server.

Triggers

The .owlQuestionnaire(trigger:) modifier accepts a composable trigger. Conditions are ANDed — all must be true for the sheet to present.

// Show on third launch
.owlQuestionnaire(slug: "after-3-launches", trigger: .afterLaunches(3))

// Show after 3 launches AND at least a week since install
.owlQuestionnaire(
    slug: "weekly-checkin",
    trigger: .when(
        .launches(atLeast: 3),
        .daysSinceFirstLaunch(atLeast: 7)
    )
)

// Custom gate — show only to free users
.owlQuestionnaire(
    slug: "free-user-survey",
    trigger: .afterLaunch,
    isEligible: { !user.isPaid }
)

V1 supports four condition types: launches, foregrounds, daysSinceFirstLaunch, hoursSinceFirstLaunch. OR-logic isn't part of V1 — use the isEligible closure or split into two modifier applications with different slugs.

Dismissal

When a user taps Don't show again inside any questionnaire sheet, the SDK posts to POST /v1/questionnaires/dismiss and the server writes _questionnaires_dismissed_at to that user's app_users.properties. Once that flag is set, every future eligibility check returns eligible: false, reason: "globally_dismissed" regardless of slug. The flag survives reinstall (it's keyed on the user id on the server, not on the device).

Tapping Not now (the toolbar skip action) just dismisses the sheet without writing the flag — the next trigger evaluation can re-present.

Schema editing & snapshots

You can edit a questionnaire's schema after responses exist — rename question titles, reorder, add new questions, change option labels. Each response stores its own schema_snapshot JSONB column, so the dashboard always renders historical answers against the schema they were captured under. Removing or renaming a question id orphans those answers in analytics rollups (the question simply disappears from the chart for new responses), but the raw response data is preserved in schema_snapshot.

Slugs are immutable to keep the SDK call site stable. To replace a slug, create a new questionnaire with a new slug.

A soft-deleted questionnaire is hard-deleted by the daily soft_delete_cleanup job 7 days after deleted_at, but only once all of its responses have also been removed — the FK from questionnaire_responses is ON DELETE RESTRICT, so a retired questionnaire with surviving response history sticks around until the responses are cleaned up too.

Analytics

GET /v1/projects/:projectId/questionnaires/:id/analytics returns pre-aggregated distributions per question:

  • text — the 10 most recent answers (text isn't aggregated)
  • single_choice / multi_choice — counts per option, with total_answered so percentages render correctly
  • rating — bucket counts for each 1–5 value plus the arithmetic mean
  • nps — bucket counts for each 0–10 value, plus a detractor/passive/promoter split and the standard NPS score (% promoters − % detractors)

The dashboard renders these as horizontal bars, star bars, and an 11-column NPS histogram.

Status Lifecycle

Each response moves through the same four states as feedback — any transition is allowed:

new → in_review → addressed → dismissed

Statuses are independent of the global "Don't show again" dismissal — they're a triage signal for your team, not an SDK signal.

Comments

Each response gets a comment thread mirroring feedback comments — author-only edit, author-or-admin delete, both users and agent keys can comment.

Permissions

  • questionnaires:read — read definitions, responses, analytics
  • questionnaires:write — create/update/comment, update response status
  • Client SDK keys use events:write for ingest (the same scope as feedback)
  • Soft-deleting a questionnaire is user-only. Agent keys get 403 — by design, to prevent an agent from removing live SDK integrations

Ready to get started?

Connect your agent via MCP or CLI and start tracking.