Skip to main content
This page is about moving selected surveys and their response data between two running Formbricks instances — a staging box to production, a self-hosted instance to Formbricks Cloud, one company’s instance to another after a split. It uses nothing but the public management API, so the two instances can be on different versions, different databases, and different object storage.
Moving a whole instance? Do not use this page. If you control both machines and want everything — users, organizations, workspaces, contacts, displays, tags, file uploads, webhooks — then a Postgres dump plus a copy of your S3 bucket is faster, complete, and keeps every ID intact:
Restore into an instance running the same Formbricks version as the source, then upgrade it following the migration guide. The API route below exists for the cases a dump cannot serve: you only want some surveys, the target already has data you must keep, or you do not own the database on one of the two ends.

What carries over

The API route rebuilds surveys from their public survey document and replays responses onto them. That is enough to keep every answer readable and correctly dated, but it is not a byte-for-byte copy. Carried over
  • Survey name, type, status, metadata, languages and every translation
  • Blocks, elements, choices, logic, endings, variables, hidden fields
  • Block, element and ending IDs, verbatim. This is the part that matters: a response’s data is keyed by element ID and its endingId names an ending, so keeping those IDs is what makes a replayed response land on the right question instead of being rejected
  • App-survey display settings and triggers (see App surveys)
  • Every response’s data, variables, ttc, meta, language, endingId and singleUseIdttc with one caveat
  • Each response’s createdAt and updatedAt, so your charts keep their shape
Not carried over
  • The survey ID. Every survey gets a fresh one, so every link URL changes — see Finish by hand
  • Response IDs — fresh ones are assigned
  • Styling and theme overrides, follow-up emails, quotas, single-use link settings, survey PIN, reCAPTCHA, custom head scripts, custom slug
  • Contact targeting (segments) on app surveys
  • Uploaded files, display records, response tags, contacts — see Known limitations
Importing responses fires the target’s response pipeline. Every POST to the responses endpoint emits responseCreated and, for finished responses, responseFinished — which dispatches webhooks, integrations (Airtable, Google Sheets, Notion, Slack), workflows, and survey follow-up emails configured in the target workspace.Before importing, remove or disable webhooks, integrations and workflows in the target workspace, and add follow-up emails only after the import has finished. Otherwise a few thousand replayed responses become a few thousand real emails.

Before you start

1

Create an API key on each instance

On both instances, create a key that covers the workspace you are moving from or to — see Generate API Key. Read access is enough on the source; the target needs write or manage.
Pick the workspaces when you create the key — they cannot be changed later. A key’s Workspace Access list and permission levels are fixed at creation; editing a key afterwards only renames it. There is no “all workspaces” option either, so a workspace created after the key is unreachable by it. In both cases the symptom is a 403 on every write while reads elsewhere keep working, and the fix is to delete the key and create a new one with the right workspaces selected.
2

Note both workspace IDs

The workspace ID is in the app URL: https://your-instance.com/workspaces/<workspaceId>/surveys. (GET /api/v2/me also lists them, but only for keys that additionally have Organization read access.)
3

Install curl and jq

The scripts below use nothing else. jq 1.6 or newer.
4

Plan around the rate limit

Management API requests are limited to 100 per minute per API key. Every survey and every single response is one request, so 5,000 responses take at least 50 minutes. The scripts pace themselves with a THROTTLE sleep and retry on 429. If you own the target instance and want it to go faster, set RATE_LIMITING_DISABLED=1 there for the duration of the import and restart it, then turn it back on.

Copying a single survey

