These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Motion?
Motion is a productivity platform that automatically schedules tasks directly on your calendar through a scheduling engine that respects fixed calendar events and reschedules work based on urgency and deadlines.
The platform provides a REST API that supports creating, finding, and updating tasks, but available evidence does not clearly document delete operations, webhooks, or fully comprehensive CRUD capabilities, so it appears best suited for integrations where external systems primarily push and manage tasks in Motion's scheduling engine.
Why Integrate Motion with Strapi
Connecting Motion with your Strapi CMS delivers specific advantages for content-driven development workflows:
- Automated task creation when content status changes through Strapi's webhook support, with shared visibility into assigned workflow tasks and publishing schedules across team members
- Event-driven task generation that reduces manual management, keeping developers focused on building features rather than tracking workflows
- Separation of concerns where developers implement the integration once using custom controllers and services, while content teams manage workflows through Strapi's Admin Panel
- Content review automation where articles moving to "Ready for Review" status in Strapi trigger middleware that creates Motion tasks for reviewers, automatically scheduled using Motion's system based on priority and deadlines
The integration pushes content updates to Motion via API, keeping teams aligned on content deliverables. Motion's API lacks webhook support, so this integration uses one-way push patterns from Strapi to Motion.
Content teams manage workflows through familiar Strapi interfaces without requiring developer intervention for routine operations, though read-only Motion task queries require API calls for up-to-date scheduling information due to Motion's API limitations.
How to Integrate Motion with Strapi
This integration connects Strapi's lifecycle events to Motion's task creation API through custom controllers, services, and webhook handlers. Start with a Strapi v5 application, a Motion account with API access, and basic familiarity with JavaScript and REST APIs.
Prerequisites
Before starting, gather these credentials:
- Motion API key from Settings in your Motion dashboard
- Motion workspace ID where tasks will be created
- Node.js 18 or later installed
- Strapi v5 application (create one with
npx create-strapi@latest)
Set Up Environment Variables
Store your Motion credentials in Strapi's .env file:
# Motion API Configuration
MOTION_API_KEY=your-motion-api-key-here
MOTION_API_BASE_URL=https://api.usemotion.com
MOTION_WORKSPACE_ID=workspace-123
API_TIMEOUT_MS=5000These environment variables keep sensitive credentials out of your codebase and allow different values across development, staging, and production environments.
Build the Motion API Service
Services in Strapi encapsulate business logic and external API calls. Create your Motion API service at ./src/api/motion-integration/services/motion-api.js:
const axios = require('axios');
module.exports = ({ strapi }) => ({
async createTask(taskData) {
const apiKey = process.env.MOTION_API_KEY;
const baseURL = process.env.MOTION_API_BASE_URL;
try {
const response = await axios.post(
`${baseURL}/v1/tasks`,
{
name: taskData.name,
workspaceId: process.env.MOTION_WORKSPACE_ID,
dueDate: taskData.dueDate,
duration: taskData.duration || 60,
priority: taskData.priority || 'MEDIUM',
description: taskData.description,
labels: taskData.labels || []
},
{
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
timeout: 5000
}
);
strapi.log.info('Motion task created:', response.data.id);
return response.data;
} catch (error) {
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || 'Unknown error';
throw new Error(`Motion API Error (${status}): ${message}`);
} else if (error.request) {
throw new Error('Motion API timeout: no response received');
} else {
throw new Error(`Request setup error: ${error.message}`);
}
}
},
async syncContentToMotion(contentEntry) {
const taskData = {
name: `Review: ${contentEntry.title}`,
workspaceId: process.env.MOTION_WORKSPACE_ID,
description: `Content ID: ${contentEntry.id}\nType: article\nURL: ${process.env.FRONTEND_URL}/content/${contentEntry.slug}`,
dueDate: contentEntry.publishDate || new Date(Date.now() + 86400000).toISOString(),
priority: contentEntry.priority === 'urgent' ? 'HIGH' : 'MEDIUM',
labels: ['strapi-sync', 'content-review']
};
return await this.createTask(taskData);
}
});This service handles all Motion API communication with proper error handling for network issues, API errors, and timeouts.
Wire Up the Controller
Controllers handle HTTP requests and call your service layer. Add this controller at ./src/api/motion-integration/controllers/motion-integration.js:
module.exports = {
async createTask(ctx) {
try {
const { taskData } = ctx.request.body;
if (!taskData || !taskData.name) {
return ctx.badRequest('Task name is required', {
field: 'name'
});
}
const result = await strapi
.service('api::motion-integration.motion-api')
.createTask(taskData);
ctx.body = {
success: true,
data: result,
meta: {
timestamp: new Date().toISOString()
}
};
} catch (error) {
strapi.log.error('Motion task creation failed:', error);
if (error.message.includes('429')) {
return ctx.status(429).send({
error: 'Rate limit exceeded',
message: 'Please wait before making more requests'
});
}
if (error.message.includes('timeout') || error.message.includes('ECONNABORTED')) {
return ctx.status(504).send({
error: 'Motion API timeout',
message: 'No response from Motion API'
});
}
if (error.message.includes('401') || error.message.includes('Unauthorized')) {
return ctx.status(401).send({
error: 'Authentication failed',
message: 'Invalid or missing Motion API key'
});
}
ctx.throw(500, `Failed to create Motion task: ${error.message}`);
}
},
async syncContent(ctx) {
try {
const { contentId } = ctx.params;
const content = await strapi.entityService.findOne(
'api::article.article',
contentId,
{
populate: '*'
}
);
if (!content) {
return ctx.notFound('Content not found');
}
const motionTask = await strapi
.service('api::motion-integration.motion-api')
.syncContentToMotion(content);
ctx.body = {
success: true,
data: {
content: content,
motionTask: motionTask
}
};
} catch (error) {
strapi.log.error('Content sync failed:', error);
ctx.throw(500, `Sync failed: ${error.message}`);
}
}
};Define Routes
Routes connect HTTP endpoints to controller methods. Add this configuration at ./src/api/motion-integration/routes/motion-integration.js:
module.exports = {
routes: [
{
method: 'POST',
path: '/motion/tasks',
handler: 'motion-integration.createTask',
config: {
policies: [],
middlewares: []
}
},
{
method: 'POST',
path: '/motion/sync/:contentId',
handler: 'motion-integration.syncContent',
config: {
policies: [],
middlewares: []
}
}
]
};Automate Task Creation
Document Service Middleware is one supported pattern in Strapi v5 for reacting to certain content changes, and is often better suited than database lifecycle hooks for Strapi 5's document-handling scenarios, but it is not described in the official docs as the primary or officially recommended pattern for all content changes.
This replaces traditional lifecycle hooks for most use cases, though lifecycle hooks remain necessary for operations involving the users-permissions plugin or upload package. Configure this in ./src/index.js:
module.exports = {
register({ strapi }) {
strapi.documents.use((ctx, next) => {
if (ctx.contentType.uid === 'api::article.article') {
if (ctx.action === 'publish') {
// Async task creation doesn't block content publishing
setImmediate(async () => {
try {
await strapi
.service('api::motion-integration.motion-api')
.createTask({
name: `Review published article: ${ctx.params.data.title}`,
workspaceId: process.env.MOTION_WORKSPACE_ID,
dueDate: new Date(Date.now() + 86400000).toISOString(),
duration: 30,
priority: 'MEDIUM',
description: `Article ID: ${ctx.params.id}\n\n[View in Strapi](${process.env.STRAPI_URL}/admin/content-manager/collection-types/api::article.article/${ctx.params.id})`,
labels: ['strapi-auto', 'content-review']
});
strapi.log.info('Motion task created for article:', ctx.params.data.title);
} catch (error) {
strapi.log.error('Auto task creation failed:', {
message: error.message,
article: ctx.params.data.title,
error: error
});
}
});
}
}
return next();
});
}
};This middleware intercepts every document operation, checks if it's an article being published, and creates a corresponding Motion task. Using setImmediate ensures task creation happens asynchronously without blocking the publish operation.
Staying Within Rate Limits
Motion enforces rate limits of 12 requests per minute for individual accounts and 120 for team accounts. To avoid hitting these limits, implement this rate limiter:
class MotionRateLimiter {
constructor(tier = 'individual') {
// Configure based on Motion's tier-based rate limits
const tierConfig = {
individual: { maxRequests: 12, timeWindow: 60000 }, // 12 req/minute
team: { maxRequests: 120, timeWindow: 60000 }, // 120 req/minute
enterprise: { maxRequests: null, timeWindow: null } // Custom limits
};
const config = tierConfig[tier];
this.maxRequests = config.maxRequests;
this.timeWindow = config.timeWindow;
this.requests = [];
}
async throttle() {
const now = Date.now();
// Remove requests outside the time window
this.requests = this.requests.filter(
time => now - time < this.timeWindow
);
// If at capacity, wait until oldest request expires
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const waitTime = this.timeWindow - (now - oldestRequest) + 1;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
// Record this request
this.requests.push(now);
}
}
const rateLimiter = new MotionRateLimiter(12, 60000);
// src/api/motion-integration/services/motion-api.js
const axios = require('axios');
module.exports = ({ strapi }) => ({
async createTask(taskData) {
const apiKey = process.env.MOTION_API_KEY;
const baseURL = process.env.MOTION_API_BASE_URL;
// Implement rate limiting for Individual/Base Tier (12 requests/minute)
await rateLimiter.throttle();
try {
const response = await axios.post(
`${baseURL}/v1/tasks`,
{
name: taskData.name,
workspaceId: process.env.MOTION_WORKSPACE_ID,
dueDate: taskData.dueDate,
duration: taskData.duration || 60,
priority: taskData.priority || 'MEDIUM',
description: taskData.description,
labels: taskData.labels || []
},
{
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
timeout: parseInt(process.env.API_TIMEOUT_MS, 10) || 5000
}
);
strapi.log.info('Motion task created:', response.data);
return response.data;
} catch (error) {
if (error.response) {
// Motion API returned error
const status = error.response.status;
const message = error.response.data?.error?.message || 'Unknown error';
throw new Error(`Motion API Error (${status}): ${message}`);
} else if (error.request) {
// No response received
throw new Error('Motion API timeout: no response received');
} else {
// Request configuration error
throw new Error(`Request setup error: ${error.message}`);
}
}
}
});This rate limiter tracks request timestamps and automatically delays requests when approaching the limit.
Testing Your Integration
Write a test script to verify the integration works correctly:
async function testMotionIntegration() {
try {
const taskData = {
name: 'Test Task from Strapi',
dueDate: new Date(Date.now() + 86400000).toISOString(),
priority: 'MEDIUM',
workspaceId: process.env.MOTION_WORKSPACE_ID
};
const result = await strapi
.service('api::motion-integration.motion-api')
.createTask(taskData);
console.log('✓ Task created successfully:', result.id);
} catch (error) {
console.error('✗ Integration test failed:', error.message);
}
}Run this test after starting your Strapi server to help indicate Motion API connectivity and authentication, assuming it is implemented to call a real Motion endpoint with the correct API key, headers, and explicit checks on the HTTP status and response body.
Project Example: Content Review Workflow System
This example illustrates a potential content review workflow where, with custom automation (such as webhooks and an integration service), Strapi can trigger Motion tasks when content enters the review stage, and then update content status when reviews are complete.
If you've manually tracked content reviews in spreadsheets, you know the pain: missed reviews when tasks aren't created, articles stuck in 'ready for review' status with no clear owner. This automation solves those bottlenecks.
Architecture Overview
The workflow follows this pattern:
- Content author marks article as "Ready for Review" in Strapi
- Document Service Middleware detects the status change and triggers a custom webhook to transformation endpoint
- Middleware transforms Strapi payload to Motion task format and calls Motion API's POST /v1/tasks endpoint
- Motion task is created and assigned to the designated reviewer
- Task appears on reviewer's Motion calendar with a deadline
- When task is completed, a scheduled job polls Strapi and Motion APIs to check for completion and updates Strapi content status accordingly
Content Type Configuration
First, create an Article content type in Strapi's Content-Type Builder with these fields:
Motion-Strapi Integration Content Type Schema
For tracking content through review workflows while syncing with Motion, implement a custom Strapi content type with the following fields:
title(Text, required): Content/task titlecontent(Markdown / plain text): Detailed content or task description written in GitHub Flavored Markdownstatus(Enumeration: draft, ready_for_review, approved, published): Workflow stage determining webhook triggersreviewerId(Relation to User): Strapi user assigned to review; maps to Motion's assigneeId for task assignmentmotionTaskId(Text, private field): Stores Motion API task ID (from POST /v1/tasks response) for bidirectional linkingpriority(Enumeration: normal, urgent): Maps to Motion task priority (MEDIUM for normal, HIGH for urgent)
This structure enables the integration pattern: when status changes to "ready_for_review", a webhook triggers creating a Motion task with the reviewerId as assignee, storing the returned Motion task ID. The private motionTaskId field prevents accidental exposure while enabling lookup for task synchronization.
Enhanced Middleware for Review Workflow
Replace the basic middleware with this detailed review workflow handler in ./src/index.js:
module.exports = {
register({ strapi }) {
strapi.documents.use(async (ctx, next) => {
if (ctx.contentType.uid === 'api::article.article' && ctx.action === 'update') {
// Get old status before update
const currentEntry = await strapi.entityService.findOne(
'api::article.article',
ctx.params.id
);
const oldStatus = currentEntry?.status;
const newStatus = ctx.params.data.status;
if (newStatus === 'ready_for_review' && oldStatus !== 'ready_for_review') {
setImmediate(async () => {
try {
// Fetch reviewer user details
const reviewer = await strapi.entityService.findOne(
'plugin::users-permissions.user',
ctx.params.data.reviewerId,
{ populate: '*' }
);
if (!reviewer || !reviewer.motionUserId) {
strapi.log.warn('Reviewer missing or has no Motion user ID:', ctx.params.data.reviewerId);
return;
}
const response = await strapi
.service('api::motion-integration.motion-api')
.createTask({
name: `Review: ${ctx.params.data.title}`,
description: `# Content Review Required\n\n**Author:** ${ctx.state.user.username}\n**Priority:** ${ctx.params.data.priority || 'normal'}\n\n[View in Strapi](${process.env.STRAPI_URL}/admin/content-manager/collection-types/api::article.article/${ctx.params.id})`,
dueDate: new Date(Date.now() + 172800000).toISOString(),
priority: ctx.params.data.priority === 'urgent' ? 'HIGH' : 'MEDIUM',
assigneeId: reviewer.motionUserId,
labels: ['content-review', `strapi-${ctx.params.id}`]
});
// Store Motion task ID for tracking
await strapi.entityService.update(
'api::article.article',
ctx.params.id,
{
data: { motionTaskId: response.id }
}
);
strapi.log.info(`Review task created: ${response.id}`);
} catch (error) {
strapi.log.error('Review task creation failed:', error);
}
});
}
}
return next();
});
}
};This middleware creates Motion tasks when content enters review status, assigns them to the appropriate reviewer, and stores the task ID for future reference.
Review Dashboard API
Next, build an API endpoint that displays all pending reviews with their associated Motion tasks. Add this controller at ./src/api/review-dashboard/controllers/review-dashboard.js:
module.exports = {
async getPendingReviews(ctx) {
try {
const articles = await strapi.entityService.findMany('api::article.article', {
filters: { status: 'ready_for_review' },
populate: ['reviewer']
});
const reviewsWithTasks = await Promise.all(
articles.map(async (article) => {
let motionTask = null;
if (article.motionTaskId) {
try {
const response = await fetch(
`${process.env.MOTION_API_BASE_URL}/v1/tasks/${article.motionTaskId}`,
{
headers: {
'X-API-Key': process.env.MOTION_API_KEY,
'Content-Type': 'application/json'
},
timeout: parseInt(process.env.API_TIMEOUT_MS, 10) || 5000
}
);
if (response.ok) {
motionTask = await response.json();
} else if (response.status === 404) {
strapi.log.warn(`Motion task not found: ${article.motionTaskId}`);
} else {
const errorData = await response.json();
strapi.log.warn(`Motion API error (${response.status}): ${errorData.error?.message}`);
}
} catch (error) {
strapi.log.warn(`Failed to fetch Motion task ${article.motionTaskId}: ${error.message}`);
}
}
return {
id: article.id,
title: article.title,
priority: article.priority,
reviewer: article.reviewer?.username,
motionTask: motionTask ? {
id: motionTask.id,
completed: motionTask.completed,
dueDate: motionTask.dueDate,
status: motionTask.status
} : null
};
})
);
ctx.body = {
data: reviewsWithTasks,
meta: {
total: reviewsWithTasks.length,
pending: reviewsWithTasks.filter(r => !r.motionTask?.completed).length
}
};
} catch (error) {
strapi.log.error('Dashboard fetch failed:', error);
ctx.throw(500, 'Failed to load review dashboard');
}
}
};Configure a route for this controller in ./src/api/review-dashboard/routes/review-dashboard.js:
module.exports = {
routes: [
{
method: 'GET',
path: '/review-dashboard/pending',
handler: 'review-dashboard.getPendingReviews',
config: {
policies: [],
middlewares: []
}
}
]
};Frontend Dashboard Component
Now build a React component to display the review dashboard. This example uses your preferred frontend framework with Strapi:
import { useState, useEffect } from 'react';
export default function ReviewDashboard() {
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/review-dashboard/pending')
.then(r => r.json())
.then(data => {
setReviews(data.data);
setLoading(false);
})
.catch(error => {
console.error('Failed to load reviews:', error);
setLoading(false);
});
}, []);
if (loading) return <div>Loading reviews...</div>;
return (
<div className="review-dashboard">
<h2>Pending Content Reviews</h2>
<div className="review-grid">
{reviews.map(review => (
<div key={review.id} className="review-card">
<h3>{review.title}</h3>
<div className="review-meta">
<span className={`priority ${review.priority}`}>
{review.priority}
</span>
<span className="reviewer">
Reviewer: {review.reviewer}
</span>
</div>
{review.motionTask && (
<div className="motion-task">
<p>Due: {new Date(review.motionTask.dueDate).toLocaleDateString()}</p>
<span className={`status ${review.motionTask.completed ? 'completed' : 'pending'}`}>
{review.motionTask.completed ? 'Completed' : 'In Progress'}
</span>
</div>
)}
</div>
))}
</div>
</div>
);
}This dashboard provides real-time visibility into content reviews, showing both Strapi content status and corresponding Motion task progress. For more guidance on building React frontends with Strapi, check out the React integration guide.
Scheduled Status Updates
Since Motion's API currently does not provide documented webhook support, implement a polling mechanism that periodically checks task completion status and updates Strapi accordingly.
This one-way synchronization pattern works within Motion's API constraints, which limit operations to GET and POST with no UPDATE/DELETE. Set up a polling job to check task completion in your bootstrap function:
const cron = require('node-cron');
module.exports = {
async bootstrap({ strapi }) {
// Run every hour to poll Motion API for completed tasks
// Individual tier rate limit: 12 requests/minute (1 per 5 seconds)
cron.schedule('0 * * * *', async () => {
try {
const pendingReviews = await strapi.entityService.findMany('api::article.article', {
filters: {
status: 'ready_for_review',
motionTaskId: { $notNull: true }
}
});
for (const article of pendingReviews) {
try {
const response = await fetch(
`${process.env.MOTION_API_BASE_URL}/v1/tasks/${article.motionTaskId}`,
{
headers: {
'X-API-Key': process.env.MOTION_API_KEY,
'Content-Type': 'application/json'
},
timeout: 5000
}
);
if (!response.ok) {
// Handle Motion API error response format
const errorData = await response.json();
strapi.log.error(`Motion API Error (${response.status}):`, errorData.error?.message);
continue;
}
const task = await response.json();
// Check if task is completed
if (task.completed) {
await strapi.entityService.update(
'api::article.article',
article.id,
{
data: {
status: 'approved',
lastMotionSyncAt: new Date().toISOString()
}
}
);
strapi.log.info(`Article ${article.id} approved via Motion task completion`);
}
} catch (taskError) {
strapi.log.error(`Failed to check task ${article.motionTaskId}:`, taskError.message);
// Continue processing other articles despite individual failures
}
}
} catch (error) {
strapi.log.error('Status sync failed:', error);
}
});
}
};This scheduled job automatically creates review tasks in Motion when content is published in Strapi, syncing the content publication workflow into Motion's task management and scheduling system.
Strapi Open Office Hours
If you have any questions about Strapi 5 or would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST.
For more details, visit the Strapi documentation and Motion documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.