Medusa/developer documentation/Routes

Routes

Expose HTTP API endpoints, configure authentication, and handle JSON requests and responses.

Routes expose module operations over HTTP on Medusa's internal API server (port 3001 or SERVER.API.PORT). Medusa automatically registers HTTP routes, handles Bearer token authentication, IP whitelisting, JSON parsing, error status codes, and serialization.

Routes reside in src/routes/ and load from every route.js file located at any directory depth.

Base Class Definition

modules/example/src/routes/items/route.js
import { Routes } from "#medusa/modules";

export class ItemsRoute extends Routes {
    constructor(medusa) {
        super(medusa, {
            name: "items",
            path: "/items",
            authentication: { key: "secret" }
        });
    }

    async GET(request, response) {
        const handler = this.medusa.modules.handlers.get(this.module, "items");
        const guildId = request.query.guildId;
        if (!guildId) return handler.fail(400, "Query parameter 'guildId' is required.");

        return handler.listForGuild(guildId);
    }

    async POST(request, response) {
        const handler = this.medusa.modules.handlers.get(this.module, "items");
        const { guildId, userId, identifier, name, quantity, price } = request.body;

        return handler.create(guildId, userId, identifier, name, { quantity, price });
    }

    async DELETE(request, response) {
        const handler = this.medusa.modules.handlers.get(this.module, "items");
        const identifier = request.query.identifier;
        if (!identifier) return handler.fail(400, "Query parameter 'identifier' is required.");

        return handler.remove(identifier);
    }
}

Constructor Options

The configuration object passed to super(medusa, meta) accepts the following properties:

PropertyTypeDefaultDescription
namestringRequiredUnique route identifier.
pathstringRequiredRelative path mounted under /api/modules/<module>.
authenticationobjectnullAuthentication configuration. When omitted, the route is public.

Path Namespacing

Medusa automatically namespaces all module routes under /api/modules/<moduleName>:

ModuleRoute pathFull Mounted URL
example/items/api/modules/example/items
example/items/:id/api/modules/example/items/:id
ticket/transcripts/api/modules/ticket/transcripts
minecraft/link/verify/api/modules/minecraft/link/verify

Authentication Options

The authentication object supports the following security modes:

authentication: { key: "secret" }

authentication: { key: "api" }

authentication: { key: "secret", ip: true }

Authentication Modes

ModeHeader RequirementVerification Behavior
{ key: "secret" }Authorization: Bearer <SECRET>Compares against AUTHENTICATION.SECRET configured in config.json. Rejects invalid requests with HTTP 401.
{ key: "api" }Authorization: Bearer <API_KEY>Compares against API keys generated in the Medusa dashboard. Records an entry in the system audit log. Rejects invalid keys with HTTP 401.
{ ip: true }Client IP addressRestricts access to IP addresses listed in ALLOWED_IPS in the database settings. Rejects unauthorized IPs with HTTP 403.
null / omittedNonePublicly accessible route with no authentication check.

Supported HTTP Methods

Implement any of the following standard HTTP verb methods on your route class:

MethodExpress Route BindingTypical Use Case
GET(request, response)app.get(path, handler)Retrieve records or query state.
POST(request, response)app.post(path, handler)Create records or trigger actions.
PUT(request, response)app.put(path, handler)Replace an entire record or resource.
DELETE(request, response)app.delete(path, handler)Remove records or resources.
PATCH(request, response)app.patch(path, handler)Partially update fields on a record.

Request and Response Handling

Each method receives standard Express request and response objects:

Reading Input

  • request.body: Parsed JSON request payload.
  • request.query: Parsed URL query parameters (e.g. ?guildId=123).
  • request.params: Route parameters if path uses route parameters (e.g. path: "/items/:id").
  • request.headers: HTTP request headers.
  • request.ip: Client IP address.
  • this.guild(request): The server selected in the dashboard (sent as the x-guild-id header), or the primary GUILD_ID for requests that do not carry one. Scope every database query and config read to it so each server sees only its own data. Dashboard requests to a module that is turned off in the selected server get a 404 before the route runs.
  • request.headers["x-bot"]: The bot selected in the dashboard, as "primary" or the bot's user id. Use it with this.medusa.discord.owner(module).id to only return data for modules that bot runs.

