Learning Objectives
By the end of this lesson, you will be able to:
- Chain multiple integrations in a single workflow (Zoom → Notion → Slack)
- Use Slack messaging with threading and search capabilities
- Access Zoom meeting data, recordings, clips, and transcripts
- Create and update Notion pages using document templates
- Handle integration-specific conventions (Stripe cents, Gmail query syntax)
- Implement error isolation for optional workflow steps
Master the integration patterns for Slack, Zoom, Stripe, and more. Each service has its own conventions.
Step-by-Step: Chain Multiple Integrations
Step 1: Define Your Integration Flow
Plan the data flow between services:
Zoom (trigger) → AI (transform) → Notion (save) → Slack (notify)
Step 2: Declare All Integrations
Add all required integrations to your workflow:
integrations: [
{ service: 'zoom', scopes: ['meeting:read', 'recording:read'] },
{ service: 'notion', scopes: ['read_pages', 'write_pages'] },
{ service: 'slack', scopes: ['chat:write'], optional: true },
],
Step 3: Get Source Data
Fetch data from the triggering service:
async execute({ trigger, integrations }) {
const { zoom } = integrations;
const meetingId = trigger.data.object.id;
const transcriptResult = await zoom.getTranscript({ meetingId });
if (!transcriptResult.success) {
console.warn('No transcript available');
// Continue without transcript
}
const transcript = transcriptResult.data?.transcript_text || '';
}
Step 4: Transform and Save
Process the data and save to your destination:
async execute({ trigger, inputs, integrations }) {
const { zoom, notion } = integrations;
// Get source data
const transcriptResult = await zoom.getTranscript({
meetingId: trigger.data.object.id,
});
// Create destination record
const page = await notion.createPage({
parentDatabaseId: inputs.notionDatabase,
properties: {
Name: { title: [{ text: { content: trigger.data.object.topic } }] },
Date: { date: { start: new Date().toISOString().split('T')[0] } },
},
children: transcriptResult.success
? [{ type: 'paragraph', paragraph: { rich_text: [{ text: { content: transcriptResult.data.transcript_text } }] } }]
: [],
});
return { success: true, pageId: page.data?.id };
}
Step 5: Add Optional Notification
Notify users without failing the workflow:
async execute({ trigger, inputs, integrations }) {
const { zoom, notion, slack } = integrations;
// ... previous steps ...
// Notify (optional - don't fail workflow if this fails)
if (slack && inputs.slackChannel) {
const notifyResult = await slack.sendMessage({
channel: inputs.slackChannel,
text: `Meeting notes ready: ${page.data?.url}`,
});
if (!notifyResult.success) {
console.warn('Slack notification failed:', notifyResult.error);
}
}
return { success: true, pageId: page.data?.id };
}
Step 6: Test Each Integration Separately
Before combining:
# Test Zoom integration
workway dev
curl localhost:8787/test-zoom -d '{"meetingId": "123"}'
# Test Notion integration
curl localhost:8787/test-notion -d '{"title": "Test Page"}'
# Test full chain
curl localhost:8787/execute -d '{"object": {"id": "123", "topic": "Test"}}'
Integration Basics
ActionResult Pattern
All integration methods return ActionResult objects:
| Property | Type | Description |
|---|---|---|
success |
boolean |
Whether the call succeeded |
data |
T | undefined |
The response data if successful |
error |
string | undefined |
Error message if failed |
All integrations share common patterns:
async execute({ integrations }) {
const { slack, zoom, stripe, notion } = integrations;
// Each is a pre-authenticated client
// Token refresh, rate limiting handled automatically
// All methods return ActionResult
const result = await zoom.getMeetings({ days: 1 });
if (result.success) {
const meetings = result.data;
// ...
} else {
console.error('Failed:', result.error);
}
}
Important: Integration methods return ActionResult objects with { success, data, error } pattern.
Slack Integration
The Slack integration (packages/integrations/src/slack/) provides methods for messaging, channels, and user lookup.
Posting Messages
// Simple message
const result = await slack.sendMessage({
channel: inputs.slackChannel,
text: 'Meeting notes are ready!',
});
if (result.success) {
console.log('Sent message:', result.data.ts);
}
// With threading support
await slack.sendMessage({
channel: inputs.slackChannel,
text: 'Follow-up comment',
thread_ts: parentMessageTs, // Reply in thread
reply_broadcast: true, // Also post to channel
});
Channel Operations
// List channels the bot has access to
const channelsResult = await slack.listChannels({
limit: 100,
excludeArchived: true,
types: 'public_channel,private_channel',
});
if (channelsResult.success) {
for (const channel of channelsResult.data) {
console.log(channel.name, channel.id);
}
}
// Get messages from a channel with human-friendly since
const messagesResult = await slack.getMessages({
channel: channelId,
since: '24h', // Last 24 hours
humanOnly: true, // Filter out bots and system messages
});
// Get messages since a specific date
const weekMessages = await slack.getMessages({
channel: channelId,
since: '7d', // Last 7 days
});
// Search messages across channels
const searchResult = await slack.searchMessages('project update', {
count: 20,
sort: 'timestamp',
});
User Lookup
// Get user by ID
const userResult = await slack.getUser({ user: userId });
if (userResult.success) {
console.log(userResult.data.real_name);
console.log(userResult.data.profile?.email);
}
// Direct message a user (use user ID as channel)
await slack.sendMessage({
channel: userResult.data.id,
text: 'Your report is ready',
});
Zoom Integration
The Zoom integration (packages/integrations/src/zoom/) provides methods for meetings, recordings, clips, and transcripts.
Meeting Data
// Get user's recent meetings (last N days)
const meetingsResult = await zoom.getMeetings({ days: 7 });
if (meetingsResult.success) {
for (const meeting of meetingsResult.data) {
console.log(meeting.topic, meeting.start_time);
}
}
// Get specific meeting
const meetingResult = await zoom.getMeeting({ meetingId: '123456789' });
// Returns: { id, topic, start_time, duration, host_id, join_url }
// Get meeting recordings
const recordingsResult = await zoom.getRecordings({ meetingId: '123456789' });
if (recordingsResult.success && recordingsResult.data) {
const recording = recordingsResult.data;
console.log('Share URL:', recording.share_url);
for (const file of recording.recording_files) {
console.log(file.file_type, file.download_url);
}
}
Meeting Transcripts
// Get transcript (OAuth API - may lack speaker names)
const transcriptResult = await zoom.getTranscript({ meetingId: '123456789' });
if (transcriptResult.success && transcriptResult.data) {
const transcript = transcriptResult.data;
console.log('Source:', transcript.source); // 'oauth_api' or 'browser_scraper'
console.log('Has speakers:', transcript.has_speaker_attribution);
console.log('Text:', transcript.transcript_text);
if (transcript.speakers) {
console.log('Speakers:', transcript.speakers.join(', '));
}
}
// Get transcript with browser fallback for speaker attribution
const transcriptWithSpeakers = await zoom.getTranscript({
meetingId: '123456789',
fallbackToBrowser: true,
shareUrl: recording.share_url, // Required for browser fallback
});
Zoom Clips
// Get user's clips
const clipsResult = await zoom.getClips({ days: 30 });
if (clipsResult.success) {
for (const clip of clipsResult.data) {
console.log(clip.title, clip.duration, clip.share_url);
}
}
// Get clip transcript (requires browser scraper)
const clipTranscript = await zoom.getClipTranscript({
shareUrl: clip.share_url,
});
Compound Method: Meetings with Transcripts
// Get meetings and their transcripts in one call
const result = await zoom.getMeetingsWithTranscripts({
days: 1,
fallbackToBrowser: false,
});
if (result.success) {
for (const { meeting, transcript } of result.data) {
console.log('Meeting:', meeting.topic);
if (transcript) {
console.log('Transcript length:', transcript.transcript_text.length);
}
}
}
Webhook Events
import { defineWorkflow, webhook } from '@workwayco/sdk';
// ...
trigger: webhook({
service: 'zoom',
event: 'recording.completed',
}),
async execute({ trigger }) {
const meeting = trigger.data.object;
// meeting.id, meeting.topic, meeting.duration
}
Stripe Integration
The Stripe integration (packages/integrations/src/stripe/) provides methods for payments, customers, and subscriptions. Important: Stripe uses cents, not dollars.
Customer Operations
// Get customer by ID
const customerResult = await stripe.getCustomer('cus_xxx');
if (customerResult.success) {
console.log(customerResult.data.email, customerResult.data.name);
}
// List customers (with optional filters)
const customersResult = await stripe.listCustomers({
limit: 10,
email: '[email protected]',
});
// Create customer
const newCustomerResult = await stripe.createCustomer({
email: '[email protected]',
name: 'New User',
description: 'Created via workflow',
metadata: { source: 'workway' },
});
Payment Intents
// Create a payment intent ($20.00 = 2000 cents)
const paymentResult = await stripe.createPaymentIntent({
amount: 2000, // Always in cents!
currency: 'usd',
customer: 'cus_xxx',
description: 'Order #123',
metadata: { order_id: '123' },
automatic_payment_methods: { enabled: true },
});
if (paymentResult.success) {
console.log('Payment ID:', paymentResult.data.id);
console.log('Status:', paymentResult.data.status);
console.log('Client Secret:', paymentResult.data.client_secret);
}
// Get specific payment
const payment = await stripe.getPaymentIntent('pi_xxx');
// List payment intents
const paymentsResult = await stripe.listPaymentIntents({
limit: 10,
customer: 'cus_xxx',
});
Subscription Management
// Create subscription
const subResult = await stripe.createSubscription({
customer: 'cus_xxx',
priceId: 'price_xxx',
quantity: 1,
trial_period_days: 14,
metadata: { plan: 'pro' },
});
// Get subscription
const subscription = await stripe.getSubscription('sub_xxx');
if (subscription.success) {
console.log('Status:', subscription.data.status);
console.log('Current period end:', subscription.data.current_period_end);
}
// List subscriptions by customer
const subsResult = await stripe.listSubscriptions({
customer: 'cus_xxx',
status: 'active',
});
// Cancel subscription (at period end or immediately)
await stripe.cancelSubscription('sub_xxx', { cancel_at_period_end: true });
Charges (Legacy)
// List charges
const chargesResult = await stripe.listCharges({
limit: 10,
customer: 'cus_xxx',
});
// Get specific charge
const charge = await stripe.getCharge('ch_xxx');
Webhook Verification
// Parse and verify webhook signature
const eventResult = await stripe.parseWebhookEvent(
rawBody, // Raw request body string
signatureHeader, // Stripe-Signature header
webhookSecret // Your endpoint secret
);
if (eventResult.success) {
const event = eventResult.data;
console.log('Event type:', event.type); // e.g., 'payment_intent.succeeded'
console.log('Event data:', event.data.object);
}
Webhook Events in Workflows
import { defineWorkflow, webhook } from '@workwayco/sdk';
// ...
trigger: webhook({
service: 'stripe',
event: 'payment_intent.succeeded',
}),
async execute({ trigger }) {
const payment = trigger.data.data.object;
const amountDollars = payment.amount / 100; // Convert cents to dollars!
const customerId = payment.customer;
console.log(`Payment of ${amountDollars} received from ${customerId}`);
}
Notion Integration
The Notion integration (packages/integrations/src/notion/) provides methods for pages, databases, and content blocks.
Database Operations
// Query database with filter
const result = await notion.queryDatabase({
databaseId: databaseId,
filter: {
property: 'Status',
status: { equals: 'In Progress' },
},
sorts: [
{ property: 'Created', direction: 'descending' },
],
});
if (result.success) {
for (const page of result.data) {
console.log(page.id, page.properties);
}
}
// Get database schema
const dbResult = await notion.getDatabase(databaseId);
if (dbResult.success) {
console.log('Properties:', Object.keys(dbResult.data.properties));
}
// Search across pages and databases
const searchResult = await notion.search({
query: 'Project',
filter: { property: 'object', value: 'page' },
});
Creating Pages
// Create page in database
const pageResult = await notion.createPage({
parentDatabaseId: databaseId,
properties: {
Name: { title: [{ text: { content: 'New Item' } }] },
Status: { status: { name: 'New' } },
Priority: { number: 1 },
Tags: { multi_select: [{ name: 'urgent' }] },
DueDate: { date: { start: '2024-01-20' } },
},
});
if (pageResult.success) {
console.log('Created page:', pageResult.data.url);
}
// Create page with content blocks
await notion.createPage({
parentDatabaseId: databaseId,
properties: {
Name: { title: [{ text: { content: 'Meeting Notes' } }] },
},
children: [
{
type: 'heading_1',
heading_1: { rich_text: [{ text: { content: 'Overview' } }] },
},
{
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: 'Details here...' } }] },
},
{
type: 'bulleted_list_item',
bulleted_list_item: { rich_text: [{ text: { content: 'First point' } }] },
},
{
type: 'code',
code: {
rich_text: [{ text: { content: 'const x = 1;' } }],
language: 'typescript',
},
},
],
});
Document Templates (Zuhandenheit)
// Create structured documents from templates
// Developer thinks "create meeting notes" not "construct 100 block objects"
const docResult = await notion.createDocument({
database: databaseId,
template: 'meeting', // 'summary', 'report', 'notes', 'article', 'meeting', 'feedback'
data: {
title: 'Weekly Standup - 2024-01-15',
summary: 'Team discussed Q1 priorities and blockers.',
date: '2024-01-15',
mood: 'positive',
sections: {
decisions: ['Approved new feature spec', 'Delayed launch by 1 week'],
actionItems: ['Review PR #123', 'Schedule design review'],
keyTopics: ['Performance improvements', 'User feedback'],
},
content: fullTranscriptText, // Optional: raw content in toggle
},
});
Update Existing Pages
// Update page properties
const updateResult = await notion.updatePage({
pageId: pageId,
properties: {
Status: { status: { name: 'Complete' } },
},
});
// Archive page
await notion.updatePage({
pageId: pageId,
archived: true,
});
// Get page content blocks
const blocksResult = await notion.getBlockChildren({ blockId: pageId });
if (blocksResult.success) {
for (const block of blocksResult.data) {
console.log(block.type);
}
}
Gmail Integration
Reading Email
// Search emails
const emails = await gmail.getMessages({
query: 'from:[email protected] is:unread',
maxResults: 10,
});
// Get full message
const message = await gmail.getMessage(messageId);
// Returns: { id, subject, from, to, date, body, attachments }
Sending Email
// Simple email
await gmail.sendEmail({
to: '[email protected]',
subject: 'Meeting Follow-up',
body: 'Thanks for meeting today...',
});
// HTML email
await gmail.sendEmail({
to: '[email protected]',
subject: 'Your Report',
html: '<h1>Report</h1><p>Details...</p>',
});
// With CC and attachments
await gmail.sendEmail({
to: '[email protected]',
cc: ['[email protected]'],
subject: 'Report Attached',
body: 'Please find attached...',
attachments: [
{ filename: 'report.pdf', content: pdfBuffer },
],
});
Label Management
// Add label
await gmail.addLabel(messageId, labelId);
// Remove label
await gmail.removeLabel(messageId, labelId);
// Mark as read
await gmail.markAsRead(messageId);
Integration Patterns
Chaining Services
async execute({ trigger, inputs, integrations }) {
const { zoom, notion, slack } = integrations;
// 1. Get meeting data from Zoom
const meetingId = trigger.data.object.id;
const transcriptResult = await zoom.getTranscript({ meetingId });
// 2. Save to Notion using document template
const page = await notion.createDocument({
database: inputs.notionDatabase,
template: 'meeting',
data: {
title: trigger.data.object.topic,
summary: transcriptResult.success ? 'Meeting transcript captured' : 'No transcript',
date: new Date().toISOString().split('T')[0],
sections: transcriptResult.success && transcriptResult.data.speakers
? { speakers: transcriptResult.data.speakers }
: {},
content: transcriptResult.data?.transcript_text,
},
});
// 3. Notify via Slack
await slack.sendMessage({
channel: inputs.slackChannel,
text: `Meeting notes ready: ${page.data?.url}`,
});
return { success: true, pageId: page.data?.id };
}
Conditional Integration Use
async execute({ inputs, integrations }) {
const { notion, slack } = integrations;
// Always save to Notion
const page = await notion.createPage({
parentDatabaseId: inputs.notionDatabase,
properties: {
Name: { title: [{ text: { content: 'New Entry' } }] },
},
});
// Optionally notify Slack
if (inputs.enableSlackNotification && inputs.slackChannel) {
await slack.sendMessage({
channel: inputs.slackChannel,
text: `New entry: ${page.data?.url}`,
});
}
return { success: true };
}
Error Recovery
async execute({ integrations }) {
const { notion, slack } = integrations;
const page = await notion.createPage({
parentDatabaseId: inputs.notionDatabase,
properties: {
Name: { title: [{ text: { content: 'Entry' } }] },
},
});
// Slack failure shouldn't fail the whole workflow
const slackResult = await slack.sendMessage({
channel: inputs.slackChannel,
text: `Page created: ${page.data?.url}`,
});
if (!slackResult.success) {
console.warn('Slack notification failed:', slackResult.error);
}
return { success: true, pageId: page.data?.id };
}
Common Pitfalls
Not Checking ActionResult Success
All integration methods return ActionResult - ignoring it causes crashes:
// Wrong - assumes success
async execute({ integrations }) {
const result = await integrations.zoom.getMeetings();
const meetings = result.data; // undefined if failed
console.log(`Found ${meetings.length} meetings`); // Crashes
}
// Right - check before accessing data
async execute({ integrations }) {
const result = await integrations.zoom.getMeetings();
if (!result.success) {
return { success: false, error: result.error };
}
const meetings = result.data || [];
console.log(`Found ${meetings.length} meetings`);
}
Wrong Property Names for Notion
Notion's API has specific property formats:
// Wrong - incorrect property structure
await notion.pages.create({
properties: {
Name: 'Meeting Notes', // Wrong: needs title array
Date: '2024-01-15', // Wrong: needs date object
},
});
// Right - correct Notion property format
await notion.pages.create({
properties: {
Name: { title: [{ text: { content: 'Meeting Notes' } }] },
Date: { date: { start: '2024-01-15' } },
},
});
Slack Message Options
Always provide fallback text for notifications:
// sendMessage requires channel and text
const result = await slack.sendMessage({
channel: channelId,
text: 'Hello world',
});
// With threading
await slack.sendMessage({
channel: channelId,
text: 'Reply to thread',
thread_ts: originalMessageTs, // Thread parent timestamp
});
// Common mistake: forgetting to check success
if (!result.success) {
console.error('Slack send failed:', result.error);
}
Stripe Amount in Wrong Units
Stripe uses cents, not dollars:
// Wrong - $10 becomes $1000
const payment = await stripe.createPaymentIntent({
amount: 10, // This is 10 cents, not $10
});
// Right - convert to cents
const payment = await stripe.createPaymentIntent({
amount: 10 * 100, // $10.00 = 1000 cents
});
Gmail Query Syntax Errors
Gmail search uses specific operators:
// Wrong - natural language query
await gmail.getMessages({ query: 'emails from john last week' });
// Right - Gmail search operators
await gmail.getMessages({
query: 'from:[email protected] after:2024/01/08 is:unread',
});
Chaining Without Error Isolation
One failure shouldn't break everything:
// Wrong - entire chain fails if Slack fails
async execute({ integrations }) {
const meeting = await integrations.zoom.getMeeting({ meetingId: id });
const page = await integrations.notion.createPage({
parentDatabaseId: dbId,
properties: { /* ... */ },
});
await integrations.slack.sendMessage({ channel, text }); // If this fails, no return
return { success: true, pageId: page.data.id };
}
// Right - isolate optional steps
async execute({ integrations }) {
const meeting = await integrations.zoom.getMeeting({ meetingId: id });
if (!meeting.success) return { success: false, error: meeting.error };
const page = await integrations.notion.createPage({
parentDatabaseId: dbId,
properties: { /* ... */ },
});
if (!page.success) return { success: false, error: page.error };
// Slack is optional - don't fail workflow if it fails
const slackResult = await integrations.slack.sendMessage({ channel, text });
if (!slackResult.success) {
console.warn('Slack notification failed:', slackResult.error);
}
return { success: true, pageId: page.data.id };
}
Zoom Transcript Timing
Transcripts aren't immediately available after meetings:
// Wrong - expects transcript right after meeting ends
trigger: webhook({ service: 'zoom', event: 'meeting.ended' }),
async execute({ trigger, integrations }) {
const transcript = await integrations.zoom.getTranscript({ meetingId });
// Often fails: transcript not yet processed
}
// Right - wait for recording.completed event
trigger: webhook({ service: 'zoom', event: 'recording.completed' }),
async execute({ trigger, integrations }) {
const meetingId = trigger.data.object.id;
const transcript = await integrations.zoom.getTranscript({ meetingId });
// Transcript available after recording processed
}
Praxis
Build a workflow that chains multiple integrations:
Praxis: Ask Claude Code: "Help me create a workflow that chains Zoom → Notion → Slack when a meeting ends"
Implement the chaining pattern:
async execute({ trigger, inputs, integrations }) {
const { zoom, notion, slack } = integrations;
// 1. Get data from source
const meetingId = trigger.data.object.id;
const transcriptResult = await zoom.getTranscript({ meetingId });
// 2. Transform and save using document template
const page = await notion.createDocument({
database: inputs.notionDatabase,
template: 'meeting',
data: {
title: trigger.data.object.topic,
summary: transcriptResult.success ? 'Transcript captured' : 'No transcript available',
date: new Date().toISOString().split('T')[0],
content: transcriptResult.data?.transcript_text,
},
});
// 3. Notify (with error isolation)
const slackResult = await slack.sendMessage({
channel: inputs.slackChannel,
text: `Meeting notes ready: ${page.data?.url}`,
});
if (!slackResult.success) {
console.warn('Notification failed:', slackResult.error);
}
return { success: true };
}
Test each integration call individually before chaining them together.
Reflection
- Which integration patterns will you use most?
- How does service chaining create compound outcomes?
- What error scenarios should your workflows handle gracefully?