Backend Work Instructions
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_SECRETon the server only. - Read
WIDGET_TENANT_IDandHOST_ORIGINfrom 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.