The marketing contacts model is a meter, not a database. You pay for what is marked marketing, not what exists. Yet every portal we touch is bloated with twelve-month-cold contacts still flagged marketing because nobody dared toggle them off. Renewal lands, finance screams, marketing scrambles, and somebody mass-unmarks 40k contacts in an afternoon. Then nurtures break. Then attribution breaks. Then a deal closes against an “unmarketing” contact and the source field is blank.
This is the cleanup that does not blow up downstream.
The two questions before you touch anything
Forget engagement first. Answer these.
- Is the contact in an active deal pipeline stage that is not closed?
- Is the contact enrolled in an active workflow that sends marketing email?
If yes to either, leave them alone regardless of last open date. Cleanup that respects pipeline does not get reversed at the next pipeline review.
Build the candidate list, do not eyeball it
Use a programmatic active list with hard filters. Do not let anyone “review” a 30k list in the UI. Nobody reviews it. They scroll.
// Active list filters (logical equivalent)
const filters = {
marketingContactStatus: "MARKETING",
lastEmailOpenDate: { lt: "now-365d" },
lastEmailClickDate: { lt: "now-365d" },
lastPageSeenDate: { lt: "now-365d" },
lifecycleStage: { in: ["subscriber", "lead"] }, // never customer
associatedDealStage: { notIn: activePipelineStages },
hsEmailHardBounced: false,
};
Add a negative filter for any contact whose associated company has a deal closed in the last 18 months. Customer adjacency is a real signal even when the contact themselves is silent.
Stage one: suppress, do not delete
Marking non-marketing is reversible. Deleting is not. Stage one of every cleanup is the toggle, not the trash can.
Use the marketing contacts API, not the UI bulk action. The UI silently caps and stalls on large sets.
const token = process.env.HUBSPOT_PRIVATE_APP_TOKEN;
async function unmarkBatch(ids) {
const res = await fetch(
"https://api.hubapi.com/marketing/v3/marketing-events/marketing-contacts/unmark",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ inputs: ids.map((id) => ({ id })) }),
},
);
if (!res.ok) throw new Error(`unmark failed: ${res.status}`);
return res.json();
}
Batch in 100. Sleep 200ms between calls. Marketing contact state changes are async and the daily contact tier update runs at midnight UTC, so plan the run window accordingly.
Stage two: watch the leading indicators
Do not measure success by contact count. Measure by these, for at least one full email cycle.
- Workflow enrollment count vs prior week
- Email send eligibility deltas on active sends
- Sales-owned contact email open rate, week over week
- Deal velocity from MQL to SQL for net-new contacts
If sales open rate dips, you unmarked someone they were quietly working. Re-mark fast.
Stage three: hard delete only graveyards
After 90 days non-marketing with zero new activity, eligible for deletion. Even then, prefer GDPR delete only for contacts who actually requested it. For the rest, archive is plenty.
Hard delete kills attribution history. Original source, first conversion, first touch revenue, all gone. If finance ever asks “where did our 2024 pipeline come from,” they will not love an empty answer.
The nurture safety net
Before any unmark batch runs, build a re-engagement workflow that catches anyone unmarked who later opens, clicks, or visits.
// Custom code action: re-mark on engagement
exports.main = async (event) => {
const contactId = event.object.objectId;
const marketingStatus =
event.inputFields["hs_marketing_status"];
const recentEngagement =
event.inputFields["hs_last_engagement_date"];
if (
marketingStatus === "NON_MARKETING" &&
daysSince(recentEngagement) < 14
) {
await hsClient.crm.contacts.basicApi.update(contactId, {
properties: { hs_marketing_status: "MARKETING" },
});
}
};
Run this nightly against a list of unmarked contacts. Cheap insurance.
The list of mistakes you will be tempted to make
- Unmarking by hard-bounce alone. Bounces churn for reasons unrelated to interest. Pair with engagement age.
- Trusting “last activity date.” It updates on every form submission, every chat, every page view from anyone with a cookie. Use specific engagement properties, not the aggregate.
- Deleting on the same day you unmark. You need the suppression window to catch your mistakes.
- Including customers in cleanup. Closed-won contacts are not marketing cost worth optimizing. Lifecycle filter exists for a reason.
Communicate the cut to sales
Send sales the list of contacts whose owner is them, before unmark, with one column: last contacted by sales. Most reps will scan their column and flag five or ten. Honor those flags. The cleanup that includes sales input keeps trust. The cleanup that surprises them gets reversed by an executive escalation on Monday.
See also our HubSpot data quality discipline guide for the steady-state side of this work, and the marketing contacts pricing tier traps post for the cost math.
Bottom line
- Pipeline-active and workflow-active contacts are never cleanup candidates, regardless of silence.
- Unmark in stages with the API, not the UI; suppression is reversible, deletion is not.
- Build the re-engagement workflow before the purge, not after.
- Measure cleanup success by sales open rate and workflow eligibility, not contact count alone.
- Loop sales in with a per-rep preview list; ambushed reps reverse cleanups fast.