The most expensive HubSpot incident is not a deleted contact or a bad import. It is a workflow that fires 50,000 emails in 90 minutes because someone toggled a property on an active list and re-enrollment was on. The deliverability hit lasts six weeks. The unsubscribe spike lasts forever. The Slack postmortem lasts one Monday morning and never quite ends.
HubSpot does not give you a “max sends per hour” knob on the workflow itself. You build it. Here is how.
What actually happens during a blowout
A workflow with a contact enrollment trigger like “lifecycle stage equals lead” runs continuously. Someone bulk-edits 50k contacts to lead because of a botched import. Re-enrollment evaluates. 50k contacts enroll within a single minute. The first action is “send marketing email.” HubSpot queues, throttles internally a bit, and the sends still land within the hour.
Mailbox providers see 50k sends from your sender in an hour. They throttle you. Spam folder placement spikes. Engagement collapses. Domain reputation needs weeks.
The pattern: enrollment gate plus drip queue
Two pieces. A gate at enrollment that drops anyone exceeding rolling thresholds. A drip queue that paces the rest.
The gate
Use a custom code action as the first workflow step. It reads a rolling counter from a HubDB table or an external store and rejects enrollment if the threshold is exceeded.
const HOURLY_CAP = 500;
exports.main = async (event, callback) => {
const now = Date.now();
const windowStart = now - 60 * 60 * 1000;
const counter = await getCounter("workflow_42_send_count");
const recent = counter.events.filter((t) => t > windowStart);
if (recent.length >= HOURLY_CAP) {
return callback({
outputFields: { proceed: "false", reason: "rate_limited" },
});
}
recent.push(now);
await setCounter("workflow_42_send_count", {
events: recent.slice(-HOURLY_CAP),
});
callback({ outputFields: { proceed: "true" } });
};
Branch on proceed. Anything false goes to a delay-then-re-enroll branch with a 30-minute pause. Anything true proceeds to the send.
The drip queue
For a planned campaign blast that is large by design, use a different pattern: a delay distributed by contact ID modulo.
exports.main = async (event, callback) => {
const contactId = event.object.objectId;
const bucket = contactId % 12; // 12 buckets across an hour
const delayMinutes = bucket * 5;
callback({ outputFields: { delayMinutes } });
};
Use the output to feed a custom-delay step. 50k contacts across 12 buckets is ~4,000 per 5-minute window. Mailbox providers tolerate that. They do not tolerate 50k in 60 seconds.
Re-enrollment is the trap
Default re-enrollment looks innocent. It says “re-enroll when the trigger criteria are met again.” What it actually does is re-enroll any contact whose property changes such that the criteria pass again.
If your trigger is “lifecycle stage is lead,” and someone moves 30k contacts from MQL back to lead in a backfill, all 30k re-enroll. Re-enrollment was never about catching natural state changes. It was about catching campaign-driven returns. Treat it that way.
For high-volume workflows, turn re-enrollment off. Build a separate workflow with explicit enrollment from a campaign list. Bulk operations no longer auto-fire your nurture.
The sentinel workflow
Build a small workflow whose only job is to detect blowouts in progress.
- Trigger: any workflow enrolls more than N contacts in M minutes
- Action: Slack the marketing-ops channel, optionally auto-pause the offending workflow
HubSpot does not natively expose enrollment-rate triggers. You fake it with a scheduled workflow that queries the enrollment-history API every 5 minutes.
async function checkEnrollmentRate() {
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const res = await fetch(
`https://api.hubapi.com/automation/v4/flows/${FLOW_ID}/enrollment-history?since=${since}`,
{ headers: { Authorization: `Bearer ${TOKEN}` } },
);
const { results } = await res.json();
if (results.length > 200) {
await postSlack(
`Workflow ${FLOW_ID} enrolled ${results.length} contacts in 5 min`,
);
await pauseWorkflow(FLOW_ID);
}
}
Run it every 5 minutes. The first false positive teaches you to tune the threshold. The first true positive saves a sender domain.
Suppression list discipline
The throttle is upstream. Suppression is downstream. Maintain a global suppression list that any marketing workflow honors, populated by:
- Hard bounce in the last 30 days
- Spam complaint, ever
- Unsubscribe from any list
- More than 4 sends in the trailing 7 days from your sender
The last rule is the one most teams miss. Frequency capping is a deliverability hygiene control, not a customer comfort control.
What to test before you ship
- Simulate a bulk property update against staging and watch the enrollment rate
- Disable then re-enable re-enrollment and confirm the rate gate fires
- Send through a seed list with mailbox-monitoring tools to confirm placement does not shift under the drip pace
See HubSpot email deliverability monitoring and the API rate limit survival post for adjacent guardrails.
Bottom line
- Default re-enrollment plus a bulk property update is how every 50k accident starts.
- Build the enrollment-rate gate as the first workflow step, not the last.
- Drip large planned blasts with a modulo-bucket delay, not a single trigger.
- Run a sentinel workflow that polls enrollment history and auto-pauses runaway flows.
- Frequency cap your sender at 4 sends in 7 days; deliverability beats clever cadence.