theyasked.me

Docs

iOS and Android

Embed the board in a WKWebView or Android WebView with SSO.

TheyAsked runs inside your iOS, Android, React Native or Flutter app as a WebView pointed at the embed surface. That is what Canny and Featurebase ship too: one code path, always up to date, no SDK release when the board changes. Your app supplies the identity (an SSO token from your backend), so people never sign in twice, and every vote and post is tagged with the platform.

The embed URL

https://theyasked.me/embed/<workspace>?platform=ios&token=<sso-jwt>
Param Values
platform ios · android · web Recorded as the source of posts, votes and comments (ios / android), and on the person.
token SSO JWT Signed by your backend (SSO.md). Identified in the background; stripped from history.
theme light · dark · auto Match your app. Omit to use the workspace's setting (or the system).
accent, font, radius hex, font id, 0/4/6/10/16 Per-app theme tweaks (THEMING.md).
tab feedback · roadmap · changelog Start tab. changelog is the "What's new" view.
board board slug Default board for the feed and new posts.
post post slug Open a request directly (deep links).
list list key Show a Vote list (integrations) instead of the board: logo tiles, Vote buttons and “Request another…”. No feed header; your ✕ closes it.
hideHeader 1 Hide the embed header when your native nav bar shows the title.
close 1 / 0 Show / hide the embed's close button (emits theyasked:close).
locale e.g. ar Sets the document language.

The page is designed at 360–390 px: 44 px touch targets, safe-area insets (viewport-fit=cover), bottom-sheet composer, sticky "Ask for something" above the home indicator. Sessions are bearer tokens held in the page (memory + sessionStorage); third-party cookies are never needed.

Send the user's group

Put the user's account, family, team or class in the token as company (alias group) so requests count once per group and a shared subscription isn't counted once per member. A family app, parent and kid in the same family (the kid has no email):

