> ## Documentation Index
> Fetch the complete documentation index at: https://gleanfeed.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Capture feedback in your own UI

> Build a custom feedback form and send requests—with customer and page context—into Glean Feed.

This quickstart adds a feedback form that looks and behaves like the rest of your product. You own the interface; Glean Feed receives each request for triage, roadmap planning, and—when you choose—publication on your portal.

If you are still choosing an integration, start with the [Headless SDK overview](/headless/overview).

There are two ways to send a submission. Pick based on where your code runs:

* **Server proxy — the safe default.** Your app posts the feedback to *your* backend, and your backend forwards it to Glean Feed with an [API key](/api/authentication). The key never touches the browser.
* **Browser-direct, signed.** For convenience, the browser can submit directly using a customer-specific **signed user token** from [identify](/widget/identity). No workspace secret or API key ships to the client.

<Warning>
  Browser code is visible to your customers, so **never** put an API key (`gf_live_…` / `gf_test_…`)
  in client-side JavaScript. If you can run server code, prefer the server proxy. Use browser-direct
  only with a signed user token, never a raw key.
</Warning>

## Before you start

| Value                     | Where to find it                                               | Ships to the browser?                   |
| ------------------------- | -------------------------------------------------------------- | --------------------------------------- |
| **Workspace ID**          | Settings → General                                             | Yes — safe, not a secret                |
| **API key**               | Settings → API (server proxy only)                             | **No — server-side only**               |
| **Signed user token**     | Returned by [identify](/widget/identity) (browser-direct only) | Yes — per customer, valid up to 90 days |
| **Board ID** *(optional)* | Settings → Boards                                              | Yes                                     |

Which board a submission lands on decides whether it's **public or private** — feedback inherits its board's visibility. Omit the board and it goes to your first submittable (public or private) board. There's no separate "make this public" flag; choose the board and its settings do the rest.

## Quickstart: submit through your server

Your form posts to an endpoint you own. That endpoint forwards the request with a Glean Feed API key. This is the recommended path because the credential stays on your server and your application decides which signed-in customer is making the request.

Create an API key with `feedback:write`, then keep it in a server-only environment variable. See [Authenticate API requests](/api/authentication) for key creation and storage guidance.

```js theme={null}
// Your backend—for example, an Express route handler.
app.post("/give-feedback", async (req, res) => {
  const headers = {
    authorization: `Bearer ${process.env.GLEANFEED_API_KEY}`, // server-side only
    "content-type": "application/json",
  };
  // A delivery ID protects retries of this HTTP operation. Keep it distinct from
  // the source record ID below so source conflicts remain detectable.
  if (req.body.deliveryId) headers["idempotency-key"] = req.body.deliveryId;

  const r = await fetch("https://app.gleanfeed.com/api/v1/feedback", {
    method: "POST",
    headers,
    body: JSON.stringify({
      title: req.body.title,
      description: req.body.body,
      // Attribute it to the signed-in user so it powers "your requests" + status emails.
      submitter: { email: req.user.email, name: req.user.name, externalId: req.user.id },
      // Private source identity and triage context — never shown on the public portal.
      source: {
        kind: "custom_form",
        externalId: req.body.requestId,
        url: req.body.pageUrl,
        capturedAt: new Date().toISOString(),
        metadata: { plan: req.user.plan, appVersion: "4.2.0" },
      },
    }),
  });
  if (!r.ok) return res.status(502).json({ error: "Could not submit feedback." });
  res.json(await r.json());
});
```

Post `{ "title": "Dark mode please", "body": "A dark theme would help at night.", "pageUrl": "https://app.acme.com/settings", "requestId": "request-123", "deliveryId": "delivery-456" }` from your form to `/give-feedback`. Reuse `deliveryId` only when retrying that exact HTTP operation. A successful call returns the created Glean Feed request. Open **Feedback** in your Glean Feed dashboard and confirm that the title, customer, source URL, and metadata are present.

