The communications platform that puts data protection first.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
Rocket.Chat/docs/api-endpoint-migration.md

22 KiB

API Endpoint Migration Guide

Migration from the legacy API.v1.addRoute() pattern to the new API.v1.get() / .post() / .put() / .delete() pattern with request/response validation.

Why

  • Response validation in test mode catches mismatches between code and types
  • Type safety with AJV-compiled schemas for request and response
  • OpenAPI docs generated automatically from schemas
  • Consistent error format across all endpoints

Legacy Pattern (BEFORE)

import { isChannelsAddAllProps } from '@rocket.chat/rest-typings';

import { API } from '../api';

API.v1.addRoute(
	'channels.addAll',
	{
		authRequired: true,
		validateParams: isChannelsAddAllProps,
	},
	{
		async post() {
			const { activeUsersOnly, ...params } = this.bodyParams;
			const findResult = await findChannelByIdOrName({ params, userId: this.userId });

			await addAllUserToRoomFn(this.userId, findResult._id, activeUsersOnly === 'true' || activeUsersOnly === 1);

			return API.v1.success({
				channel: await findChannelByIdOrName({ params, userId: this.userId }),
			});
		},
	},
);

Source: apps/meteor/app/api/server/v1/channels.ts

New Pattern (AFTER)

import {
	ajv,
	isReportHistoryProps,
	validateBadRequestErrorResponse,
	validateUnauthorizedErrorResponse,
	validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';

import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';

// See "Known Pitfall: Date | string unions" — IModerationAudit uses a relaxed inline schema
const paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({
	type: 'object',
	properties: {
		reports: {
			type: 'array',
			items: {
				type: 'object',
				properties: {
					userId: { type: 'string' },
					username: { type: 'string' },
					name: { type: 'string' },
					message: { type: 'string' },
					msgId: { type: 'string' },
					ts: { type: 'string' },
					rooms: { type: 'array', items: { type: 'object' } },
					roomIds: { type: 'array', items: { type: 'string' } },
					count: { type: 'number' },
					isUserDeleted: { type: 'boolean' },
				},
				required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'],
				additionalProperties: false,
			},
		},
		count: { type: 'number' },
		offset: { type: 'number' },
		total: { type: 'number' },
		success: { type: 'boolean', enum: [true] },
	},
	required: ['reports', 'count', 'offset', 'total', 'success'],
	additionalProperties: false,
});

API.v1.get(
	'moderation.reportsByUsers',
	{
		authRequired: true,
		permissionsRequired: ['view-moderation-console'],
		query: isReportHistoryProps,
		response: {
			200: paginatedReportsResponseSchema,
			400: validateBadRequestErrorResponse,
			401: validateUnauthorizedErrorResponse,
			403: validateForbiddenErrorResponse,
		},
	},
	async function action() {
		const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;
		const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);
		const { sort } = await this.parseJsonQuery();

		const latest = _latest ? new Date(_latest) : new Date();
		const oldest = _oldest ? new Date(_oldest) : new Date(0);

		const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapeRegExp(selector), {
			offset,
			count,
			sort,
		}).toArray();

		return API.v1.success({
			reports,
			count: reports.length,
			offset,
			total: reports.length === 0 ? 0 : await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapeRegExp(selector), true),
		});
	},
);

Source: apps/meteor/app/api/server/v1/moderation.ts

Step-by-Step Migration

1. Identify the HTTP method

Look at the handler keys inside the addRoute call:

Handler key New method
async get() API.v1.get(...)
async post() API.v1.post(...)
async put() API.v1.put(...)
async delete() API.v1.delete(...)

2. Replace addRoute with the HTTP method

// BEFORE
API.v1.addRoute('endpoint.name', options, { async get() { ... } });

// AFTER
API.v1.get('endpoint.name', options, async function action() { ... });

The handler becomes a standalone async function action() (named function, not arrow function).

3. Move validateParams to query or body

HTTP method Option name
GET, DELETE query: validatorFn
POST, PUT body: validatorFn
// BEFORE
{
	validateParams: isSomeEndpointProps;
}

// AFTER (GET)
{
	query: isSomeEndpointProps;
}

// AFTER (POST)
{
	body: isSomeEndpointProps;
}

The validateParams option is deprecated. Do not use it in new code.

4. Create response schemas

Define response schemas using ajv.compile<T>() before the endpoint registration. Every response schema must include the success field. When the response contains complex types from @rocket.chat/core-typings, prefer using $ref instead of { type: 'object' } (see Using Typia $ref for Complex Types).

const myResponseSchema = ajv.compile<{ items: SomeType[]; count: number }>({
	type: 'object',
	properties: {
		items: { type: 'array', items: { $ref: '#/components/schemas/SomeType' } },
		count: { type: 'number' },
		success: { type: 'boolean', enum: [true] },
	},
	required: ['items', 'count', 'success'],
	additionalProperties: false,
});