{ "id": "u_54", "email": "ana@example.com", "name": "Ana", "attributes": { "role": "parent" },
  "company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 } }
{ "id": "u_55", "name": "Maya", "attributes": { "role": "kid", "ageBand": "9-12" },
  "company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 } }

More models (B2B, stores, schools, clans) and the revenue rules: SSO.md. Using an AI assistant? Point it at https://theyasked.me/llms.txt.

Recommended presentation: a modal sheet

Open the board as a modal (bottom sheet / full-screen modal), not as a pushed screen.

  • Your app owns one control: the ✕ (close) on the sheet's header, which dismisses the whole board.
  • The board owns navigation inside it: with hideHeader=1 it shows "← All requests" on request pages.
  • A pushed screen gives users two back arrows (your navigation bar's and ours) that do different things, which is confusing. A modal avoids that: ✕ always means "close feedback", ← always means "back to the list".

Recommended setup:

  • iOS: .sheet / .fullScreenCover (SwiftUI) or UISheetPresentationController with a large detent; a header with your title + ✕.
  • Android: ModalBottomSheet (Compose) or a full-screen dialog; a header with title + ✕. Map the system back to TheyAskedEmbed.back() while canGoBack is true, otherwise dismiss.
  • React Native / Flutter: a modal route or bottom sheet with a ✕ header.
  • URL: /embed/<workspace>?platform=ios|android&hideHeader=1&token=<sso>.
  • Listen for theyasked:close (sent when back is called on the list) to dismiss the sheet.

Only use a pushed screen if your app already hides its own navigation bar for it; then leave hideHeader off so the board shows its own header with Back.

The bridge

The embed posts the same JSON object to every host it can find:

Host Channel the embed calls
iOS WKWebView window.webkit.messageHandlers.theyasked.postMessage(msg) (object)
Android WebView window.TheyAskedBridge.postMessage(json) (@JavascriptInterface)
React Native window.ReactNativeWebView.postMessage(json)
Flutter webview_flutter TheyAskedBridge.postMessage(json) (JavaScript channel)
{ "source": "theyasked-embed", "type": "theyasked:post_created", "postId": "pst_…", "slug": "dark-mode", "title": "Dark mode", "pending": false }
type Payload Typical reaction
theyasked:ready workspace, platform, tabs (+ list for a Vote list) Hide your spinner
theyasked:identified id, kind, name
theyasked:token_expired reason Fetch a fresh token from your backend and push it (below)
theyasked:identify_failed reason Log it; the board keeps working anonymously if allowed
theyasked:post_created postId, slug, title, pending Analytics, a thank-you
theyasked:voted postId, voted, voteCount (+ listKey in a Vote list) Analytics
theyasked:requested listKey, postId, title, matched, pending A Vote list's “Request another…” (matched: already requested, the vote went there)
theyasked:changelog_seen at (unix ms) Clear your own "What's new" badge
theyasked:navigate tab, post Update your nav title
theyasked:close Dismiss the screen
theyasked:resize height Only useful for self-sizing containers
theyasked:open_url url (https) Open it in the system browser (below)

Commands into the page: evaluate window.TheyAskedEmbed.identify("<token>"), window.TheyAskedEmbed.logout(), window.TheyAskedEmbed.open("changelog") / window.TheyAskedEmbed.open(null, "<post-slug>") or window.TheyAskedEmbed.theme({ mode: "dark" }). (A postMessage({ source: 'theyasked-host', type: 'identify', token }) works as well, and so does { source: 'theyasked-host', type: 'theme', theme: { mode: 'dark' } }.)

Follow your app's dark mode

Load the embed with the app's current mode (theme=dark|light), then push changes while the screen is open; nothing reloads. Colours, fonts and corners come from Settings → Appearance (THEMING.md), including "Hide the embed header in mobile apps".

// SwiftUI: in your UIViewRepresentable
func updateUIView(_ web: WKWebView, context: Context) {
    let mode = context.environment.colorScheme == .dark ? "dark" : "light"
    web.evaluateJavaScript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '\(mode)' })")
}
// Compose: re-run when the system theme flips
val dark = isSystemInDarkTheme()
LaunchedEffect(dark) {
    web?.evaluateJavascript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${if (dark) "dark" else "light"}' })", null)
}
// React Native
const scheme = useColorScheme();
useEffect(() => {
  ref.current?.injectJavaScript(`window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${scheme ?? 'light'}' }); true;`);
}, [scheme]);
// Flutter: e.g. in didChangePlatformBrightness()
final dark = WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark;
_controller.runJavaScript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${dark ? 'dark' : 'light'}' })");

Payloads never contain session tokens or email addresses.

Compatibility with Loop-era apps

Apps built before the rename keep working without a release:

  • webkit.messageHandlers.loop and window.LoopBridge still receive every event, in the old shape ({ "source": "loop-embed", "type": "loop:close", … }). The new channels get the new shape.
  • React Native and the widget iframe have a single channel, so they receive both shapes, new first. Filter on source (the snippets above do).
  • window.LoopEmbed is an alias of window.TheyAskedEmbed, and commands sent with source: 'loop-host' or 'loop-widget' are accepted.

When you next ship, register the theyasked handler / TheyAskedBridge channel instead of the old one (registering both makes the events arrive twice).

External links (theyasked:open_url)

The embed shows a small "Powered by theyasked.me" badge at the bottom of each tab (mandatory, THEMING.md). With platform=ios|android and a native channel present, tapping it does not navigate the WebView: the embed posts { "source": "theyasked-embed", "type": "theyasked:open_url", "url": "https://theyasked.me/?ref=…" } (loop:open_url on the Loop-era channels). Open url in the system browser:

case "theyasked:open_url":
    if let s = msg["url"] as? String, let url = URL(string: s), url.scheme == "https" {
        UIApplication.shared.open(url)
    }
"theyasked:open_url" -> msg.optString("url").takeIf { it.startsWith("https://") }?.let {
    context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it)))
}

React Native: Linking.openURL(msg.url); Flutter: launchUrl(Uri.parse(msg['url']), mode: LaunchMode.externalApplication) (url_launcher). Without a native channel the link opens normally (target="_blank"), so apps that don't handle the event still work; adding the handler only keeps the user out of an in-app page.

Back button: list ↔ request page

