Skip to main content

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

Understanding Stack9 Automations

Automations in Stack9 consist of:

  1. Trigger - What starts the workflow
  2. Conditions (optional) - When to run actions
  3. Actions - What to execute
  4. Parameters - Data passed to actions

Trigger Types

TriggerWhen It FiresUse Case
webhookHTTP request receivedExternal integrations, webhooks
afterCreateAfter entity createdWelcome emails, notifications
afterUpdateAfter entity updatedStatus change handlers
afterDeleteAfter entity deletedCleanup operations
afterWorkflowMoveWorkflow status changedMulti-stage processes
scheduledCron scheduleReports, 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:

  1. Customer is created in database
  2. Trigger fires with afterCreate
  3. 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 status field 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

PatternDescription
0 * * * *Every hour at minute 0
0 9 * * *Every day at 9:00 AM
0 9 * * 1Every Monday at 9:00 AM
0 0 1 * *First day of every month at midnight
*/15 * * * *Every 15 minutes
0 0 * * 0Every 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:

  1. Customer created
  2. Trial account created (14 days)
  3. Welcome email sent
  4. Customer synced to CRM
  5. 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:

  1. Status changed to active
  2. Activation email sent (conditional)
  3. 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

  1. Keep workflows focused - One workflow per business process
  2. Use descriptive names - Clear action and workflow names
  3. Add error handling - Don't let one failure stop entire workflow
  4. Log extensively - Makes debugging much easier
  5. Test thoroughly - Test both success and failure paths

Performance

  1. Avoid N+1 queries - Fetch related data in bulk
  2. Use batch processing - Process records in batches for scheduled jobs
  3. Add timeouts - Don't let long-running actions block forever
  4. Cache when possible - Cache frequently accessed data
  5. Monitor execution time - Watch for slow actions

Error Handling

  1. Catch exceptions - Always try/catch in action types
  2. Return meaningful errors - Help users understand what went wrong
  3. Use retry logic - Retry failed external API calls
  4. Fail gracefully - Continue workflow when non-critical actions fail
  5. Alert on failures - Monitor critical workflow failures

Security

  1. Validate webhook data - Never trust external input
  2. Use authentication - Require auth tokens for webhooks
  3. Rate limit webhooks - Prevent abuse
  4. Sanitize parameters - Prevent injection attacks
  5. Audit logging - Log who triggered what

Troubleshooting

Automation Not Triggering

Problem: Workflow doesn't run when expected

Solutions:

  1. Check trigger configuration (type, entity name)
  2. Verify conditions are met
  3. Look for errors in logs: npm run dev
  4. Ensure entity name matches exactly
  5. Restart dev server after changes

Actions Not Executing

Problem: Actions in workflow don't run

Solutions:

  1. Check action type is registered in src/index.ts
  2. Verify actionType key matches action type key property
  3. Validate parameters are correct
  4. Check action conditions are met
  5. Look for errors in action logs

Template Expression Not Working

Problem: {{trigger.entity.field}} not replaced with value

Solutions:

  1. Verify field name is correct
  2. Check field exists on entity
  3. Use correct syntax (double curly braces)
  4. Check for typos in field name
  5. Test with simple expression first

Scheduled Job Not Running

Problem: Cron job doesn't execute

Solutions:

  1. Verify cron schedule syntax
  2. Check job is registered in app.json
  3. Ensure automation key matches automation name
  4. Wait for next scheduled time
  5. Check server timezone settings

Performance Issues

Problem: Workflows taking too long to execute

Solutions:

  1. Profile action execution times
  2. Optimize database queries
  3. Use batch processing for large datasets
  4. Cache frequently accessed data
  5. Consider async processing for long operations

Next Steps

Congratulations! You've mastered Stack9 workflows. Continue learning: