Motia Icon
Getting Started

HTTP Handler Migration Guide

Migrating HTTP step handlers to the new MotiaHttpArgs-based signature with { request, response } destructuring and SSE support.

This guide covers migrating HTTP step handlers from the old (req, ctx) signature to the new MotiaHttpArgs-based approach. It also introduces Server-Sent Events (SSE) support.

This guide focuses specifically on HTTP handler signature changes. For a complete migration from Motia v0.17.x to 1.0-RC, see the full migration guide.

What Changed

HTTP step handlers now receive a MotiaHttpArgs object as their first argument instead of a bare request object. This object contains both request and response, enabling streaming patterns like SSE alongside standard request/response flows.

AspectOldNew
First arg (TS/JS)req (request object directly){ request, response } (MotiaHttpArgs)
First arg (Python)req (dict-like object)request: ApiRequest or args: MotiaHttpArgs
Body access (TS/JS)req.bodyrequest.body
Path params (TS/JS)req.pathParamsrequest.pathParams
Headers (TS/JS)req.headersrequest.headers
Body access (Python)req.get("body", {})request.body
Path params (Python)req.get("pathParams", {}).get("id")request.path_params.get("id")
Return type (Python){"status": 200, "body": {...}}ApiResponse(status=200, body={...})
Middleware placementConfig root: middleware: [...]Inside trigger: { type: 'http', ..., middleware: [...] }
Middleware first argreq{ request, response }

TypeScript / JavaScript

Standard HTTP Handler

import { type Handlers, type StepConfig } from 'motia'
 
export const config = {
  name: 'GetUser',
  triggers: [
    { type: 'http', path: '/users/:id', method: 'GET' },
  ],
  enqueues: [],
} as const satisfies StepConfig
 
export const handler: Handlers<typeof config> = async (req, { logger }) => {
  const userId = req.pathParams.id
  logger.info('Getting user', { userId })
  return { status: 200, body: { id: userId } }
}

Key changes:

  1. Destructure { request } (or { request, response } for SSE) from the first argument
  2. Access request.body, request.pathParams, request.queryParams, request.headers
  3. Return value stays the same: { status, body, headers? }

Types

interface MotiaHttpArgs<TBody = unknown> {
  request: MotiaHttpRequest<TBody>
  response: MotiaHttpResponse
}
 
interface MotiaHttpRequest<TBody = unknown> {
  pathParams: Record<string, string>
  queryParams: Record<string, string | string[]>
  body: TBody
  headers: Record<string, string | string[]>
  method: string
  requestBody: ChannelReader
}
 
type MotiaHttpResponse = {
  status: (statusCode: number) => void
  headers: (headers: Record<string, string>) => void
  stream: NodeJS.WritableStream
  close: () => void
}

Multi-Trigger Steps

When using ctx.match(), the HTTP branch handler also receives MotiaHttpArgs:

return ctx.match({
  http: async (request) => {
    const { userId } = request.body
    return { status: 200, body: { ok: true } }
  },
})

Python

Standard HTTP Handler

config = {
    "name": "GetUser",
    "triggers": [
        {"type": "http", "path": "/users/:id", "method": "GET"}
    ],
    "enqueues": [],
}
 
async def handler(req, ctx):
    user_id = req.get("pathParams", {}).get("id")
    ctx.logger.info("Getting user", {"userId": user_id})
    return {"status": 200, "body": {"id": user_id}}

Key changes:

  1. Import ApiRequest, ApiResponse, FlowContext from motia
  2. Use http() helper for trigger definitions
  3. Handler signature: request: ApiRequest[Any] and ctx: FlowContext[Any]
  4. Access typed properties: request.body, request.path_params, request.query_params, request.headers
  5. Return ApiResponse(status=..., body=...) instead of a plain dict

Python Types

class ApiRequest(BaseModel, Generic[TBody]):
    path_params: dict[str, str]
    query_params: dict[str, str | list[str]]
    body: TBody | None
    headers: dict[str, str | list[str]]
 
class ApiResponse(BaseModel, Generic[TOutput]):
    status: int
    body: Any
    headers: dict[str, str] = {}

Middleware

Placement Change

Middleware has moved from the config root into the HTTP trigger object.

export const config = {
  name: 'ProtectedEndpoint',
  triggers: [
    { type: 'http', path: '/protected', method: 'GET' },
  ],
  middleware: [authMiddleware],
  enqueues: [],
} as const satisfies StepConfig

Middleware Signature Change

const authMiddleware: ApiMiddleware = async (req, ctx, next) => {
  if (!req.headers.authorization) {
    return { status: 401, body: { error: 'Unauthorized' } }
  }
  return next()
}

Server-Sent Events (SSE)

SSE is enabled by the response object in MotiaHttpArgs. Instead of returning a response, you write directly to the stream.

src/sse-example.step.ts
import { type Handlers, http, type StepConfig } from 'motia'
 
export const config = {
  name: 'SSE Example',
  description: 'Streams data back to the client as SSE',
  flows: ['sse-example'],
  triggers: [http('POST', '/sse')],
  enqueues: [],
} as const satisfies StepConfig
 
export const handler: Handlers<typeof config> = async ({ request, response }, { logger }) => {
  logger.info('SSE request received')
 
  response.status(200)
  response.headers({
    'content-type': 'text/event-stream',
    'cache-control': 'no-cache',
    connection: 'keep-alive',
  })
 
  const chunks: string[] = []
  for await (const chunk of request.requestBody.stream) {
    chunks.push(Buffer.from(chunk).toString('utf-8'))
  }
 
  const items = ['alpha', 'bravo', 'charlie']
  for (const item of items) {
    response.stream.write(`event: item\ndata: ${JSON.stringify({ item })}\n\n`)
    await new Promise((resolve) => setTimeout(resolve, 500))
  }
 
  response.stream.write(`event: done\ndata: ${JSON.stringify({ total: items.length })}\n\n`)
  response.close()
}

SSE key points:

  • Destructure both request and response from the first argument
  • Use response.status() and response.headers() to configure the response
  • Write SSE-formatted data to response.stream (TS/JS) or response.writer.stream (Python)
  • Call response.close() when done streaming
  • Do not return a response object

Migration Checklist

TypeScript / JavaScript

  • Change handler first argument from (req, ctx) to ({ request }, ctx) for all HTTP steps
  • Replace req.body with request.body
  • Replace req.pathParams with request.pathParams
  • Replace req.queryParams with request.queryParams
  • Replace req.headers with request.headers
  • Move middleware arrays from config root into HTTP trigger objects
  • Update middleware functions: change (req, ctx, next) to ({ request }, ctx, next)
  • Update ctx.match() HTTP handlers: change (request) => to ({ request }) =>

Python

  • Add imports: from motia import ApiRequest, ApiResponse, FlowContext, http
  • Use http() helper in trigger definitions
  • Change handler signature to handler(request: ApiRequest[Any], ctx: FlowContext[Any]) -> ApiResponse[Any]
  • Replace req.get("body", {}) with request.body
  • Replace req.get("pathParams", {}).get("id") with request.path_params.get("id")
  • Replace req.get("queryParams", {}) with request.query_params
  • Replace req.get("headers", {}) with request.headers
  • Return ApiResponse(status=..., body=...) instead of plain dicts
  • For SSE: use MotiaHttpArgs instead of ApiRequest

On this page