Docs
Learn how to run studies, connect Palette to your tools, recruit your own users, and verify events from Palette.
Last updated: August 14, 2026.
Studies
Palette runs three study types: user studies, microstudies, and usability tests. Every launched study appears on the Research page and follows the same lifecycle: launching starts recruitment, each response appears under the study's Responses tab as its participant completes it, and once the study finishes Palette prepares a summary from the full set of responses. Until the summary compiles, the Summary tab reads Summary is being prepared.
The Responses count reads N of M: completed responses against the participant target set at launch. Only completed, qualified responses count; screened-out sessions never do. A study's status is one of:
- Active means the study is live and collecting responses.
- Paused means recruitment is paused. The study keeps its collected responses and can resume.
- Stopped means the study was ended early. Recruitment ends and the summary is prepared from the responses collected so far.
- Completed means the study reached its target and finished collecting responses.
The summary is organized as Findings, Contradictions, and Implications. Every item cites the participants behind it, and opening a citation shows the cited participant's response, so each conclusion can be traced back to its evidence.
User study
A user study is a full-length interview or survey. Participants answer the study's questions in the Palette portal, typing or recording video for open-ended questions, and can be recruited from Palette's participant pool or from your own users. Each response lists every question with the participant's answer, and the study completes when the target number of participants have responded.
Microstudy
A microstudy is a short study of one or a few questions, served inline to visitors on your own site through the snippet. Visitors answer in place without leaving the page, so responses arrive quickly and in volume. Microstudies use the same Responses count, statuses, and summary as user studies.
Usability test
A usability test asks participants to complete tasks on your site while Palette captures how they do. On top of the shared vocabulary above, each response reports:
- Goals reached: how many of the study's tasks the participant completed, as N of M.
- Task outcome: per task, Direct success, Indirect success, Gave up, Timed out, or Incomplete. Not attempted means the participant never started the task. No interaction data recorded means the participant answered the task's questions but no interaction capture arrived; it marks a gap in the data, not a failed task.
- Task time: the time from starting a task to reaching its goal.
- Total friction: the number of friction events across the participant's attempted tasks: rage clicks, dead clicks, hesitation, and backtracking.
- SEQ: the Single Ease Question, answered after each task on a 1 to 7 scale where 7 is easiest. Average SEQ is the participant's mean across tasks; the summary also reports it per task across participants.
- Replay: when session recording captured, the response includes a replay of the participant's screen. When it did not, the response reads Replay is not available for this participant.
The usability summary reports per-task success rates, median times, and ranked friction issues ordered by severity, each citing the affected participants.
Slack
Palette connects to Slack so your team can ask research questions and launch studies without leaving the channel. Slack is available on Pro workspaces. Free workspaces can review the integration, then upgrade from Workspace settings to connect it.
- Open Workspace settings, Integrations, Slack, connect your workspace, and choose the channels Palette can read.
- Invite Palette into a channel with
/invite @Palette, then mention it or send a direct message. - Follow the first message to link your Slack identity to your Palette account.
- Ask a grounded question or ask Palette to launch a usability study in plain language. Approve or decline the study with the buttons on Palette's message before it launches.
Studies launched from Slack recruit visitors from your own installed sites at no cost. Paid studies that recruit an outside panel are reviewed and approved in the Palette web app.
Palette MCP Server
The Palette MCP server gives MCP-compatible clients like Cursor, VS Code, and Claude Code access to your workspace research. Your coding agent can search your evidence, read insights and plans, and, with the right key, update opportunities or launch a study while it works.
MCP access is available on Pro workspaces. A key's Read, Write, and Commission permissions control what it can do after the workspace has Pro access.
- Create a key in Workspace settings, Keys. Keys are personal to you and carry read access by default.
- Grant Write to update opportunities and plans, or Commission to launch and approve studies.
- Copy the key when it is shown; it is not shown again.
- Add the configuration for your client and replace
mcp_<paste-generated-key>with that key.
Cursor
Add this to your Cursor MCP configuration, usually .cursor/mcp.json in your project.
{
"mcpServers": {
"palette": {
"url": "https://api.palettelabs.ai/mcp/",
"headers": {
"Authorization": "Bearer mcp_<paste-generated-key>"
}
}
}
}VS Code
Add this to .vscode/mcp.json in your workspace, then start the palette server.
{
"servers": {
"palette": {
"type": "http",
"url": "https://api.palettelabs.ai/mcp/",
"headers": {
"Authorization": "Bearer mcp_<paste-generated-key>"
}
}
}
}Claude Code
Add this to .mcp.json in your project root, or run claude mcp add.
{
"mcpServers": {
"palette": {
"type": "http",
"url": "https://api.palettelabs.ai/mcp/",
"headers": {
"Authorization": "Bearer mcp_<paste-generated-key>"
}
}
}
}Snippet
Install the Palette snippet to recruit your own site visitors into studies and run inline microstudies.
- Copy your tag from Workspace settings, Recruit.
- Paste it before the closing body tag on every page you want to include.
- Load the page and confirm the Recruit keys table receives its first ping.
One snippet installation per workspace serves studies across all of its projects.
<script src="https://api.palettelabs.ai/hosting/snippet/palette.js" data-key="pk_live_..." async></script>Test and live keys
Each workspace has a matched pair of keys, both visible in the Recruit keys table. Use the pk_live_ key on your production site: it only accepts responses from the Allowed origins you set when you mint the pair, so responses from anywhere else are blocked. Use the pk_test_ key on staging or localhost: it accepts localhost on any port, so you can verify the install locally without configuring origins first. Swap the key in the tag's data-key attribute for the environment you are on; the rest of the tag is identical.
Confirming it's live
Once the snippet loads on a page it pings Palette, and the Install column in Workspace settings, Recruit reflects what it sees for that key:
- Installed on your-site.com means a ping arrived in the last five minutes. The snippet is live.
- Last seen 20m ago — check your site means the most recent ping is older than five minutes. The tag may have been removed, or that page just has not been visited lately.
- Waiting for first ping… means the key has never been seen. Confirm the tag is on a page that has loaded in a browser since you added it.
- Seen on your-site.com — not in this key's allowed origins; responses are blocked means the snippet loaded, but the page's origin is not in the live key's Allowed origins, so responses will not be accepted. Add the origin to the key to clear it.
Segmenting invites with identify()
Once installed, the snippet can already target invites by page and by sample rate. To show an invite only to certain visitors — say, users on your Pro plan, or admins — tell Palette who the current visitor is by calling identify(). The snippet has no other way to know a visitor's plan, role, or account age: that information lives in your app, so you pass it in.
identify() is a queued command. Define the queue and push your call before the snippet tag; because the tag is async, the queue holds your call until the snippet boots and replays it. The first argument is your own id for the logged-in visitor — render it per user, never a hardcoded string — and the second is a flat object of the traits you want to segment on:
<script>
window.Palette = window.Palette || [];
// Pass your own id for the logged-in visitor and the traits to segment on.
// Render it per user - don't hardcode one id.
window.Palette.push(["identify", currentUser.id, { plan: currentUser.plan, role: currentUser.role }]);
</script>
<script src="https://api.palettelabs.ai/hosting/snippet/palette.js" data-key="pk_live_..." async></script>Then author the match in the study's Recruit card, under Segment: add a condition like plan is pro. The snippet evaluates that rule in the visitor's browser against the traits you passed, and shows the invite only when it matches.
Traits are deliberately simple:
- Up to 50 traits per visitor, with scalar values only — text, numbers,
true/false, ornull. Objects and arrays are ignored, and extra traits are dropped. Trait names may be up to 64 characters, values up to 512. - Everything compares as text, case-sensitively:
1.0reads as"1",trueas"true", andnullas"null"— soseatsis5matches a trait passed as the number 5. - A visitor is shown the invite only when every trait the rule references is present and matches — a visitor missing a referenced trait never sees the invite. Traits last for a single page load, so call
identify()again after navigation or login. If an invite is not showing, confirm your call ran on that page and carried the trait name your rule uses. - Trait names beginning with
behavior.are reserved for metrics Palette collects on its own (page views, scroll depth) — you can segment on those without passing anything.
Keep secrets out of traits. Your Segment rule ships to every visitor as part of the snippet's public configuration and is readable by anyone who inspects your site. Segment on coarse, non-secret attributes — plan tier, role, signup cohort — and never put a token, email, or anything sensitive in a trait name or value.
Verifying identified users
Unsigned identify() is enough for targeting and best-effort deduplication. Sign it when Palette must trust that the ID belongs to a user authenticated by your server — for example for rewards or a durable user panel. In Workspace settings, Recruit, generate an identity signing secret and copy it into a server-only PALETTE_RECRUIT_SECRET environment variable. Do not expose it through browser JavaScript or a public environment-variable prefix.
Sign on your server
Sign the exact user ID you will send. The expiry is Unix seconds and must be no more than 15 minutes ahead; ten minutes is recommended.
import { createHmac } from "node:crypto";
function signPaletteIdentity(userId) {
const normalized = String(userId).normalize("NFC");
const expiry = Math.floor(Date.now() / 1000) + 600;
const signature = createHmac("sha256", process.env.PALETTE_RECRUIT_SECRET)
.update(`${normalized}.${expiry}`, "utf8")
.digest("hex");
return { userId: normalized, signature, expiry };
}Pass the result to the snippet
Return only the user ID, signature, and expiry to the authenticated page, then supply the signature options as the fourth argument.
window.Palette = window.Palette || [];
window.Palette.push([
"identify",
signedIdentity.userId,
{ plan: currentUser.plan, role: currentUser.role },
{ signature: signedIdentity.signature, expiry: signedIdentity.expiry },
]);Reset on logout or account change
Palette cannot observe your app's authentication state. Call reset after logout succeeds and before identifying another user. The next login should call signed identify again. Reset clears the current browser identity, traits, and signed claim; it does not delete earlier responses.
// Run after logout succeeds and before identifying another user.
window.Palette = window.Palette || [];
window.Palette.push(["reset"]);- The signature is lowercase-hex HMAC-SHA256 over
NFC(userId).expiry. - Generate a fresh signature per page load or identify call. Palette allows 60 seconds of clock skew.
- A signature proves the user ID, not browser-supplied trait values. Do not authorize sensitive actions from traits.
- During rotation, both the new and previous secret work. Retire the previous secret after your deployment has completed.
Webhooks
Palette sends signed JSON events to one endpoint you control, configured in Workspace settings, Recruit, under Outbound event webhook. You supply both the HTTPS endpoint URL and the signing secret (generate it yourself, for example openssl rand -base64 32). The secret is stored encrypted, never shown again, and every delivery is signed with it so your server can prove the event came from Palette. The URL must be public HTTPS; redirects are never followed.
This webhook secret is separate from the identity signing secret: webhook signing proves events sent by Palette, while identity signing proves user IDs sent by your server.
Events
participant.reward_earned: sent when a verified participant recruited from your site completes a reward-bearing study and the reward is granted, either automatically or after you approve it in the reward queue. Grant the credit in your own system when you receive it. reward.amount is a decimal string; for type: credit the denomination is yours to interpret (your credits, not dollars). user_id is the userId your site passed to the snippet.
{
"event": "participant.reward_earned",
"test": false,
"study_id": "3ba16efb-304b-4070-a782-ad13eca4fc6d",
"user_id": "user-4821",
"reward": { "type": "credit", "amount": "5.00", "currency": "USD" },
"verification": { "completed": true, "quality_passed": true },
"completed_at": "2026-07-28T04:18:30+00:00",
"idempotency_key": "3ba16efb-304b-4070-a782-ad13eca4fc6d:user-4821"
}panel.invite: sent when you launch a recontact campaign to your standing panel. Palette never contacts your users directly: deliver invite_copy through your own channel (email, in-app), link the user to return_url (it carries the invite token), and include unsubscribe_url so they can opt out of future invites.
{
"event": "panel.invite",
"test": false,
"campaign_id": "6f2f9f3e-8d1e-4f6a-9d5b-a1c2d3e4f5a6",
"delivery_id": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
"study_id": "3ba16efb-304b-4070-a782-ad13eca4fc6d",
"user_id": "user-4821",
"audience": "Pro users",
"invite_copy": "We shipped the flow you asked about - 5 minutes to try it?",
"return_url": "https://app.example.com/research?palette_panel_invite=<token>",
"unsubscribe_url": "https://api.palettelabs.ai/api/recruit/panel/opt-out/?token=<token>",
"idempotency_key": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
}New event types and new fields on existing events may be added over time. Ignore events and fields you do not recognize instead of failing on them.
Verifying signatures
Every request carries one header:
X-Palette-Signature: t=1753673910,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt is the Unix timestamp of the send; v1 is lowercase-hex HMAC-SHA256 of {t}.{raw_body} keyed with your signing secret. To verify: read the raw request bytes, check t is within 5 minutes of your clock, compute the HMAC over {t}. plus those exact bytes, and compare to v1 with a constant-time comparison. The timestamp is inside the signed content, so one check covers both integrity and freshness. The most common mistake is hashing re-serialized JSON instead of the raw request body; key order and whitespace differ and verification fails.
const crypto = require("node:crypto");
// rawBody MUST be the exact request bytes (e.g. express.raw()).
// Re-serializing parsed JSON produces different bytes and fails verification.
function verifyPaletteSignature(secret, header, rawBody) {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split(/=(.*)/s).slice(0, 2)),
);
const timestamp = Number.parseInt(parts.t, 10);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // ±5 min
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
# raw_body MUST be the exact request bytes (request.body / request.get_data()).
# Re-serializing parsed JSON produces different bytes and fails verification.
def verify_palette_signature(secret: str, header: str, raw_body: bytes) -> bool:
pairs = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(pairs["t"])
if abs(time.time() - timestamp) > 300: # ±5 min
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, pairs["v1"])Responding, retries, and duplicates
Return any 2xx status to acknowledge; the response body is ignored. On 5xx, 408, 429, timeouts, or connection errors Palette retries up to 8 attempts over roughly a day, with exponential backoff (1m, 4m, 16m, 64m, ~4h, then 8h between attempts) and honoring a Retry-After header. Any other 4xx is treated as permanent and not retried.
Delivery is at-least-once: the same event can arrive twice, so deduplicate on idempotency_key (for rewards it is {study_id}:{user_id}, one reward per participant per study; for invites it is the delivery id). Process each key once, and return 2xx for keys you have already processed.
The Test webhook button in Workspace settings sends a real signed delivery with test: true. Verify it and return 2xx like any other event, but do not grant credits or send invites for test events.