With hideHeader=1 your app draws the top bar, so the embed shows an in-page "← All requests" link on request pages. To make your own back button (and Android's back / iOS swipe) behave like a browser instead of closing the screen:

  • Listen for theyasked:navigate; its payload has canGoBack: true while a request page is open.
  • When canGoBack is true, send the embed back instead of popping your screen:
// any host: postMessage
webview.postMessage(JSON.stringify({ source: 'theyasked-host', type: 'back' }));
// or evaluate JS (iOS evaluateJavaScript / Android evaluateJavascript / Flutter runJavaScript)
window.TheyAskedEmbed.back();
  • If back arrives while the list is showing, the embed emits theyasked:close, so you can pop your screen.
  • A Vote list (?list=) is always the root view: back emits theyasked:close and canGoBack is never true.

Swift:

if canGoBack { webView.evaluateJavaScript("window.TheyAskedEmbed.back()") } else { dismiss(animated: true) }

Kotlin (Compose BackHandler):

BackHandler(enabled = canGoBack) { webView.evaluateJavascript("window.TheyAskedEmbed.back()", null) }

React Native: in your onMessage, track canGoBack and call webviewRef.current?.injectJavaScript('window.TheyAskedEmbed.back(); true;') from your header/BackHandler. Flutter: in PopScope(canPop: !canGoBack, onPopInvoked: (_) => controller.runJavaScript('window.TheyAskedEmbed.back()')).

Token refresh

Mint tokens with exp ≈ 1 hour. If the screen stays open longer, or the user switches account:

  1. The embed emits theyasked:token_expired when a token is rejected as expired.
  2. Your app asks its backend for a new token.
  3. It calls window.TheyAskedEmbed.identify(newToken). The session continues; nothing reloads.

On sign-out in your app call window.TheyAskedEmbed.logout() (or just discard the WebView).

"What's new" badge outside the WebView

GET /api/v1/w/<workspace>/changelog/unread?since=<unix ms> → { "count": 2, "latest": { … } }. Store the at from theyasked:changelog_seen and pass it as since.


iOS: WKWebView + SwiftUI

import SwiftUI
import WebKit

struct TheyAskedFeedbackView: UIViewRepresentable {
    let workspace: String
    let fetchToken: () async throws -> String   // calls YOUR backend
    var tab: String = "feedback"
    var onClose: () -> Void = {}

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    func makeUIView(context: Context) -> WKWebView {
        let config = WKWebViewConfiguration()
        config.userContentController.add(context.coordinator, name: "theyasked")
        let web = WKWebView(frame: .zero, configuration: config)
        web.isOpaque = false
        web.scrollView.contentInsetAdjustmentBehavior = .never   // the page handles safe areas
        context.coordinator.web = web
        Task { await context.coordinator.load() }
        return web
    }

    func updateUIView(_ web: WKWebView, context: Context) {}

    static func dismantleUIView(_ web: WKWebView, coordinator: Coordinator) {
        web.configuration.userContentController.removeScriptMessageHandler(forName: "theyasked")
    }

    final class Coordinator: NSObject, WKScriptMessageHandler {
        let parent: TheyAskedFeedbackView
        weak var web: WKWebView?
        init(_ parent: TheyAskedFeedbackView) { self.parent = parent }

        @MainActor func load() async {
            let token = (try? await parent.fetchToken()) ?? ""
            var c = URLComponents(string: "https://theyasked.me/embed/\(parent.workspace)")!
            c.queryItems = [
                .init(name: "platform", value: "ios"),
                .init(name: "tab", value: parent.tab),
                .init(name: "close", value: "1"),
                .init(name: "token", value: token),
            ]
            web?.load(URLRequest(url: c.url!))
        }

        func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) {
            guard let msg = message.body as? [String: Any], let type = msg["type"] as? String else { return }
            switch type {
            case "theyasked:close":
                parent.onClose()
            case "theyasked:token_expired":
                Task { @MainActor in
                    guard let token = try? await parent.fetchToken() else { return }
                    let js = "window.TheyAskedEmbed && window.TheyAskedEmbed.identify(\(Self.jsString(token)))"
                    _ = try? await web?.evaluateJavaScript(js)
                }
            case "theyasked:post_created":
                print("TheyAsked: new idea", msg["title"] ?? "")
            default: break
            }
        }

        static func jsString(_ s: String) -> String {
            let data = try! JSONSerialization.data(withJSONObject: [s])
            return String(String(data: data, encoding: .utf8)!.dropFirst().dropLast())
        }
    }
}

// Usage
struct SettingsScreen: View {
    @State private var showFeedback = false
    var body: some View {
        Button("Feedback") { showFeedback = true }
            .sheet(isPresented: $showFeedback) {
                TheyAskedFeedbackView(workspace: "acme", fetchToken: Api.shared.theyaskedToken) { showFeedback = false }
                    .ignoresSafeArea()
            }
    }
}

UIKit: the coordinator above works unchanged with a plain WKWebView in a view controller.

Android: WebView + Jetpack Compose

class TheyAskedBridge(private val onMessage: (JSONObject) -> Unit) {
    @JavascriptInterface
    fun postMessage(json: String) = onMessage(JSONObject(json))
}

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun TheyAskedFeedbackScreen(
    workspace: String,
    fetchToken: suspend () -> String,   // calls YOUR backend
    onClose: () -> Unit,
) {
    val scope = rememberCoroutineScope()
    var web by remember { mutableStateOf<WebView?>(null) }
    var initialToken by remember { mutableStateOf<String?>(null) }
    LaunchedEffect(Unit) { initialToken = runCatching { fetchToken() }.getOrDefault("") }

    BackHandler(enabled = web?.canGoBack() == true) { web?.goBack() }   // in-embed navigation

    val token = initialToken ?: return
    AndroidView(
        modifier = Modifier.fillMaxSize().systemBarsPadding(),
        factory = { ctx ->
            WebView(ctx).apply {
                settings.javaScriptEnabled = true
                settings.domStorageEnabled = true   // sessionStorage for the TheyAsked session
                addJavascriptInterface(TheyAskedBridge { msg ->
                    post {
                        when (msg.optString("type")) {
                            "theyasked:close" -> onClose()
                            "theyasked:token_expired" -> scope.launch {
                                val fresh = fetchToken()
                                evaluateJavascript("window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${JSONObject.quote(fresh)})", null)
                            }
                        }
                    }
                }, "TheyAskedBridge")
                val url = Uri.parse("https://theyasked.me/embed/$workspace").buildUpon()
                    .appendQueryParameter("platform", "android")
                    .appendQueryParameter("close", "1")
                    .appendQueryParameter("token", token)
                    .build().toString()
                loadUrl(url)
                web = this
            }
        },
    )
}

