skip to content
san.is
Table of Contents

SaaS businesses tracking subscriptions in HubSpot face a common challenge: when a customer upgrades or downgrades their plan, you need to close out the old subscription and start the new one seamlessly. Manual processes inevitably lead to data inconsistencies, incorrect billing periods, and broken reporting.

This article walks through building automated workflows that handle subscription migrations properly.

The Subscription Migration Scenario

Picture this data structure in HubSpot:

Custom Object: Subscription

  • Subscription ID (unique identifier)
  • Plan Name (e.g., “Professional”, “Enterprise”)
  • Start Date
  • End Date
  • Status (Active, Expired, Cancelled)
  • Previous Subscription ID (reference to prior subscription)
  • Action (New, Plan Migration Upgrade, Plan Migration Downgrade, Renewal)

When a customer upgrades from Professional to Enterprise:

  1. A new subscription record is created with Action = “Plan Migration Upgrade”
  2. The Previous Subscription ID references the old Professional subscription
  3. The old subscription’s End Date should automatically update to (new Start Date - 1 day)
  4. The old subscription’s Status should change to “Expired”

Without automation, this becomes a manual nightmare.

Why Native Workflows Fall Short

You might think: “I’ll just create a workflow that triggers when a subscription is created, then updates the associated previous subscription.”

The problem: HubSpot workflows can’t natively look up a record by a property value (the Previous Subscription ID) and update it. You can only update associated records through explicit associations, not property-based lookups.

This is where custom coded actions become essential.

The Solution: Custom Coded Workflow

Step 1: Workflow Configuration

Create a Custom Object-based workflow (Subscription object) with these enrollment triggers:

Action is any of: Plan Migration Upgrade, Plan Migration Downgrade
AND
Previous Subs ID is known

This ensures the workflow only fires for migrations that reference an existing subscription.

Step 2: The Custom Code Action

Here’s the complete custom coded action that:

  1. Fetches the previous subscription using its ID
  2. Calculates the new end date (new start date minus 1 day)
  3. Updates the previous subscription’s end date
const hubspot = require('@hubspot/api-client');
exports.main = async (event, callback) => {
const hubspotClient = new hubspot.Client({
accessToken: process.env.HUBSPOT_API_KEY
});
// Get input values from the enrolled subscription
const previousSubsId = event.inputFields['previous_subscription_id'];
const newStartDate = event.inputFields['start_date'];
if (!previousSubsId || !newStartDate) {
callback({
outputFields: {
status: 'error',
message: 'Missing required fields'
}
});
return;
}
try {
// Calculate the end date (new start date minus 1 day)
const startDate = new Date(newStartDate);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() - 1);
// Format as ISO date string (YYYY-MM-DD)
const formattedEndDate = endDate.toISOString().split('T')[0];
// Update the previous subscription
// Replace 'subscriptions' with your custom object's API name
await hubspotClient.crm.objects.basicApi.update(
'subscriptions', // Your custom object API name
previousSubsId,
{
properties: {
'end_date': formattedEndDate,
'status': 'Expired'
}
}
);
callback({
outputFields: {
status: 'success',
updated_subscription_id: previousSubsId,
new_end_date: formattedEndDate
}
});
} catch (error) {
console.error('Error updating previous subscription:', error.message);
callback({
outputFields: {
status: 'error',
message: error.message
}
});
}
};

Step 3: Configure Secrets

In the workflow’s custom code action settings:

  1. Add a secret called HUBSPOT_API_KEY
  2. Use a private app access token with crm.objects.custom.read and crm.objects.custom.write scopes

Step 4: Configure Input Fields

Add these input fields to your custom code action:

  • previous_subscription_id → mapped to your “Previous Subs ID” property
  • start_date → mapped to the “Start Date” property

Handling Automatic Status Updates

The code above explicitly sets Status to “Expired”, but you might prefer status to be calculated based on dates. If you have a separate workflow that sets Status based on End Date:

