@crvouga/mockingbird-service-formbricks
v3.9.0
Published
Stateful emulator of open-source Formbricks: the client environment state (seeded with a synthetic survey corpus), response creation with upstream's validation and {code, message, details} errors, the v1 management API, the widget script, and signed respo
Maintainers
Readme
@crvouga/mockingbird-service-formbricks
Local emulators. Real API contracts. Part of Mockingbird.
Stateful emulator of open-source Formbricks (6.x) for
test suites: the client environment state the JS SDK loads surveys from, response creation with
upstream's validation and {code, message, details} errors, the v1 management API (responses and
surveys, x-api-key), the widget script the web SDK loads, and the response pipeline's
responseCreated / responseFinished webhooks. Every namespace is seeded with a small
synthetic survey corpus (src/corpus/surveys.json: NPS, Onboarding, Product Feedback, a link
survey and a paused survey; invented content).
- Operation coverage: SUPPORT.md
- The contract (
openapi.yaml) is hand-authored from upstream Formbricks 6.0.0's route handlers and the docs.
Install
npm install -D @crvouga/mockingbird-service-formbricksESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
npx mockingbird-formbricks serve, createServer from ./server (Node), or createRuntime
with any Fetch server.
Usage
Point the SDK's appUrl (and your management API base URL) at the emulator, use the workspace id
cworkspace000000000000001 (or its legacy environment id cenvironment0000000000001, as older
SDKs send), and any management API key.
npx mockingbird-formbricks serve --port 8813 \
--webhook-url http://127.0.0.1:3000/webhooks/formbricks \
--webhook-secret "whsec_…"Boot it with your own settings and surveys, without a wrapper process or a PUT after boot:
npx mockingbird-formbricks serve \
--settings '{"workspaces": ["cworkspace000000000000002"], "legacyEnvironmentIds": {"cenvironment0000000000002": "cworkspace000000000000002"}}' \
--surveys ./surveys.json--settings <json|file> (or MOCKINGBIRD_SETTINGS, or a fleet entry's settings) takes the
fields of GET|PUT /__admin/settings, checked with their types (a misspelled key is an error). They
are every namespace's defaults, a reset returns to them, and a field you give replaces the
built-in value whole (give legacyEnvironmentIds and the default environment alias is gone).
--workspace-id adds to the booted workspaces. --surveys <file> (a list, or {surveys: [...]})
adds your surveys after the corpus ones for every namespace, and they survive a reset. A survey
whose id the corpus (or an earlier row) already has is skipped, a survey with no workspaceId is served by
every configured workspace, and type, status and a legacy environmentId are handled as
PUT /__admin/surveys handles them. --surveys-mode replace uses the file as the whole corpus.
In a fleet, set options: {"surveys": "surveys.json"}.
import { WORKSPACE_ID } from "@crvouga/mockingbird-service-formbricks"
import { createServer } from "@crvouga/mockingbird-service-formbricks/server"
// Test setup: the emulator, posting response webhooks to the app's handler.
const server = await createServer({
webhooks: { url: "http://127.0.0.1:3000/webhooks/formbricks", secret: "whsec_…" },
})
// Application code: appUrl is server.url. Submit a response to the seeded NPS survey.
const appUrl = server.url
const created = await fetch(`${appUrl}/api/v2/client/${WORKSPACE_ID}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
surveyId: "csurveynps000000000000001",
finished: true,
data: { nps_score: 9 },
}),
})
// → {data: {id, quotaFull: false}}. The emulator validates it, stores it, and posts
// responseFinished to the webhook.
// Test control: read what was stored.
const stored = await fetch(`${appUrl}/__admin/responses`)
await server.close()Routes
Client routes take a workspace id or a legacy (pre-Formbricks-5) environment id in the path; both resolve to the same workspace.
| Route | Behaviour |
| --- | --- |
| GET /api/v1/client/{workspace}/environment | {data: {data: {surveys, actionClasses, workspace, project}, expiresAt}} (project is upstream's legacy alias of workspace). Only app surveys inProgress, with the SDK's survey fields and projectOverwrites mirroring workspaceOverwrites. A malformed id is 400 Invalid ID format; an unknown one 404 Workspace not found. |
| POST /api/v2/client/{workspace}/responses | {surveyId, finished, data, contactId?, meta?, …} → {data: {id, quotaFull: false}}. An unknown workspace is 404; input errors are 400 Fields are missing or incorrectly formatted with zod 4 details ({surveyId: "Invalid cuid2", finished: "Invalid input: expected boolean, received undefined"}; userId is not part of the v2 input and is ignored); a contactId while contacts are off is 403 (Enterprise); an unknown survey 404; a survey of another workspace 400 Survey is part of another workspace; a survey not inProgress 403 Survey is not accepting submissions. data keeps only element ids and declared hidden fields (hiddenFields.fieldIds); other keys are dropped. Before element validation, a multipleChoiceSingle or multipleChoiceMulti value (a string, or any string in an array) that equals no choice label in the response language — the body's language, or the label's default when it is omitted; getLocalizedValue does not fall back — and is longer than 250 characters is 400 Response exceeds character limit with details: {questionId}, and nothing is stored. An exact label match is not length-checked; an unmatched entry of 250 characters or fewer passes this check. Element validation defaults to the elements present in data (finished or not). validation: "finished-validates-all" (pre-#7292 Formbricks) checks every element when finished is true and only the present ones when it is false; a failure is 400 and stores nothing. Either way: required → Please fill out this field; a choice outside a choice element's options (when it has no "other") → Please enter a valid format; openText email / url / phone → Please enter a valid …; 400 Validation failed with details: {"response.data.<elementId>": "<messages joined by '; '>"}. meta keeps source, url, action and the auto-captured page / UTM / screen keys, plus userAgent and country (from CF-IPCountry). language is canonicalized (en → en-US); a finished response's ttc gains _total. |
| GET /api/v1/management/responses?surveyId=&limit=&skip= | {data: [response]}, newest first; an unknown surveyId is 404. |
| GET /api/v1/management/responses/{id} | {data: response} or 404. |
| GET /api/v1/management/surveys?limit=&offset= | {data: [survey]}, most recently updated first. |
| POST /api/v1/management/surveys | {workspaceId (or legacy environmentId), name, type?, status?, blocks \| questions} → {data: survey}. Legacy questions become one block each; both or neither is 400; a missing workspace id is 400 workspaceId must be provided, an unknown one 404. |
| GET /api/v1/management/surveys/{id} | {data: survey} or 404. |
| GET /js/formbricks.umd.cjs | A no-op script defining window.formbricks (every SDK method resolves). |
Management surveys carry questions derived from blocks (block button labels on the block's last
element), workspaceId, the legacy environmentId, and projectOverwrites. Management routes
need x-api-key (any non-empty key, unless apiKeys is set), else 401
{code: "not_authenticated", message: "Not authenticated", details: {"x-Api-Key": "…"}}.
Webhooks
POST <webhook-url> with {webhookId, event, data: {...response, survey: {title, type, status,
createdAt, updatedAt}}}, headers webhook-id, webhook-timestamp and, when the endpoint has a
whsec_… secret, webhook-signature: v1,<base64 HMAC-SHA256> (Standard Webhooks). Only events an
endpoint subscribes to are sent: responseFinished by default (events: ["responseCreated",
"responseFinished"] for both). --webhook-token appends a ?token= for receivers that check one.
Retries, GET /__admin/webhooks, …/events, …/replay, …/flush and
PUT /__admin/webhook-endpoints work as usual.
Admin (beyond the standard contract)
| Route | Effect |
| --- | --- |
| GET /__admin/responses?surveyId= | Stored responses, oldest first. |
| GET\|PUT /__admin/surveys | Read, or add / replace surveys ([{id, name, type?, status?, workspaceId?, blocks?, questions?, …}]; no workspaceId = served by every configured workspace). |
| GET\|PUT /__admin/contacts | Read, or add contacts ([{id, userId?, attributes?, workspaceId?}]) for contactId responses. |
| GET\|PUT /__admin/settings | {workspaces?, legacyEnvironmentIds?, apiKeys?, webhookId?, contactsEnabled?, validation?}. validation is present-only (default, Formbricks after #7292) or finished-validates-all (before #7292: a finished response validates every element). createRuntime({settings}) accepts the same fields. |
Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets):
rate_limited (429 too_many_requests), server_error (500), missing_response_id (stored, but
no id in the answer), environment_unavailable, data_as_string (management data as a JSON
string), management_unauthorized, connection_drop, duplicate (the next webhook twice),
webhook_drop, webhook_reorder.
Namespaces
x-mockingbird-namespace, a /__admin/ns/<name> prefix on the app URL, or by credential: the workspace
(or legacy environment) id in the client paths (the SDK cannot add headers) or the management
x-api-key, through PUT /__admin/credentials {"credentials": {"<workspace id or key>":
"<namespace>"}}.
Deliberately not modelled
- Displays, contacts / identify,
userandstorageroutes of the client API (the widget script is a no-op, so the SDK never calls them); response updates (PUT …/responses/{id}). - Quotas (always
quotaFull: false), single-use links, PIN protection, reCAPTCHA, email verification, file-upload checks, customvalidation.rules, survey logic, and the full survey schema on create (only the shapes above are checked). - The Embedded Data ingest contract beyond its allow-list (locked fields, coercion, size limits),
userAgentparsing (always{device: "desktop"}), and language canonicalization beyond common bare codes. - Organizations, API-key permissions (
unauthorized), and the rest of the management API; the Formbricks UI.
API
| Export | Kind | Description |
| --- | --- | --- |
| FormbricksAPI | class | The in-process emulator: fetch(request), reset(), responses(), render(record), wireSurvey(survey), pipeline(event, record, survey), state. Options: sqlite, now, namespace, surveys, settings, onWebhook. |
| createRuntime | function | The emulator with the full service contract (health, admin, namespaces, credentials, presets, webhooks). Options: webhooks: {url, secret?, events?, retryDelaysMs?, fetch?}, surveys (replaces the corpus; serve --surveys appends), settings, clock, seed, adminKey, onLog, sqlite. |
| FORMBRICKS_PRESETS | object | Every named fault preset. |
| FORMBRICKS_NAMESPACE | string | The service name, "formbricks". |
| WEBHOOK_PATH | string | A conventional receiver path for examples, /webhooks/formbricks. |
| formbricksCredential | function | The workspace id or API key a request carries. |
| formbricksError | function | Build Formbricks' {code, message, details} error response. |
| validateResponseData | function | Upstream's element validation: (survey, data, language?, {validation?, finished?}) → {elementId: messages} or null. |
| CORPUS_SURVEYS | value | The synthetic survey corpus every namespace is seeded with. |
| WORKSPACE_ID, ENVIRONMENT_ID, DEFAULT_SETTINGS | values | The default workspace, its legacy environment id, and the default settings. |
| document, operationIds, supportedOperationIds | values | The vendored OpenAPI contract and its operation ids. |
| createServer, serveTarget, DEFAULT_PORT (./server) | Node | Serve over node:http; the serve CLI target; port 8813. |
Part of mockingbird.