Only expose TheyAskedBridge to your TheyAsked origin: keep navigation inside the WebView limited to it (a WebViewClient.shouldOverrideUrlLoading that opens other hosts in the browser).

React Native: react-native-webview

import { useCallback, useRef } from 'react';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';

export function TheyAskedFeedback({ workspace, token, fetchToken, onClose }: {
  workspace: string;
  token: string;                      // from YOUR backend
  fetchToken: () => Promise<string>;
  onClose: () => void;
}) {
  const ref = useRef<WebView>(null);
  const uri = `https://theyasked.me/embed/${workspace}?platform=${Platform.OS}&close=1&token=${encodeURIComponent(token)}`;

  const onMessage = useCallback(async (e: WebViewMessageEvent) => {
    const msg = JSON.parse(e.nativeEvent.data);
    if (msg.source !== 'theyasked-embed') return;
    if (msg.type === 'theyasked:close') onClose();
    if (msg.type === 'theyasked:token_expired') {
      const fresh = await fetchToken();
      ref.current?.injectJavaScript(`window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${JSON.stringify(fresh)}); true;`);
    }
  }, [fetchToken, onClose]);

  return (
    <WebView
      ref={ref}
      source={{ uri }}
      onMessage={onMessage}
      originWhitelist={['https://theyasked.me']}
      setSupportMultipleWindows={false}
      contentInsetAdjustmentBehavior="never"
      style={{ flex: 1 }}
    />
  );
}

(import { Platform } from 'react-native'.) Expo: identical with react-native-webview from Expo.

Flutter: webview_flutter

import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class TheyAskedFeedback extends StatefulWidget {
  const TheyAskedFeedback({super.key, required this.workspace, required this.fetchToken});
  final String workspace;
  final Future<String> Function() fetchToken;   // calls YOUR backend
  @override
  State<TheyAskedFeedback> createState() => _TheyAskedFeedbackState();
}

class _TheyAskedFeedbackState extends State<TheyAskedFeedback> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel('TheyAskedBridge', onMessageReceived: (m) async {
        final msg = jsonDecode(m.message) as Map<String, dynamic>;
        switch (msg['type']) {
          case 'theyasked:close':
            if (mounted) Navigator.of(context).pop();
          case 'theyasked:token_expired':
            final fresh = await widget.fetchToken();
            await _controller.runJavaScript('window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${jsonEncode(fresh)})');
        }
      });
    _load();
  }

  Future<void> _load() async {
    final token = await widget.fetchToken();
    final uri = Uri.https('theyasked.me', '/embed/${widget.workspace}', {
      'platform': Platform.isIOS ? 'ios' : 'android',
      'close': '1',
      'token': token,
    });
    await _controller.loadRequest(uri);
  }

  @override
  Widget build(BuildContext context) => Scaffold(body: WebViewWidget(controller: _controller));
}

Testing without an app

apps/widget/demo/mobile.html is a phone-frame simulator: it signs a token in the page with the dev secret, loads the embed with platform=ios|android, logs every bridge event and has buttons for "push fresh token", "push expired token" (exercises theyasked:token_expired → refresh) and "logout".

pnpm dev                                   # TheyAsked on :5173 / :8787
cd apps/widget && python3 -m http.server 5199
open "http://127.0.0.1:5199/demo/mobile.html?host=http://localhost:5173"

Sign a token from the command line with npx tsx scripts/sso-token.mts --platform ios --urls 1.

Checklist

  • Tokens are signed on your server, exp ≈ 1 h, platform set.
  • theyasked:token_expired fetches a fresh token and calls TheyAskedEmbed.identify.
  • theyasked:open_url opens the URL in the system browser.
  • theyasked:close dismisses the screen; Android back navigates inside the embed first.
  • domStorageEnabled on Android; WebView restricted to your TheyAsked origin.
  • Theme passed (theme=dark) when your app is in dark mode, and TheyAskedEmbed.theme() called when it changes.