Neut Widget Backend Guide

This guide covers the customer backend endpoint used by the browser quickstart.

The backend endpoint creates a fresh iframe URL for each widget mount. It does not call Neut while building the URL. The hosted iframe calls Neut POST /api/widgets/bootstrap after it loads.

Your page may call this same endpoint again later if the iframe posts a neut-widget-refresh-request message after its widget session expires.

Endpoint Contract

Create this endpoint on your website backend:

POST /api/widget-embed-params

Request body:

{
  "widget_id": "bill-preview",
  "instance_id": "bill-preview-article-123-main",
  "bill_type": "HR",
  "bill_number": 1,
  "congress": 119,
  "colorway": "Default"
}

Success response:

{
  "widget_id": "bill-preview",
  "embed_url": "https://widgets.neut.us/bill-preview-embed.html?tenant_id=..."
}

Error response:

{
  "error": {
    "code": "INVALID_WIDGET_REQUEST",
    "message": "Unsupported widget_id."
  }
}

Signing Contract

Your backend signs this canonical value:

tenant_id.widget_id.origin.timestamp.nonce.bootstrap

Where:

  • tenant_id is WIDGET_TENANT_ID
  • widget_id is the validated widget ID
  • origin is HOST_ORIGIN
  • timestamp is a whole-second Unix timestamp string
  • nonce is a fresh random value
  • bootstrap is the literal purpose string

The signature is HMAC-SHA256 using NEUT_MINT_SECRET, encoded as unpadded base64url.

bootstrap_timestamp must stay in the same base-10 whole-second form that was signed. Do not sign fractional seconds, exponent notation, leading plus signs, or alternate textual forms.

Complete Node/Express Example

This example validates the current public-data widgets, signs a bootstrap assertion, and returns the hosted iframe URL.

const crypto = require("node:crypto");
const express = require("express");

const app = express();
app.use(express.json());

const WIDGET_PATHS = {
  "bill-preview": "/bill-preview-embed.html",
  "vote-breakdown": "/vote-breakdown-embed.html",
  "legislation-by-subject": "/legislation-by-subject-embed.html",
};

const SUBJECTS = new Set([
  "Animals & Agriculture",
  "Business & Trade",
  "Crime",
  "Culture and Society",
  "Education",
  "Energy & Conservation",
  "Finance & Taxes",
  "Geopolitics",
  "Government Operations",
  "Health & Social Welfare",
  "Human, Civil, & Immigration Rights",
  "Infrastructure & Housing",
  "Labor & Employment",
  "National Security & International Affairs",
  "STEM",
]);

const STATUS_FILTERS = new Set([
  "All",
  "Introduced",
  "With First Chamber",
  "With Second Chamber",
  "Presented to President",
  "Became Law",
  "Vetoed by President",
  "Passed Resolutions",
]);

