Subscription Workflows: Handling Plan Migrations
/ 6 min read
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:
- A new subscription record is created with Action = “Plan Migration Upgrade”
- The Previous Subscription ID references the old Professional subscription
- The old subscription’s End Date should automatically update to (new Start Date - 1 day)
- 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 DowngradeANDPrevious Subs ID is knownThis 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:
- Fetches the previous subscription using its ID
- Calculates the new end date (new start date minus 1 day)
- 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:
- Add a secret called
HUBSPOT_API_KEY - Use a private app access token with
crm.objects.custom.readandcrm.objects.custom.writescopes
Step 4: Configure Input Fields
Add these input fields to your custom code action:
previous_subscription_id→ mapped to your “Previous Subs ID” propertystart_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 restawait 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 ActionThis 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 statusconst 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 associationawait 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:
- Create test subscriptions with a specific naming convention (e.g., “TEST - Professional”)
- Add a workflow filter to only enroll test records during development
- Check the workflow history after each test to verify outputs
- 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
statusequals “error” → Send internal notification
If/then branch: status equals "error" → Send internal email to [email protected] → Include error message in email bodyLog 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
- Subscription migrations require updating records that aren’t directly associated - custom code is necessary
- Use the Previous Subscription ID as a lookup key, not an association
- Handle upgrades and downgrades differently based on business rules
- Add delays or status checks to handle rapid sequential migrations
- 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.