For endpoints that return only { success: true }:

const successResponseSchema = ajv.compile<void>({
	type: 'object',
	properties: { success: { type: 'boolean', enum: [true] } },
	required: ['success'],
	additionalProperties: false,
});

5. Add the response object

Always include error schemas for relevant status codes:

response: {
  200: myResponseSchema,
  400: validateBadRequestErrorResponse,
  401: validateUnauthorizedErrorResponse,
}

Add 403: validateForbiddenErrorResponse when the endpoint has permissionsRequired.

6. Update imports

import {
	ajv,
	isMyEndpointProps, // request validator (from rest-typings)
	validateBadRequestErrorResponse,
	validateUnauthorizedErrorResponse,
	validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';

Using Typia $ref for Complex Types

For response fields that use complex types already defined in @rocket.chat/core-typings (like IMessage, ISubscription, ICustomSound, IPermission, etc.), do not rewrite the JSON schema manually. Instead, use $ref to link to the typia-generated schema.

How it works

  1. packages/core-typings/src/Ajv.ts generates JSON schemas from TypeScript types at compile time using typia:

    import typia from 'typia';
    import type { ICustomSound } from './ICustomSound';
    // ...
    
    export const schemas = typia.json.schemas<
    	[
    		ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall,
    		CallHistoryItem,
    		ICustomUserStatus,
    		SlashCommand,
    	],
    	'3.0'
    >();
    
  2. apps/meteor/app/api/server/ajv.ts registers all generated schemas into the shared AJV instance:

    import { schemas } from '@rocket.chat/core-typings';
    import { ajv } from '@rocket.chat/rest-typings';
    
    const components = schemas.components?.schemas;
    if (components) {
    	for (const key in components) {
    		if (Object.prototype.hasOwnProperty.call(components, key)) {
    			ajv.addSchema(components[key], `#/components/schemas/${key}`);
    		}
    	}
    }
    
  3. Endpoints reference the schema by $ref instead of writing it inline:

    const customSoundsResponseSchema = ajv.compile<PaginatedResult<{ sounds: ICustomSound[] }>>({
    	type: 'object',
    	properties: {
    		sounds: {
    			type: 'array',
    			items: { $ref: '#/components/schemas/ICustomSound' },
    		},
    		count: { type: 'number' },
    		offset: { type: 'number' },
    		total: { type: 'number' },
    		success: { type: 'boolean', enum: [true] },
    	},
    	required: ['sounds', 'count', 'offset', 'total', 'success'],
    	additionalProperties: false,
    });
    

Source: apps/meteor/app/api/server/v1/custom-sounds.ts

Available $ref schemas

These types are already registered and available via $ref:

  • #/components/schemas/ISubscription
  • #/components/schemas/IInvite
  • #/components/schemas/ICustomSound
  • #/components/schemas/IMessage
  • #/components/schemas/IOAuthApps
  • #/components/schemas/IPermission
  • #/components/schemas/IMediaCall
  • #/components/schemas/IEmailInbox
  • #/components/schemas/IImport
  • #/components/schemas/IIntegrationHistory
  • #/components/schemas/ICalendarEvent
  • #/components/schemas/IRole
  • #/components/schemas/IRoom
  • #/components/schemas/IUser
  • #/components/schemas/IModerationAudit
  • #/components/schemas/IModerationReport
  • #/components/schemas/IBanner
  • #/components/schemas/CallHistoryItem
  • #/components/schemas/ICustomUserStatus
  • #/components/schemas/SlashCommand
  • #/components/schemas/VideoConferenceCapabilities
  • #/components/schemas/CloudRegistrationIntentData
  • #/components/schemas/CloudRegistrationStatus

Union types — these TypeScript types are unions, so typia generates individual schemas for each variant instead of a single named schema. Use oneOf to reference them (see below):

TypeScript type Generated schemas
IIntegration IIncomingIntegration, IOutgoingIntegration
VideoConference IDirectVideoConference, IGroupVideoConference, ILivechatVideoConference, IVoIPVideoConference
VideoConferenceInstructions DirectCallInstructions, ConferenceInstructions, LivechatInstructions
CloudConfirmationPollData CloudConfirmationPollDataPending, CloudConfirmationPollDataSuccess

Plus any sub-types that these reference internally (e.g., MessageMention, IVideoConferenceUser, VideoConferenceStatus, etc.).

Handling union types with oneOf

When a TypeScript type is a union (e.g., IIntegration = IIncomingIntegration | IOutgoingIntegration), typia generates separate schemas for each variant but no single named schema for the union itself. Use oneOf to reference the variants:

// Single field
integration: {
  oneOf: [
    { $ref: '#/components/schemas/IIncomingIntegration' },
    { $ref: '#/components/schemas/IOutgoingIntegration' },
  ],
},

// Array of union type
integrations: {
  type: 'array',
  items: {
    oneOf: [
      { $ref: '#/components/schemas/IIncomingIntegration' },
      { $ref: '#/components/schemas/IOutgoingIntegration' },
    ],
  },
},

Handling nullable types

When a field can be null, combine nullable: true with $ref:

// Nullable $ref
report: { nullable: true, $ref: '#/components/schemas/IModerationReport' },

// Nullable union
integration: {
  nullable: true,
  oneOf: [
    { $ref: '#/components/schemas/IIncomingIntegration' },
    { $ref: '#/components/schemas/IOutgoingIntegration' },
  ],
},

Handling intersection types with allOf

When a response intersects a type with additional properties (e.g., VideoConferenceInstructions & { providerName: string }), use allOf:

data: {
  allOf: [
    {
      oneOf: [
        { $ref: '#/components/schemas/DirectCallInstructions' },
        { $ref: '#/components/schemas/ConferenceInstructions' },
        { $ref: '#/components/schemas/LivechatInstructions' },
      ],
    },
    { type: 'object', properties: { providerName: { type: 'string' } }, required: ['providerName'] },
  ],
},

This also applies when the API spreads a type at root level alongside success (e.g., API.v1.success(emailInbox) producing { ...emailInbox, success: true }):

{
  allOf: [
    { $ref: '#/components/schemas/IEmailInbox' },
    { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] },
  ],
}

