Build your own capture UI

The embed widget is a thin wrapper around one public HTTP endpoint. That endpoint is documented, unauthenticated, and open to any origin — so if the widget's corner box is not what you want, skip it and post to the endpoint from your own component.

You keep your theme, your placement, and your trigger. You give up nothing but the ten minutes of markup.

When to build your own

Reach for this when any of these is true:

  • the capture box has to match your theme, including dark mode;
  • it has to live somewhere specific — inside a settings dialog, at the end of an empty state, in a sidebar;
  • it has to be triggered by your app: a button you own, an error boundary, the third failed search, a shake gesture;
  • you want to prefill the body with context your app already has, like the screen the person was on.

Convo's own maker app and Can's sidebar Feedback button both take this path. What you cannot get this way is identity — see below.

The contract

POST https://api.convo.randomfact.com/widget/{workspace}/{KEY}/feedback
Content-Type: application/json

{workspace} is your workspace slug and {KEY} your product key — the same pair as your public URL. The key is case-insensitive; web and WEB reach the same board.

The body is strict JSON: an unrecognised field is an error, not something ignored.

Field Type Required Notes
text string, 1–50,000 chars Yes The message. Rendered as Markdown on the board.
title string, 1–500 chars No A headline. Omit the key entirely when you have none — an empty string is a validation error, and omitting it is what asks the server to derive a headline from text.
fp string, 1–120 chars Yes Your stable per-browser id. See below.
honeypot string No Your hidden field's value. Empty for a person.

Responses:

200 {"itemId":"WEB-42"}
404 {"error":"No board at that address."}
429 {"error":"Try again in a bit."}
400 {"error":"body/title Too small: expected string to have >=1 characters","code":"invalid-argument"}

The failure codes are worth reading carefully, because they are not evenly split:

  • 404 means the workspace slug does not resolve. That is the only thing it means.
  • 429 is everything else the server refuses: the rate limits, a filled honeypot, a product key that does not exist, and a product that is not public. The message is identical in every case, on purpose — a uniform, terse refusal gives an abusive script nothing to tune against. The cost is that during development a 429 is ambiguous, so check your board's own URL loads before you go hunting for a rate limit.
  • 400 is the only chatty one: a schema violation, naming the offending field.

There is no authentication. CORS is fully open — access-control-allow-origin: *, methods POST, GET, OPTIONS, request header content-type — and the OPTIONS preflight your browser sends is answered with a 204 and a one-hour access-control-max-age. Nothing to configure on your side.

A fingerprint your posts can cohere around

fp is how Convo groups one person's anonymous posts. Anything stable per browser works, but reuse the embed widget's own key scheme and you get two things beyond stability: posts left through your component and through the widget in the same browser attach to the same person record, and the board can find the key. A visitor there is identified by an anonymous sign-in session rather than by fp, so the board reads the key, hands it to Convo once, and the server moves everything posted under that fingerprint onto that session. Pick your own key scheme and you keep the coherence but lose the bridge.

The key is convo_fp_ followed by the board:

convo_fp_acme-co/WEB

Mint a value on first use, keep it in localStorage, and fall back to a throwaway id when storage throws — private browsing and strict extensions both do. A throwaway id still posts fine; it just will not cohere with anything else.

Do not put anything identifying in fp. It is an opaque per-browser token, not an account.

A honeypot your users never see

The endpoint's spam gate is a field bots fill in and people cannot see. Render a real text input, keep it out of the tab order and out of the accessibility tree, and send whatever is in it:

<input type="text" tabindex="-1" autocomplete="off" aria-hidden="true"
	style="position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0" />

Do not use display: none or hidden — some bots skip fields they can tell are invisible. Offscreen is the point.

Sample: curl

The fastest way to prove your board is reachable before you write any UI:

curl -X POST https://api.convo.randomfact.com/widget/acme-co/WEB/feedback \
	-H 'content-type: application/json' \
	-d '{
		"text": "The export button times out on large boards.",
		"title": "Export times out",
		"fp": "curl-smoke-test",
		"honeypot": ""
	}'
{"itemId":"WEB-42"}

Drop the title line and the server derives a headline from text instead.

Sample: plain JavaScript