const COLORWAYS = new Set([
  "Default",
  "Dark",
  "Paper",
  "WashedSage",
  "Slate",
  "MonoLight",
]);

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing server config: ${name}`);
  return value;
}

function requireText(input, key) {
  const value = input[key];
  if (typeof value !== "string" || !value.trim()) {
    throw new Error(`${key} is required.`);
  }
  return value.trim();
}

function optionalText(input, key) {
  const value = input[key];
  if (value === undefined || value === null || value === "") return undefined;
  if (typeof value !== "string" || !value.trim()) {
    throw new Error(`${key} must be a string.`);
  }
  return value.trim();
}

function requiredPositiveInteger(input, key) {
  const raw = input[key];
  if (raw === undefined || raw === null || raw === "") {
    throw new Error(`${key} is required.`);
  }

  const value = Number(raw);
  if (!Number.isInteger(value) || value <= 0) {
    throw new Error(`${key} must be a positive integer.`);
  }
  return value;
}

function optionalPositiveInteger(input, key, defaultValue) {
  const raw = input[key] ?? defaultValue;
  if (raw === undefined || raw === null || raw === "") return undefined;

  const value = Number(raw);
  if (!Number.isInteger(value) || value <= 0) {
    throw new Error(`${key} must be a positive integer.`);
  }
  return value;
}

function optionalBoolean(input, key, defaultValue) {
  const raw = input[key];
  if (raw === undefined || raw === null || raw === "") return defaultValue;
  if (raw === true || raw === "true") return true;
  if (raw === false || raw === "false") return false;
  throw new Error(`${key} must be true or false.`);
}

function validateCommon(input) {
  const widgetId = requireText(input, "widget_id");
  if (!WIDGET_PATHS[widgetId]) {
    throw new Error("Unsupported widget_id.");
  }

  const instanceId = requireText(input, "instance_id");
  if (instanceId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(instanceId)) {
    throw new Error(
      "instance_id must use only letters, numbers, dot, underscore, colon, or hyphen and be 128 characters or fewer."
    );
  }

  const colorway = optionalText(input, "colorway") || "Default";
  if (!COLORWAYS.has(colorway)) {
    throw new Error("Unsupported colorway.");
  }

  return {
    widget_id: widgetId,
    instance_id: instanceId,
    article_id: optionalText(input, "article_id"),
    colorway,
  };
}

function validatePagedLedgerFields(input) {
  return {
    congress: requiredPositiveInteger(input, "congress"),
    page: optionalPositiveInteger(input, "page", 1),
    limit: optionalPositiveInteger(input, "limit", 10),
  };
}

function validateOptionalMemberFields(input) {
  const memberType = optionalText(input, "member_type");
  const lastName = optionalText(input, "last_name");
  const stateCodeRaw = optionalText(input, "state_code");
  const district = optionalPositiveInteger(input, "district");
  const hasMemberInput = Boolean(memberType || lastName || stateCodeRaw || 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 || !stateCodeRaw) {
    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".');
  }

  const stateCode = stateCodeRaw.toUpperCase();
  if (!/^[A-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,
    district,
  };
}

function validateWidgetRequest(input) {
  if (!input || typeof input !== "object" || Array.isArray(input)) {
    throw new Error("Request body must be a JSON object.");
  }

  const common = validateCommon(input);

  if (common.widget_id === "bill-preview") {
    const tempTextUrl = optionalText(input, "temp_text_url");
    if (tempTextUrl) {
      const parsed = new URL(tempTextUrl);
      if (parsed.protocol !== "https:") {
        throw new Error("temp_text_url must be an HTTPS URL.");
      }
    }

    return {
      ...common,
      bill_type: requireText(input, "bill_type"),
      bill_number: requiredPositiveInteger(input, "bill_number"),
      congress: requiredPositiveInteger(input, "congress"),
      temp_text_url: tempTextUrl,
      temp_title: optionalText(input, "temp_title"),
    };
  }

  if (common.widget_id === "vote-breakdown") {
    const memberFilters = validateOptionalMemberFields(input);
    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 {
      ...common,
      ...validatePagedLedgerFields(input),
      ...memberFilters,
    };
  }

  const subject = requireText(input, "subject");
  if (!SUBJECTS.has(subject)) {
    throw new Error("Unsupported subject.");
  }

  const statusFilter = optionalText(input, "status_filter") || "All";
  if (!STATUS_FILTERS.has(statusFilter)) {
    throw new Error("Unsupported status_filter.");
  }

  return {
    ...common,
    ...validatePagedLedgerFields(input),
    ...validateOptionalMemberFields(input),
    subject,
    status_filter: statusFilter,
    show_subject_filter: optionalBoolean(input, "show_subject_filter", true),
    show_status_filter: optionalBoolean(input, "show_status_filter", true),
  };
}

function validateOrigin(origin) {
  const parsed = new URL(origin);
  if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
    throw new Error("HOST_ORIGIN must be scheme://host[:port] with no path/query/hash.");
  }
  return `${parsed.protocol}//${parsed.host}`;
}