For the full request and response schema, see [`POST /feedback`](/api/endpoints#feedback).

The `submitter` is **unverified** — your server vouches for it, and the API key is the trust boundary. Glean Feed finds or creates a customer profile from the email or `externalId` so repeat submitters converge on one profile. Omit `submitter` entirely for anonymous feedback.

Creating a submitter for the first time counts toward the workspace's [tracked customer limit](/portal/accounts#tracked-customer-limits). At the limit, `POST /feedback` returns `403 forbidden` for a new submitter. Existing submitters continue to work, and omitting `submitter` still creates anonymous feedback. Do not retry a new submitter until the workspace has available capacity or upgrades.

`source.externalId` is the durable identity of the record in the source system. Replaying the same
workspace, source kind, and external ID with the same feedback returns the original request instead
of creating a duplicate. Reusing that identity with different feedback returns `409 conflict` and
does not alter either request. This is separate from `Idempotency-Key`, which protects retries of one
API operation and returns that operation's cached response before source-conflict evaluation. Use a
distinct delivery-attempt key when you need both protections.

When you send both fields and the email is already reserved by workspace team history or another signed identity, Glean Feed preserves the submitter as an `externalId`-only customer instead of merging identities. If you send only an email that belongs to team history, the request is rejected; include a stable customer `externalId` or omit `submitter` for anonymous feedback.

<Note>
  A no-login "Give feedback" button on a static or marketing site — where you have no signed-in
  customer — should still go through your server (or a serverless function) with the API key. A
  public, no-key browser endpoint for fully anonymous capture is coming separately; until then, the
  server proxy is the anonymous path.
</Note>

## Alternative: submit directly from the browser

If you already sign customers in with [identify](/widget/identity), the widget holds a signed user token and can submit directly — no backend round-trip, no secret in the client.

**Prerequisite:** call `init` with your `workspaceId`, then `identify` the customer (the signature is computed on *your* server with your widget secret — see [identify](/widget/identity)).

```html theme={null}
<script src="https://cdn.gleanfeed.com/widget.js"></script>
<script>
  window.GleanFeed.init({
    workspace: "your-workspace-slug",
    workspaceId: "YOUR_WORKSPACE_ID",
  });

  // Sign the user in (server-signed HMAC — never sign in the browser).
  await window.GleanFeed.identify({
    userId: "user_123",
    email: "ada@acme.com",
    signature: SIGNATURE_FROM_YOUR_SERVER,
  });

  // Now wire your own button to submit.
  document.querySelector("#give-feedback").addEventListener("submit", async (e) => {
    e.preventDefault();
    const { id } = await window.GleanFeed.submitFeedback({
      title: e.target.title.value,
      body: e.target.body.value,
      sourceUrl: location.href,
      metadata: { plan: "pro", appVersion: "4.2.0" },
    });
    console.log("Submitted feedback", id);
  });
</script>
```

Using a bundler? Import the helper and pass the token yourself:

```ts theme={null}
import { feedback } from "@gleanfeed/widget";

await feedback.submit(
  { workspaceId: "YOUR_WORKSPACE_ID", portalBaseUrl: "https://app.gleanfeed.com", userToken },
  { title: "Dark mode please", body: "Would love a dark theme.", sourceUrl: location.href },
);
```

Or call the endpoint directly — it sends permissive CORS headers, so you can `fetch` it from any origin:

```bash theme={null}
curl -X POST "https://app.gleanfeed.com/api/sdk/feedback" \
  -H "content-type: application/json" \
  -d '{
    "workspaceId": "YOUR_WORKSPACE_ID",
    "userToken": "SIGNED_USER_TOKEN",
    "title": "Dark mode please",
    "body": "Would love a dark theme.",
    "sourceUrl": "https://app.acme.com/settings",
    "metadata": { "plan": "pro" }
  }'
```

Browser-direct submissions are attributed to the signed-in customer and follow the **same rules as the portal**: they land on submittable (public/private) boards, respect invite-only membership, and default to pending review so your team approves before anything appears publicly.

## React

Same two options, wrapped in a component. Both render your own form and show a submitting/submitted state — using the framework you already have.

**Server proxy (recommended).** The component posts to *your* backend, which forwards it with the API key (see [the server quickstart](#quickstart-submit-through-your-server)). No Glean Feed secret touches the browser.

```tsx theme={null}
"use client";

import { useState } from "react";

export function FeedbackButton({ pageUrl = location.href }: { pageUrl?: string }) {
  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    setState("sending");
    // Your own endpoint — it adds the API key server-side and calls Glean Feed.
    const r = await fetch("/give-feedback", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ title: form.title.value, body: form.body.value, pageUrl }),
    });
    setState(r.ok ? "done" : "error");
  }

  if (state === "done") return <p>Thanks — we got your feedback.</p>;

  return (
    <form onSubmit={onSubmit}>
      <input name="title" placeholder="Summary" required />
      <textarea name="body" placeholder="What's on your mind?" required />
      <button disabled={state === "sending"} type="submit">
        {state === "sending" ? "Sending…" : "Send feedback"}
      </button>
      {state === "error" && <p role="alert">Couldn't send that — try again.</p>}
    </form>
  );
}
```

**Browser-direct, signed.** If you already [identify](/widget/identity) customers, import the helper and pass the signed `userToken` — no backend round-trip, still no long-lived secret in the client.

```tsx theme={null}
"use client";

import { useState } from "react";
import { feedback } from "@gleanfeed/widget";

const config = { workspaceId: "YOUR_WORKSPACE_ID", portalBaseUrl: "https://app.gleanfeed.com" };

export function FeedbackButton({ userToken }: { userToken: string }) {
  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    setState("sending");
    try {
      // The signed user token stands in for a key — safe to ship to the browser.
      await feedback.submit(
        { ...config, userToken },
        { title: form.title.value, body: form.body.value, sourceUrl: location.href },
      );
      setState("done");
    } catch {
      setState("error");
    }
  }

  if (state === "done") return <p>Thanks — we got your feedback.</p>;

  return (
    <form onSubmit={onSubmit}>
      <input name="title" placeholder="Summary" required />
      <textarea name="body" placeholder="What's on your mind?" required />
      <button disabled={state === "sending"} type="submit">
        {state === "sending" ? "Sending…" : "Send feedback"}
      </button>
      {state === "error" && <p role="alert">Couldn't send that — try again.</p>}
    </form>
  );
}
```

## Source context is private

Everything in `source` is **triage context for your team** — it is stored separately from the public
request and appears only in the authenticated dashboard. It is never returned by public feedback API
reads or rendered on the public portal, even when the feedback lands on a public board. Only the
`title` and `description` follow the board's visibility.

The older top-level `sourceUrl` and `metadata` fields remain accepted for existing integrations, but
new server integrations should send the nested `source` object. Credential-like metadata keys,
embedded URL credentials, tokens, raw payloads, and message history are rejected. Store only the
small source facts your team needs to triage the request.

## Limits

Validation runs before anything is written, so oversized payloads fail fast and nothing is stored. Browser-direct JSON requests are capped at 1 MiB of encoded data; larger requests return `413`.

| Field                          | Limit                                                                     |
| ------------------------------ | ------------------------------------------------------------------------- |
| Encoded browser-direct request | 1 MiB; larger JSON requests return `413`                                  |
| `title`                        | 1–200 characters                                                          |
| `description` / `body`         | up to 10,000 characters                                                   |
| `sourceUrl`                    | `http`/`https`, up to 2,048 characters                                    |
| `metadata`                     | up to 10 keys; keys ≤40 chars; values coerced to strings, ≤500 chars each |
| `source.kind`                  | lowercase source slug, up to 32 characters                                |
| `source.externalId`            | source-native record ID, up to 255 characters                             |
| `source.capturedAt`            | RFC 3339 timestamp                                                        |

Capture endpoints are rate-limited. Server-proxy traffic is limited per API key and per workspace; browser-direct is limited per customer and per IP. On a limit you'll get a `429` — back off and retry. Submissions inherit your board's moderation settings, so pending items wait for approval before they're publicly visible.