About twenty lines, no dependencies, works anywhere fetch does:

const API = "https://api.convo.randomfact.com";
const BOARD = "acme-co/WEB"; // "{workspace}/{PRODUCT KEY}"
const FP_KEY = `convo_fp_${BOARD}`;

function fingerprint() {
	const mint = () =>
		`w-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
	try {
		const existing = localStorage.getItem(FP_KEY);
		if (existing) return existing;
		const fp = mint();
		localStorage.setItem(FP_KEY, fp);
		return fp;
	} catch {
		// Storage blocked (private mode): a fresh id still posts fine.
		return mint();
	}
}

async function sendFeedback({ text, title = "", honeypot = "" }) {
	const res = await fetch(`${API}/widget/${BOARD}/feedback`, {
		method: "POST",
		headers: { "content-type": "application/json" },
		// The schema is strict and rejects an empty title, so omit the key
		// entirely when the box is blank and let the server derive one.
		body: JSON.stringify({
			text: text.trim(),
			...(title.trim() ? { title: title.trim() } : {}),
			fp: fingerprint(),
			honeypot,
		}),
	});
	if (!res.ok) throw new Error(`Convo returned ${res.status}`);
	return res.json(); // { itemId: "WEB-42" }
}

Wire it to a form of your own, reading the body, the optional title, and the honeypot's value.

Sample: React

A complete drop-in component. Copy it into your project, render <FeedbackForm workspace="acme-co" productKey="WEB" />, and style it however you like — it uses no design system, and every element is a plain one you can replace.

import { useState } from "react";

const API = "https://api.convo.randomfact.com";
const SITE = "https://convo.randomfact.com";

/**
 * A stable per-browser id. It reuses the embed widget's localStorage key so a
 * person's in-app posts cohere with anything they left through the widget or
 * the board itself. Storage blocked (private mode) still posts fine.
 */
function fingerprint(board: string): string {
	const mint = () =>
		`w-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
	try {
		const key = `convo_fp_${board}`;
		const existing = localStorage.getItem(key);
		if (existing) return existing;
		const fp = mint();
		localStorage.setItem(key, fp);
		return fp;
	} catch {
		return mint();
	}
}

export function FeedbackForm({
	workspace,
	productKey,
}: {
	workspace: string;
	productKey: string;
}) {
	const board = `${workspace}/${productKey}`;
	const [title, setTitle] = useState("");
	const [text, setText] = useState("");
	// The honeypot: bots fill it, people never see it.
	const [honeypot, setHoneypot] = useState("");
	const [state, setState] = useState<"idle" | "sending" | "sent" | "failed">(
		"idle",
	);

	async function send() {
		const body = text.trim();
		if (!body || state === "sending") return;
		setState("sending");
		try {
			const res = await fetch(`${API}/widget/${board}/feedback`, {
				method: "POST",
				headers: { "content-type": "application/json" },
				// The schema is strict and rejects an empty title, so omit the key
				// entirely when the box is blank and let the server derive one.
				body: JSON.stringify({
					text: body,
					...(title.trim() ? { title: title.trim() } : {}),
					fp: fingerprint(board),
					honeypot,
				}),
			});
			if (!res.ok) throw new Error(String(res.status));
			setTitle("");
			setText("");
			setState("sent");
		} catch {
			setState("failed");
		}
	}

	return (
		<form
			onSubmit={(event) => {
				event.preventDefault();
				void send();
			}}
			style={{ display: "grid", gap: 8, maxWidth: 360 }}
		>
			<input
				type="text"
				value={title}
				onChange={(event) => setTitle(event.target.value)}
				maxLength={500}
				aria-label="Title (optional)"
				placeholder="Title (optional)"
			/>
			<textarea
				rows={3}
				value={text}
				onChange={(event) => setText(event.target.value)}
				onKeyDown={(event) => {
					if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
						event.preventDefault();
						void send();
					}
				}}
				placeholder="Tell us anything…"
			/>
			<input
				type="text"
				tabIndex={-1}
				autoComplete="off"
				aria-hidden="true"
				value={honeypot}
				onChange={(event) => setHoneypot(event.target.value)}
				style={{ position: "absolute", left: -9999, width: 1, height: 1 }}
			/>
			<div
				style={{
					display: "flex",
					alignItems: "center",
					justifyContent: "space-between",
					gap: 8,
				}}
			>
				<a href={`${SITE}/${board}`} target="_blank" rel="noopener noreferrer">
					See the board
				</a>
				<button type="submit" disabled={state === "sending" || !text.trim()}>
					{state === "sending" ? "Sending…" : "Send"}
				</button>
			</div>
			{state === "sent" && <p role="status">Thanks — posted.</p>}
			{state === "failed" && <p role="status">Could not send. Try again?</p>}
		</form>
	);
}

