theyasked.me

Docs

SSO

Sign a JWT on your server; TheyAsked trusts it. Group users for any model: accounts, stores, families, classes. Compatible with Upvoty- and Canny-style claims.

Your users are already signed in to your product. TheyAsked should know who they are the moment they open the widget, the mobile board or the portal, with no second sign-up. You do that by signing a short token on your backend with the workspace's SSO secret and handing it to TheyAsked. TheyAsked verifies it, creates or updates the person (and their company, plan and MRR) and starts a session.

Everything here works the same for the web widget, mobile WebViews and the public portal.

your backend ──signs──▶ token ──▶ widget / WebView / portal link ──▶ POST /api/v1/w/:ws/identify
                                                                     ├─ verify HS256 signature (constant time)
                                                                     ├─ upsert end-user + company + attributes
                                                                     ├─ merge their anonymous votes/comments
                                                                     └─ session (bearer for widget/WebView, cookie for portal)

1. Get the secret

Dashboard → Settings → SSO. Copy the secret into your server's environment (THEYASKED_SSO_SECRET). It never goes to a browser or an app binary. Rotating it invalidates tokens signed with the old one.

Local development: the seeded acme workspace uses acme-dev-sso-secret.

2. Token format

An HS256 JWT ({"alg":"HS256","typ":"JWT"}), signed with the secret. Any JWT library works. A compact form without a header (base64url(payload).base64url(hmac)) is also accepted.

Claim Required Notes
id yes* Your stable user id. The same id in two of your products is the same TheyAsked person.
email yes* Recommended: enables email notifications and email matching.
name Shown on comments and to your team.
avatarURL https image URL (avatar is accepted too).
company The account / group the user belongs to (a customer account, store, family, class, clan…). Alias: group, same shape; if both are sent company wins. { id, name, plan?, mrr?, currency?, employeeCount?, attributes? }. Upserted by id (your stable id, string or number). mrr is whole currency units per month and drives revenue-at-stake. See Grouping users.
plan, mrr For users without a group. Inside a group, company.plan wins and a member's own mrr is ignored for revenue.
attributes Flat map (≤ 50 keys, keys ≤ 64 chars) of strings (≤ 2048 chars) / numbers / booleans / null for segments. role is the conventional key for roles.
platform web · ios · android (also set by the embed URL).
appVersion, locale, country Mobile and analytics context. country is ISO-3166 alpha-2.
iat Issued-at, seconds. Tokens older than the workspace's max age (default 24 h) are rejected.
exp Expiry, seconds. Recommended: 1 hour.

* id and/or email. With ssoMatchBy: 'id' (default) TheyAsked matches on id, falling back to email for email-only tokens. With ssoMatchBy: 'email' it matches on email first (pick it if your previous SSO matched people by email).

Compatible with Canny- and Upvoty-style claims. Canny tokens (companies: [{ id, name }]) and Upvoty Custom SSO tokens ({ id?, email?, name?, avatar?, extra_data? }, scalar extra_data → attributes, extra_data.company, extra_data.group, extra_data.plan and extra_data.mrr promoted) are accepted unchanged. Moving from either means swapping the secret and the URL, nothing else.

Grouping users: B2B and B2C

Most people who use your product belong to something: a customer account, a store, a family, a class, a clan. Send that as company in the token (alias group, same shape). The field is called company for every business model, B2B or B2C; it simply means "the account / group this person belongs to".

Why it matters:

  • Requests count once per group. Five people from one family asking for the same thing are one family's ask, not five unrelated people. Each request records company_count, the distinct groups among its voters ("asked by 3 families"), and Demand weighs groups more than individual votes.
  • Revenue is not double-counted. A shared subscription sent as company.mrr counts once per group per request, however many members vote.
  • Segments. The product team can filter and split insights by group, by the group's plan and attributes, and by each person's role.

Without a group every member looks like an unrelated individual: demand is over-counted and revenue cannot be rolled up. Send the group for every user who has one.

What to send as the group

Business model Group (company) Typical attributes.role
B2B SaaS the customer account / organization / workspace admin, member, viewer
Agency / consultancy tools the client manager, contributor
Marketplaces, e-commerce platforms the store / seller / merchant owner, staff
Education the school, or the class teacher, student, parent
Healthcare the clinic / practice practitioner, admin
Consumer subscriptions (family plans) the family / household parent, kid, adult
Communities, games the club / team / clan / guild captain, member
Individuals only (no shared account) omit company (optional)