// Option: Only update end date, let status workflow handle the rest
await hubspotClient.crm.objects.basicApi.update(
'subscriptions',
previousSubsId,
{
properties: {
'end_date': formattedEndDate
// Status will update via separate date-based workflow
}
}
);

Dealing with Chain Migrations

What happens when a customer upgrades twice quickly? Customer goes:

  • Professional → Enterprise (Migration A)
  • Enterprise → Enterprise Plus (Migration B)

Migration B triggers and needs to close Enterprise. But if Migration A hasn’t completed yet, you might have inconsistent data.

Solution: Add Processing Delay

Add a 5-minute delay before your custom code action:

Trigger → Delay 5 minutes → Custom Code Action

This gives previous workflows time to complete.

Solution: Check Status Before Update

Modify the code to verify the previous subscription is still Active:

// First, fetch the previous subscription to check its status
const previousSub = await hubspotClient.crm.objects.basicApi.getById(
'subscriptions',
previousSubsId,
['status', 'end_date']
);
if (previousSub.properties.status !== 'Active') {
callback({
outputFields: {
status: 'skipped',
message: 'Previous subscription already inactive'
}
});
return;
}
// Proceed with update...

Handling Downgrades with Effective Dates

Downgrades often have different business rules. Maybe the old plan continues until its natural end date, and the new plan starts after:

const action = event.inputFields['action'];
if (action === 'Plan Migration Downgrade') {
// For downgrades, the old subscription keeps its original end date
// Just update status to "Pending Expiry" or similar
await hubspotClient.crm.objects.basicApi.update(
'subscriptions',
previousSubsId,
{
properties: {
'status': 'Pending Downgrade',
'pending_replacement_id': event.object.objectId
}
}
);
} else {
// For upgrades, close immediately
await hubspotClient.crm.objects.basicApi.update(
'subscriptions',
previousSubsId,
{
properties: {
'end_date': formattedEndDate,
'status': 'Expired'
}
}
);
}

Associating Old and New Subscriptions

Beyond property updates, you might want to create explicit associations between the old and new subscription records:

// After updating the previous subscription, create an association
await hubspotClient.crm.associations.v4.basicApi.create(
'subscriptions',
event.object.objectId, // New subscription
'subscriptions',
previousSubsId, // Previous subscription
[
{
associationCategory: 'USER_DEFINED',
associationTypeId: YOUR_ASSOCIATION_TYPE_ID // Get from schema endpoint
}
]
);

This creates a navigable link in the HubSpot UI between related subscriptions.

Testing Your Workflow

Before enabling for all records:

  1. Create test subscriptions with a specific naming convention (e.g., “TEST - Professional”)
  2. Add a workflow filter to only enroll test records during development
  3. Check the workflow history after each test to verify outputs
  4. Review the updated records to confirm property changes applied correctly

Error Handling and Monitoring

Add Error Notifications

After your custom code action, add a branch:

  • If status equals “error” → Send internal notification
If/then branch:
status equals "error"
→ Send internal email to [email protected]
→ Include error message in email body

Log Everything

Use console.log statements in your custom code. These appear in the workflow action’s execution logs:

console.log(`Processing migration for subscription ${event.object.objectId}`);
console.log(`Previous subscription: ${previousSubsId}`);
console.log(`Calculated end date: ${formattedEndDate}`);

Implementation Checklist

  • Create custom object with required properties
  • Set up custom association labels if linking subscriptions
  • Create private app with necessary scopes
  • Build workflow with enrollment triggers
  • Add custom coded action with proper error handling
  • Test with sample records
  • Monitor execution logs for first week
  • Document the automation for your team

Key Takeaways

  1. Subscription migrations require updating records that aren’t directly associated - custom code is necessary
  2. Use the Previous Subscription ID as a lookup key, not an association
  3. Handle upgrades and downgrades differently based on business rules
  4. Add delays or status checks to handle rapid sequential migrations
  5. Build in error handling and notifications for failed updates

This pattern extends to any scenario where one record creation should trigger updates to a related record identified by a property value rather than an explicit association.


Managing complex subscription logic in HubSpot? Connect with me on LinkedIn to discuss your implementation.