Types that are NOT good $ref candidates

Some TypeScript types cannot (or should not) be represented as a single $ref:

  • Complex union types with many variants: ISetting (union of ISettingBase | ISettingEnterprise | ISettingColor | ...) generates too many individual schemas without a clean single reference.
  • Pick<> / Omit<> types: Pick<IUser, 'username' | 'name' | '_id'> is not registered in typia as a standalone schema. Leave these as { type: 'object' } or { type: ['object', 'null'] }.
  • Intersection + union types: LoginServiceConfiguration and similar complex composed types.
  • Types with Date | string fields: See Known Pitfall: Date | string unions below.

For these cases, keep using { type: 'object' } as a placeholder.

Known Pitfall: Date | string unions

Some core-typings define timestamp fields as Date | string (e.g., IModerationAudit.ts, IModerationReport.ts). When typia generates the JSON Schema for these fields, it creates a oneOf with two branches: one for Date (which maps to { type: "string", format: "date-time" }) and one for string (which maps to { type: "string" }).

The problem: an ISO date string like "2026-03-11T16:07:21.755Z" satisfies both schemas simultaneously, causing AJV to fail with:

must match exactly one schema in oneOf (passingSchemas: 0,1)

This happens because oneOf requires exactly one match, but the value is both a valid date-time string and a valid string.

Workaround: Use a relaxed inline schema instead of $ref for these types, defining ts as { type: 'string' }. Add a TODO comment referencing this issue so it can be tracked:

// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.

Long-term fix: Revise the core-typings to narrow ts to string (which is what MongoDB aggregation pipelines and JSON.stringify actually return), or adjust the AJV/typia schema generation to handle Date | string unions correctly (e.g., using anyOf instead of oneOf, or collapsing Date into string).

Adding a new type to typia

If you need a $ref for a type that is not yet registered:

  1. Edit packages/core-typings/src/Ajv.ts
  2. Import the type and add it to the typia.json.schemas<[...]>() type parameter list
  3. Rebuild core-typings: yarn workspace @rocket.chat/core-typings run build
  4. The new schema will be automatically registered at startup via apps/meteor/app/api/server/ajv.ts

Chaining Endpoints and Type Augmentation

Migrated endpoints must always be chained when a file registers multiple endpoints. Store the result in a variable, then use ExtractRoutesFromAPI to extract the route types and augment the Endpoints interface in rest-typings. This is what makes the endpoints fully typed across the entire codebase (SDK, client, tests).

Full example

import type { IInvite } from '@rocket.chat/core-typings';
import {
	ajv,
	isFindOrCreateInviteParams,
	validateBadRequestErrorResponse,
	validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';

import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';

// Chain all endpoints from this file into a single variable
const invites = API.v1
	.get(
		'listInvites',
		{
			authRequired: true,
			response: {
				200: ajv.compile<IInvite[]>({
					type: 'array',
					items: { $ref: '#/components/schemas/IInvite' },
					additionalProperties: false,
				}),
				401: validateUnauthorizedErrorResponse,
			},
		},
		async function action() {
			const result = await listInvites(this.userId);
			return API.v1.success(result);
		},
	)
	.post(
		'findOrCreateInvite',
		{
			authRequired: true,
			body: isFindOrCreateInviteParams,
			response: {
				200: findOrCreateInviteResponseSchema,
				400: validateBadRequestErrorResponse,
				401: validateUnauthorizedErrorResponse,
			},
		},
		async function action() {
			const { rid, days, maxUses } = this.bodyParams;
			return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite);
		},
	);

