User onboarding is one of the most critical processes for any SaaS business. It's your first, best chance to make a great impression. But so often, the backend workflows that power onboarding are a fragile collection of scripts and API calls. What happens when your email service has a hiccup, or the CRM API times out? You end up with partially onboarded users, a system in an inconsistent state, and a nightmare for your support team.
There has to be a better way. Enter Atomic Actions.
This tutorial will show you how to build a complete, resilient user onboarding workflow using the .do platform. We'll define each step—from sending a welcome email to provisioning a user account and updating your CRM—as a reusable, API-driven Action. The result? A bulletproof workflow that is reliable, scalable, and easy to audit.
Let's build a workflow that includes:
Before we write any code, let's understand the fundamental building block of the .do platform: the Atomic Action.
Think of an Atomic Action like a database transaction. It's the smallest, indivisible unit of work in your system—a task designed to either succeed completely or fail cleanly without any side effects. There's no "in-between."
This is a game-changer for workflow automation. Instead of writing a long script where one failure can corrupt your data, you encapsulate each task into a self-contained, guaranteed Action. These Actions are:
By defining these Actions as code, you focus purely on your business logic, while .do handles the complex, failure-prone infrastructure.
Let's start with a classic onboarding task: sending a welcome email. Using the .do SDK, we can define this as a named Action.
This code defines a single, indivisible task named send-welcome-email. It takes a userId, finds the user, and sends them an email. If the user isn't found, it throws an error, and the platform marks the Action as failed—it never even attempts to send an email. This is atomicity in action.
A real onboarding workflow is more than just one email. Let's define the other building blocks for our process. The beauty of this approach is that each piece of business logic gets its own clean, maintainable Action.
First, an Action to create the user in our primary database. This Action will return the new userId, which we can use in subsequent steps.
Next, an Action to add the new user to our CRM (e.g., HubSpot, Salesforce).
We now have three independent, reusable, and robust Actions. Each one can be tested, versioned, and scaled on its own.
With our building blocks defined, we can now orchestrate them to form our complete onboarding workflow. This is where the reliability of the .do agentic workflow platform truly shines.
We can trigger these actions from our application backend—for instance, right after a user signs up through our API.
This is fundamentally more reliable than a simple script. If the update-crm-contact Action fails, the .do platform's built-in retry logic will kick in. If it ultimately fails, the entire process halts, and the failure is logged. You're never left wondering if a user got a welcome email but isn't in your CRM. The state of your workflow is always consistent and auditable.
You've just built a production-grade business process, not just a script. By replacing fragile, monolithic code with a composition of atomic actions, you gain powerful advantages:
Ready to start building your own bulletproof workflows? Visit action.do and transform your business process automation with Atomic Actions as Code.
import { Action, client } from '@do-sdk/core';
// Define an atomic action as code
const sendWelcomeEmail = new Action({
name: 'send-welcome-email',
handler: async (inputs: { userId: string }) => {
const user = await db.users.find(inputs.userId);
if (!user) {
throw new Error('User not found.');
}
await email.send({
to: user.email,
subject: 'Welcome to .do!',
body: `Hi ${user.name}, welcome aboard!`
});
return { success: true, messageId: '...' };
}
});
// an action to create a user in our database
const createUserInDB = new Action({
name: 'create-user-in-db',
handler: async (inputs: { name: string; email: string; }) => {
// Your logic to insert a user into a database
const newUser = await db.users.create({
data: { name: inputs.name, email: inputs.email }
});
console.log(`Created user ${newUser.id}`);
return { success: true, userId: newUser.id };
}
});
// an action to update our CRM
const updateCrmContact = new Action({
name: 'update-crm-contact',
handler: async (inputs: { email: string; name: string; }) => {
// Your logic to call a CRM API
await crmClient.contacts.createOrUpdate({
email: inputs.email,
properties: {
firstname: inputs.name,
lifecyclestage: 'lead'
}
});
console.log(`Contact created/updated in CRM for ${inputs.email}`);
return { success: true };
}
});
// In your sign-up API route or controller...
export async function handleUserSignup(request) {
const { name, email } = request.body;
try {
// STEP 1: Create the user. This must succeed first.
const { userId } = await client.action('create-user-in-db').invoke({ name, email });
// STEP 2 & 3: With a user ID, trigger follow-up actions.
// These can run in parallel for efficiency!
await Promise.all([
client.action('send-welcome-email').invoke({ userId }),
client.action('update-crm-contact').invoke({ email, name })
]);
console.log(`User onboarding for ${email} completed successfully.`);
return { status: 201, message: 'User onboarded successfully!' };
} catch (error) {
// If any Action fails (even after retries), the workflow stops.
// The .do platform provides a full audit trail for debugging.
console.error('Onboarding workflow failed:', error.message);
// You could even trigger another Action to alert an admin!
return { status: 500, message: 'An error occurred during onboarding.' };
}
}