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_KEY=your-neut-tenant-key
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.
Congress example:
<section
data-neut-widget="bill-preview"
data-instance-id="bill-preview-article-123-main"
data-article-id="article-123"
data-source="congress"
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>
State example:
<section
data-neut-widget="bill-preview"
data-instance-id="bill-preview-ca-ab-123"
data-article-id="article-456"
data-source="state"
data-state="CA"
data-bill-identifier="AB 123"
data-session="2025"
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>
Omit data-source for Congress-only pages if you prefer the backward-compatible default. When
omitted, the widget behaves as source=congress.
For same-day Congress bill coverage before Congress data has updated, you may also include
data-temp-text-url and data-temp-title. Those fallback fields are Congress-only
and should not be used for state bill previews.
Vote Ledger
Use this widget to show House roll call votes for a representative, or a general House roll call feed.
<section
data-neut-widget="vote-ledger"
data-instance-id="vote-ledger-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 Ledger"
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 ledger...</p>
</section>
To request a general House roll call feed, omit data-member-type, data-last-name,
data-state-code, and data-district together.
vote-ledger currently supports House roll call votes only. Senate recorded votes are not available in this widget yet.
Legislation Log
Use this widget on an issue or policy page. It supports Congress parent-subject mode, Congress saved custom subject filters, and a statewide state bill ledger.
Congress Parent Subject
<section
data-neut-widget="legislation-log"
data-source="congress"
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-page="1"
data-limit="10"
data-colorway="WashedSage"
>
<iframe
data-neut-frame
title="Neut Legislation Log"
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>
Omit data-source for backward-compatible Congress pages if you prefer. When omitted, the widget
behaves as source=congress.
To request general legislation on a subject, omit data-member-type,
data-last-name, data-state-code, and data-district together.
Congress Custom Subject Filter
<section
data-neut-widget="legislation-log"
data-source="congress"
data-instance-id="legislation-healthcare-access"
data-congress="119"
data-custom-subject-filter-id="550e8400-e29b-41d4-a716-446655440000"
data-status-filter="All"
data-show-status-filter="false"
data-page="1"
data-limit="10"
data-colorway="WashedSage"
>
<iframe
data-neut-frame
title="Neut Legislation Log"
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>
Do not include data-subject when using data-custom-subject-filter-id.
State Bill Ledger
<section
data-neut-widget="legislation-log"
data-source="state"
data-instance-id="legislation-ca-activity"
data-state="CA"
data-page="1"
data-limit="10"
data-colorway="WashedSage"
>
<iframe
data-neut-frame
title="Neut State Bill Ledger"
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 state bills...</p>
</section>
State mode always uses subject=All and status_filter=All. Do not include Congress,
member, or custom-filter fields in the state payload.
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-ledger",
"legislation-log",
]);
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-ledger 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 readRequiredText(value, fieldName) {
const text = readOptionalText(value);
if (!text) {
throw new Error(`${fieldName} is required.`);
}
return text;
}
function readBillPreviewSource(container) {
const source = (container.dataset.source || "congress").trim().toLowerCase();
if (source === "congress" || source === "state") {
return source;
}
throw new Error('bill-preview source must be "congress" or "state".');
}
function readStateCode(value, fieldName) {
const text = readRequiredText(value, fieldName);
const normalized = text.toUpperCase();
if (!/^[A-Z]{2}$/.test(normalized)) {
throw new Error(`${fieldName} must be a two-letter state or territory code.`);
}
return normalized;
}
function readLegislationLogSource(container) {
const source = (container.dataset.source || "congress").trim().toLowerCase();
if (source === "congress" || source === "state") {
return source;
}
throw new Error('legislation-log source must be "congress" or "state".');
}
function buildPayload(container) {
const widgetId = container.dataset.neutWidget;
if (!SUPPORTED_NEUT_WIDGETS.has(widgetId)) {
throw new Error("Unsupported Neut widget.");
}
if (widgetId === "bill-preview") {
const source = readBillPreviewSource(container);
const payload = {
...basePayload(container, widgetId),
source,
};
if (source === "state") {
return {
...payload,
state: readStateCode(container.dataset.state, "state"),
bill_identifier: readRequiredText(
container.dataset.billIdentifier,
"bill_identifier"
),
session: readOptionalText(container.dataset.session),
};
}
return {
...payload,
bill_type: readRequiredText(container.dataset.billType, "bill_type"),
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-ledger") {
return {
...basePayload(container, widgetId),
...readMemberFilters(container),
congress: readRequiredInt(container.dataset.congress, "congress"),
page: readOptionalInt(container.dataset.page, "page", "1"),
limit: readOptionalInt(container.dataset.limit, "limit", "10"),
};
}
if (widgetId === "legislation-log") {
const source = readLegislationLogSource(container);
const payload = {
...basePayload(container, widgetId),
source,
};
if (source === "state") {
return {
...payload,
state: readStateCode(container.dataset.state, "state"),
subject: "All",
status_filter: "All",
page: readOptionalInt(container.dataset.page, "page", "1"),
limit: readOptionalInt(container.dataset.limit, "limit", "10"),
};
}
const customSubjectFilterId = readOptionalText(
container.dataset.customSubjectFilterId
);
const congressPayload = {
...payload,
...readMemberFilters(container),
congress: readRequiredInt(container.dataset.congress, "congress"),
status_filter: container.dataset.statusFilter || "All",
show_status_filter: readBool(container.dataset.showStatusFilter, true),
page: readOptionalInt(container.dataset.page, "page", "1"),
limit: readOptionalInt(container.dataset.limit, "limit", "10"),
};
if (customSubjectFilterId) {
return {
...congressPayload,
custom_subject_filter_id: customSubjectFilterId,
show_subject_filter: false,
};
}
return {
...congressPayload,
subject: readRequiredText(container.dataset.subject, "subject"),
show_subject_filter: readBool(container.dataset.showSubjectFilter, true),
};
}
throw new Error("Unsupported Neut widget.");
}
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_KEYcomes 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_KEY, 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.