// Extract route types from the chained result
type InvitesEndpoints = ExtractRoutesFromAPI<typeof invites>;

// Augment the Endpoints interface so the SDK, client hooks, and tests see these routes
declare module '@rocket.chat/rest-typings' {
	// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
	interface Endpoints extends InvitesEndpoints {}
}

Source: apps/meteor/app/api/server/v1/invites.ts

Rules

  1. Always chain: Every .get() / .post() / .put() / .delete() call in the same file should be chained on the same variable (e.g., const myEndpoints = API.v1.get(...).post(...).get(...)).
  2. Store in a const: The chained result must be stored in a variable so typeof can extract its type.
  3. Extract with ExtractRoutesFromAPI: Use type MyEndpoints = ExtractRoutesFromAPI<typeof myEndpoints> to get the typed route definitions.
  4. Augment Endpoints: Use declare module '@rocket.chat/rest-typings' to merge the extracted types into the global Endpoints interface. This is what makes useEndpoint('listInvites') and similar SDK calls type-safe.
  5. Import ExtractRoutesFromAPI from '../ApiClass' (relative to the endpoint file in v1/).

What augmentation enables

Once the Endpoints interface is augmented, the entire stack benefits:

  • Client SDK: useEndpoint('listInvites') gets typed params and response
  • REST client: api.get('/v1/listInvites') is type-checked
  • Tests: response shape is inferred from the endpoint definition
  • OpenAPI: routes appear in the generated documentation

Endpoints with Multiple HTTP Methods

When an addRoute registers both GET and POST (or other combinations), split them into separate calls:

// BEFORE
API.v1.addRoute('endpoint', { authRequired: true, validateParams: { GET: isGetProps, POST: isPostProps } }, {
  async get() { ... },
  async post() { ... },
});

// AFTER
API.v1.get('endpoint', {
  authRequired: true,
  query: isGetProps,
  response: { 200: getResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse },
}, async function action() { ... });

API.v1.post('endpoint', {
  authRequired: true,
  body: isPostProps,
  response: { 200: postResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse },
}, async function action() { ... });

Test Changes

Migrating an endpoint changes how validation errors are returned. Tests must be updated accordingly.

errorType changes for query parameter validation

The new router returns a different errorType for query parameter validation errors:

// BEFORE (addRoute with validateParams)
expect(res.body).to.have.property('errorType', 'invalid-params');

// AFTER (.get() with query)
expect(res.body).to.have.property('errorType', 'error-invalid-params');

This only affects query parameter validation (GET/DELETE). Body parameter validation (POST/PUT) keeps 'invalid-params'.

Error message format changes

The [invalid-params] suffix is removed from error messages:

// BEFORE
expect(res.body).to.have.property('error', "must have required property 'platform' [invalid-params]");

// AFTER
expect(res.body).to.have.property('error', "must have required property 'platform'");

Summary of test changes per endpoint

When migrating an endpoint, search for its tests and update:

  1. errorType from 'invalid-params' to 'error-invalid-params' (for query params only)
  2. Remove ' [invalid-params]' suffix from error message assertions
  3. Verify that status codes remain the same (400 for validation errors)

Tracking Migration Progress

# Summary by file
node scripts/list-unmigrated-api-endpoints.mjs

# Full list with line numbers (JSON)
node scripts/list-unmigrated-api-endpoints.mjs --json

The script scans for API.v1.addRoute and API.default.addRoute calls in apps/meteor/app/api/.

Reference Files

Pattern File
Chaining + augmentation apps/meteor/app/api/server/v1/invites.ts
Chaining + augmentation + $ref apps/meteor/app/api/server/v1/custom-sounds.ts
GET with $ref to typia schemas apps/meteor/app/api/server/v1/custom-sounds.ts
GET with pagination apps/meteor/app/api/server/v1/moderation.ts
POST endpoint apps/meteor/app/api/server/v1/import.ts
Multiple endpoints (misc) apps/meteor/app/api/server/v1/misc.ts
GET with permissions apps/meteor/app/api/server/v1/permissions.ts
Typia schema generation packages/core-typings/src/Ajv.ts
AJV schema registration apps/meteor/app/api/server/ajv.ts
Error response validators packages/rest-typings/src/v1/Ajv.ts
Request validators (examples) packages/rest-typings/src/v1/moderation/
Router implementation packages/http-router/src/Router.ts
Unmigrated endpoints script scripts/list-unmigrated-api-endpoints.mjs