Setup Guide
Neut Widget Customer Quickstart
This guide shows the shortest production-safe path for adding Neut widgets to a customer website.
The browser must not create Neut widget tokens or sign bootstrap assertions. Your page asks your own backend for a fresh iframe URL, and your backend signs a short-lived bootstrap assertion with the Neut credentials provided to you.
For full parameter tables and backend examples, see Customer Widget Reference and Customer Widget Backend Guide.
Integration Flow
- Add one or more empty Neut iframe placeholders to the page.
- Put widget variables on each placeholder container, usually from your CMS.
- Browser JavaScript sends those variables to your backend.
- Your backend validates the variables and returns a fresh
embed_url. - Browser JavaScript sets the iframe
src. - The hosted Neut iframe exchanges the bootstrap assertion for a short-lived widget token.
- The iframe sends resize messages to the host page.
-
If the widget session expires, the iframe sends a refresh request so your page can ask your backend for a
new
embed_url.
Server Configuration
Configure these values on your server:
HOST_ORIGIN=https://www.customer-site.com
WIDGET_TENANT_ID=your-neut-tenant-id
WIDGET_ORIGIN=https://widgets.neut.us
NEUT_MINT_KEY_ID=your-mint-key-id
NEUT_MINT_SECRET=your-mint-secret
HOST_ORIGIN must be the exact origin of the customer website, with no path, query, or trailing
slash. Example: https://www.example.com.
NEUT_MINT_SECRET must stay on your server. Never send it to browser JavaScript.
Step 1: Add Widget Markup
Each widget needs:
data-neut-widget: one of the supported widget IDsdata-instance-id: a unique ID for this widget placement- widget-specific
data-*values - an empty iframe marked with
data-neut-frame - a visible status element for fallback text
Use a stable instance_id with only letters, numbers, dot, underscore, colon, or hyphen. Keep
it 128 characters or fewer.
Bill Preview
Use this widget when the page is about a specific bill.
<section
data-neut-widget="bill-preview"
data-instance-id="bill-preview-article-123-main"
data-article-id="article-123"
data-bill-type="HR"
data-bill-number="1"
data-congress="119"
data-colorway="Default"
>
<iframe
data-neut-frame
title="Neut Bill Preview"
style="width: 100%; max-width: 1000px; min-height: 320px; border: 0; display: block; margin: 16px 0;"
loading="lazy"
></iframe>
<p class="neut-widget-status">Loading bill preview...</p>
</section>
For same-day bill coverage before Congress data has updated, you may also include
data-temp-text-url and data-temp-title.
Vote Breakdown
Use this widget to show House roll call votes for a representative, or a general House roll call feed.
<section
data-neut-widget="vote-breakdown"
data-instance-id="vote-breakdown-lofgren-ca-18"
data-article-id="member-votes-001"
data-member-type="Representative"
data-last-name="Lofgren"
data-state-code="CA"
data-district="18"
data-congress="119"
data-limit="5"
data-colorway="Paper"
>
<iframe
data-neut-frame
title="Neut Vote Breakdown"
style="width: 100%; max-width: 1200px; min-height: 520px; border: 0; display: block; margin: 16px 0;"
loading="lazy"
></iframe>
<p class="neut-widget-status">Loading vote breakdown...</p>
</section>
data-district = null for At-large representatives.
To request a general House roll call feed, omit data-member-type, data-last-name,
data-state-code, and data-district together.
vote-breakdown currently supports House roll call votes only. Senate recorded votes are not available in this widget yet.
Legislation By Subject
Use this widget on an issue or policy subject page, either for a single member or for general legislation on that subject.
<section
data-neut-widget="legislation-by-subject"
data-instance-id="legislation-lofgren-health"
data-article-id="health-legislation-001"
data-member-type="Representative"
data-last-name="Lofgren"
data-state-code="CA"
data-district="18"
data-congress="119"
data-subject="Health & Social Welfare"
data-status-filter="All"
data-show-subject-filter="false"
data-show-status-filter="false"
data-limit="10"
data-colorway="WashedSage"
>
<iframe
data-neut-frame
title="Neut Legislation By Subject"
style="width: 100%; max-width: 980px; min-height: 760px; border: 0; display: block; margin: 16px 0;"
loading="lazy"
></iframe>
<p class="neut-widget-status">Loading legislation...</p>
</section>
To request general legislation on a subject, omit data-member-type,
data-last-name, data-state-code, and data-district together.
Step 2: Add Browser JavaScript
This script mounts every [data-neut-widget] block on the page, so it supports multiple widgets
of the same type. It also listens for iframe resize messages and expired-session refresh requests.
<script>
const SUPPORTED_NEUT_WIDGETS = new Set([
"bill-preview",
"vote-breakdown",
"legislation-by-subject",
]);
function readRequiredInt(value, fieldName) {
if (value === undefined || value === null || value === "") {
throw new Error(`${fieldName} is required.`);
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${fieldName} must be a positive integer.`);
}
return parsed;
}
function readOptionalInt(value, fieldName, defaultValue) {
const raw = value || defaultValue;
if (raw === undefined || raw === null || raw === "") return undefined;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${fieldName} must be a positive integer.`);
}
return parsed;
}
function readBool(value, defaultValue) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
if (String(value).toLowerCase() === "true") return true;
if (String(value).toLowerCase() === "false") return false;
throw new Error("Boolean values must be true or false.");
}
function readOptionalText(value) {
if (value === undefined || value === null || value === "") {
return undefined;
}
return String(value).trim() || undefined;
}
function basePayload(container, widgetId) {
const instanceId = container.dataset.instanceId;
if (!instanceId) {
throw new Error("Widget instance_id is required.");
}
return {
widget_id: widgetId,
instance_id: instanceId,
article_id: container.dataset.articleId || undefined,
colorway: container.dataset.colorway || undefined,
};
}
function readMemberFilters(container) {
const memberType = readOptionalText(container.dataset.memberType);
const lastName = readOptionalText(container.dataset.lastName);
const stateCode = readOptionalText(container.dataset.stateCode);
const district = readOptionalInt(container.dataset.district, "district");
const hasMemberInput = Boolean(memberType || lastName || stateCode || district);
if (!hasMemberInput) {
return {};
}
// This helper is shared across member-targeted widgets.
// vote-breakdown applies an extra House-only Representative check later.
if (!memberType || !lastName || !stateCode) {
throw new Error(
"member_type, last_name, and state_code are required when targeting a single member."
);
}
if (memberType !== "Representative" && memberType !== "Senator") {
throw new Error('member_type must be "Representative" or "Senator".');
}
if (!/^[A-Za-z]{2}$/.test(stateCode)) {
throw new Error("state_code must be a two-letter state code.");
}
if (district && memberType !== "Representative") {
throw new Error('district is only allowed when member_type is "Representative".');
}
return {
member_type: memberType,
last_name: lastName,
state_code: stateCode.toUpperCase(),
district,
};
}
async function requestEmbedUrl(payload) {
const response = await fetch("/api/widget-embed-params", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message || "Widget setup failed.");
}
if (!data.embed_url || data.widget_id !== payload.widget_id) {
throw new Error("Backend returned an invalid widget response.");
}
return data;
}
function buildPayload(container) {
const widgetId = container.dataset.neutWidget;
if (!SUPPORTED_NEUT_WIDGETS.has(widgetId)) {
throw new Error("Unsupported Neut widget.");
}
if (widgetId === "bill-preview") {
return {
...basePayload(container, widgetId),
bill_type: container.dataset.billType,
bill_number: readRequiredInt(container.dataset.billNumber, "bill_number"),
congress: readRequiredInt(container.dataset.congress, "congress"),
temp_text_url: container.dataset.tempTextUrl || undefined,
temp_title: container.dataset.tempTitle || undefined,
};
}
if (widgetId === "vote-breakdown") {
const memberFilters = readMemberFilters(container);
if (
memberFilters.member_type &&
memberFilters.member_type !== "Representative"
) {
throw new Error(
'vote-breakdown currently supports House roll call votes only; member_type must be "Representative".'
);
}
return {
...basePayload(container, widgetId),
...memberFilters,
congress: readRequiredInt(container.dataset.congress, "congress"),
page: readOptionalInt(container.dataset.page, "page", "1"),
limit: readOptionalInt(container.dataset.limit, "limit", "10"),
};
}
return {
...basePayload(container, widgetId),
...readMemberFilters(container),
congress: readRequiredInt(container.dataset.congress, "congress"),
subject: container.dataset.subject,
status_filter: container.dataset.statusFilter || "All",
show_subject_filter: readBool(container.dataset.showSubjectFilter, true),
show_status_filter: readBool(container.dataset.showStatusFilter, true),
page: readOptionalInt(container.dataset.page, "page", "1"),
limit: readOptionalInt(container.dataset.limit, "limit", "10"),
};
}
function setStatus(container, message, isError = false) {
const status = container.querySelector(".neut-widget-status");
if (!status) return;
status.textContent = message;
status.dataset.state = isError ? "error" : "ready";
}
async function mountNeutWidget(container) {
const frame = container.querySelector("iframe[data-neut-frame]");
if (!frame) return;
try {
const payload = buildPayload(container);
let expectedOrigin = null;
async function loadFrame() {
const data = await requestEmbedUrl(payload);
expectedOrigin = new URL(data.embed_url).origin;
frame.src = data.embed_url;
}
window.addEventListener("message", (event) => {
if (event.origin !== expectedOrigin || !event.data) return;
if (event.data.widget !== payload.widget_id) return;
if (event.data.instance_id !== payload.instance_id) return;
if (event.data.type === "neut-widget-resize") {
const height = Number.parseInt(event.data.height, 10);
if (!Number.isNaN(height) && height > 0) {
frame.style.height = `${Math.ceil(height)}px`;
}
return;
}
if (event.data.type === "neut-widget-refresh-request") {
setStatus(container, "Refreshing widget session...");
loadFrame()
.then(() => setStatus(container, "Widget loaded."))
.catch((error) => {
setStatus(container, error.message || "Widget refresh failed.", true);
});
}
});
await loadFrame();
setStatus(container, "Widget loaded.");
} catch (error) {
setStatus(container, error.message || "Widget setup failed.", true);
}
}
document
.querySelectorAll("[data-neut-widget]")
.forEach(mountNeutWidget);
</script>
Step 3: Add Your Backend Endpoint
Create this endpoint on your own website backend:
POST /api/widget-embed-params
The endpoint should:
- Accept the browser payload.
- Validate fields for the requested widget.
- Read tenant, origin, and Neut mint credentials from server-side configuration.
- Sign a short-lived bootstrap assertion.
- Build a hosted Neut iframe URL.
- Return:
{
"widget_id": "bill-preview",
"embed_url": "https://widgets.neut.us/bill-preview-embed.html?..."
}
See Customer Widget Backend Guide for a complete Node/Express example.
If the iframe later posts a neut-widget-refresh-request message, call this same backend
endpoint again and replace the iframe src with the new embed_url.
Launch Checklist
NEUT_MINT_SECRETis server-only.WIDGET_TENANT_IDcomes from server configuration, not browser input.HOST_ORIGINis explicitly configured in production.- Browser-provided widget variables are validated before signing.
- Full
embed_urlvalues and full query strings are not logged. - Iframe resize messages are accepted only from
WIDGET_ORIGIN. - Iframe refresh requests are accepted only from
WIDGET_ORIGIN. - A visible fallback status message appears if setup fails.
Troubleshooting
If the iframe is blank
- Confirm
/api/widget-embed-paramsreturns200. - Confirm the response has
embed_urland the expectedwidget_id. -
Confirm the server has
NEUT_MINT_KEY_ID,NEUT_MINT_SECRET,WIDGET_ORIGIN,WIDGET_TENANT_ID, andHOST_ORIGIN. - Confirm required widget variables are present.
- Check browser console errors, but do not log full bootstrap URLs in production.
If the iframe has the wrong height
- Confirm the browser script listens for
messageevents. - Confirm the message origin matches
WIDGET_ORIGIN. - Confirm the message includes matching
widgetandinstance_idvalues.
If the widget expires and does not recover
- Confirm the browser script listens for
neut-widget-refresh-request. - Confirm the refresh message origin matches
WIDGET_ORIGIN. -
Confirm your page calls
/api/widget-embed-paramsagain instead of reusing the old iframe URL. - Confirm the backend returns a newly signed
embed_urlwith a fresh bootstrap nonce.