If you only need one survey moved, this is the whole job: one script, one command, no intermediate directory. It reads the survey and its responses straight from the source and writes them to the target as it goes. The workspace-wide workflow starts at Step 1 below. Everything on this page still applies — the same fields are dropped, the same file-upload answers are refused, the same response pipeline fires on the target. The one difference is app-survey triggers: with no export directory to build an ID map from, this script matches the source’s action classes to the target’s by name and drops any trigger with no counterpart, falling back to status: draft so the survey cannot go live half-configured. Create the missing actions in the target first if you want the trigger to survive.
fb-copy-survey.sh
The survey ID is the last path segment of the survey’s URL in the app, /workspaces/<workspaceId>/surveys/<surveyId>/edit.
Failures are printed per response on stderr and counted, but they do not stop the run — redirect stderr to a file if you are moving enough responses that you will want to read them afterwards. For an app survey, the trigger line tells you whether the survey came out live or as a draft:

Step 1: Export the source workspace

Steps 1 to 4 are the workspace-wide path: every survey at once, with the export kept on disk so you can inspect it, hold it as a backup, and re-run the import from it. This step touches nothing on the source instance.
fb-export.sh
The -g flag on curl is not optional — the filter[status][in] parameter contains square brackets, which curl otherwise reads as a globbing range.

Step 2: Import into the target workspace

