Stop Writing Campaign State to Customer Profiles: Journey Attributes Are the Scratchpad You Needed
For a few hundred years, the everyday way to write something down in the Greek and Roman world was to scratch it into wax. A wooden frame, a shallow well of blackened beeswax, and a stylus: pointed at one end for writing, flat at the other for smoothing the surface clean again. The flat end wasn't an afterthought. It was half the tool. Wax tablets were cheap, reusable, and everywhere—a student's exercises, a merchant's running accounts, a draft of a letter before the fair copy went onto papyrus.
The point of the wax was that it didn't last. You wrote the note, used it, and wiped it away for tomorrow. Anything that had to survive went onto something permanent instead. We still carry the phrase for wiping the slate: tabula rasa, a scraped tablet. Working notes and permanent records were different materials, on purpose.
Every Customer.io workspace I audit has lost that distinction. Campaign state—discount codes, branch flags, the last thing a webhook returned—gets written straight onto customer profiles, where it sits forever. Customer.io fixed this with the Set journey attributes action. Here's the case for actually using it: what journey attributes are, the one question that tells you whether a value belongs on the profile or the journey, and the two traps the docs mention quietly that you'll hit loudly.
Your profiles are full of dead campaign state
Open the attributes on almost any mature Customer.io profile and you'll find the graveyard: welcome_branch_2024, temp_discount_code, last_webhook_response, bf_promo_seen. None of it describes the person. All of it describes a campaign that finished months ago. It was written there because, until recently, a profile attribute was the only place to stash a value you needed two steps later in a workflow.
That junk doesn't just look untidy. It costs you. Every stale attribute is one more field in your data exports, one more thing a new teammate has to decode, and—worst of all—a live landmine in segmentation. A segment built on temp_discount_code is set quietly scoops up everyone who ran through a promo two years ago. Nobody remembers why the numbers are off, because the attribute that's skewing them hasn't been touched since.
The fix isn't a tidy-up project you'll never get to. It's writing campaign state somewhere that cleans up after itself.
What journey attributes actually are
A journey attribute is a value that lives on a person's journey through a campaign and is deleted automatically when they exit it. Per Customer.io's documentation, journey attributes "don't persist on a person's profile—they automatically expire when the person exits the campaign." They never touch the profile. That's the whole idea: a scratchpad for the workflow, wiped when the workflow is done with the person.
You can set them three ways. The first is the Set journey attributes action, a dedicated workflow block that shipped on 2 March 2026. Drag it onto the canvas, name your attribute, give it a value—a profile attribute, trigger data, a static value, Liquid, or JavaScript—and it's available downstream. You don't need to add a delay after it; the workflow waits for the update to finish before it moves on.
The second is a webhook response. When a webhook action returns data, you can store any field as a journey attribute using the response key with dot notation: a returned "weather": "rainy" becomes the value response.weather. The third is a collection query, storing the result of a lookup against one of your collections.
Journey attributes weren't brand new in March—you could already set them from a webhook or a collection query before the action existed. What the dedicated action added was a first-class way to set one on purpose, without bolting it onto something else. And there's a fourth source you're probably already using without thinking about it: LLM actions store their output as journey attributes by default. If you've run an LLM action to score intent or draft a line of copy, you've been creating journey attributes already. Whether that AI output earns its keep is a separate question, and one I've covered in the LLM Actions credits playbook.
Once a journey attribute is set, you use it like any other Liquid variable: {{journey.discount_code}} in a message, or as the basis for a branch condition or an action condition. When the journey ends, it's gone—no cleanup, no residue on the profile.
The decision rule: profile attribute or journey attribute?
Ask one question: does anything outside this campaign need this value? If the answer is no, it's a journey attribute. If the answer is yes, it belongs on the profile.
That's it. The same shape of question that decides collections versus custom objects decides this one. Run a few real cases through it and the line gets obvious:
A discount code that's only valid while someone is in a single promo campaign? Nothing outside the campaign needs it. Journey attribute—it's the example Customer.io's own docs reach for. An LLM-generated subject line for the next email in the sequence? Used once, never again. Journey attribute, which is why it's the default. The raw payload a webhook returned, that you branch on once and discard? Journey attribute.
Now the other side. A persona or a lead score you'll segment on next quarter needs to outlive the campaign, so it's a customer attribute. A "last onboarding step completed" value that other workflows read needs to be on the profile too. And if a value has to travel from this campaign into a different one, a journey attribute can't help you—it dies at the campaign boundary. That's a job for a customer attribute, or for passing data between small workflows with a Send Event hand-off.
When you're genuinely torn, default to the journey attribute. A profile attribute you didn't need is the exact mess this feature exists to prevent. A journey attribute that turns out to be needed elsewhere is a thirty-second change—the LLM action and webhook response tabs both let you switch an attribute from journey to customer with one click.
Using them without getting burned
Journey attributes are simple until two specific things bite you. Both are documented, and both are easy to miss.
The render_liquid trap
If a journey attribute's value contains Liquid, referencing it the normal way renders that Liquid as plain text. This is the one that wastes an afternoon. Say an LLM action or a webhook hands you a journey attribute called body with the value Hello {{customer.first_name}}!. Drop {{journey.body}} into your email and your customer reads, literally, "Hello {{customer.first_name}}!"—braces and all.
The fix is to wrap it: {% render_liquid journey.body %}. That tells Customer.io to evaluate the Liquid inside the value rather than print it. You'll hit this most often with AI-generated and webhook-generated content, because that's where stored values tend to carry their own personalisation tags. It's the same class of silent rendering failure I catalogue in the Liquid anti-patterns post: the template passes QA, looks fine in a quick glance, and ships wrong.
The silent 100-update limit
You can create or change up to 100 journey attributes during a single journey, and updates beyond that don't error—they just fail, and the person moves on without the update. Customer.io's limits are 100 attributes per journey, 128 bytes for a name, and 100 KiB for a value. The trap is the word "change". Re-setting the same attribute inside a loop counts every time. A workflow that updates a running counter on each pass through a cycle can quietly burn through its 100 and then stop updating, with no failed-send and no alert to tell you. If you build loop-heavy or long-running campaigns, treat 100 as a real budget, not a ceiling you'll never approach.
Previewing, and how they fail
Journey attributes don't render in a message preview unless you give the editor sample data. Open the Sample data panel, select the Journey tab, and type test values under your attribute names—otherwise the preview shows nothing and you'll think something's broken. The more important failure mode is at send time: if Customer.io can't retrieve a value for a journey attribute when it tries to send, the message fails to send. A journey attribute you reference but never reliably set is a broken send waiting to happen, so set fallbacks on anything generated by an LLM or a webhook.
The cleanup audit
There's no native report that flags stale profile attributes, so this is a manual pass—but a worthwhile one. Open your workspace's list of customer attributes in the Data Index and read down it with one filter in mind: which of these describe the person, and which describe a campaign? The campaign ones are your candidates—anything with a temp_ prefix, a year baked into the name, a branch flag, a stored webhook response.
For each candidate, find the workflow that writes it and ask the decision-rule question again. If nothing outside that campaign reads the value, rebuild the logic to use a journey attribute instead: swap the "create or update person attribute" step for a Set journey attributes action, or move a webhook's output from the customer tab to the journey tab. Then, once nothing depends on the old profile attribute, remove it.
Do the highest-traffic campaigns first, since that's where new junk accumulates fastest. Getting the schema right at the source is the same discipline as getting your event schema right on day one: a little structure now saves a lot of archaeology later. You won't clear the graveyard in an afternoon, but every campaign you migrate is one that stops adding to it.
Frequently asked questions
Q: What is a journey attribute in Customer.io?
A journey attribute is a temporary value stored on a person's journey through a campaign, used for personalisation, conditions, and branching inside that workflow. Unlike a customer attribute, it never appears on the person's profile and is deleted automatically when they exit the campaign. Customer.io's docs describe it as a way to "temporarily store data that you want to use in your campaign".
Q: When do journey attributes expire?
They expire when the person exits the campaign—when their journey ends. There's no fixed time window and no retention setting; expiry is tied to the journey, not the clock. The moment someone leaves the campaign, Customer.io deletes their journey attribute data.
Q: Do journey attributes show up on the customer's profile?
No. That's the entire point of them. Journey attributes live on the journey and never persist to the profile, which is what keeps your customer records and segments clean. If you need a value to appear on the profile, use a customer attribute instead.
Q: How do I reference a journey attribute in Liquid?
Use the journey key: {{journey.discount_code}}. If the value is an object, access properties directly with {{journey.promo_course.name}}. If it's an array, loop over it with {% for item in journey.course_recommendations %} or pull items with filters like first, last, and map.
Q: Why is my journey attribute rendering as raw Liquid text?
Because the value itself contains Liquid, and {{journey.body}} prints that Liquid as static text rather than evaluating it. Wrap it in {% render_liquid journey.body %} so the Liquid inside the value renders. You'll see this most with LLM-generated or webhook-generated content that carries its own personalisation tags.
Q: How many journey attributes can I set in one campaign?
You can create or change up to 100 journey attributes during a single journey. Updates beyond that limit fail, and the person moves forward without the update—silently, with no error. Attribute names are capped at 128 bytes and values at 100 KiB. Re-setting the same attribute in a loop counts against the 100 each time.
Q: Should I use a profile attribute or a journey attribute for a discount code?
Use a journey attribute if the code is only valid while the person is in that campaign—nothing outside the campaign needs it, so it shouldn't live on the profile. It's the example Customer.io's own documentation uses. Use a customer attribute only if the code has to be readable after the campaign ends, for instance in a later, separate workflow.
Q: Do LLM actions create journey attributes?
Yes. LLM actions store their output as journey attributes by default, so the data expires when the person exits the campaign. If you want to use an LLM's output outside the campaign, you change the output to a customer attribute in the action's Response tab.
Q: How do I set a journey attribute from a webhook response?
In the webhook action's Response tab, click Add attribute, choose journey attribute, and set its value from the response using dot notation—a returned "weather": "rainy" becomes response.weather. For an array response, use bracket notation, where [0] is the first element in the array. You can also use Liquid to set the value conditionally.
Q: Can I use journey attributes in branch and action conditions?
Yes. Once set, a journey attribute works in any condition in the workflow—multi-split branches, action conditions on a message, conditions on a delay. You might send people down different paths based on a journey attribute value, or only send a message when the attribute contains a certain value.
Q: Do I need a delay after the Set journey attributes action?
No. The workflow waits for the attribute update to finish before moving to the next step, so an extra delay does nothing. The same is true when a webhook action sets attributes: the person waits to move forward until the update completes.
Q: Why isn't my journey attribute showing in the message preview?
The preview needs sample data. Open the Sample data panel, select the Journey tab, and enter test values under your attribute names. Without sample values, the preview can't render the attribute and shows nothing—which looks like a bug but isn't.
Q: What happens if a journey attribute is missing when the message sends?
The message fails to send. If Customer.io can't retrieve a value for a referenced journey attribute at send time, it won't send the message, the same as any other unresolved Liquid variable. Set fallback values on attributes generated by LLM actions or webhooks so a missing value doesn't break the send.
Q: How do I migrate temporary profile attributes to journey attributes?
Audit your customer attributes in the Data Index and flag the ones that describe a campaign rather than the person. For each, find the workflow that writes it, and if nothing outside that campaign reads the value, swap the profile-attribute step for a Set journey attributes action or move a webhook's output to the journey tab. Remove the old profile attribute once nothing depends on it. There's no automated report for this, so work through your highest-traffic campaigns first.
Sources
- Set journey attributes. Customer.io Docs. Updated 8 June 2026.
- New campaign action: Set journey attributes. Customer.io Release Notes. 2 March 2026.
- LLM actions: Generate data & decisions with AI. Customer.io Docs. Updated 8 June 2026.
- Send and receive data with webhook actions. Customer.io Docs. Updated 8 June 2026.
- Wax Tablet. The History of the Book, NTU. 6 March 2021.
- Wax tablet. Wikipedia. Accessed 16 June 2026.


