Skip to main content

Overview

Motia makes it easy to build type-safe REST APIs with automatic validation and error handling. This guide walks you through creating production-ready API endpoints.

Your first API endpoint

Let’s start with a simple GET endpoint that returns a greeting.
1

Create your step file

Create a new file steps/hello-api.step.ts:
steps/hello-api.step.ts
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'HelloAPI',
  description: 'Receives hello request and enqueues event for processing',
  triggers: [
    {
      type: 'http',
      path: '/hello',
      method: 'GET',
      responseSchema: {
        200: z.object({
          message: z.string(),
          status: z.string(),
          appName: z.string(),
        }),
      },
    },
  ],
  enqueues: ['process-greeting'],
  flows: ['hello-world-flow'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { enqueue, logger }) => {
  const appName = 'My App'
  const timestamp = new Date().toISOString()

  logger.info('Hello API endpoint called', { appName, timestamp })

  await enqueue({
    topic: 'process-greeting',
    data: {
      timestamp,
      appName,
      greetingPrefix: process.env.GREETING_PREFIX || 'Hello',
      requestId: Math.random().toString(36).substring(7),
    },
  })

  return {
    status: 200,
    body: {
      message: 'Hello request received! Check logs for processing.',
      status: 'processing',
      appName,
    },
  }
}
2

Test your endpoint

Start your Motia server and test the endpoint:
curl http://localhost:3000/hello
You’ll get a response like:
{
  "message": "Hello request received! Check logs for processing.",
  "status": "processing",
  "appName": "My App"
}

POST endpoints with validation

Now let’s create a POST endpoint with request body validation.
steps/api.step.ts
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'CreatePetOrder',
  description: 'Create a pet and optionally place a food order',
  flows: ['pet-store'],
  triggers: [
    {
      type: 'http',
      method: 'POST',
      path: '/pets',
      bodySchema: z.object({
        pet: z.object({
          name: z.string(),
          photoUrl: z.string().url(),
        }),
        foodOrder: z
          .object({
            quantity: z.number().positive(),
          })
          .optional(),
      }),
      responseSchema: {
        200: z.object({
          id: z.string(),
          name: z.string(),
          photoUrl: z.string(),
          traceId: z.string(),
        }),
      },
    },
  ],
  enqueues: ['process-food-order'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  request,
  { logger, traceId, enqueue }
) => {
  logger.info('Processing pet creation', { body: request.body })

  const { pet, foodOrder } = request.body || {}
  
  // Create pet record
  const newPetRecord = await createPet(pet)

  // Enqueue food order processing if provided
  if (foodOrder) {
    await enqueue({
      topic: 'process-food-order',
      data: {
        quantity: foodOrder.quantity,
        email: 'customer@example.com',
        petId: newPetRecord.id,
      },
    })
  }

  return { 
    status: 200, 
    body: { ...newPetRecord, traceId } 
  }
}
Motia automatically validates the request body against your bodySchema. Invalid requests receive a 400 response before your handler runs.

Multiple response schemas

Define different response schemas for different status codes:
triggers: [
  {
    type: 'http',
    method: 'POST',
    path: '/orders/manual',
    bodySchema: z.object({
      user: z.object({
        verified: z.boolean(),
      }),
      amount: z.number(),
      description: z.string(),
    }),
    responseSchema: {
      200: z.object({
        message: z.string(),
        orderId: z.string(),
        processedBy: z.string(),
      }),
      403: z.object({
        error: z.string(),
      }),
    },
  },
]
Then in your handler:
export const handler: Handlers<typeof config> = async ({ request }, ctx) => {
  const { user, amount, description } = request.body

  if (!user.verified) {
    return {
      status: 403,
      body: { error: 'User must be verified to place orders' },
    }
  }

  const orderId = `order-${Date.now()}`

  return {
    status: 200,
    body: {
      message: 'Order processed successfully',
      orderId,
      processedBy: 'api',
    },
  }
}

Error handling

Motia provides automatic error handling, but you can also handle errors explicitly:
export const handler: Handlers<typeof config> = async ({ request }, ctx) => {
  try {
    const result = await externalApiCall(request.body)
    
    return {
      status: 200,
      body: { success: true, data: result },
    }
  } catch (error) {
    ctx.logger.error('External API call failed', { error })
    
    return {
      status: 500,
      body: { 
        error: 'Failed to process request',
        message: error.message,
      },
    }
  }
}

Conditional triggers

Add conditions to control when your API endpoint is triggered:
import type { TriggerCondition } from 'motia'

const isVerifiedUser: TriggerCondition<{
  user: { verified: boolean }
  amount: number
}> = (input, ctx) => {
  if (ctx.trigger.type !== 'http' || !input) return false
  return input.body.user.verified === true
}

export const config = {
  name: 'ProcessVerifiedOrder',
  triggers: [
    {
      type: 'http',
      method: 'POST',
      path: '/orders/verified',
      bodySchema: z.object({
        user: z.object({ verified: z.boolean() }),
        amount: z.number(),
      }),
      condition: isVerifiedUser,
    },
  ],
} as const satisfies StepConfig

Multi-trigger steps

Your step can respond to both HTTP requests and queue events:
import { http, queue, step } from 'motia'
import { z } from 'zod'

const orderSchema = z.object({
  email: z.string().email(),
  quantity: z.number(),
  petId: z.string(),
})

export const stepConfig = {
  name: 'ProcessOrder',
  flows: ['orders'],
  triggers: [
    queue('process-food-order', { input: orderSchema }),
    http('POST', '/process-food-order', { bodySchema: orderSchema }),
  ],
  enqueues: ['notification'],
}

export const { config, handler } = step(stepConfig, async (_input, ctx) => {
  const data = ctx.getData()

  ctx.logger.info('Processing order', {
    input: data,
    triggerType: ctx.trigger.type,
  })

  const order = await createOrder(data)
  await ctx.state.set('orders', order.id, order)

  // Different response based on trigger type
  return ctx.match({
    http: async () => ({
      status: 200,
      body: { success: true, order },
    }),
    queue: async () => {
      // Queue triggers don't need HTTP response
      ctx.logger.info('Order processed from queue')
    },
  })
})

Best practices

Use Zod for validation

Define schemas for all inputs and outputs. Motia automatically validates requests.

Log important events

Use ctx.logger to log key operations. Logs are automatically traced.

Return proper status codes

Use appropriate HTTP status codes: 200 for success, 400 for bad requests, 500 for errors.

Handle errors gracefully

Catch exceptions and return meaningful error messages to clients.

Next steps

Background Jobs

Learn how to process work asynchronously with queues

Real-time Streaming

Add WebSocket and SSE support for real-time updates

Build docs developers (and LLMs) love