GraphQL API
Call the federated GraphQL endpoint, authenticate, paginate, and subscribe.
For credentials, see Authentication & Permissions.
Endpoint
Every backend service (Core API on 3300, each plugin on 3305+) serves an Apollo Server v4 subgraph at its own /graphql using @apollo/subgraph. The gateway composes them into a supergraph with Rover (supergraph-compose.ts), spawns Apollo Router bound to 127.0.0.1:50000 (APOLLO_ROUTER_PORT), and proxies the public /graphql to it on port 4000 (PORT).
Always call the gateway, never a subgraph port: the gateway's userMiddleware is what turns your credential into the user/clientportal/cpuser context headers subgraphs trust. The router config propagates all request headers to subgraphs (headers.all.request.propagate.matching: ".*").
curl --fail http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer <team-token>" \
--data '{"query":"query Health { __typename }"}'
A 200 with an errors array is still a failed operation: the router sets include_subgraph_errors: all, so resolver errors appear there. Check errors independently of HTTP status. Router and subgraph timeouts are 300s (traffic_shaping).
Authentication headers
| Header | Principal |
|---|---|
Authorization: Bearer <jwt> / auth-token cookie | Team member |
erxes-app-token | Machine app (app:<id>) |
x-app-token (+ optional client-auth-token) | Client Portal (+ portal user) |
Widget operations (widgets*, cp*, helpCenterGetConfigByDomain) skip the team-member wrapper and need only their own context; see Authentication for the full check order.
Pagination
erxes-api-shared defines two parameter styles; each query declares one:
- Cursor (
GQL_CURSOR_PARAM_DEFS, constants.ts):limit,cursor,cursorMode(inclusive/exclusive),direction(forward/backward),orderBy(JSON),sortMode,aggregationPipeline([JSON]). Backed bycursorPaginate:limitdefaults to 20 and must be 1–100; a cursor is base64 JSON of the item's sort fields plus_id. - Offset (
GQL_OFFSET_PARAM_DEFS):page,perPage,sortField,sortDirection; used by a minority of legacy queries.
Cursor-style lists return an envelope such as DealsListResponse { list, totalCount, pageInfo }; PageInfo is { hasNextPage, hasPreviousPage, startCursor, endCursor } (from commonTypeDefs.ts). Feed endCursor back as cursor:
query Deals($cursor: String) {
deals(
pipelineId: "PIPELINE_ID"
limit: 20
cursor: $cursor
cursorMode: exclusive
direction: forward
orderBy: { createdAt: -1 }
) {
list {
_id
name
createdAt
}
totalCount
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
First page: omit cursor. Next page: pass the previous pageInfo.endCursor. backward walks earlier pages; hasPreviousPage/hasNextPage flip accordingly.
Subscriptions
The gateway mounts a graphql-ws server (graphql-transport-ws protocol, subscription/index.ts) on the same HTTP server at path /graphql, so ws://<gateway>:4000/graphql. Connection auth reads the auth-token cookie from the upgrade request; each plugin registers its subscription resolvers over Redis pub/sub (hasSubscriptions in startPlugin).
import { createClient } from 'graphql-ws';
const client = createClient({ url: 'wss://gateway.example.com/graphql' });
Proxies in front of the gateway must allow WebSocket upgrade on /graphql.
Introspection and limits
- Introspection: enabled when
NODE_ENV=developmentorINTROSPECTION=truein the gateway environment. In production it is off unless you opt in, and the gateway logs a warning if you do. - Rate limiting: a global limiter caps each IP at 5000 requests per 15 minutes (
/healthand/bullmq-boardexempt). Core's/initial-setuproute is separately capped at 100 per 15 minutes. - Optional query armor: setting
GRAPHQL_LIMITERparses and validates every request body against the supergraph and enforces character limit 10,000, zero aliases, and depth ≤ 50; invalid documents get a400before reaching the router.
Naming conventions
Operation names are prefixed by their module (cms*, sales*, cp* portal-scoped, clientPortal*, widgets* public widget) and must be unique repo-wide. The cp/widgets prefixes mean "portal/widget context", not "read-only": enforce status filters in your queries.