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_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 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.

<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:

  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_ID 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_ID, 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.