Sending Responses

Whatever value your method returns is automatically converted to JSON:

async GET(request) {
    return { status: "operational", timestamp: Date.now() };
}

If you send headers or stream data manually via response, Medusa does not intervene:

async GET(request, response) {
    response.setHeader("Content-Type", "text/plain");
    return response.send("Raw text output");
}

If a route returns undefined without sending headers, Medusa responds with HTTP 501 Not Implemented.

Error Handling

When any error is thrown inside a route method or called handler (such as this.fail(status, message)), Medusa catches the error and responds:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
    "error": "Query parameter 'guildId' is required."
}

If the error does not have an attached status property, Medusa defaults to HTTP 500 Internal Server Error.

Discord Activities and Embedded Apps

Public routes can serve embedded applications running inside Discord voice channels using the Discord Embedded App SDK.

Serving On-Demand React Bundles

To bundle React applications on demand without separate build steps, routes can bundle TSX resources using esbuild:

modules/music/src/routes/activity/route.js
import { Routes } from "#medusa/modules";
import fs from "fs";
import path from "path";
import { fileURLToPath, pathToFileURL } from "url";

export class ActivityRoute extends Routes {
    constructor(medusa) {
        super(medusa, {
            name: "activity",
            path: "/activity"
        });
        this.cachedStamp = 0;
        this.cachedBundle = null;
    }

    async bundle() {
        const file = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../resources/activity/app.tsx");
        const stamp = fs.existsSync(file) ? Math.floor(fs.statSync(file).mtimeMs) : 0;
        if (this.cachedBundle && this.cachedStamp === stamp) return this.cachedBundle;

        const dashboardFolder = this.medusa.dashboard ? this.medusa.dashboard.folder : path.resolve("src/dashboard");
        const esbuildPath = path.join(dashboardFolder, "node_modules", "esbuild", "lib", "main.js");
        const { build } = await import(pathToFileURL(esbuildPath).href);

        const result = await build({
            entryPoints: [file],
            bundle: true,
            write: false,
            format: "esm",
            target: "es2022",
            minify: true,
            nodePaths: [path.join(dashboardFolder, "node_modules")],
            define: { "process.env.NODE_ENV": JSON.stringify("production") }
        });

        this.cachedStamp = stamp;
        this.cachedBundle = result.outputFiles[0].text;
        return this.cachedBundle;
    }

    async GET(request, response) {
        if (request.query && request.query.bundle === "1") {
            response.setHeader("Content-Type", "text/javascript; charset=utf-8");
            response.setHeader("Cache-Control", "no-cache");
            response.send(await this.bundle());
            return;
        }

        response.setHeader("Content-Type", "text/html; charset=utf-8");
        response.send(this.loadHtml());
    }
}

Discord Developer Portal Activity Configuration

  1. Embedded App SDK: Toggle Enable Activities in the Discord Developer Portal under Embedded App SDK.
  2. URL Mappings: Under URL Mappings, map root prefix / to your host domain (e.g. api.yourdomain.com or your tunnel host). The Target field only accepts a hostname; do not include a path segment like /api/..., as Discord will forward the request path directly to the target.
  3. OAuth2 Redirect: Add https://<application-id>.discordsays.com/.proxy/api/modules/<module>/activity under OAuth2 Redirects.
  4. Primary Entry Point Command: Register a Primary Entry Point command (type: "primary_entry_point", handler: 2) so the Activity appears directly in Discord's App Launcher, voice channel activities list, and user profiles.
  5. Channel Invites: The bot creates an invite using targetType: 2 (EmbeddedApplication) targeting its own client ID to generate native Discord launch cards.