@scopmx/surveybase-server
v1.3.0
Published
Survey backend. Mount it on the host Express app and the host MongoDB.
Readme
@scopmx/surveybase-server
Express router and MongoDB models for Surveybase. Mount it in your own Express app, next to your own MongoDB connection. It serves:
- the admin API used by
<SurveyAdmin />; - the public API used by
<SurveyWidget />on your website.
Frontend package: @scopmx/surveybase-widget.
Options marked (next release) are in the source but not in the published
1.1.0yet.
Contents
- Install
- Quick start
surveyApi(options)mountSurveyApi(app, options)- All options
- Environment variables
- Keys and admin access
- Live results
- Rate limits
- CORS
- Endpoints
- Database
- Production checklist
Install
npm install @scopmx/surveybase-server express mongoose| Requirement | Version |
| ----------------- | ------- |
| Node.js | >= 18 |
| express (peer) | ^4.21 |
| mongoose (peer) | ^8 |
Quick start
const express = require("express");
const mongoose = require("mongoose");
const { surveyApi } = require("@scopmx/surveybase-server");
const app = express();
mongoose.connect(process.env.MONGODB_URI);
app.use(
"/api/survey",
surveyApi({
authorize: (req) => isAdminSession(req), // your own login check
defaultTheme: { primaryColor: "#0D447A" }
})
);
app.listen(4000);surveyApi(options)
Returns an Express Router. Mount it at any path, usually /api/survey (the frontend default).
- It works before MongoDB is connected. Requests wait up to 15 seconds for the connection, then answer
503. - Setup (models, install keys, live results) runs once, on the first connection.
- If setup fails, the next request retries it.
mountSurveyApi(app, options)
Mounts the router on app at options.basePath and finishes setup before it resolves. Use it when you want the keys at startup.
await mongoose.connect(process.env.MONGODB_URI);
const { basePath, siteKey, secretKey, workspaceId } = await mountSurveyApi(app, {
connection: mongoose.connection,
basePath: "/api/survey"
});connectionis required, and it must already be connected. Otherwise it throws.- It resolves
{ basePath, secretKey, siteKey, workspaceId }.secretKeyis empty unless it was generated during this start.
Other exports: DEFAULT_BASE_PATH ("/api/survey"), and the TypeScript types SurveyApiOptions, SurveyInstallKeys, LiveResultsAdapter and LiveResultsMessage.
All options
Every option is optional, except connection for mountSurveyApi.
Connection and routing
| Option | Type | Default | Description |
| ------------ | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| connection | Mongoose Connection | mongoose.connection | Connection the survey models use. Required for mountSurveyApi. |
| basePath | string | "/api/survey" | URL prefix. Used by mountSurveyApi only; with surveyApi you choose the path in app.use. |
| cors | false \| object | { origin: true } | CORS for the public widget routes (/public, /health). Takes cors options. false skips CORS. |
| adminCors | false \| object | see CORS | CORS for the admin routes (/auth, /projects, /workspace). |
Access and keys
| Option | Type | Default | Description |
| ------------------- | -------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| authorize | (req) => boolean \| Promise<boolean> | none | Lets admin requests in without an sk_ key, for example by checking your app's session or a short-lived token. Returning true grants full admin access. |
| secretKey | string | SURVEY_SECRET_KEY | Admin key: sk_ plus at least 32 random characters. Only a SHA-256 hash is stored. |
| previousSecretKey | string | SURVEY_PREVIOUS_SECRET_KEY | Old key that keeps working during a key rotation. |
| siteKey | string | SURVEY_SITE_KEY, the stored key, or generated | Public pk_ key used by the widget. |
| logKeys | boolean | false | Print the site key and a masked secret key on startup. |
Install
| Option | Type | Default | Description |
| -------------------------- | ------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| workspaceId | string | the only workspace | Pin the install to one workspace id. |
| workspaceName | string | SURVEY_WORKSPACE_NAME or "Workspace" | Used only when the install creates the workspace. |
| admin | { name?, email? } | SURVEY_ADMIN_NAME / SURVEY_ADMIN_EMAIL, or "Admin" / "[email protected]" | First admin user, used only when the install creates the workspace. |
| migrateLegacyCollections | boolean | false | Renames old unprefixed collections (users, workspaces, members, projects, responses) to survey_*. Never enable it on a database where your app owns a users or projects collection. An existing survey_* target is kept as survey_*_backup_<time>. |
Surveys
| Option | Type | Default | Description |
| -------------- | --------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| defaultTheme | { primaryColor?: string } | { primaryColor: "#3d2ec5" } | (next release) Colour for new surveys: blank ones, ones from a template, and duplicates whose source has no colour. Existing surveys keep their colour. primaryColor must be a 6-digit hex (#0D447A); anything else throws at startup. A colour sent by the admin (defaultSurveyColor) wins. |
Limits and live results
| Option | Type | Default | Description |
| ---------------- | ------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------ |
| rateLimit | false \| object | see Rate limits | Per-IP limits for the public routes. false turns them off. |
| liveResults | "auto" \| "change-streams" \| "poll" \| "memory" \| adapter | "auto" | How new responses reach open admin pages. See Live results. |
| livePollMs | number | 15000 | Poll interval sent to admins when live results fall back to polling. |
| maxLiveStreams | number | 20 | Live result connections allowed open at once, per workspace, on this instance. |
Environment variables
Options always win over environment variables.
| Variable | Same as option |
| ---------------------------- | ------------------- |
| SURVEY_SECRET_KEY | secretKey |
| SURVEY_PREVIOUS_SECRET_KEY | previousSecretKey |
| SURVEY_SITE_KEY | siteKey |
| SURVEY_WORKSPACE_NAME | workspaceName |
| SURVEY_ADMIN_NAME | admin.name |
| SURVEY_ADMIN_EMAIL | admin.email |
With NODE_ENV=production, install errors stop the server instead of only being logged.
Keys and admin access
There is a single admin: whoever passes the check below can manage every survey.
An admin request is accepted when either:
- it sends a valid secret key as
x-survey-key: sk_...orAuthorization: Bearer sk_...(the current or the previous key); or - it does not send an
sk_key and yourauthorize(req)returnstrue.
Otherwise the server answers 401. A suspended workspace answers 403.
Recommended setup: use authorize with your own login, and on the frontend send your token with getToken. Never put the sk_ key in browser code.
surveyApi({
authorize: async (req) => {
const token = (req.get("authorization") || "").replace(/^Bearer\s+/i, "");
const user = await verifyJwt(token).catch(() => null);
return Boolean(user?.isAdmin);
}
});Key behaviour:
- Without
secretKey, a key is generated on the first start and printed once. Save it. - To rotate, set the new key as
secretKeyand the old one aspreviousSecretKey, deploy, then removepreviousSecretKey. - The site key (
pk_) is public. It only lets the widget read live surveys and submit answers.
Live results
Open admin results pages receive new responses as they arrive (Server-Sent Events on /projects/:id/responses/stream). Choose how instances share them:
| Value | Behaviour |
| ------------------ | ------------------------------------------------------------------------ |
| "auto" (default) | MongoDB change streams on a replica set, otherwise polling. |
| "change-streams" | Same as "auto", but logs an error when change streams are unavailable. |
| "poll" | Admins refresh every livePollMs. |
| "memory" | In-process only. Correct for one instance. |
| adapter object | Your own pub/sub, for example Redis. |
Adapter interface:
interface LiveResultsAdapter {
publish(message: { projectId: string; response: object }): void | Promise<void>;
// Must also receive messages published by this same process.
subscribe(handler: (message) => void): void | (() => void);
}The frontend can turn live updates off completely with results={{ live: false }}. The admin then only loads data on open, on filter changes and on Refresh.
Rate limits
Fixed one-minute windows, counted per IP address, in memory, per instance. They apply to the public routes only.
| Key | Default | Applies to |
| -------------- | ------- | -------------------------------------------------------- |
| windowMs | 60000 | Window length in ms |
| list | 120 | GET /public/projects |
| views | 60 | POST /public/projects/:publicKey/views, per survey |
| responses | 60 | POST /public/projects/:publicKey/responses, per survey |
| newResponses | 10 | New responses created, per survey |
surveyApi({ rateLimit: { list: 300, newResponses: 20 } }); // override some
surveyApi({ rateLimit: false }); // turn off- Over the limit, the server answers
429with aRetry-Afterheader. - Behind a proxy or load balancer, set
app.set("trust proxy", ...)soreq.ipis the visitor's address. - With several instances, also put a shared limiter in front.
CORS
| Routes | Option | Default |
| ----------------------------------------- | ----------- | --------------------------------------------------------------------------- |
| Public: /public/*, /health | cors | { origin: true } (any site can embed the widget) |
| Admin: /auth, /projects, /workspace | adminCors | cors when it lists specific origins, otherwise false (same origin only) |
surveyApi({
cors: { origin: ["https://www.example.com"] },
adminCors: { origin: ["https://admin.example.com"], credentials: true }
});credentials: true with an open origin (true, "*" or none) throws at startup.
Endpoints
All paths are relative to the mount path (default /api/survey). Bodies are JSON (limit 1 MB). Errors are { "error": "..." } with a matching status code.
Public (widget)
Each route also exists under /public/sites/:siteKey/..., for one API serving several sites.
| Method | Path | Description |
| ------ | --------------------------------------- | ------------------------------------------------------------ |
| GET | /health | Health check. |
| GET | /public/projects | Live surveys for the visitor. Query: traits, respondent. |
| POST | /public/projects/:publicKey/views | Count a view. |
| POST | /public/projects/:publicKey/responses | Save or update an answer set. |
Admin (needs admin access)
| Method | Path | Description |
| -------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET | /auth/me | Current admin and workspace. |
| GET | /workspace | Workspace settings, including the site key. |
| PATCH | /workspace | Update workspace settings. |
| GET | /projects | List surveys. Query: status = draft, live or archived. |
| POST | /projects | Create a survey. Body: { name, theme?: { primaryColor } }. Without a colour it uses defaultTheme; an invalid hex returns 400. |
| GET | /projects/:id | One survey with questions. |
| PATCH | /projects/:id | Update name, status, theme, targeting, thank-you screen, and so on. |
| DELETE | /projects/:id | Delete a survey and its responses. |
| POST | /projects/:id/duplicate | Copy a survey, keeping its colour. |
| POST | /projects/:id/questions | Add a question. |
| PUT | /projects/:id/questions/reorder | Reorder questions. |
| PATCH | /projects/:id/questions/:questionId | Update a question. |
| DELETE | /projects/:id/questions/:questionId | Delete a question. |
| GET | /projects/:id/responses | Responses for the summary (latest 1,000 by default, limit up to 2,000). |
| GET | /projects/:id/responses/page | (next release) Paged list, newest first: { responses, nextCursor }. limit is 30 by default, up to 100; pass cursor to get the next page. |
| GET | /projects/:id/responses/answers | Text answers for one question: questionId, q, limit (8 by default, up to 30), cursor. |
| GET | /projects/:id/responses/contacts/:questionId | (next release) Contact form table: page, limit (10, 25, 50 or 100), sort = newest or oldest, q. |
| GET | /projects/:id/responses/contacts/:questionId/emails | (next release) Unique emails (up to 5,000, with truncated). countOnly=1 returns only the count. |
| GET | /projects/:id/responses/stream | Live results (Server-Sent Events). |
Shared response filters (query string) for the responses, page, answers and contacts endpoints:
| Param | Values |
| ----------------------- | ---------------------------------------------------------------------- |
| from, to | ISO dates. from is inclusive, to is exclusive. |
| compareFrom | ISO date. Start of the previous period, for comparison (summary only). |
| device | desktop or mobile |
| status | completed or partial |
| search | Text search across answers and visitor traits |
| questionId + answer | Responses whose answer to that question matches |
Database
- Collections are prefixed
survey_(survey_users,survey_workspaces,survey_projects,survey_responses, plussurvey_install_lockused during the first start), so they live safely next to your own collections. - Indexes are created by Mongoose on startup. Response paging uses
{ projectId: 1, createdAt: -1, _id: -1 }. - Use a replica set (for example MongoDB Atlas) if you want
liveResults: "auto"to use change streams.
Production checklist
- Set
secretKey(orSURVEY_SECRET_KEY), or useauthorizewith your own login. Keepsk_keys out of browser code. - Set
app.set("trust proxy", ...)behind a proxy, so rate limits see real visitor IPs. - Restrict
corsto your site origins if the widget only runs on known domains. - Running several instances: use
liveResults: "auto"on a replica set,"poll", or an adapter. Do not use"memory". - Leave
migrateLegacyCollectionsoff unless you are upgrading an old standalone install. - Optional: set
defaultThemeso new surveys match your brand.
