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

  1. Add one or more empty Neut iframe placeholders to the page.
  2. Put widget variables on each placeholder container, usually from your CMS.
  3. Browser JavaScript sends those variables to your backend.
  4. Your backend validates the variables and returns a fresh embed_url.
  5. Browser JavaScript sets the iframe src.
  6. The hosted Neut iframe exchanges the bootstrap assertion for a short-lived widget token.
  7. The iframe sends resize messages to the host page.
  8. 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 IDs
  • data-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:

  1. Accept the browser payload.
  2. Validate fields for the requested widget.
  3. Read tenant, origin, and Neut mint credentials from server-side configuration.
  4. Sign a short-lived bootstrap assertion.
  5. Build a hosted Neut iframe URL.
  6. 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_SECRET is server-only.
  • WIDGET_TENANT_KEY comes from server configuration, not browser input.
  • HOST_ORIGIN is explicitly configured in production.
  • Browser-provided widget variables are validated before signing.
  • Full embed_url values 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

  1. Confirm /api/widget-embed-params returns 200.
  2. Confirm the response has embed_url and the expected widget_id.
  3. Confirm the server has NEUT_MINT_KEY_ID, NEUT_MINT_SECRET, WIDGET_ORIGIN, WIDGET_TENANT_KEY, and HOST_ORIGIN.
  4. Confirm required widget variables are present.
  5. Check browser console errors, but do not log full bootstrap URLs in production.

If the iframe has the wrong height

  1. Confirm the browser script listens for message events.
  2. Confirm the message origin matches WIDGET_ORIGIN.
  3. Confirm the message includes matching widget and instance_id values.

If the widget expires and does not recover

  1. Confirm the browser script listens for neut-widget-refresh-request.
  2. Confirm the refresh message origin matches WIDGET_ORIGIN.
  3. Confirm your page calls /api/widget-embed-params again instead of reusing the old iframe URL.
  4. Confirm the backend returns a newly signed embed_url with a fresh bootstrap nonce.