The endpoints used to create surveys are strict: they accept a survey document, not the resource you just read. The jq filter in the script does the translation, and each line of it exists because the API rejects the request without it:
  • del(.id, .workspaceId, .createdAt, .updatedAt, .archivedAt) — read-only fields. The document endpoint rejects unknown keys rather than ignoring them, so leaving them in returns 400 with invalid_params.
  • .languages |= map({code, default, enabled})GET returns an extra alias per language that POST rejects.
  • .type = (if .type == "link" then "link" else "app" end) — older instances still store the legacy website and web types; only link and app can be created.
  • .metadata = (.metadata // {})metadata may be null on read but must be an object on write.
  • Trigger rewriting and the app-survey status fallback — see App surveys.
Block, element and ending IDs are not stripped. The API accepts them, and keeping them is the whole trick: a replayed response’s data is keyed by element ID and its endingId points at an ending, so carrying those IDs over is what makes the responses land on the right questions instead of being rejected as invalid.
fb-import.sh
Two files are written next to the dump and are worth keeping: survey-id-map.tsv maps every old survey ID to its new one, and action-class-map.tsv does the same for actions. You need the survey map to update links, QR codes, embed snippets and SDK calls that referenced the old IDs.
The import is not idempotent. There is no natural unique key on a survey or a response, so a second run creates a second copy of everything rather than skipping what exists. If a run fails partway, delete what it created in the target before retrying — or trim the input files down to what is missing.

Step 3: Verify

Compare the exported count against what landed, survey by survey.
fb-verify.sh
A MISMATCH line points at responses the target refused. fb-import.sh printed the reason for each on stderr — the usual cause is a file upload, see below. Then open one imported survey’s summary page and confirm the answers are attributed to the right questions. If element IDs had been lost, the responses would have been rejected outright rather than silently misfiled, so a summary that reads correctly is a good signal.

Step 4: Finish by hand

  • Re-point everything that used the old survey ID. Link survey URLs are https://your-instance.com/s/<surveyId>, so every shared link, QR code, email campaign and embed snippet needs the new ID from survey-id-map.tsv. Old links keep working on the source instance if it is still running — if it is not, they break.
  • Rebuild what the survey document does not carry: styling, follow-up emails, quotas, single-use link settings, survey PIN, reCAPTCHA, custom head scripts, custom slug.
  • Re-create app-survey targeting (see below), then re-enable webhooks, integrations and workflows.
  • Update your SDK setup if you are moving app surveys: workspaceId and appUrl in Formbricks.setup() both still point at the old instance.

App surveys

App surveys reference things that live inside a workspace and therefore cannot be copied by value. Triggers point at action classes by ID. fb-import.sh handles this: it re-creates every action class in the target first (POST /api/v1/management/action-classes, which unlike the v3 read endpoint also accepts a noCodeConfig), then rewrites distribution.triggers[].actionClassId through the resulting map. Triggers whose action class failed to create are dropped. That matters, because an app survey with no trigger cannot be live:
The script therefore forces status: "draft" on any app survey that ended up with an empty trigger list, rather than failing the whole survey. Targeting is dropped by the script (del(.targeting)). Segment filters reference contact-attribute keys, segments and — for surveyInteraction filters — other survey IDs, none of which exist under the same IDs in the target. Re-create targeting in the app after the import.
A live app survey that loses its targeting targets everyone. If the source survey was scoped to a segment, keep it as a draft until you have rebuilt the filters in the target.

Known limitations

Renamed choices reject their older responses

This is the one that costs the most responses in practice. A choice answer is stored as the choice’s label, not its id — so if anyone renamed or removed a choice after responses came in, the stored answers no longer match the survey. The source instance keeps them either way; it never re-validates on read. The target does validate on create, and rejects them:
Run this against the export before importing, so you find out from a dump rather than from a half-migrated survey:
fb-check-choices.sh
Twelve of that survey’s thirteen responses predate a choice rename, and all twelve would be refused. Your options, in order of how much they cost you:
  1. Add the historical labels back as choices on the target survey, import, then remove them again. Validation only runs on create, so the imported responses stay valid afterwards. This keeps every response at the price of two PATCH calls.
  2. Rewrite the old label to the new one in the export, if the mapping is unambiguous — jq -c '.data.<elementId> = "I have a problem"'. You are editing history, so only do this when the rename was cosmetic.
  3. Accept the loss and import the rest. The script reports each failure with its response id.
An element with an Other option will list its free-text answers here too, because they match no choice label by definition. Those import fine — ignore them.

File-upload answers are rejected

Uploaded files stay in the source instance’s object storage, and their URLs encode the workspace and survey they belong to:
Both IDs change during the migration, so the target refuses the response:
To carry these responses over you have to move the files first: download each one from the source, upload it to the target under the new survey and element, rewrite the URL in the response’s data, and only then POST the response. If the files are not worth that, strip the file-upload element’s key from data so the rest of the answer imports — you lose the attachment, not the response:

Surveys with no blocks cannot be created

An untouched draft can have zero blocks. Reading it works, creating it does not:
Either skip these (there is nothing in them) or recreate them in the app.

Time-to-complete needs _total stripped

A finished response’s ttc (time to complete, in milliseconds) carries a _total key alongside the per-element timings. On create, the target recomputes _total by summing the values it was given — and if you passed the stored _total along, that sum includes it, so the imported response ends up with exactly double the real total. Nothing errors; the number is just silently wrong. The scripts on this page therefore send ttc with _total removed and let the target recompute it:
Only finished responses carry _total, and stripping it is safe either way. If you wrote your own importer, check one finished response’s ttc._total against the source before trusting the rest.

Displays are not migrated

The Displays figure on a survey summary, and the Starts % / Completed % / drop-off numbers derived from it, come from display records — impressions, not responses. Those are not exposed by the management API, so an imported survey shows - for displays and 0% for the rates that divide by them. Response counts, completion counts and every per-question summary are correct.

Response tags and contacts

Response tags are readable via GET /api/v1/management/responses but no create endpoint accepts them, so they cannot be restored through the API. Contacts are workspace-scoped and are not migrated by these scripts. Responses that were tied to a contact are imported unlinked. fb-import.sh passes the source contact’s userId through as the response’s userId: if a contact with that userId already exists in the target workspace, the response is attached to it and the target’s own contact attributes are copied onto the response. If no such contact exists, the response is created anonymously — this is silent, not an error.

Everything outside surveys and responses

Users, teams, organization settings, contacts and their attributes, segments, webhooks, integrations, API keys, workflows and dashboards are out of scope here. Contacts and contact-attribute keys have their own management endpoints (/api/v2/management/contacts, /api/v2/management/contact-attribute-keys) if you need them; the rest is a pg_dump job.

Troubleshooting


Need help? Reach out in GitHub Discussions.