Neut Widget Backend Guide

Build the customer backend endpoint used by the browser quickstart. The endpoint creates a fresh iframe URL for each widget mount; the hosted iframe calls Neut after it loads.

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
Part Source
tenant_id WIDGET_TENANT_ID
widget_id The validated widget ID.
origin HOST_ORIGIN
timestamp Whole-second Unix timestamp string.
nonce Fresh random value.

The signature is HMAC-SHA256 using NEUT_MINT_SECRET, encoded as unpadded base64url. Keep bootstrap_timestamp in the exact base-10 whole-second form that was signed.

Node/Express Example

This example validates widget input, signs the 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",
};

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

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 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(WIDGET_PATHS[input.widget_id], 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.",
      },
    });
  }
});

Keep the full validation set from the reference in your implementation: supported widget IDs, colorways, subject values, status filters, member fields, and positive integer checks.

Security Checklist

  • Keep NEUT_MINT_SECRET on the server only.
  • Read WIDGET_TENANT_ID and HOST_ORIGIN from server configuration.
  • Do not accept tenant, origin, bootstrap, or token values from the browser.
  • Validate all browser-provided widget variables before signing.
  • Generate a fresh nonce for each embed URL.
  • Do not log full embed URLs, query strings, assertions, nonces, authorization headers, or tokens.
  • If your app runs behind a proxy, make sure it overwrites client-provided forwarded host/proto headers.

Authorization Boundary

Current widgets expose public civic data. The bootstrap assertion authorizes tenant, widget type, customer origin, timestamp, nonce, and purpose. Display parameters like bill_number, subject, or last_name are not part of the current authorization boundary, but your backend should still validate them for correctness and user experience.