# Getting Started
{/* source: apps/web/lib/board-templates.ts */}
{/* source: apps/web/lib/plan-features.ts */}
Get up and running with FeedIndex in minutes.
## Create an account [#create-an-account]
Sign up at [feedindex.app](https://feedindex.app) using Google or a magic link. No password needed.
## Create your first board [#create-your-first-board]
After signing in, you'll land on the dashboard. Click **New Board** to create your first feedback board. Choose from three templates:
* **Feature Requests** — Collect and prioritize feature ideas
* **Bug Reports** — Track and resolve bugs reported by users
* **General Feedback** — Collect open-ended feedback
Each template comes with pre-configured statuses. You can customize statuses later in board settings.
## Share your board [#share-your-board]
Every board is accessible through your public portal at:
```
https://feedindex.app/p/[your-org-slug]/[board-slug]
```
Share this URL with your users so they can submit feedback and vote on existing posts.
## Plans [#plans]
FeedIndex offers three tiers:
| | Free | Starter | Pro |
| ------------------------------ | ---- | --------- | --------- |
| Boards | 1 | 3 | 10 |
| Posts per board | 30 | Unlimited | Unlimited |
| Team members | 1 | 3 | 10 |
| Browser push to voters | Yes | Yes | Yes |
| Custom domain | — | Yes | Yes |
| Roadmap | — | Yes | Yes |
| CSV import | — | Yes | Yes |
| Slack notifications | — | Yes | Yes |
| Completion emails to submitter | — | Yes | Yes |
| Private boards | — | — | Yes |
| Changelog | — | — | Yes |
| Board analytics | — | — | Yes |
| Custom statuses | — | — | Yes |
| CSV export | — | — | Yes |
| API keys | — | — | Yes |
| Webhooks | — | — | 5 |
| Team audit log | — | — | Yes |
## Next steps [#next-steps]
* [Explore the public portal](/portal) to see what your users experience
* [Embed the widget](/widget) on your website for in-app feedback
* [Configure Slack notifications](/integrations/slack) to stay on top of new submissions
# Welcome
Welcome to the FeedIndex documentation.
FeedIndex is a simple, affordable feedback tool for solo founders and small SaaS teams. Collect feature requests, bug reports, and ideas from your users — then prioritize what to build next.
## Quick links [#quick-links]
Create your account and first board
How your users interact with your feedback boards
Show users what's planned and in progress
Publish product updates linked to feedback
Embed a feedback widget on your site
Integrate with the public API
Get notified when things happen
Connect to Slack and more
# LLMs
This documentation is available in machine-readable formats that AI assistants and large language models can consume directly.
## Available endpoints [#available-endpoints]
| URL | Description |
| ---------------------------------- | --------------------------------------------------------------------------------------------- |
| [`/llms.txt`](/llms.txt) | Index of all documentation pages with titles and URLs |
| [`/llms-full.txt`](/llms-full.txt) | Full content of all pages concatenated into a single file |
| `/:path.mdx` | Markdown source of any individual page (e.g., [`/getting-started.mdx`](/getting-started.mdx)) |
## Usage [#usage]
Feed `/llms-full.txt` to an LLM for complete context about FeedIndex, or use `/llms.txt` to discover pages and fetch individual ones with the `.mdx` suffix.
These endpoints follow the [`llms.txt` convention](https://llmstxt.org) for making documentation accessible to AI tools.
# API Keys
{/* source: apps/web/lib/api-key-auth.ts */}
{/* source: apps/web/lib/actions/api-keys.ts */}
API keys let you authenticate requests to the FeedIndex public API. Available on the **Pro** plan. Only workspace owners can generate and manage keys.
## Generating a key [#generating-a-key]
1. Go to **Workspace Settings > API Keys**
2. Give the key a name (e.g., "Production backend")
3. Click **Generate**
4. Copy the key immediately — it is shown only once
The full key is displayed only at creation. FeedIndex stores a SHA-256 hash of the key, so it cannot be recovered. If you lose a key, revoke it and generate a new one.
## Key format [#key-format]
API keys use the format:
```
fi_<64 hex characters>
```
For example: `fi_a1b2c3d4e5f6...` (67 characters total).
## Using your key [#using-your-key]
Include the key as a Bearer token in the `Authorization` header:
```bash
curl -H "Authorization: Bearer fi_your_key_here" \
https://feedindex.app/api/v1/public/boards/your-board-slug/posts
```
All write endpoints (`POST`) require an API key. Read endpoints (`GET`) are public and do not require authentication.
## Revoking a key [#revoking-a-key]
Go to **Workspace Settings > API Keys**, find the key by name, and click **Revoke**. The key is invalidated immediately — any requests using it will receive a `401` response.
# Boards
{/* source: apps/web/app/api/v1/public/boards/[id]/route.ts */}
{/* source: apps/web/app/api/v1/public/boards/[id]/tags/route.ts */}
## Get board [#get-board]
Retrieve metadata for a board by its ID. No authentication required.
```
GET /api/v1/public/boards/:id
```
### Response [#response]
```json
{
"name": "Feature Requests",
"description": "Share your ideas for new features",
"isPaid": true,
"customDomain": "feedback.yoursite.com",
"thankYouMessage": "Thanks for your feedback!"
}
```
| Field | Type | Description |
| ----------------- | -------------- | ------------------------------------------ |
| `name` | string | Board name |
| `description` | string \| null | Board description |
| `isPaid` | boolean | Whether the workspace is on a paid plan |
| `customDomain` | string \| null | Verified custom domain, if any |
| `thankYouMessage` | string \| null | Custom message shown after post submission |
## List board tags [#list-board-tags]
Retrieve all tags for a board. No authentication required.
```
GET /api/v1/public/boards/:id/tags
```
### Response [#response-1]
```json
[
{
"id": "uuid",
"name": "UI",
"slug": "ui",
"colour": "#3b82f6"
},
{
"id": "uuid",
"name": "Performance",
"slug": "performance",
"colour": "#10b981"
}
]
```
# API Overview
{/* source: apps/web/lib/api-key-auth.ts */}
{/* source: apps/web/lib/rate-limit.ts */}
The FeedIndex API lets you programmatically access boards, posts, and votes. All endpoints are under `https://feedindex.app/api/v1/public/`.
## Authentication [#authentication]
Write endpoints on the board API require an [API key](/api/api-keys) (Pro plan). Include your key in the `Authorization` header:
```bash
curl -H "Authorization: Bearer fi_your_api_key_here" \
https://feedindex.app/api/v1/public/boards/your-board-slug/posts
```
Read endpoints (`GET`) are public and do not require authentication.
## Rate limits [#rate-limits]
| Endpoint | Limit |
| ---------------------------- | ------------------ |
| Get board / list posts (GET) | 60 requests/minute |
| List tags (GET) | 30 requests/minute |
| Submit post (POST) | 5 requests/minute |
| Vote (POST) | 30 requests/minute |
Rate limits are per IP address. Exceeding the limit returns `429 Too Many Requests`.
## Response format [#response-format]
All responses return JSON. Successful responses include the requested data directly. Errors return:
```json
{
"error": "Description of what went wrong"
}
```
# Posts
{/* source: apps/web/app/api/v1/public/boards/[id]/posts/route.ts */}
{/* source: apps/web/lib/validation/post-submission.ts */}
## List posts [#list-posts]
Retrieve approved posts for a board, sorted by vote count (highest first). Returns up to 50 posts. No authentication required.
```
GET /api/v1/public/boards/:id/posts
```
### Query parameters [#query-parameters]
| Parameter | Type | Description |
| --------- | ------ | ----------------------------------------------------------------------------------- |
| `tags` | string | Comma-separated tag slugs. Only posts matching **all** specified tags are returned. |
### Response [#response]
Returns a flat JSON array of posts:
```json
[
{
"id": "uuid",
"title": "Dark mode support",
"description": "It would be great to have a dark theme option",
"status": "planned",
"statusLabel": "Planned",
"statusColour": "#3b82f6",
"voteCount": 42,
"hasVoted": false,
"tags": [
{ "id": "uuid", "slug": "ui", "name": "UI", "colour": "#3b82f6" }
]
}
]
```
The `hasVoted` field reflects whether the current caller has voted, based on IP address and User-Agent.
## Submit post [#submit-post]
Create a new post on a board. Requires an [API key](/api/api-keys).
```
POST /api/v1/public/boards/:id/posts
```
### Request body [#request-body]
```json
{
"title": "Add keyboard shortcuts",
"description": "It would be great to navigate with j/k keys",
"email": "user@example.com",
"name": "Jane Doe"
}
```
| Field | Required | Description |
| ------------- | -------- | ------------------------------------------- |
| `title` | Yes | Post title (max 120 characters) |
| `description` | No | Post body (max 50,000 characters) |
| `email` | Yes | Submitter email |
| `name` | No | Submitter display name (max 100 characters) |
### Response (201 Created) [#response-201-created]
```json
{
"id": "uuid",
"title": "Add keyboard shortcuts",
"description": "It would be great to navigate with j/k keys",
"voteCount": 0,
"status": "under_review",
"statusLabel": "Under Review",
"statusColour": "#a1a1aa",
"hasVoted": false
}
```
Posts may require approval before appearing publicly, depending on the board's auto-publish setting.
# Votes
{/* source: apps/web/app/api/v1/public/posts/[id]/vote/route.ts */}
## Toggle vote [#toggle-vote]
Toggle a vote on a post. If the caller hasn't voted, this adds a vote. If they have, it removes it. Requires an [API key](/api/api-keys).
```
POST /api/v1/public/posts/:id/vote
```
No request body is needed. The vote is identified by the caller's IP address and User-Agent (SHA-256 hashed).
The vote endpoint only works when **anonymous voting** is enabled on the board. If anonymous voting is disabled, the endpoint returns `403`.
### Response [#response]
```json
{
"hasVoted": true,
"voteCount": 43
}
```
| Field | Type | Description |
| ----------- | ------- | ----------------------------------------- |
| `hasVoted` | boolean | Whether the caller now has an active vote |
| `voteCount` | number | Updated total vote count for the post |
### Deduplication [#deduplication]
**For this API endpoint**, votes are deduplicated by a salted SHA-256 hash of the caller's IP address + User-Agent — because server-to-server requests have no browser cookie to key on. The same client (same IP + UA) toggling the endpoint will alternate between adding and removing their vote.
**On the hosted portal** (`/p/[orgSlug]/[boardSlug]`), FeedIndex uses a different mechanism: a random identifier stored in a first-party cookie. No IP address is processed for portal voting. The cookie-based and hash-based votes share the same underlying column, so a voter who votes both through the portal and through this API from the same device will be counted twice (different identifiers). In practice this rarely matters because this endpoint is for server-to-server automation, not end-user interaction.
# CSV Export
{/* source: apps/web/app/api/v1/boards/[boardId]/export/route.ts */}
Download all posts from a board as a CSV file for analysis, reporting, or migration. Available on **Pro** plan.
## Exported columns [#exported-columns]
The CSV file includes the following columns for each post:
| Column | Description |
| ------------- | ------------------------- |
| `title` | Post title |
| `description` | Post body |
| `status` | Current status label |
| `email` | Author email address |
| `votes` | Total vote count |
| `created` | Date the post was created |
## How to export [#how-to-export]
1. Navigate to your board in the dashboard
2. Open **Board Settings** or click the **Export** option in the board toolbar
3. Click **Export CSV** to download
The file downloads immediately to your browser.
## Permissions [#permissions]
Only workspace **owners** can export board data. This restriction exists because the export includes author email addresses, which are considered private information.
## Tips [#tips]
* Use exports for backup, analysis in a spreadsheet, or migrating data to another tool
* The exported format is compatible with the [CSV Import](/docs/integrations/csv-import) format, making it easy to move posts between boards
# CSV Import
{/* source: apps/web/lib/actions/import-posts.ts */}
Import existing feedback from other tools by uploading a CSV file. Available on **Starter** plan and above.
## Format [#format]
Your CSV file should have these columns:
| Column | Required | Description |
| ------------- | -------- | ------------------------------------------------- |
| `title` | Yes | Post title (max 120 characters) |
| `description` | No | Post body (max 5,000 characters) |
| `status` | No | Status label (matched to existing board statuses) |
| `email` | No | Submitter email |
| `votes` | No | Initial vote count (default: 0) |
## How to import [#how-to-import]
1. Navigate to your board in the dashboard
2. Click **Import** in the board toolbar
3. Upload your CSV file (max 500 posts per import)
4. Review the preview table — FeedIndex shows how statuses will be matched
5. Confirm to import
## Status matching [#status-matching]
FeedIndex matches your CSV status values to the board's existing statuses using **exact, case-insensitive matching** on the status label or slug. For example, "In Progress" and "in\_progress" both match the "In Progress" status.
Unmatched statuses default to the board's first status (typically "Under Review" or "New").
## Tips [#tips]
* Export from your current tool first, then adjust the CSV columns to match the format above
* All imported posts are automatically approved (no moderation queue)
* Import is additive — it won't overwrite or duplicate existing posts
# Custom Domain
{/* source: apps/web/lib/actions/domains.ts */}
{/* source: apps/web/lib/vercel-api.ts */}
Custom domains let you serve your public feedback portal from a domain like `feedback.yoursite.com` instead of `feedindex.app/p/your-org`. Available on **Starter** plan and above.
## Setup [#setup]
1. Go to **Workspace Settings > Integrations > Custom Domain**
2. Enter your desired domain (e.g., `feedback.yoursite.com`)
3. Add the DNS records shown in the settings:
* **CNAME** record pointing to `cname.feedindex.app`
* **TXT** record for domain verification
4. Click **Verify** — FeedIndex checks the TXT record to confirm ownership
5. Once verified, SSL is provisioned automatically
## DNS configuration [#dns-configuration]
At your DNS provider, create these records:
| Type | Name | Value |
| ----- | ---------------------------- | --------------------- |
| CNAME | `feedback` | `cname.feedindex.app` |
| TXT | `_feedindex-verify.feedback` | (shown in settings) |
DNS propagation can take up to 48 hours, though it typically completes within minutes.
## How it works [#how-it-works]
When a user visits your custom domain, FeedIndex serves your public portal (all boards, roadmap, and changelog) with root-relative URLs. The experience is seamless — users never see `feedindex.app`.
## Limitations [#limitations]
* One custom domain per workspace
* Subdomains only (e.g., `feedback.yoursite.com`), not apex domains
* The custom domain serves the entire portal, not individual boards
# Email Notifications
{/* source: apps/web/lib/notifications/status-change.ts */}
{/* source: apps/web/lib/notifications/weekly-digest.ts */}
FeedIndex sends targeted email notifications to keep post submitters and workspace owners informed without flooding inboxes. No external setup is required — notifications are controlled through board settings.
## Completion emails to submitters [#completion-emails-to-submitters]
When a post is marked complete, FeedIndex emails the submitter to tell them their feedback shipped. This is the close-the-loop moment voters care about most.
**How it works:**
* **One email per post, on completion only.** The submitter gets an email the moment a post moves to a status mapped to the **Complete** roadmap column — no emails for intermediate transitions like *Planned* → *In Progress*.
* With the default board statuses, this means an email fires when a post reaches **Done**. If you've created a custom "Shipped" status and mapped it to the Complete column, that triggers the email too.
* Intermediate and terminal-but-not-complete transitions (*Under Review*, *Planned*, *In Progress*, *Closed*) are silent.
* The submitter must have provided an email address when submitting the post.
**To enable or disable:**
1. Navigate to your board in the dashboard.
2. Open **Board Settings → Moderation**.
3. Toggle **Email submitter when complete**.
This setting is per-board. Email notifications are available on the **Starter** plan and above — Free-tier boards can't turn this toggle on.
## Browser push to voters [#browser-push-to-voters]
Completion emails reach the one person who submitted the post. To reach everyone else who voted, FeedIndex supports opt-in browser push notifications — see the [push notifications guide](./push-notifications) for details.
## Weekly digest [#weekly-digest]
Every Monday, workspace owners receive a digest email summarising activity across all boards. It's a low-signal overview that keeps you in touch without logging in daily.
**The digest includes, for each board:**
* Number of new posts
* Number of new votes
* Status changes
* The top post of the week
Boards with zero activity are skipped so the digest stays short.
**To enable or disable:**
1. Navigate to your board in the dashboard.
2. Open **Board Settings → Moderation**.
3. Toggle **Weekly email digest**.
## Tips [#tips]
* The completion email is deliberately the *only* status-change email FeedIndex sends. Intermediate updates stay in-product (status badges, changelog) so voters aren't spammed.
* Pair completion emails with the public [changelog](../portal/changelog) for a durable record of what shipped.
* The weekly digest helps you stay on top of feedback trends while you're heads-down building.
# Browser Push Notifications
{/* source: apps/web/lib/push/send.ts */}
{/* source: apps/web/lib/actions/push.ts */}
{/* source: apps/web/components/push-subscribe-button.tsx */}
FeedIndex can notify voters directly in their browser when a post they care about is marked complete. It's a frictionless alternative to email — no address collection, no per-send fee, and it works even on the free plan.
## How it works [#how-it-works]
1. A voter visits any public post page on your portal.
2. They click **Notify me when this ships** below the post description.
3. Their browser prompts for notification permission.
4. When they allow it, a subscription is stored, scoped to that specific post.
5. The moment an admin moves the post to a status mapped to the **Complete** roadmap column, every subscriber gets a browser notification.
6. Clicking the notification opens the post on your portal.
## What gets collected [#what-gets-collected]
* A push subscription **endpoint URL** (assigned by the browser's push service — FCM for Chrome, APNs for Safari, Mozilla Push Service for Firefox).
* Two **public encryption keys** so we can encrypt the payload for delivery.
* No user ID, no email, no IP address. The endpoint is the pseudonymous identifier.
Subscriptions are per-post. Revoking in browser settings or clicking "Notifications on → Unsubscribe" on the post removes the subscription immediately. Dead endpoints (users who clear browser data or disable notifications) are automatically pruned when delivery fails.
## Plan availability [#plan-availability]
Browser push is **free on every plan**. Free-tier boards can send push notifications even though email status notifications require Starter+.
## Supported browsers [#supported-browsers]
| Browser | Supported |
| ------------------------------- | -------------------------------------------------------------- |
| Chrome / Edge / Brave (desktop) | Yes |
| Firefox (desktop) | Yes |
| Safari 16+ (macOS) | Yes |
| Safari iOS/iPadOS 16.4+ (PWA) | Yes, but only when your portal is installed to the home screen |
| Safari iOS (normal browser tab) | No |
The **Notify me when this ships** button is hidden on browsers that don't support the Web Push API, so users never see a non-functional button.
## When does it fire? [#when-does-it-fire]
The same rule as completion emails: push fires only when the new status's roadmap column is **Complete**. Intermediate transitions (*Planned*, *In Progress*) are silent. The default **Done** status is Complete; any custom status you've placed in the Complete column fires too.
Closed posts (marked as won't-do) do **not** fire push.
## How it pairs with email [#how-it-pairs-with-email]
| Audience | Channel |
| --------------- | ------------------------------------------------------------------------ |
| Post submitter | Email when the post is marked complete (Starter+ feature) |
| Voters (opt-in) | Browser push when the post is marked complete (free on every plan) |
| Everyone else | Status badge update on the portal + a changelog entry if you publish one |
## Setup [#setup]
For board owners: nothing to configure. The button appears automatically on public post pages for portals that ship with a valid [VAPID](https://datatracker.ietf.org/doc/html/rfc8292) public key. FeedIndex's hosted product has this set up for you.
For self-hosters: generate a VAPID key pair (`pnpm dlx web-push generate-vapid-keys`) and set `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and optionally `VAPID_SUBJECT`. When these are unset, the subscribe button is hidden and the send path silently no-ops.
## Privacy [#privacy]
Push subscriptions are pseudonymous — the browser issues the endpoint URL; we never learn the user's identity or device details. No subscription data is shared with third parties. See the [privacy policy](https://feedindex.app/privacy) for the full disclosure.
# Slack
{/* source: apps/web/lib/notifications/slack.ts */}
Connect FeedIndex to Slack to receive notifications in a channel of your choice. Available on **Starter** plan and above.
## Setup [#setup]
1. Create an **Incoming Webhook** in your Slack workspace ([Slack docs](https://api.slack.com/messaging/webhooks))
2. Copy the webhook URL
3. Go to **Workspace Settings > Integrations**
4. Paste the webhook URL and save
## Notifications [#notifications]
FeedIndex sends Slack notifications for:
* **New post submitted** — title, board name, and a link to the post
* **Post status changed** — old and new status, post title, and a link
Notifications are fire-and-forget. If Slack is temporarily unavailable, the notification is skipped silently — it does not block the user action.
# Team Members
{/* source: apps/web/app/(app)/workspace/(settings)/members/page.tsx */}
Add team members to your workspace so they can help manage feedback boards, respond to posts, and update statuses.
## Plan limits [#plan-limits]
The number of team members (including the workspace owner) depends on your plan:
| Plan | Team members |
| ----------- | -------------- |
| **Free** | 1 (owner only) |
| **Starter** | Up to 3 |
| **Pro** | Up to 10 |
## Roles [#roles]
| Role | Permissions |
| ---------- | --------------------------------------------------------------- |
| **Owner** | Full access — manage boards, members, billing, and all settings |
| **Member** | Manage posts and comments across all boards in the workspace |
## Inviting members [#inviting-members]
1. Go to **Workspace Settings > Members**
2. Enter the email address of the person you want to invite
3. Click **Send Invite**
The invitee receives an email with a link to accept the invitation. Once accepted, they appear in the members list and can access the workspace.
## Removing members [#removing-members]
1. Go to **Workspace Settings > Members**
2. Click **Remove** next to the member you want to remove
Only workspace owners can remove members. Removed members immediately lose access to the workspace.
## Tips [#tips]
* Invitees need a FeedIndex account — if they don't have one, they'll be prompted to sign up when accepting the invite
* You can see pending invitations in the members list until they're accepted
# Board Analytics
{/* source: apps/web/lib/workspace-analytics.ts */}
Get a high-level view of feedback activity across your workspace. Available on **Pro** plan.
## Accessing analytics [#accessing-analytics]
1. Go to **Workspace Settings > Analytics**
## Aggregate metrics [#aggregate-metrics]
The analytics dashboard shows these metrics across all your active boards:
| Metric | Description |
| ------------------------ | ------------------------------------------------------------------- |
| **Total posts** | Total number of posts across all boards |
| **Total votes** | Total number of votes across all boards |
| **Total status changes** | How many times posts have had their status updated |
| **Resolution rate** | Percentage of posts that reached a terminal status (Done or Closed) |
| **Average time to done** | Average hours from post creation to reaching a terminal status |
## Board breakdown [#board-breakdown]
Below the aggregate metrics, a table shows per-board stats:
| Column | Description |
| --------- | ----------------------------- |
| **Board** | Board name |
| **Posts** | Number of posts in that board |
| **Votes** | Number of votes in that board |
This lets you compare activity levels across boards and identify which boards are getting the most engagement.
## Tips [#tips]
* **Resolution rate** helps you track how effectively you're closing the loop on feedback — a low rate might mean posts are piling up without action
* **Average time to done** helps you understand your response time — are you addressing feedback quickly or is it sitting for weeks?
* Use the board breakdown to spot boards that might need more attention or could be consolidated
# Changelog
{/* source: apps/web/lib/actions/changelog.ts */}
{/* source: apps/web/app/p/[orgSlug]/changelog/rss/route.ts */}
The changelog lets you share product updates with your users and connect them to the feedback that drove the changes. Available on the **Pro** plan.
## Creating an entry [#creating-an-entry]
1. Go to **Workspace > Changelog** in the dashboard
2. Click **New Entry**
3. Add a title and write the body using the rich-text editor
4. Optionally link a board and select related posts — these appear as "Related feedback" on the published entry
5. Save as draft or publish immediately
## Draft and publish workflow [#draft-and-publish-workflow]
Changelog entries support a draft/publish lifecycle:
* **Draft** — visible only to you in the dashboard
* **Published** — visible on the public changelog with the publish date shown
* **Unpublish** — returns a published entry to draft state
When publishing an entry, you can optionally **mark all linked posts as complete**. This updates the status of every linked post to the board's "complete" status in one action.
## Public URL [#public-url]
The changelog is visible at:
```
https://feedindex.app/p/[your-org-slug]/changelog
```
Each entry has its own page at `/changelog/[entry-id]`. If you use a custom domain, the paths are root-relative (e.g., `feedback.yoursite.com/changelog`).
## RSS feed [#rss-feed]
An Atom feed is available at:
```
https://feedindex.app/p/[your-org-slug]/changelog/rss
```
Users and feed readers can subscribe to get notified of new entries. The feed respects your custom domain if configured.
# Public Portal
{/* source: apps/web/app/p/[orgSlug]/layout.tsx */}
{/* source: apps/web/app/p/_components/portal-layout.tsx */}
Every FeedIndex workspace gets a public portal where your users can browse feedback boards, vote on posts, and follow your product's progress.
## Portal URL [#portal-url]
Your portal is available at:
```
https://feedindex.app/p/[your-org-slug]
```
Find your portal URL in the workspace dashboard. If you have a [custom domain](/integrations/custom-domain) configured, the portal is served from that domain instead.
## What users see [#what-users-see]
The portal shows all your **public** boards in one place. Depending on your plan, the portal may also include:
* **Boards** — all public feedback boards with post counts
* **Roadmap** — a Kanban view of planned, in-progress, and completed work (Starter+)
* **Changelog** — a feed of product updates and releases (Pro)
## Navigation [#navigation]
When your workspace has multiple public boards, the portal displays a sidebar with links to each board, plus the Roadmap and Changelog tabs (if enabled by your plan). With a single public board, the portal uses a simpler top navigation.
## Voting and submissions [#voting-and-submissions]
Users can vote on posts and submit new feedback directly from the portal.
**Submitting a post** always requires an email address, even for anonymous voters — this is how FeedIndex emails the submitter when the post is marked complete. No account signup is required; the email is captured on the submission form.
**Voting anonymously** is enabled by default. When a voter casts their first vote, FeedIndex sets a random identifier in a first-party `fi_anon` cookie and uses that to prevent duplicate votes. No IP address is collected. If a board owner disables anonymous voting, users must sign in before voting — the portal provides a sign-in option for this.
Opt-in [browser push notifications](/integrations/push-notifications) let voters get a notification the moment a post ships, without providing an email.
## Custom domain [#custom-domain]
On the Starter plan and above, you can serve the portal from your own domain (e.g., `feedback.yoursite.com`). See [Custom Domain](/integrations/custom-domain) for setup instructions.
# Roadmap
{/* source: apps/web/app/p/_components/public-roadmap-view.tsx */}
{/* source: apps/web/lib/board-templates.ts */}
The public roadmap gives your users visibility into what you're working on. Available on the **Starter** plan and above.
## How it works [#how-it-works]
The roadmap displays posts across all your boards in a three-column Kanban layout:
| Column | Description |
| --------------- | --------------------------------- |
| **Planned** | Work you've committed to building |
| **In Progress** | Work currently underway |
| **Complete** | Recently shipped work |
Posts appear on the roadmap based on their status. Each status can be mapped to one of the three columns — or left unmapped to keep it off the roadmap.
## Setting up the roadmap [#setting-up-the-roadmap]
1. Go to any board's **Settings > Customization** tab
2. In the **Post statuses** section, choose a **Roadmap column** for each status (Planned, In Progress, or Complete) or leave it as "None"
3. The roadmap tab appears automatically in your portal once at least one status is mapped to a column
The roadmap tab only appears in the portal navigation when at least one status across any board has a roadmap column assigned. If no statuses are mapped, the tab is hidden.
## Default mappings [#default-mappings]
Board templates come with sensible defaults. For example, the **Feature Requests** template maps:
* **Planned** → Planned column
* **In Progress** → In Progress column
* **Done** → Complete column
* **Under Review** and **Closed** → not shown on roadmap
## Public URL [#public-url]
The roadmap is visible at:
```
https://feedindex.app/p/[your-org-slug]/roadmap
```
Or at the root `/roadmap` path if you use a custom domain.
Users can vote on posts directly from the roadmap view.
# Webhooks
{/* source: apps/web/lib/webhooks.ts */}
{/* source: apps/web/lib/actions/webhooks.ts */}
Webhooks let you receive HTTP POST requests when events occur in FeedIndex. Available on the **Pro** plan (up to 5 webhooks).
## Setup [#setup]
1. Go to **Workspace Settings > Integrations**
2. Click **Add Webhook**
3. Enter your endpoint URL
4. Choose a board to filter events, or leave as **All boards** to receive events from every board
5. Select which events to subscribe to
6. Save — your signing secret is shown once, copy it immediately
## Signatures [#signatures]
Every webhook request includes an `X-FeedIndex-Signature` header — an HMAC-SHA256 of the request body, computed with your signing secret.
The secret is shared only between FeedIndex and your server. Because HMAC is a *keyed* hash, nobody without the secret can produce a signature that will match when you recompute it on your end. That's what proves a request really came from us: if someone POSTs to your webhook URL with a forged payload, they can't compute the right signature, and your verification will reject it.
See [Signature Verification](/webhooks/verification) for code examples and the exact verification steps.
## Board filtering [#board-filtering]
Each webhook can be scoped to a specific board or set to **All boards**. When scoped to a board, only events from that board are delivered. Use this to send different boards' events to different endpoints — or leave as **All boards** and route events yourself using the `boardId` field in the payload.
## Event types [#event-types]
| Event | Trigger |
| --------------------- | -------------------------- |
| `post.created` | A new post is submitted |
| `post.status_changed` | A post's status is updated |
| `post.approved` | A pending post is approved |
| `post.deleted` | A post is deleted |
## Payload format [#payload-format]
All webhook payloads follow this structure:
```json
{
"event": "post.created",
"data": {
"postId": "uuid",
"postTitle": "Add dark mode",
"boardId": "uuid",
"boardName": "Feature Requests"
},
"timestamp": "2026-03-22T14:00:00.000Z"
}
```
The `data` object always includes `boardId` and `boardName`. Additional fields vary by event type — for example, `post.status_changed` also includes `fromStatus` and `toStatus`. The `timestamp` is an ISO 8601 string.
## Limits [#limits]
Each Pro workspace can create up to **5 webhooks**. If you need more granular routing, use a single "All boards" webhook and route events in your own endpoint using the `boardId` field.
## Delivery [#delivery]
* Webhooks are delivered as HTTP POST requests with a JSON body
* The destination must resolve to a public IP address on every delivery attempt
* Your endpoint must return a `2xx` status code within 10 seconds
* Failed deliveries are not retried (fire-and-forget)
* Two headers are included: `X-FeedIndex-Signature` and `X-FeedIndex-Event`
# Signature Verification
{/* source: apps/web/lib/webhooks.ts */}
Every webhook request includes an HMAC-SHA256 signature so you can verify it came from FeedIndex.
## Why this works [#why-this-works]
The signature is an HMAC — a *keyed* hash. Anyone can hash a payload, but only someone who knows your signing secret can produce a signature that matches when you recompute it on your end. The secret is shared only between FeedIndex and your server; nobody else ever sees it.
That's what makes verification safe:
* **Forged requests fail.** If an attacker POSTs their own payload to your webhook URL, they can't compute the right signature without the secret, so your comparison rejects it.
* **Tampered requests fail.** Changing even one byte of the body invalidates the signature, so a real request can't be modified in transit.
* **Replayed requests can be rejected** by checking the `timestamp` field in the body and discarding payloads older than a few minutes.
## Headers [#headers]
| Header | Description |
| ----------------------- | ------------------------------------------ |
| `X-FeedIndex-Signature` | HMAC-SHA256 hex digest of the request body |
| `X-FeedIndex-Event` | The event type (e.g., `post.created`) |
## Verification [#verification]
Compute the HMAC-SHA256 of the raw request body using your webhook signing secret, then compare it to the `X-FeedIndex-Signature` header.
### Node.js example [#nodejs-example]
```javascript
import crypto from "crypto";
function verifyWebhook(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
```
### Python example [#python-example]
```python
import hmac
import hashlib
def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
```
## Security tips [#security-tips]
* Always use a timing-safe comparison to prevent timing attacks
* Store your signing secret securely (environment variable, not source code)
* The `timestamp` field in the JSON body can be used to reject replayed requests (e.g., discard payloads older than 5 minutes)
# Configuration
{/* source: packages/widget/src/types.ts */}
{/* source: apps/web/app/(app)/workspace/widgets/[id]/_components/widget-embed-section.tsx */}
The widget is configured entirely from the dashboard. The script tag only needs the `data-widget` attribute — all other settings are loaded from the server when the widget initializes.
## Configuring the widget [#configuring-the-widget]
Go to **Workspace > Widgets** and select your widget. You can configure:
| Setting | Options | Description |
| ------------- | ------------------------------------------------------ | ------------------------------------------------------- |
| Position | `bottom-right`, `bottom-left`, `top-right`, `top-left` | Where the floating button appears |
| Trigger label | Any text | Text shown on the floating button (default: "Feedback") |
| Trigger icon | Chat, Lightbulb, Megaphone, Heart, None | Icon shown on the floating button |
| Theme | Light, Dark, System | Widget colour scheme |
| Accent colour | Any hex colour | Brand colour for buttons and highlights |
## Embed code [#embed-code]
The embed code is always the same minimal snippet:
```html
```
No `data-*` attributes are needed for configuration — changing settings in the dashboard updates the widget everywhere it's embedded, without touching your code.
## Allowed origins [#allowed-origins]
For security, the widget only accepts post submissions from domains you've approved.
Configure allowed origins in **Workspace > Widgets > Allowed Origins**. Add every domain where the widget is embedded (e.g., `https://yoursite.com`).
If no origins are configured, all submissions are blocked. Add at least one origin to enable feedback submissions.
Voting is always allowed regardless of origin configuration.
# Customization
{/* source: packages/widget/src/types.ts */}
{/* source: apps/web/lib/actions/widgets.ts */}
## Accent colour [#accent-colour]
Set a custom accent colour to match your brand. This colour is applied to the floating button and interactive elements inside the widget. Configure it in the widget editor under **Workspace > Widgets**.
## Trigger icons [#trigger-icons]
Choose from built-in icon presets for the floating button:
* **Chat** (default) — speech bubble icon
* **Lightbulb** — idea/suggestion icon
* **Megaphone** — announcement icon
* **Heart** — appreciation icon
* **None** — text only, no icon
## Logo [#logo]
Upload your logo in the widget editor to display it in the widget header. This helps users recognize the feedback widget as part of your product.
## Draft and publish [#draft-and-publish]
The widget editor uses a draft/publish workflow:
1. Make changes in the editor — they are saved as a **draft** automatically
2. Preview your changes in the live preview panel
3. Click **Publish** to apply the draft to the live widget
4. Or click **Discard** to revert to the currently published configuration
The live widget continues showing the last published configuration until you explicitly publish a new draft. This lets you experiment without affecting your users.
# Widget Installation
{/* source: packages/widget/src/index.ts */}
The FeedIndex widget lets your users submit feedback and vote on posts without leaving your site.
## Script tag [#script-tag]
Add this snippet before the closing `