Shape: { id, name, plan?, mrr?, currency?, employeeCount?, attributes? }.

  • id: your stable database id for the group (string or number). Never the display name, which can change or collide.
  • mrr: whole currency units per month for the whole group (49 means $49/month). plan is a label.
  • attributes: flat map, ≤ 50 keys (≤ 64 chars each) of strings (≤ 2048 chars), numbers, booleans or null. Insights read a few of them:
    • renewalDate (ISO date) and health (good / at_risk / poor, or a 0–100 score) drive "revenue at renewal risk".
    • status: "churned" (plus churnedAt) tracks churned revenue.
    • Anything else (members, kids, seats, industry…) is a segment fact.

If a token carries both company and group, company wins. Upvoty-style extra_data.group is promoted the same way as extra_data.company.

What your team sees: the group label

Dashboard → Settings → General → Customers sets what groups are called in your workspace: Company (default), Account, Organization, Team, Workspace, Family, Household, School, Class, Club, Clinic, Store, or a custom singular and plural (≤ 24 characters each). The dashboard navigation, the People and Companies pages, a request's "Who asked" panel ("3 families"), insights columns, segments and revenue, Pulse, insight CSV exports, MCP text and Ask answers all use that word (the full data export keeps its company columns).

Turn off Users belong to groups if your users are individuals only; group UI is then hidden.

Field names in the token, the REST API and MCP stay company, companyId, list_companies and get_company whatever the label. The public portal never shows groups.

Settings → Single sign-on shows how many people identified in the last 30 days carried a group. When that share is low, it suggests sending company.

Payload examples

Sign each payload as in section 3 (add iat / exp).

(a) B2B SaaS: seats, MRR, renewal and health

{
  "id": "u_981",
  "email": "lina@bloom.co",
  "name": "Lina",
  "company": {
    "id": "acct_42",
    "name": "Bloom Beauty",
    "plan": "Enterprise",
    "mrr": 4200,
    "currency": "USD",
    "employeeCount": 120,
    "attributes": { "seats": 40, "industry": "retail", "renewalDate": "2027-03-01", "health": "good" }
  },
  "attributes": { "role": "admin" }
}

Unlocks: requests count once per account; revenue at stake per account; "ship X before Bloom renews"; filter admins vs members; segment by industry or seats.

(b) Commerce platform: the store is the group

{
  "id": "u_3307",
  "email": "omar@sunnyside.shop",
  "name": "Omar",
  "company": {
    "id": 88120,
    "name": "Sunnyside Ceramics",
    "plan": "Advanced",
    "mrr": 299,
    "attributes": { "gmvBand": "50k-250k", "products": 340, "country": "AE", "channels": "online+pos" }
  },
  "attributes": { "role": "owner" }
}

Staff of the same store send the same company.id with "role": "staff". mrr is what the merchant pays you, not their sales.

Unlocks: requests count once per store; revenue at stake per store; owners vs staff; segment by GMV band or catalogue size ("high-GMV stores want this 3× more").

(c) Family app: parents and kids in one family

Parent:

{
  "id": "u_54",
  "email": "ana@example.com",
  "name": "Ana",
  "company": {
    "id": "fam_9",
    "name": "The Rivera family",
    "plan": "Family",
    "mrr": 12,
    "attributes": { "members": 4, "kids": 2 }
  },
  "attributes": { "role": "parent" }
}

Kid (no email, no avatar, no birth date; here the group is sent with the group alias):

{
  "id": "u_55",
  "name": "Maya",
  "group": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 },
  "attributes": { "role": "kid", "ageBand": "9-12" }
}

group and company are the same claim; if a token sent both, company would win. Use one consistently.

Unlocks: requests count once per family; revenue at stake per family ($12, not $12 per member); filter parents vs kids ("what do parents want vs kids").

(d) School: teachers and students

{
  "id": "stu_2291",
  "name": "Leo",
  "company": {
    "id": "class_7b_2026",
    "name": "Year 7B",
    "attributes": { "school": "Northfield Primary", "schoolId": "sch_14", "grade": 7, "students": 28 }
  },
  "attributes": { "role": "student" }
}

A person has one group, so pick one level:

  • Class when feedback is driven by classroom use. Put the school in the group's attributes (school, schoolId) so you can still segment by school. Don't put the school's subscription on every class (each class counts its own mrr, so the total would be multiplied); send the class's share or omit mrr.
  • School when the school is the paying customer and you care about school-level demand and revenue. Put the class in the user's attributes (class: "7B") if you need it.

Unlocks: requests count once per class (or school); teachers vs students vs parents; segment by school, grade or size.

(e) Game or community: the clan

{
  "id": "p_77120",
  "name": "NightOwl",
  "company": {
    "id": "clan_5512",
    "name": "Iron Wolves",
    "attributes": { "members": 24, "region": "EU", "tier": "gold" }
  },
  "attributes": { "role": "captain" }
}

A free product sends no mrr. If clans buy a subscription, add "plan": "Clan Pro", "mrr": 15 to the group. Players are often minors: follow the privacy rules below.

