Moderated content feed
On Pro plan and higher, a form can expose a public, moderated feed of its approved submissions, with anonymous reactions and a report control. Visitors submit through your normal form, you approve what should be public, and your site shows it.
The short version, for static sites:
- No API key. The feed, its files, reactions and reports are public endpoints on
https://f.bootform.com. Nothing secret goes in your page. - CORS is open on every one of them (feed, files, react, report, status). Any origin can call them with
fetch(), so a site on GitHub Pages, Netlify or plain HTML works with no server of your own. - Photos and files are included. A file uploaded under a public field comes with a public link you can use directly as an
<img src>. - Only what you approve is shown, and only the fields you mark public.
Good fits: testimonials, guestbooks, community boards, a map of reported places, lost and found, giveaways and swaps. For listings that go stale, give the form an item status such as "taken" or "found": visitors mark an item, and the feed shows it.
Setting it up
From your form's Workflow tab, under Moderated content feed:
- Enable submission moderation turns on the Approve/Reject/Undecided control inside each submission (see Managing submissions) and the Pending review folder. The feed shows nothing until this is on.
- Public fields lists the fields that are safe to show publicly, file fields included. Every other field stays private even though it's stored: the feed omits it entirely rather than blanking it.
- Report threshold hides a submission from the feed (pending your re-review) once it collects this many reports. Off by default; leave blank to keep it off. To bring a hidden submission back, approve it again: that starts its report count over. Visitors who already reported it can't report it a second time.
- Item status (optional) lets visitors mark an item, for example as "taken". See Item status.
Every submission needs your approval before it appears. New submissions start as pending, and only the ones you approve are published.
Setting it up through the API
The same settings are part of PATCH https://api.bootform.com/forms/{form_id} (with an API key):
{
"content_feed": {
"moderation_enabled": true,
"public_fields": ["description", "category", "lat", "lng", "photo"],
"report_threshold": 3,
"item_status": { "label": "taken", "threshold": 1 }
}
}public_fields replaces the whole list each time you send it. Send report_threshold: 0 to turn auto-hide off. An AI agent connected to the MCP server can do the same with bootform_configure_form (content_feed_enabled, public_fields, report_threshold, item_status_label). To approve a submission, PATCH .../forms/{form_id}/submissions/{submission_id} with {"moderation_status": "approved"} (or rejected, or pending to undo). The MCP tool bootform_moderate_submission does the same. Approving before moderation_enabled is on returns 422 MODERATION_NOT_ENABLED.
What the feed returns
GET https://f.bootform.com/forms/{form_id}/feed?page=1&per_page=50form_id is the form's ID or its vanity slug. Items are approved submissions, newest first:
{
"ok": true,
"items": [
{
"id": "0b9c6a7e-4c2d-4f1a-9a55-2f6d1e0c8b11",
"fields": { "description": "Two chairs, good condition", "category": "furniture", "lat": "37.98", "lng": "23.72" },
"created_at": "2026-10-09T08:30:00Z",
"reactions": { "up": 4, "down": 0 },
"status": { "label": "taken", "set": false, "set_at": null, "count": 0 },
"files": [
{
"field": "photo",
"name": "chairs.jpg",
"mime": "image/jpeg",
"size": 184320,
"url": "https://f.bootform.com/forms/11111111-1111-4111-8111-111111111111/feed/0b9c6a7e-4c2d-4f1a-9a55-2f6d1e0c8b11/files/0"
}
]
}
],
"total": 1,
"page": 1,
"per_page": 50
}Field values are always strings, exactly as submitted. status is null unless the form has an item status. Full schema: openapi.json / openapi.yaml.
Item status
An item status is one word or short phrase visitors can mark a feed item with: "taken" on a giveaway, "found" on a lost item, "sold", "full" on an event. It keeps a listing accurate without you moderating every change, and without a backend of your own.
Turn it on in the same Workflow tab section: type the label, choose how many visitors must mark an item before it counts (1 by default), and optionally hide marked items after some hours. Through the API it is content_feed.item_status (above): sending it replaces the whole status, and an empty label removes it. Through MCP, bootform_configure_form takes item_status_label, item_status_threshold and item_status_hide_after_hours.
Visitors mark an item with no key:
POST https://f.bootform.com/forms/{form_id}/feed/{submission_id}/status (no body)The answer is the item's status after the mark, for example {"ok": true, "status": {"label": "taken", "set": true, "set_at": "2026-10-09T12:00:00Z", "count": 1}}. Each IP address can mark an item once (429 on a repeat), and a form with no item status answers 422 STATUS_NOT_ENABLED.
The feed shows it on every item: set turns true once enough visitors marked it, or once you set it yourself. A set item stays in the feed with its status, unless you chose to hide marked items after a delay. To list only what's still available, add ?status=open; ?status=set lists only the marked ones.
You can correct it from the submission's page in the dashboard (Mark as taken or Clear), with PATCH .../submissions/{submission_id} and {"feed_status": "set"} or {"feed_status": "clear"}, or with MCP bootform_moderate_submission (feed_status). Your "set" counts whatever the visitor count; "clear" removes the status and every visitor mark, so the item starts over.
For a map of giveaways, for example, load ?status=open for the pins and give each popup a "Mark as taken" button that calls the status endpoint.
Photos and files
A file appears in files when the form field it was uploaded under is in Public fields, so a <input type="file" name="photo"> becomes public by adding photo to the list. Files under any other field stay private. File uploads need the Starter plan or higher (the feed itself needs Pro), and the size limits are on the pricing page.
Each file's url is a stable BootForm link. Use it as it is, for example as an <img src>:
- Every request checks that the submission is still in the feed. While it is, the link redirects to the file, served from storage for 15 minutes at a time.
- When you reject or archive the submission, mark it spam, remove the field from Public fields, or it reaches the report threshold, the link starts answering
404within about a minute. - Use
mimeto decide how to show a file: renderimage/*inline, link to anything else.
Files uploaded before 9 October 2026 didn't record which field they came from, so they stay private.
Photos carry their location. A photo straight from a phone usually has GPS coordinates in its EXIF metadata, and BootForm stores and serves files exactly as uploaded. If the place a photo was taken shouldn't be public, strip the metadata in the browser before submitting, for example by redrawing the image onto a canvas:
async function withoutMetadata(file, maxSize = 1600) {
const bitmap = await createImageBitmap(file)
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement('canvas')
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85))
return new File([blob], 'photo.jpg', { type: 'image/jpeg' })
}
// before sending: formData.set('photo', await withoutMetadata(formData.get('photo')))This also shrinks large photos, so uploads stay well under your plan's per-file limit.
Showing the feed on your site
Option 1: the hosted widget
Copy the snippet from the same Workflow tab section and paste it anywhere on your page:
<script src="https://bootform.com/widget/v1/bootform-widget.js" data-form-id="11111111-1111-4111-8111-111111111111" async></script>It renders itself in place of the tag: a list of approved submissions (public fields only, images shown inline, other files as links), thumbs up/down reaction buttons with live counts, and a report control. It's framework-free, has no dependencies, and uses plain (not shadow-DOM-isolated) CSS. Embed it more than once on the same page for multiple feeds.
| Attribute | Default | Description |
|---|---|---|
data-form-id | (required) | Your form's ID or vanity slug |
data-api-base | https://f.bootform.com | Override the API host. Only needed for local or staging testing |
data-per-page | 20 | Items loaded per page/"Load more" click |
Customizing the widget's look
The widget takes its text color from your page and uses translucent borders and transparent buttons, so it fits a light or a dark site without any setup. It renders directly into the page (no shadow DOM), so your own CSS overrides it. It just needs to load after the widget's script and use a class with equal or higher specificity. Every element carries one of these classes:
| Class | Element |
|---|---|
.bf-feed | The root container |
.bf-feed__status | Loading/empty-state text |
.bf-feed__error | Error banner |
.bf-feed__list | The <ul> of submissions |
.bf-feed__item | One submission's card |
.bf-feed__fields | A submission's field list (<dl>) |
.bf-feed__files | The row of a submission's files |
.bf-feed__file | One file's link |
.bf-feed__image | An image shown inline |
.bf-feed__meta | The row under a submission's fields (timestamp + actions) |
.bf-feed__actions | Reaction/report button group |
.bf-feed__reaction | A single reaction button |
.bf-feed__reaction--active | A reaction button the current visitor has already picked |
.bf-feed__report | The report button |
.bf-feed__mark | The "Mark as ..." button (item status) |
.bf-feed__badge | The status badge on a marked item |
.bf-feed__item--set | A card whose item status is set |
.bf-feed__load-more | The "Load more" button |
For example, to match your own brand color instead of the default blue and square off the cards:
.bf-feed__item {
border-radius: 0;
}
.bf-feed__reaction--active {
border-color: #16a34a;
color: #16a34a;
}Option 2: your own UI with fetch()
For cards, a map or anything else, call the same public endpoints the widget uses. No key, no proxy, straight from the browser:
<div id="items"></div>
<script type="module">
const FORM_ID = '11111111-1111-4111-8111-111111111111' // your form's ID
const res = await fetch(`https://f.bootform.com/forms/${FORM_ID}/feed?per_page=100`)
const { items } = await res.json()
const list = document.getElementById('items')
for (const item of items) {
const card = document.createElement('article')
const photo = item.files.find((f) => f.mime.startsWith('image/'))
if (photo) {
const img = document.createElement('img')
img.src = photo.url
img.alt = item.fields.description ?? ''
img.loading = 'lazy'
card.append(img)
}
const text = document.createElement('p')
text.textContent = item.fields.description ?? '' // textContent, never innerHTML: values are visitor input
card.append(text)
list.append(card)
}
</script>Treat every field value as untrusted visitor input: insert it with textContent (or your framework's normal escaping), never as HTML.
Reactions, reports and item status:
POST https://f.bootform.com/forms/{form_id}/feed/{submission_id}/react { "type": "up" } (or "down")
POST https://f.bootform.com/forms/{form_id}/feed/{submission_id}/report (no body)
POST https://f.bootform.com/forms/{form_id}/feed/{submission_id}/status (no body; needs an item status on the form)Limits and timing
| What | Limit |
|---|---|
per_page | Default 50, maximum 200 |
| Order | Newest first (created_at) |
| Feed requests | 60 per minute per IP address, per form |
| File requests | 600 per minute per IP address, per form |
| Reactions | One per IP address per submission (sending the other type switches it), remembered for 90 days after the last one; one react request per submission per minute |
| Reports | One per IP address per submission, ever |
| Item status marks | One per IP address per submission, ever |
| Delay after approving or rejecting | Up to 30 seconds for the feed, about a minute for a file link (responses are cached) |
| Delay after changing the feed's settings | None: public fields and turning the feed on or off apply to the next request |
Going over a limit returns 429 with code RATE_LIMITED.
"One per visitor" means one per IP address. There's no cookie and no account, so people sharing a network (an office, a school, some mobile carriers) share one reaction, one report and one status mark, and someone who switches networks can react again. If one stray mark shouldn't flip an item, raise the item status threshold. Good enough for a sense of what visitors think. Not a vote you could hold an election on.
Keep API keys out of your page
API keys can do everything on your account: there are no read-only or per-form keys. Never put one in client-side JavaScript, a static site's source, or a public repository. The feed exists so you never have to: it is the public, read-only view of exactly the submissions you approved.