Building Workflows
Learn how to build powerful automation workflows in Stack9. This guide covers webhook endpoints, entity lifecycle triggers, scheduled jobs, conditional logic, and error handling for real-world automation scenarios.
What You'll Build
In this guide, you'll build a Customer Onboarding Workflow that includes:
- Webhook triggers for external system integration
- Entity lifecycle automation (afterCreate, afterUpdate)
- Scheduled jobs for recurring tasks
- Conditional actions based on data
- Multi-step workflows with error handling
- Integration with external services
Time to complete: 45-60 minutes
Prerequisites
- Completed Implementing Business Logic guide
- Understanding of Action Types
- Basic knowledge of REST APIs
Understanding Stack9 Automations
Automations in Stack9 consist of:
- Trigger - What starts the workflow
- Conditions (optional) - When to run actions
- Actions - What to execute
- Parameters - Data passed to actions
Trigger Types
| Trigger | When It Fires | Use Case |
|---|---|---|
webhook | HTTP request received | External integrations, webhooks |
afterCreate | After entity created | Welcome emails, notifications |
afterUpdate | After entity updated | Status change handlers |
afterDelete | After entity deleted | Cleanup operations |
afterWorkflowMove | Workflow status changed | Multi-stage processes |
scheduled | Cron schedule | Reports, cleanup, sync jobs |
Step 1: Create the Customer Entity
Create src/entities/custom/customer.json:
{
"name": "customer",
"label": "Customer",
"primaryField": "email",
"fields": [
{
"label": "Email",
"key": "email",
"type": "TextField",
"typeOptions": {
"required": true
}
},
{
"label": "First Name",
"key": "first_name",
"type": "TextField",
"typeOptions": {
"required": true
}
},
{
"label": "Last Name",
"key": "last_name",
"type": "TextField",
"typeOptions": {
"required": true
}
},
{
"label": "Phone",
"key": "phone",
"type": "TextField"
},
{
"label": "Company",
"key": "company",
"type": "TextField"
},
{
"label": "Status",
"key": "status",
"type": "SingleDropDown",
"typeOptions": {
"options": [
{ "value": "trial", "label": "Trial" },
{ "value": "active", "label": "Active" },
{ "value": "inactive", "label": "Inactive" },
{ "value": "churned", "label": "Churned" }
],
"default": "trial"
}
},
{
"label": "Onboarding Status",
"key": "onboarding_status",
"type": "SingleDropDown",
"typeOptions": {
"options": [
{ "value": "pending", "label": "Pending" },
{ "value": "email_sent", "label": "Email Sent" },
{ "value": "account_setup", "label": "Account Setup" },
{ "value": "completed", "label": "Completed" }
],
"default": "pending"
}
},
{
"label": "Trial Expires At",
"key": "trial_expires_at",
"type": "DateField"
},
{
"label": "Last Activity",
"key": "last_activity_at",
"type": "DateField"
}
]
}
Step 2: Create Action Types for Onboarding
Send Welcome Email Action
Create src/action-types/sendWelcomeEmail.ts:
import { Record, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
const SendWelcomeEmailParams = Record({
customer_id: Number,
});
/**
* Send Welcome Email
*
* Sends personalized welcome email to new customers
*/
export class SendWelcomeEmail extends S9AutomationActionType {
key = 'send_welcome_email';
name = 'Send Welcome Email';
description = 'Send personalized welcome email to customer';
async exec(params: any) {
const { customer_id } = SendWelcomeEmailParams.check(params);
const { db, connectors, logger } = this.context;
try {
// Fetch customer details
const customer = await db('customer')
.where({ id: customer_id })
.first();
if (!customer) {
throw new Error(`Customer ${customer_id} not found`);
}
// Send welcome email
const emailConnector = connectors['email_service'];
await emailConnector.call({
method: 'POST',
path: '/send',
body: {
to: customer.email,
subject: 'Welcome to Our Platform!',
template: 'welcome',
data: {
first_name: customer.first_name,
company: customer.company,
trial_expires_at: customer.trial_expires_at,
},
},
});
// Update onboarding status
await db('customer')
.where({ id: customer_id })
.update({
onboarding_status: 'email_sent',
_updated_at: new Date(),
});
logger.info(`Welcome email sent to ${customer.email}`);
return {
success: true,
message: 'Welcome email sent successfully',
};
} catch (error) {
logger.error(`Error sending welcome email: ${error.message}`);
throw error;
}
}
}
Create Trial Account Action
Create src/action-types/createTrialAccount.ts:
import { Record, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
/**
* Create Trial Account
*
* Sets up trial account with default settings
*/
export class CreateTrialAccount extends S9AutomationActionType {
key = 'create_trial_account';
name = 'Create Trial Account';
description = 'Create trial account with default configuration';
async exec(params: any) {
const { customer_id } = params;
const { db, logger } = this.context;
try {
const customer = await db('customer')
.where({ id: customer_id })
.first();
if (!customer) {
throw new Error(`Customer ${customer_id} not found`);
}
// Set trial expiration (14 days from now)
const trialExpiresAt = new Date();
trialExpiresAt.setDate(trialExpiresAt.getDate() + 14);
// Update customer with trial details
await db('customer')
.where({ id: customer_id })
.update({
trial_expires_at: trialExpiresAt,
status: 'trial',
_updated_at: new Date(),
});
// Create default settings for customer
await db('customer_settings').insert({
customer_id: customer_id,
feature_flags: JSON.stringify({
advanced_analytics: false,
api_access: true,
custom_branding: false,
}),
_created_at: new Date(),
_updated_at: new Date(),
});
logger.info(`Trial account created for customer ${customer.email}`);
return {
success: true,
trial_expires_at: trialExpiresAt,
message: 'Trial account created successfully',
};
} catch (error) {
logger.error(`Error creating trial account: ${error.message}`);
throw error;
}
}
}
Sync to CRM Action
Create src/action-types/syncToCRM.ts:
import { Record, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
/**
* Sync to CRM
*
* Syncs customer data to external CRM system
*/
export class SyncToCRM extends S9AutomationActionType {
key = 'sync_to_crm';
name = 'Sync to CRM';
description = 'Sync customer data to external CRM';
async exec(params: any) {
const { customer_id } = params;
const { db, connectors, logger } = this.context;
try {
const customer = await db('customer')
.where({ id: customer_id })
.first();
if (!customer) {
throw new Error(`Customer ${customer_id} not found`);
}
// Sync to CRM via connector
const crmConnector = connectors['crm_api'];
const response = await crmConnector.call({
method: 'POST',
path: '/contacts',
body: {
email: customer.email,
first_name: customer.first_name,
last_name: customer.last_name,
phone: customer.phone,
company: customer.company,
status: customer.status,
custom_fields: {
onboarding_status: customer.onboarding_status,
trial_expires_at: customer.trial_expires_at,
},
},
});
logger.info(
`Customer ${customer.email} synced to CRM with ID ${response.id}`
);
return {
success: true,
crm_id: response.id,
message: 'Customer synced to CRM successfully',
};
} catch (error) {
logger.error(`Error syncing to CRM: ${error.message}`);
// Don't throw - allow workflow to continue even if CRM sync fails
return {
success: false,
error: error.message,
};
}
}
}
Send Trial Expiry Reminder
Create src/action-types/sendTrialExpiryReminder.ts:
import { S9AutomationActionType } from '@april9/stack9-sdk';
/**
* Send Trial Expiry Reminder
*
* Sends reminder emails to customers whose trials are expiring soon
*/
export class SendTrialExpiryReminder extends S9AutomationActionType {
key = 'send_trial_expiry_reminder';
name = 'Send Trial Expiry Reminder';
description = 'Send reminder to customers with expiring trials';
async exec(params: any) {
const { db, connectors, logger } = this.context;
try {
// Find customers with trials expiring in 3 days
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
const expiringCustomers = await db('customer')
.where('status', 'trial')
.where('trial_expires_at', '<=', threeDaysFromNow)
.where('trial_expires_at', '>', new Date());
logger.info(
`Found ${expiringCustomers.length} customers with expiring trials`
);
const emailConnector = connectors['email_service'];
let sentCount = 0;
for (const customer of expiringCustomers) {
// Calculate days remaining
const daysRemaining = Math.ceil(
(new Date(customer.trial_expires_at).getTime() - new Date().getTime()) /
(1000 * 60 * 60 * 24)
);
// Send reminder email
await emailConnector.call({
method: 'POST',
path: '/send',
body: {
to: customer.email,
subject: `Your Trial Expires in ${daysRemaining} Days`,
template: 'trial_expiry_reminder',
data: {
first_name: customer.first_name,
days_remaining: daysRemaining,
trial_expires_at: customer.trial_expires_at,
},
},
});
sentCount++;
logger.info(`Sent trial expiry reminder to ${customer.email}`);
}
return {
success: true,
customers_notified: sentCount,
message: `Sent reminders to ${sentCount} customers`,
};
} catch (error) {
logger.error(`Error sending trial expiry reminders: ${error.message}`);
throw error;
}
}
}
Step 3: Register Action Types
Update src/index.ts:
import { SendWelcomeEmail } from './action-types/sendWelcomeEmail';
import { CreateTrialAccount } from './action-types/createTrialAccount';
import { SyncToCRM } from './action-types/syncToCRM';
import { SendTrialExpiryReminder } from './action-types/sendTrialExpiryReminder';
export const actionTypes = [
SendWelcomeEmail,
CreateTrialAccount,
SyncToCRM,
SendTrialExpiryReminder,
];
Step 4: Create Workflow - New Customer Onboarding
Create src/automations/when_customer_created.json:
{
"name": "when_customer_created",
"label": "When Customer Created",
"description": "Onboard new customers with trial setup and welcome email",
"trigger": {
"type": "afterCreate",
"entity": "customer"
},
"actions": [
{
"name": "create_trial",
"actionType": "create_trial_account",
"params": {
"customer_id": "{{trigger.entity.id}}"
}
},
{
"name": "send_welcome",
"actionType": "send_welcome_email",
"params": {
"customer_id": "{{trigger.entity.id}}"
}
},
{
"name": "sync_to_crm",
"actionType": "sync_to_crm",
"params": {
"customer_id": "{{trigger.entity.id}}"
}
}
]
}
How it works:
- Customer is created in database
- Trigger fires with
afterCreate - Actions run sequentially:
- Create trial account (14 days)
- Send welcome email
- Sync customer to CRM
Step 5: Create Workflow - Customer Status Change
Create src/automations/when_customer_status_changed.json:
{
"name": "when_customer_status_changed",
"label": "When Customer Status Changed",
"description": "Handle customer status transitions",
"trigger": {
"type": "afterUpdate",
"entity": "customer"
},
"conditions": [
{
"field": "status",
"operator": "changed"
}
],
"actions": [
{
"name": "send_activation_email",
"actionType": "send_email",
"params": {
"to": "{{trigger.entity.email}}",
"template": "account_activated",
"data": {
"first_name": "{{trigger.entity.first_name}}"
}
},
"conditions": [
{
"field": "{{trigger.entity.status}}",
"operator": "equals",
"value": "active"
}
]
},
{
"name": "send_cancellation_survey",
"actionType": "send_email",
"params": {
"to": "{{trigger.entity.email}}",
"template": "cancellation_survey",
"data": {
"first_name": "{{trigger.entity.first_name}}"
}
},
"conditions": [
{
"field": "{{trigger.entity.status}}",
"operator": "equals",
"value": "churned"
}
]
},
{
"name": "update_crm_status",
"actionType": "sync_to_crm",
"params": {
"customer_id": "{{trigger.entity.id}}"
}
}
]
}
Conditional Actions:
- Only runs if
statusfield changed - Sends activation email only if status is now
active - Sends cancellation survey only if status is now
churned - Always syncs status to CRM
Step 6: Create Webhook - External System Integration
Create src/automations/webhook_customer_signup.json:
{
"name": "webhook_customer_signup",
"label": "Customer Signup Webhook",
"description": "Receive customer signups from external systems",
"trigger": {
"type": "webhook",
"method": "POST",
"path": "/customer-signup"
},
"actions": [
{
"name": "validate_email",
"actionType": "validate_email",
"params": {
"email": "{{trigger.body.email}}"
}
},
{
"name": "create_customer",
"actionType": "create_entity",
"params": {
"entity": "customer",
"data": {
"email": "{{trigger.body.email}}",
"first_name": "{{trigger.body.first_name}}",
"last_name": "{{trigger.body.last_name}}",
"phone": "{{trigger.body.phone}}",
"company": "{{trigger.body.company}}",
"status": "trial"
}
}
}
]
}
How to call:
curl -X POST https://your-app.com/webhooks/customer-signup \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"phone": "+1234567890",
"company": "Acme Inc"
}'
Step 7: Create Scheduled Job - Daily Trial Reminders
Create src/automations/scheduled_trial_reminders.json:
{
"name": "scheduled_trial_reminders",
"label": "Daily Trial Reminders",
"description": "Send daily reminders for expiring trials",
"trigger": {
"type": "scheduled",
"schedule": "0 9 * * *"
},
"actions": [
{
"name": "send_reminders",
"actionType": "send_trial_expiry_reminder",
"params": {}
}
]
}
Cron Schedule: 0 9 * * * = Every day at 9:00 AM
Common Cron Patterns
| Pattern | Description |
|---|---|
0 * * * * | Every hour at minute 0 |
0 9 * * * | Every day at 9:00 AM |
0 9 * * 1 | Every Monday at 9:00 AM |
0 0 1 * * | First day of every month at midnight |
*/15 * * * * | Every 15 minutes |
0 0 * * 0 | Every Sunday at midnight |
Step 8: Register Scheduled Jobs
Update src/app.json:
{
"name": "Your App Name",
"cronJobs": [
{
"name": "Daily Trial Reminders",
"schedule": "0 9 * * *",
"automationKey": "scheduled_trial_reminders"
}
]
}
Step 9: Advanced Patterns
Pattern 1: Chained Actions with Dependencies
Actions can access results from previous actions:
{
"actions": [
{
"name": "fetch_user_data",
"actionType": "fetch_data",
"params": {
"user_id": "{{trigger.entity.user_id}}"
}
},
{
"name": "send_personalized_email",
"actionType": "send_email",
"params": {
"to": "{{actions.fetch_user_data.result.email}}",
"data": {
"name": "{{actions.fetch_user_data.result.name}}",
"preferences": "{{actions.fetch_user_data.result.preferences}}"
}
}
}
]
}
Pattern 2: Complex Conditionals
Multiple conditions with AND/OR logic:
{
"conditions": [
{
"operator": "and",
"conditions": [
{
"field": "{{trigger.entity.status}}",
"operator": "equals",
"value": "active"
},
{
"field": "{{trigger.entity.subscription_tier}}",
"operator": "in",
"value": ["premium", "enterprise"]
}
]
}
]
}
Pattern 3: Error Handling
Continue workflow even if action fails:
// In action type
async exec(params: any) {
try {
// Attempt risky operation
await externalApi.call();
return { success: true };
} catch (error) {
// Log error but return success
this.context.logger.error(`Non-critical error: ${error.message}`);
return {
success: true,
skipped: true,
reason: error.message,
};
}
}
Pattern 4: Batch Processing in Scheduled Jobs
Process records in batches:
export class ProcessBatchJob extends S9AutomationActionType {
async exec(params: any) {
const { db, logger } = this.context;
const BATCH_SIZE = 100;
let offset = 0;
let processed = 0;
while (true) {
// Fetch batch
const records = await db('customer')
.where('needs_processing', true)
.limit(BATCH_SIZE)
.offset(offset);
if (records.length === 0) break;
// Process batch
for (const record of records) {
await this.processRecord(record);
processed++;
}
offset += BATCH_SIZE;
logger.info(`Processed ${processed} records so far`);
}
return {
success: true,
records_processed: processed,
};
}
}
Pattern 5: Workflow State Machine
Use onboarding_status field for multi-stage workflow:
{
"name": "when_onboarding_status_changed",
"trigger": {
"type": "afterUpdate",
"entity": "customer"
},
"conditions": [
{
"field": "onboarding_status",
"operator": "changed"
}
],
"actions": [
{
"name": "send_setup_instructions",
"actionType": "send_email",
"params": {
"template": "setup_instructions"
},
"conditions": [
{
"field": "{{trigger.entity.onboarding_status}}",
"operator": "equals",
"value": "email_sent"
}
]
},
{
"name": "enable_features",
"actionType": "enable_trial_features",
"conditions": [
{
"field": "{{trigger.entity.onboarding_status}}",
"operator": "equals",
"value": "account_setup"
}
]
},
{
"name": "send_completion_email",
"actionType": "send_email",
"params": {
"template": "onboarding_complete"
},
"conditions": [
{
"field": "{{trigger.entity.onboarding_status}}",
"operator": "equals",
"value": "completed"
}
]
}
]
}
Step 10: Test Your Workflows
Test Entity Lifecycle Automation
# Create a customer (triggers onboarding workflow)
curl -X POST http://localhost:3000/api/customer \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"first_name": "Test",
"last_name": "User",
"company": "Test Co"
}'
Expected workflow:
- Customer created
- Trial account created (14 days)
- Welcome email sent
- Customer synced to CRM
- Onboarding status updated to
email_sent
Test Webhook
# Call webhook endpoint
curl -X POST http://localhost:3000/webhooks/customer-signup \
-H "Content-Type: application/json" \
-d '{
"email": "webhook@example.com",
"first_name": "Webhook",
"last_name": "Test"
}'
Test Status Change
# Update customer status (triggers status change workflow)
curl -X PUT http://localhost:3000/api/customer/1 \
-H "Content-Type: application/json" \
-d '{
"status": "active"
}'
Expected workflow:
- Status changed to
active - Activation email sent (conditional)
- CRM synced with new status
Monitor Scheduled Jobs
# View logs
npm run dev
# Logs will show:
# [INFO] Running scheduled job: scheduled_trial_reminders
# [INFO] Found 5 customers with expiring trials
# [INFO] Sent trial expiry reminder to customer@example.com
Template Expressions
Use these expressions in automation parameters:
Trigger Data
{
"params": {
"entity_id": "{{trigger.entity.id}}",
"entity_field": "{{trigger.entity.field_name}}",
"original_value": "{{trigger.entity._original.field_name}}",
"webhook_body": "{{trigger.body.field_name}}",
"webhook_query": "{{trigger.query.param_name}}",
"webhook_headers": "{{trigger.headers.header_name}}"
}
}
Action Results
{
"params": {
"previous_result": "{{actions.action_name.result.field}}",
"previous_success": "{{actions.action_name.success}}"
}
}
Built-in Functions
{
"params": {
"current_date": "{{now}}",
"current_user": "{{user.id}}",
"current_user_email": "{{user.email}}"
}
}
Best Practices
Workflow Design
- Keep workflows focused - One workflow per business process
- Use descriptive names - Clear action and workflow names
- Add error handling - Don't let one failure stop entire workflow
- Log extensively - Makes debugging much easier
- Test thoroughly - Test both success and failure paths
Performance
- Avoid N+1 queries - Fetch related data in bulk
- Use batch processing - Process records in batches for scheduled jobs
- Add timeouts - Don't let long-running actions block forever
- Cache when possible - Cache frequently accessed data
- Monitor execution time - Watch for slow actions
Error Handling
- Catch exceptions - Always try/catch in action types
- Return meaningful errors - Help users understand what went wrong
- Use retry logic - Retry failed external API calls
- Fail gracefully - Continue workflow when non-critical actions fail
- Alert on failures - Monitor critical workflow failures
Security
- Validate webhook data - Never trust external input
- Use authentication - Require auth tokens for webhooks
- Rate limit webhooks - Prevent abuse
- Sanitize parameters - Prevent injection attacks
- Audit logging - Log who triggered what
Troubleshooting
Automation Not Triggering
Problem: Workflow doesn't run when expected
Solutions:
- Check trigger configuration (type, entity name)
- Verify conditions are met
- Look for errors in logs:
npm run dev - Ensure entity name matches exactly
- Restart dev server after changes
Actions Not Executing
Problem: Actions in workflow don't run
Solutions:
- Check action type is registered in
src/index.ts - Verify
actionTypekey matches action typekeyproperty - Validate parameters are correct
- Check action conditions are met
- Look for errors in action logs
Template Expression Not Working
Problem: {{trigger.entity.field}} not replaced with value
Solutions:
- Verify field name is correct
- Check field exists on entity
- Use correct syntax (double curly braces)
- Check for typos in field name
- Test with simple expression first
Scheduled Job Not Running
Problem: Cron job doesn't execute
Solutions:
- Verify cron schedule syntax
- Check job is registered in
app.json - Ensure automation key matches automation name
- Wait for next scheduled time
- Check server timezone settings
Performance Issues
Problem: Workflows taking too long to execute
Solutions:
- Profile action execution times
- Optimize database queries
- Use batch processing for large datasets
- Cache frequently accessed data
- Consider async processing for long operations
Next Steps
Congratulations! You've mastered Stack9 workflows. Continue learning:
- Integrating External APIs - Connect to third-party services
- Automations Reference - Learn all automation features
- Action Types Reference - Master action type patterns
- Connectors Reference - External service integration