Unlocks: requests count once per clan; captains vs members; segment by region or tier; revenue per clan if clans pay.

(f) Individual consumer app: personal subscription, no group

{
  "id": "u_77",
  "email": "sam@example.com",
  "name": "Sam",
  "plan": "Premium",
  "mrr": 9,
  "attributes": { "role": "subscriber" }
}

No company: the person is their own unit. Turn off "Users belong to groups" if nobody has one.

Unlocks: revenue at stake from personal subscriptions; segment by plan (free vs premium) and by any user attribute.

Counting roles

Send roles on the user as attributes.role (parent / kid, admin / member, teacher / student, captain / member). Insights then:

  • filter by role: attr.role=parent matches the person's attribute first, then the group's;
  • split any view by role or any other attribute (segments?by=attr:role): "parents want X, kids want Y".

Roles don't change how votes or revenue are counted; they only make segments possible.

How revenue is aggregated

Per request:

  1. Each distinct group among the voters adds its company.mrr once.
  2. Voters without a group add their own mrr.
  3. Inside a group only company.mrr counts; a member's personal mrr is ignored for revenue. If members of one group pay separately, send the group's total as company.mrr.
  4. For plan segments, the group's plan wins over the user's plan.

Worked example: the Rivera family (4 members, company.mrr: 12). Ana, Maya and her brother vote for "Shared chore calendar". The request shows $12 at stake from 1 family (3 voters), not $36. If Sam (no group, mrr: 9) votes too, it becomes $21 at stake: 1 family plus 1 individual.

Privacy

  • For children send only your id, a first name or nickname, and attributes.role: "kid". No email, avatar, birth date or exact age; use an age band such as "9-12" if you need one.
  • Attributes are shown to your team. Keep them to segmentation facts: no phone numbers, addresses, health or payment data.

Using an AI assistant?

Point it at theyasked.me/llms.txt (all guides in one file: llms-full.txt). Settings → Widget, Settings → Single sign-on and the onboarding Connect step also have an Integrate with AI prompt to copy into your assistant. It includes your workspace slug, embed URL and group label, never the SSO secret: it tells the assistant to read the secret from the THEYASKED_SSO_SECRET environment variable.

3. Sign the token (server side)

Every snippet produces the same token. Keep exp short (an hour) and mint a fresh one per page load or app session.

Node.js (jsonwebtoken)

import jwt from 'jsonwebtoken';

export function theyaskedToken(user) {
  return jwt.sign(
    {
      id: String(user.id),
      email: user.email,
      name: user.name,
      avatarURL: user.avatarUrl,
      company: { id: String(user.account.id), name: user.account.name, plan: user.account.plan, mrr: user.account.mrr },
      attributes: { role: user.role },
    },
    process.env.THEYASKED_SSO_SECRET,
    { algorithm: 'HS256', expiresIn: '1h' },
  );
}

Without dependencies (Node 18+, Workers, Deno, Bun): import { signIdentityToken } from '@loop/core' or the 12 lines of WebCrypto in apps/widget/demo/index.html.

Python (PyJWT)

import os, time, jwt

def theyasked_token(user):
    now = int(time.time())
    return jwt.encode(
        {
            "id": str(user.id),
            "email": user.email,
            "name": user.name,
            "company": {"id": str(user.account.id), "name": user.account.name,
                        "plan": user.account.plan, "mrr": user.account.mrr},
            "iat": now,
            "exp": now + 3600,
        },
        os.environ["THEYASKED_SSO_SECRET"],
        algorithm="HS256",
    )

Ruby (jwt gem)

require "jwt"

def theyasked_token(user)
  now = Time.now.to_i
  JWT.encode(
    {
      id: user.id.to_s, email: user.email, name: user.name,
      company: { id: user.account.id.to_s, name: user.account.name, plan: user.account.plan, mrr: user.account.mrr },
      iat: now, exp: now + 3600
    },
    ENV.fetch("THEYASKED_SSO_SECRET"),
    "HS256"
  )
end

PHP (firebase/php-jwt)

use Firebase\JWT\JWT;

function theyasked_token(array $user): string {
    $now = time();
    return JWT::encode([
        'id' => (string) $user['id'],
        'email' => $user['email'],
        'name' => $user['name'],
        'company' => ['id' => (string) $user['account_id'], 'name' => $user['account_name'],
                      'plan' => $user['plan'], 'mrr' => $user['mrr']],
        'iat' => $now,
        'exp' => $now + 3600,
    ], getenv('THEYASKED_SSO_SECRET'), 'HS256');
}

Go (github.com/golang-jwt/jwt/v5)

