← All writing

FreeMarker in OneTrust Integration Workflows: A Practical Guide to Payloads That Do Not Break

If you have built anything non-trivial with OneTrust Integration Workflows, you have met FreeMarker. It is the templating layer that turns consent and preference data into the payload a downstream system actually receives. It is also where a surprising amount of integration pain lives, because FreeMarker is strict in ways that are not obvious until a workflow fails silently in production and a marketing platform quietly stops receiving opt-outs.

This guide is the reference I wish I had when I started. It assumes you know your way around OneTrust but have not necessarily spent much time on the templating itself. The patterns here are the ones I reach for on almost every build.

Where FreeMarker sits

Inside an Integration Workflow, FreeMarker is what you write in the transform or payload construction step. OneTrust exposes the workflow’s data (identity values, consent transactions, purpose states and so on) as variables, and your template renders those into the body you send onward, usually JSON.

The mental model that matters: FreeMarker runs server-side, it evaluates your template against whatever data the workflow happens to have at that moment, and by default it is unforgiving about anything missing. That last point is the source of most real-world failures, so it is where we start.

Null-safety, or why your workflow fails on the one record that matters

FreeMarker throws an error the moment you reference something that is null or absent, unless you tell it otherwise. In a batch of ten thousand records, the nine thousand with a complete profile render fine and the ones missing a field blow up. Those are often exactly the records you care about.

Two operators solve almost all of this.

The default operator ! supplies a fallback when a value is missing:

${user.email!""}

If user.email is absent, this renders an empty string instead of failing. You can default to anything:

${user.country!"GB"}

The existence operator ?? returns a boolean you can test:

<#if user.email??>
  "email": "${user.email}"
</#if>

For chained access, wrap the whole path in parentheses so the null-safety applies across every step, not just the last one:

${(user.address.city)!"Unknown"}

Without the parentheses, a missing address still throws before you ever reach city.

One distinction worth internalising. ?? checks that a value exists. ?has_content checks that it exists and is not empty, where empty covers an empty string or an empty list. When you are deciding whether to include a field at all, ?has_content is usually what you want:

<#if user.phone?has_content>
  "phone": "${user.phone}"
</#if>

This single habit, treating every field as potentially absent, eliminates the majority of intermittent workflow failures.

Conditional field inclusion without breaking your JSON

Once fields are optional, you hit the classic problem: building valid JSON when you do not know in advance which keys will be present. Naively wrapping each field in an <#if> leaves you with stray or missing commas.

The clean approach is to build a list of the fields that qualify, then join them:

<#assign fields = []>
<#if user.email?has_content>
  <#assign fields += ['"email": "${user.email}"']>
</#if>
<#if user.phone?has_content>
  <#assign fields += ['"phone": "${user.phone}"']>
</#if>
{
  ${fields?join(",\n  ")}
}

?join only ever puts a comma between elements that exist, so the output is always valid no matter which combination of fields turned up. This scales far better than trying to track commas by hand, and it is much easier to read six months later.

String handling: the built-ins you will use constantly

FreeMarker built-ins are invoked with ?. A handful come up on nearly every integration.

URL encoding, for anything going into a query string or a redirect:

${redirectUrl?url('UTF-8')}

JSON escaping, which you should apply to any free-text value going into a JSON payload:

"displayName": "${user.displayName?json_string}"

?json_string escapes quotes, backslashes and control characters. Skip it and a user whose name contains a quotation mark will break your entire payload. This is one of those bugs that passes every test until a real person’s data hits it.

Case and whitespace, useful for normalising identifiers before you match on them downstream:

${user.email?trim?lower_case}

Ternary logic and cleaner conditionals

FreeMarker does not have a C-style a ? b : c, but for booleans the ?then built-in gets you there:

"status": "${user.optedIn?then("subscribed", "unsubscribed")}"

For anything more involved, a block <#if> / <#else> is clearer than trying to cram logic into one line. Readability wins here; nobody thanks you for a clever one-liner they cannot debug at 5pm on a Friday.

Locale normalisation

Locale values are wildly inconsistent across systems. One platform sends en_GB, another en-GB, a third just EN. Downstream systems tend to be fussy about exactly one format. Normalise defensively:

<#assign locale = (user.locale!"en_GB")?replace("_", "-")>
<#assign lang = locale?split("-")[0]?lower_case>
${lang}

The pattern is: default the missing case, standardise the separator, split, then lowercase the part you actually need. Building this once and reusing it saves you a class of bug that is genuinely tedious to trace.

This is where templating meets the actual point of the integration. OneTrust gives you a consent or preference transaction state, and the downstream platform wants its own representation of subscribed versus suppressed. The mapping needs to be explicit, and it needs to fail safe.

The rule I follow: an unknown or missing state must never render as opted in. If you are not certain someone consented, you treat them as suppressed. Default towards suppression, always.

<#assign state = (consent.transactionState!"HARD_OPT_OUT")>
<#assign subscribed = (state == "OPT_IN")>
"subscription_state": "${subscribed?then("subscribed", "unsubscribed")}"

Note the default value. If the state is missing entirely, it falls through to the opted-out branch rather than accidentally subscribing someone. A HARD_OPT_OUT and an absent value both land in the same safe place. Getting this backwards is the kind of mistake that turns into a compliance incident, not just a bug.

The exact state values above are illustrative. Confirm the precise transaction states and their meanings against your own UCPM configuration before you rely on them.

Putting it together: a worked payload

Here is a compact Braze-style user payload that uses everything above. The data subject GUID becomes the external identifier, optional attributes are included only when present, and consent maps to a subscription state that fails safe.

<#assign attrs = []>
<#if user.firstName?has_content>
  <#assign attrs += ['"first_name": "${user.firstName?json_string}"']>
</#if>
<#if user.locale?has_content>
  <#assign loc = user.locale?replace("_", "-")?split("-")[0]?lower_case>
  <#assign attrs += ['"language": "${loc}"']>
</#if>
<#assign state = (consent.transactionState!"HARD_OPT_OUT")>
<#assign subscribed = (state == "OPT_IN")>
{
  "attributes": [
    {
      "external_id": "${dataSubjectGuid}",
      ${attrs?join(",\n      ")}
    }
  ],
  "subscription_state": "${subscribed?then("subscribed", "unsubscribed")}"
}

Every field defends against absence, the JSON stays valid whatever the record looks like, free text is escaped, locale is normalised, and consent fails safe. That is the whole discipline in one template.

Gotchas worth knowing before they cost you an afternoon

A few things that reliably catch people out:

  • Test with incomplete records, not clean ones. Your sample data is too tidy. Deliberately feed the template a record missing every optional field, and one with awkward characters in the free text.
  • Escape before you interpolate, not after. Apply ?json_string and ?url at the point of use. Trying to clean strings later is a losing game.
  • Whitespace renders. Newlines and spaces inside directives can end up in your output. If a downstream parser is strict, mind your template’s own formatting.
  • Default at the boundary. Apply defaults where a value first enters the template, so the rest of your logic can assume it is present.

A reusable checklist

Before you ship a template, run through this:

  • Every referenced field has a ! default or a ?? / ?has_content guard.
  • Chained property access is wrapped in parentheses.
  • All free-text values going into JSON pass through ?json_string.
  • Anything entering a URL passes through ?url.
  • Locale and other identifiers are normalised once, near the top.
  • Consent mapping defaults to suppressed, never to opted in.
  • The template has been tested against a deliberately incomplete record.

None of this is complicated once the habits are in place. The difference between a fragile workflow and a reliable one is almost entirely down to whether you assume your data is complete. Assume it is not, and most of the pain disappears.