function signBootstrapPayload(payload) {
  const canonical = [
    payload.tenant_id,
    payload.widget_id,
    payload.origin,
    payload.timestamp,
    payload.nonce,
    "bootstrap",
  ].join(".");

  return crypto
    .createHmac("sha256", requiredEnv("NEUT_MINT_SECRET"))
    .update(canonical)
    .digest("base64url");
}

function createWidgetEmbedParams(input) {
  const widgetPath = WIDGET_PATHS[input.widget_id];
  const timestamp = String(Math.floor(Date.now() / 1000));
  const bootstrapPayload = {
    tenant_id: requiredEnv("WIDGET_TENANT_ID"),
    widget_id: input.widget_id,
    origin: validateOrigin(requiredEnv("HOST_ORIGIN")),
    timestamp,
    nonce: crypto.randomUUID(),
    key_id: requiredEnv("NEUT_MINT_KEY_ID"),
  };

  const embedUrl = new URL(widgetPath, requiredEnv("WIDGET_ORIGIN"));
  embedUrl.searchParams.set("tenant_id", bootstrapPayload.tenant_id);
  embedUrl.searchParams.set("widget_id", bootstrapPayload.widget_id);
  embedUrl.searchParams.set("customer_origin", bootstrapPayload.origin);
  embedUrl.searchParams.set("bootstrap_timestamp", bootstrapPayload.timestamp);
  embedUrl.searchParams.set("bootstrap_nonce", bootstrapPayload.nonce);
  embedUrl.searchParams.set("bootstrap_key_id", bootstrapPayload.key_id);
  embedUrl.searchParams.set(
    "bootstrap_assertion",
    signBootstrapPayload(bootstrapPayload)
  );

  for (const [key, value] of Object.entries(input)) {
    if (value !== undefined && value !== null && key !== "widget_id") {
      embedUrl.searchParams.set(key, String(value));
    }
  }

  return { widget_id: input.widget_id, embed_url: embedUrl.toString() };
}

app.post("/api/widget-embed-params", (req, res) => {
  try {
    const input = validateWidgetRequest(req.body);
    res.json(createWidgetEmbedParams(input));
  } catch (error) {
    res.status(400).json({
      error: {
        code: "INVALID_WIDGET_REQUEST",
        message: error.message || "Widget setup failed.",
      },
    });
  }
});

In this example, both widgets accept either:

  • a general request with no member filters
  • a member-targeted request with the full member_type + last_name + state_code set

Do not accept partial member filters when building the iframe URL.

vote-breakdown currently supports House roll call votes only. Member-targeted requests must use member_type=Representative, and general requests return the House roll call feed.

Refresh Handling

If the hosted iframe posts a neut-widget-refresh-request message to the parent page, the browser should call this same backend endpoint again and replace the iframe src with the newly returned embed_url.

That refresh request means the old widget session expired. Do not reuse the previous iframe URL. Mint a fresh bootstrap nonce and assertion each time you return a replacement embed_url.

Security Checklist

  • Keep NEUT_MINT_SECRET on the server only.
  • Read WIDGET_TENANT_ID and HOST_ORIGIN from server configuration.
  • Do not accept tenant_id, customer_origin, bootstrap params, or token values from the browser.
  • Validate all browser-provided widget variables before signing.
  • Generate a fresh nonce for each embed URL.
  • Treat each embed URL as one-time use.
  • Do not log full embed_url values, full query strings, bootstrap_assertion, bootstrap_nonce, request bodies sent to /api/widgets/bootstrap, Authorization headers, or access tokens.
  • If your app runs behind a proxy, make sure the proxy overwrites client-provided x-forwarded-host and x-forwarded-proto headers.

Authorization Boundary

The current widgets expose public civic data. The bootstrap assertion authorizes tenant, widget type, customer origin, timestamp, nonce, and purpose.

Widget display and data-selection params such as bill_number, subject, page, or last_name are not part of the current authorization boundary. Users may alter those params and request different public data. Your backend should still validate them for correctness and user experience.

If a future widget exposes tenant-private, paid, embargoed, personalized, or otherwise restricted data selected by params, those protected params must be included in the signed authorization design.