import (
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

func TheyAskedToken(u User) (string, error) {
	now := time.Now()
	claims := jwt.MapClaims{
		"id":    u.ID,
		"email": u.Email,
		"name":  u.Name,
		"company": map[string]any{
			"id": u.Account.ID, "name": u.Account.Name, "plan": u.Account.Plan, "mrr": u.Account.MRR,
		},
		"iat": now.Unix(),
		"exp": now.Add(time.Hour).Unix(),
	}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(os.Getenv("THEYASKED_SSO_SECRET")))
}

C# / .NET (System.IdentityModel.Tokens.Jwt)

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;

public static string TheyAskedToken(User user)
{
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("THEYASKED_SSO_SECRET")!));
    var company = JsonSerializer.Serialize(new { id = user.AccountId, name = user.AccountName, plan = user.Plan, mrr = user.Mrr });
    var token = new JwtSecurityToken(
        claims: new[]
        {
            new Claim("id", user.Id),
            new Claim("email", user.Email),
            new Claim("name", user.Name),
            new Claim("company", company, JsonClaimValueTypes.Json),
        },
        notBefore: DateTime.UtcNow,
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
    // JwtSecurityToken adds iat via the handler when SetDefaultTimesOnTokenCreation is on (default).
    return new JwtSecurityTokenHandler().WriteToken(token);
}

Java (io.jsonwebtoken:jjwt 0.12)

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.Map;

public static String theyaskedToken(User user) {
    var key = Keys.hmacShaKeyFor(System.getenv("THEYASKED_SSO_SECRET").getBytes(StandardCharsets.UTF_8));
    var now = Instant.now();
    return Jwts.builder()
        .claim("id", user.getId())
        .claim("email", user.getEmail())
        .claim("name", user.getName())
        .claim("company", Map.of("id", user.getAccountId(), "name", user.getAccountName(),
                                 "plan", user.getPlan(), "mrr", user.getMrr()))
        .issuedAt(Date.from(now))
        .expiration(Date.from(now.plusSeconds(3600)))
        .signWith(key, Jwts.SIG.HS256)
        .compact();
}

jjwt rejects HMAC keys shorter than 256 bits. TheyAsked secrets are 43-character random strings, so this only matters for hand-made test secrets.

4. Hand the token to TheyAsked

Surface How
Web widget TheyAsked('identify', { token }) or TheyAsked('init', { workspace, token }). Re-call it after your user switches account; TheyAsked('logout') on sign-out. See WIDGET.md.
Mobile WebView Load /embed/:ws?token=…&platform=ios. Refresh by injecting window.TheyAskedEmbed.identify(newToken). See MOBILE.md.
Portal link https://theyasked.me/acme?token=… (e.g. a "Feedback" link in your app menu). The portal identifies, sets its own cookie, and strips the token from the URL.
Server to server POST /api/v1/w/:ws/identify with { "token" } returns { sessionToken, identity }.

Compatibility with Loop-era integrations. Tokens are unchanged by the rename: a backend that signs with the old LOOP_SSO_SECRET variable keeps working (only the secret value matters), and Loop('identify', { token }) and window.LoopEmbed.identify(token) remain aliases of the calls above.

Identify never blocks. The board renders straight away; identify runs in the background and any vote or comment made meanwhile waits for it, so it is attributed to the identified person. If the visitor had already voted anonymously, those votes, comments, posts and follows are merged into the identified profile (duplicates dropped) and the anonymous record is deleted.

5. Errors

401 with error.details.reason:

reason Meaning Fix
bad_signature Signed with another secret Check THEYASKED_SSO_SECRET and the workspace slug
bad_algorithm Not HS256 (alg: none, RS256) Use HS256
malformed Not a JWT / bad base64 Pass the raw token string
invalid_claims Neither id nor email, or a claim has the wrong type See the claim table
expired Past exp Mint a fresh token (the embed emits theyasked:token_expired)
too_old iat older than the max age (default 24 h) Mint tokens per session, not at deploy time
not_yet_valid iat more than 5 minutes in the future Fix server clock

Blocked end-users get 403.

6. Security model

  • The secret is per workspace, server-only and rotatable. TheyAsked stores sessions as SHA-256 hashes.
  • Signatures are compared in constant time; alg is pinned to HS256.
  • Freshness: exp plus the workspace's max-age on iat limit replay of leaked tokens.
  • Widget/WebView sessions are bearer tokens kept in memory and sessionStorage inside the TheyAsked frame, never third-party cookies. The widget hands the SSO token to the frame with postMessage targeted at the TheyAsked origin, so it never appears in a URL, a server log or a Referer.
  • Bridge events sent to the host page never contain tokens or email addresses.

7. Fallbacks for people who aren't signed in to your product

  • Anonymous: allowed per workspace (allowAnonymous, anonymousVoting). Anonymous activity merges into the person's profile when they later identify or use a magic link.
  • Magic link: the portal's "Sign in" emails a one-time link (20 minutes). The resulting account is matched to an SSO-identified person with the same email.