Four details worth keeping when you restyle it. Send is gated on the body alone, so a title by itself is never a submission. Cmd or Ctrl and Enter sends, because this box is for dashing off a sentence. The See the board link is the only route to the rest of Convo, so leave it somewhere. And both fields clear on success, so the next thought starts from an empty box.

Identity beyond anonymous

You cannot climb the identity ladder on this endpoint, and that is structural rather than an omission. The rungs above anonymous — an email address verified by a magic link, or a Google account — are a Firebase Auth session on Convo's own origin. Your page is not on that origin, so it has no such session to offer.

What this means in the moment of posting: everything that arrives here is credited to Anonymous, and until something changes, the person cannot be told when their request ships.

The board page is where it changes. Link to /{workspace}/{KEY} from your component — the sample does — and someone who wants to be recognised, take part in the thread, or hear about their request goes there and continues with Google or an email address. Because your fp uses the widget's key scheme, that visit also bridges the fingerprint: what they posted through your UI is re-credited to them, and their later posts through your UI arrive credited. The bridge is one-way and one-time — the first person to claim a fingerprint keeps it — and it only covers the board the key is named after.

Two things it does not do. Someone who never visits the board stays anonymous, so if hearing back matters to your users, give that link somewhere obvious. And a fingerprint minted with your own key scheme, or lost to blocked storage, has nothing for the board to find.

What arrives in Convo

An ordinary item. It lands at the top of the product's live feed with status new, gets the next key in sequence (WEB-42), is credited to "Anonymous", and can be replied to, tagged, merged, and moved onto the roadmap like anything else. Convo records via: "widget" on the item, which is how capture through this endpoint is distinguished from a post made on the board itself.

text becomes the item's body and is rendered as Markdown. title becomes the headline; when you omit it, the server derives one from the body, and a maker can rewrite it during triage either way.

Rate limits apply exactly as they do to the widget, because it is the same endpoint: a fixed one-hour window, 20 submissions per fingerprint and 60 per IP address, shared with plus-ones and replies made from the same browser on the board. Call it a couple of dozen an hour per person. Both limits answer 429 {"error":"Try again in a bit."}, so show your user a soft retry message rather than an error — the sample's "Could not send. Try again?" is the whole intent.

Local development

Everything above works against a local Convo running on the Firebase emulators. The endpoint is the same, but the Functions emulator serves it under a project-and-function path prefix:

POST http://127.0.0.1:5006/convo-convo-prod/us-central1/convoApi/widget/{workspace}/{KEY}/feedback

convo-convo-prod is the project id the emulator runs under, and 5006 is Convo's pinned Functions emulator port. Point your API constant at that prefix — the samples build every URL from it, so that one line is the only change.

The embed widget needs nothing extra here: it derives its API base from its own src minus /widget.js, so the emulator's project-and-function prefix comes along and data-api stays unnecessary locally.

One thing does behave differently. Semantic search is disabled outside production, so the board's similar-feedback prompt stays quiet — which does not affect this endpoint at all, since it never had one.

How Can wired it

Can — the kanban tool in the same family — has a Feedback button in its sidebar. It opens a small popover built with Can's own components and tokens, so it works in dark mode, and it posts to this endpoint at randomfact/CAN. It lives in src/features/feedback/FeedbackButton.tsx in the Can repo, and the React sample above is that component with its design system swapped out for plain elements.

Can deliberately did not use the embed widget: a cross-origin script can only render a fixed light corner box, and Can wanted its own popover, anchored to its own sidebar, in its own theme. That is the whole reason this page exists. Wire Convo into your product compares the routes; Embed the capture box is the one you skipped.