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
datais keyed by element ID and itsendingIdnames 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,endingIdandsingleUseId—ttcwith one caveat - Each response’s
createdAtandupdatedAt, so your charts keep their shape
- 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
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.
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 tostatus: 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
/workspaces/<workspaceId>/surveys/<surveyId>/edit.
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
-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. Thejq 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 returns400withinvalid_params..languages |= map({code, default, enabled})—GETreturns an extraaliasper language thatPOSTrejects..type = (if .type == "link" then "link" else "app" end)— older instances still store the legacywebsiteandwebtypes; onlylinkandappcan be created..metadata = (.metadata // {})—metadatamay benullon read but must be an object on write.- Trigger rewriting and the app-survey
statusfallback — see App surveys.
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
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.
Step 3: Verify
Compare the exported count against what landed, survey by survey.fb-verify.sh
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 fromsurvey-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:
workspaceIdandappUrlinFormbricks.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:
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.
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:fb-check-choices.sh
- 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
PATCHcalls. - 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. - 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: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: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:
_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 viaGET /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.