track() therefore means repeating the same values on every possible call.
setEmbeddedData() decouples the two. You push context whenever you have it, and every survey displayed from then on picks it up — until you remove the key, or the page loads again.
This is for website & app surveys only, where the JavaScript SDK runs. Link surveys have no SDK on the
page and are filled from URL parameters
instead. For React Native, iOS, Android and Flutter see Mobile SDKs.
What It Writes
The bag reaches a response only through the survey’s declared ingested fields, which are the entries in the survey editor’s Hidden Fields card. A key nothing declares is dropped and logged; so is a key naming a variable, a reserved field or a locked field. Nothing here is ever fatal, and a rejected key never blocks a response. That means the workflow is always: declare the field on the survey first, then push a value under the same name. Values are scoped to the response. They are never written to the contact record, so this is not a back door into contact attributes.Merge Semantics
setEmbeddedData() merges into the current bag rather than replacing it, and per key the last write wins:
null Removes, undefined Does Nothing
The two are deliberately different, and the difference is the whole reason the “pass every field unconditionally” idiom is safe:
pageType does not exist the key arrives as undefined, and it must not clear the value the previous page set. So undefined is a documented no-op, not an accident.
Accepted Values
string, number, boolean and Date, plus null to remove a key and undefined to skip it. Dates are serialized as ISO 8601, which is what a date field accepts.
Anything else is refused rather than mangled. Passing a primitive, an array or a missing object logs an error and writes nothing, so setEmbeddedData(dataLayerObj) on a page where that object does not exist is a survivable mistake rather than a broken tag. Arrays in particular would otherwise spread into junk numeric keys.
Clearing
clearEmbeddedData(someValue) where someValue turned out to be undefined is a logged no-op, for the same reason as above: code that reads the key name from its own state must not wipe the bag when that state is empty.
Lifetime
1
In memory, never persisted
The bag lives in SDK memory for the page load. It is not written to
localStorage, so a full page load
starts empty and your app re-pushes. A classic multi-page app does that for free on every navigation.2
Snapshot at display, then frozen
When a survey is shown, the bag is copied onto it. A
setEmbeddedData() call made while that survey is
on screen reaches the next response, never the one being answered.3
Cleared on an identity switch
Calling
formbricks.logout(), or formbricks.setUserId() with a different id, clears the bag: one
user’s context must not ride onto the next user’s responses on a shared device. Identifying for the first
time keeps the bag, because pushing context before you know who the user is is a legitimate order.Stable vs. Volatile Fields
Two patterns, and mixing them up is the one failure mode worth designing against. Stable context (plan, role, accountType) changes rarely. Set it once after setup and let the next page load re-push it.
Volatile context (pageType, screen, channel) is only true for the page you are on. In a single-page app one page load spans many routes, so nothing re-pushes on your behalf and a value set on /pricing is still in the bag when a survey opens on /settings.
null when the new route has no page type is what stops the previous route’s value from leaking. setEmbeddedData() is a synchronous memory write with no network call, so doing this on every route change costs nothing.
Calling It Before Setup
setEmbeddedData() is synchronous and does not go through the SDK’s command queue, so a call made before setup() finishes is kept rather than dropped. Two things do have to be true first, and they have different answers.
The script must have loaded. window.formbricks does not exist until then, and neither does anything on it — setEmbeddedData and on alike. Push your context from the script tag’s own onload, or from a Google Tag Manager tag: the dataLayer buffers across load order, so a GTM trigger cannot be too early. Do not poll for the global.
setup() may be delayed, and that is what the readiness event is for. When the script has loaded but setup() waits on something like a consent banner, subscribe first and push when it resolves:
setup() has already finished never fires and logs nothing. See Lifecycle Events for the full list and the subscription API, and Google Tag Manager for the tag-manager wiring.
Checking What Is in the Bag
The bag is invisible: it is memory, so there is nothing in devtools storage, and the API has no getter. Add?formbricksDebug=true to your URL and every write prints:
clearEmbeddedData() traces too, including the “that key was not in the bag” case, which is usually the answer to “why is my value not there”.
The trace prints keys only, never values, and only at debug level, so a respondent’s console stays
clean and nothing you pushed is echoed into it.
Precedence Against track()
The older formbricks.track("action", { hiddenFields: { … } }) form still works and still wins. An explicit per-trigger value beats the ambient bag for the same field, whatever the casing on either side, so migrating one call site at a time is safe.
Two ways to load the SDK, two ways to pick this up. The script served by your Formbricks instance
(
/js/formbricks.umd.cjs) carries it as soon as the instance is updated, because that bundle is served
rather than installed. The npm package proxies the SDK’s methods explicitly, so there you get it by bumping
@formbricks/js.