diff --git a/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx b/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx
index 85285b7aac..8168592391 100644
--- a/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx
+++ b/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx
@@ -1,5 +1,6 @@
import { type CSSProperties, useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
+import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
@@ -93,7 +94,9 @@ const DocumentViewer = () => {
return
{loading ? 'Loading…' : 'Open a document to preview it here.'}
;
}
- const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
+ const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
+ query: { id: recordId },
+ });
return (
diff --git a/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx b/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx
index 541b5086eb..36e232ab5a 100644
--- a/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx
+++ b/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx
@@ -6,6 +6,7 @@ import {
useState,
} from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
+import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
closeSidePanel,
@@ -61,41 +62,6 @@ const readValue = (event: SyntheticEvent
): string | undefined => {
return object.detail?.value ?? object.target?.value;
};
-const callAppRoute = async (
- path: string,
- method: 'GET' | 'POST',
- body?: Record,
-): Promise => {
- const apiBaseUrl = process.env.TWENTY_API_URL;
- const token =
- process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
-
- if (!apiBaseUrl || !token) {
- throw new Error('App is missing API URL or access token configuration.');
- }
-
- const response = await fetch(`${apiBaseUrl}/s${path}`, {
- method,
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
- },
- ...(body ? { body: JSON.stringify(body) } : {}),
- });
-
- if (!response.ok) {
- const errorBody = (await response.json().catch(() => null)) as {
- message?: string;
- } | null;
-
- throw new Error(
- errorBody?.message ?? `Request failed with status ${response.status}.`,
- );
- }
-
- return response.json() as Promise;
-};
-
const styles: Record = {
container: {
fontFamily: theme.fontFamily,
@@ -236,9 +202,8 @@ const GenerateDocumentForm = () => {
setSubmitting(true);
try {
- const result = await callAppRoute(
- '/documents/generate',
- 'POST',
+ const result = await new RestApiClient().post(
+ '/s/documents/generate',
{ templateId, recordId },
);
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts
index 288a0d39c6..4cd61b66f5 100644
--- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
@@ -29,22 +29,14 @@ describe('requestCallRecordingSummaryGeneration', () => {
});
});
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it('posts to the injected functions origin without the legacy prefix', async () => {
- vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
-
+ it('posts the /s-prefixed route path and lets the client resolve the url', async () => {
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
- expect(restApiClientMock).toHaveBeenCalledWith({
- baseUrl: 'https://acme.functions.example.com',
- });
+ expect(restApiClientMock).toHaveBeenCalledWith();
expect(postMock).toHaveBeenCalledWith(
- GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1'] },
);
});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts
index 5a2f68488d..684b3347dc 100644
--- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts
@@ -1,9 +1,7 @@
-import { isNonEmptyString } from '@sniptt/guards';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
-import { TWENTY_FUNCTIONS_URL_ENV_VAR_NAME } from 'src/constants/twenty-functions-url-env-var-name';
type GenerateSummariesResponse = {
outcome?: string;
@@ -66,17 +64,8 @@ export const requestCallRecordingSummaryGeneration = async ({
}
try {
- // The host injects the isolated functions origin; the legacy /s route
- // 410s post-cutoff functions and only remains for self-hosting.
- const functionsBaseUrl = process.env[TWENTY_FUNCTIONS_URL_ENV_VAR_NAME];
- const client = isNonEmptyString(functionsBaseUrl)
- ? new RestApiClient({ baseUrl: functionsBaseUrl })
- : new RestApiClient();
-
- const response = await client.post(
- isNonEmptyString(functionsBaseUrl)
- ? GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH
- : `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ const response = await new RestApiClient().post(
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds },
);
diff --git a/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts b/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts
index efc158a860..bf1af913a4 100644
--- a/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts
+++ b/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts
@@ -39,13 +39,13 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- const result = await client.get('/s/my-app/my-route');
+ const result = await client.get('/rest/companies');
expect(result).toEqual({ id: '42' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0];
- expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
+ expect(url).toBe('https://api.twenty.test/rest/companies');
expect((requestInit.headers as Headers).get('Authorization')).toBe(
'Bearer app-access-token',
);
@@ -57,7 +57,7 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- await client.post('/s/my-app/my-route', { name: 'Twenty' });
+ await client.post('/rest/companies', { name: 'Twenty' });
const [, requestInit] = fetchMock.mock.calls[0];
expect(requestInit.method).toBe('POST');
@@ -72,13 +72,13 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- await client.get('/s/my-app/my-route', {
+ await client.get('/rest/companies', {
query: { limit: 10, search: undefined, includeArchived: false },
});
const [url] = fetchMock.mock.calls[0];
expect(url).toBe(
- 'https://api.twenty.test/s/my-app/my-route?limit=10&includeArchived=false',
+ 'https://api.twenty.test/rest/companies?limit=10&includeArchived=false',
);
});
@@ -88,7 +88,7 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- await expect(client.get('/s/my-app/my-route')).rejects.toBeInstanceOf(
+ await expect(client.get('/rest/companies')).rejects.toBeInstanceOf(
RestApiClientError,
);
expect(fetchMock).not.toHaveBeenCalled();
@@ -102,7 +102,7 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- await expect(client.get('/s/my-app/my-route')).rejects.toBeInstanceOf(
+ await expect(client.get('/rest/companies')).rejects.toBeInstanceOf(
RestApiClientError,
);
expect(fetchMock).not.toHaveBeenCalled();
@@ -118,12 +118,170 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- await expect(client.get('/s/my-app/my-route')).rejects.toMatchObject({
+ await expect(client.get('/rest/companies')).rejects.toMatchObject({
status: 404,
body: { message: 'Not found' },
});
});
+ describe('app route paths', () => {
+ it('should strip the /s prefix and send app route paths to an isolated-domain functions url at the root', async () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ await client.post('/s/my-app/my-route', { remainingIds: ['a'] });
+
+ const [url, requestInit] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://acme.functions.twenty.test/my-app/my-route');
+ expect(requestInit.method).toBe('POST');
+ });
+
+ it('should join a same-site functions url that already contains /s without doubling slashes', async () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: 'https://api.twenty.test/s/',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ await client.post('/s/my-app/my-route');
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
+ });
+
+ it('should fall back to the api url /s route when the functions url is not injected', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ await client.post('/s/my-app/my-route');
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
+ });
+
+ it('should treat an empty functions url as not injected', async () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: '',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ await client.post('/s/my-app/my-route');
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
+ });
+
+ it('should keep rest paths on the api url when a functions url is injected', async () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ await client.get('/rest/companies');
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://api.twenty.test/rest/companies');
+ });
+
+ it('should keep unprefixed paths on the api url when a functions url is injected', async () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ await client.post('/my-app/my-route');
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://api.twenty.test/my-app/my-route');
+ });
+
+ it('should prefer an explicit baseUrl over the functions url and keep the path untouched', async () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
+
+ const client = new RestApiClient({
+ baseUrl: 'https://explicit.twenty.test',
+ fetch: fetchMock,
+ });
+
+ await client.post('/s/my-app/my-route');
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://explicit.twenty.test/s/my-app/my-route');
+ });
+
+ it('should resolve an app route url without sending a request', () => {
+ (globalThis as Record).process = {
+ env: {
+ TWENTY_API_URL: 'https://api.twenty.test',
+ TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
+ TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
+ },
+ };
+ const fetchMock = vi.fn();
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ const url = client.resolveUrl('/s/documents/view', {
+ query: { id: 'record-1' },
+ });
+
+ expect(url).toBe(
+ 'https://acme.functions.twenty.test/documents/view?id=record-1',
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('should resolve a rest url on the api base without sending a request', () => {
+ const fetchMock = vi.fn();
+
+ const client = new RestApiClient({ fetch: fetchMock });
+
+ const url = client.resolveUrl('/rest/companies');
+
+ expect(url).toBe('https://api.twenty.test/rest/companies');
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+ });
+
it('should refresh the access token once on a 401 and retry the request', async () => {
const fetchMock = vi
.fn()
@@ -139,7 +297,7 @@ describe('RestApiClient', () => {
const client = new RestApiClient({ fetch: fetchMock });
- const result = await client.get('/s/my-app/my-route');
+ const result = await client.get('/rest/companies');
expect(result).toEqual({ ok: true });
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
diff --git a/packages/twenty-client-sdk/src/rest/index.ts b/packages/twenty-client-sdk/src/rest/index.ts
index 73ec97ee02..14cf614d16 100644
--- a/packages/twenty-client-sdk/src/rest/index.ts
+++ b/packages/twenty-client-sdk/src/rest/index.ts
@@ -2,6 +2,7 @@ import {
DEFAULT_API_KEY_NAME,
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
+ DEFAULT_FUNCTIONS_URL_NAME,
} from 'twenty-shared/application';
const isDefined = (value: T): value is NonNullable =>
@@ -53,6 +54,13 @@ const getProcessEnvironment = (): ProcessEnvironment => {
return processObject?.env ?? {};
};
+const isAppRoutePath = (path: string): boolean => /^\/?s\//.test(path);
+
+// The server serves app routes under /s/; isolated functions domains serve
+// them at the root, so the marker prefix is stripped before joining.
+const stripAppRoutePrefix = (path: string): string =>
+ path.replace(/^(\/?)s\//, '$1');
+
const buildRequestUrl = (
baseUrl: string,
path: string,
@@ -111,6 +119,15 @@ export class RestApiClient {
return this.execute('GET', path, undefined, options);
}
+ resolveUrl(
+ path: string,
+ requestOptions?: Pick,
+ ): string {
+ const target = this.resolveTarget(path);
+
+ return buildRequestUrl(target.baseUrl, target.path, requestOptions?.query);
+ }
+
post(
path: string,
body?: unknown,
@@ -152,6 +169,31 @@ export class RestApiClient {
return baseUrl.replace(/\/+$/, '');
}
+ private resolveFunctionsBaseUrl(): string | undefined {
+ const functionsBaseUrl =
+ getProcessEnvironment()[DEFAULT_FUNCTIONS_URL_NAME];
+
+ if (!isDefined(functionsBaseUrl) || functionsBaseUrl.trim().length === 0) {
+ return undefined;
+ }
+
+ return functionsBaseUrl.trim().replace(/\/+$/, '');
+ }
+
+ private resolveTarget(path: string): { baseUrl: string; path: string } {
+ if (isDefined(this.baseUrl) || !isAppRoutePath(path)) {
+ return { baseUrl: this.resolveBaseUrl(), path };
+ }
+
+ // /s/ marks an app HTTP route. TWENTY_FUNCTIONS_URL is a complete base
+ // URL (isolated domains serve routes at the root, self-host bakes /s in);
+ // fall back to the same-site /s route when it is not injected.
+ return {
+ baseUrl: this.resolveFunctionsBaseUrl() ?? `${this.resolveBaseUrl()}/s`,
+ path: stripAppRoutePrefix(path),
+ };
+ }
+
private resolveToken(): string {
if (!isDefined(this.authorizationToken)) {
const processEnvironment = getProcessEnvironment();
@@ -304,9 +346,10 @@ export class RestApiClient {
body: unknown,
requestOptions?: RestApiRequestOptions,
): Promise {
+ const target = this.resolveTarget(path);
const url = buildRequestUrl(
- this.resolveBaseUrl(),
- path,
+ target.baseUrl,
+ target.path,
requestOptions?.query,
);
const token = this.resolveToken();
diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx
index 914ddeedcb..7cd31a71ff 100644
--- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx
+++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx
@@ -236,29 +236,22 @@ export default defineFrontComponent({
Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
-A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. Twenty injects the base URL your functions are served from into the worker as `TWENTY_FUNCTIONS_URL`, together with the `TWENTY_APP_ACCESS_TOKEN` that authenticates the call. There is no dedicated SDK client for invoking your own functions yet, so call them with a plain `fetch`:
+A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. `RestApiClient` treats paths starting with `/s/` as app routes, resolves them to the URL your functions are served from, and authenticates them with `TWENTY_APP_ACCESS_TOKEN`.
-> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://.withtwenty.com` — this is exactly what `TWENTY_FUNCTIONS_URL` resolves to. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
-
-
- The legacy `/s/` function route is **deprecated** and will be **deactivated on 2026-07-24**. Use `TWENTY_FUNCTIONS_URL` (above) instead, and migrate any hard-coded `/s/` URLs before that date. The `/s/` route remains available for self-hosting.
-
+> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://.withtwenty.com`. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
A headless front component can run the call on mount via the `Command` component, then unmount automatically:
```tsx src/front-components/sync-prs.tsx
+import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
const SyncPrs = () => {
const execute = async () => {
- await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
+ await new RestApiClient().post('/s/github/fetch-prs', {
+ owner: 'twentyhq',
+ repo: 'twenty',
});
};
@@ -274,7 +267,7 @@ export default defineFrontComponent({
});
```
-The path appended to `TWENTY_FUNCTIONS_URL` is the logic function's `httpRouteTriggerSettings.path`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
+The path passed to `RestApiClient` is the logic function's `httpRouteTriggerSettings.path`, prefixed with `/s`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
@@ -299,12 +292,12 @@ export default defineLogicFunction({
```
-`TWENTY_FUNCTIONS_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
+`TWENTY_APP_ACCESS_TOKEN` is injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
### Calling the Twenty REST API
-To read or write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It belongs to the same client family as `CoreApiClient` and `MetadataApiClient`, but targets the Twenty REST API (`/rest/...`) instead of the GraphQL API, reading its base URL from `TWENTY_API_URL`.
+To call app HTTP routes or read and write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It sends `/s/...` paths to your workspace's functions base URL and every other path, including `/rest/...`, to `TWENTY_API_URL`.
| Method | Description |
|--------|-------------|
@@ -314,6 +307,7 @@ To read or write Twenty records from a front component, use `RestApiClient` from
| `patch(path, body?, options?)` | Sends a `PATCH` request |
| `delete(path, options?)` | Sends a `DELETE` request |
| `request(method, path, options?)` | Generic request with any HTTP method |
+| `resolveUrl(path, options?)` | Resolves a path to its full URL without sending a request (for links) |
`options` accepts `headers`, `query` (a record of query-string params; nullish values are skipped), and an `AbortSignal` via `signal`. A non-`FormData` object `body` is JSON-serialized automatically. On a `401`, the client refreshes the access token once through the host and retries the request.
@@ -419,10 +413,21 @@ The following system variables are always available via `process.env`:
| Variable | Description |
|----------|-------------|
-| `TWENTY_FUNCTIONS_URL` | Base URL your app's HTTP logic functions are served from |
| `TWENTY_API_URL` | Base URL of the Twenty core API |
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to your app's role |
+### `TWENTY_FUNCTIONS_URL`
+
+Twenty also injects `TWENTY_FUNCTIONS_URL` into front components and logic functions: the base URL your app's HTTP-triggered logic functions are served from.
+
+It exists because that URL is not always the Twenty server itself. On Twenty Cloud, app routes are served on a dedicated per-workspace domain (`https://.withtwenty.com`, or the application's primary public domain when one is configured) so that app-authored responses run on an isolated origin rather than on the Twenty app origin. Self-hosted and local instances serve app routes under the `/s` prefix on the server itself and may not set the variable at all. Since the base URL varies per workspace and per instance, your code cannot hard-code it — the server injects the right value at runtime.
+
+You rarely need to read it directly. Call your routes through `RestApiClient` with a `/s/`-prefixed path and the client resolves the URL for you: it strips the `/s` prefix and targets `TWENTY_FUNCTIONS_URL`, falling back to `/s` when the variable is not set. Use `resolveUrl('/s/')` to get the absolute URL without sending a request, e.g. for a link. Read the variable directly only when building a URL by hand:
+
+```ts
+const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
+```
+
## Host communication API
Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`:
diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
index 286c8119a8..8d6ed74c98 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
@@ -51,12 +51,7 @@ export default defineLogicFunction({
```
Available trigger types:
-- **httpRoute**: Exposes your function on an HTTP path and method at your workspace's **functions base URL** — the value Twenty injects as `TWENTY_FUNCTIONS_URL` (on Twenty Cloud, a dedicated per-workspace domain):
-> e.g. `path: '/post-card/create'` is callable at `https://your-workspace.withtwenty.com/post-card/create`
-
-
-The legacy `/s/` prefix route (`https://your-twenty-server.com/s/post-card/create`) is **deprecated on Twenty Cloud** and will be deactivated on **2026-07-24**. It remains available for self-hosted and local instances that don't configure an isolated functions domain — use `TWENTY_FUNCTIONS_URL` when it's set, and fall back to `/s/` otherwise.
-
+- **httpRoute**: Exposes your function on an HTTP path and method. In app code, prefix the route path with `/s/` when using `RestApiClient`; the deployed URL uses the injected `TWENTY_FUNCTIONS_URL` base (or `/s` when it is not set).
To invoke a route-triggered logic function from a (headless) front component, see [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
diff --git a/packages/twenty-docs/developers/extend/apps/logic/overview.mdx b/packages/twenty-docs/developers/extend/apps/logic/overview.mdx
index 44122a1e87..fb0107689e 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/overview.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/overview.mdx
@@ -42,7 +42,7 @@ A logic function picks one or more triggers — every entry below is a separate
| Trigger | When it runs | Setting |
|---------|--------------|---------|
-| **HTTP route** | A request hits your function's public URL | `httpRouteTriggerSettings` |
+| **HTTP route** | A request hits your `/s/` endpoint | `httpRouteTriggerSettings` |
| **Cron** | A CRON expression matches | `cronTriggerSettings` |
| **Database event** | A workspace record is created, updated, or deleted | `databaseEventTriggerSettings` |
| **AI tool** | A Twenty AI feature decides to call your function | `toolTriggerSettings` |
diff --git a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx
index e554e1785c..b2309049ef 100644
--- a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx
+++ b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx
@@ -69,6 +69,7 @@ chapter.
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
+import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
@@ -91,15 +92,10 @@ const GenerateDocumentForm = () => {
}, []);
const generate = async () => {
- // Prefer the injected functions URL; fall back to the legacy /s prefix (self-hosted/local)
- const functionsBaseUrl =
- process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`;
- const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
- const res = await fetch(`${functionsBaseUrl}/documents/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
- body: JSON.stringify({ templateId, recordId }),
- }).then((r) => r.json());
+ const res = await new RestApiClient().post<{ success: boolean }>(
+ '/s/documents/generate',
+ { templateId, recordId },
+ );
await enqueueSnackbar({
message: res.success ? 'Document generated.' : 'Generation failed.',
variant: res.success ? 'success' : 'error',
@@ -180,6 +176,7 @@ helper.
```tsx filename="src/front-components/document-viewer.front-component.tsx"
import { CoreApiClient } from 'twenty-client-sdk/core';
+import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { Markdown } from 'src/utils/markdown-to-react';
@@ -188,9 +185,9 @@ const DocumentViewer = () => {
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
// ...load { content, file } for recordId, then derive the links:
const pdfUrl = document.file?.[0]?.url;
- const functionsBaseUrl =
- process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL ?? ''}/s`;
- const webUrl = `${functionsBaseUrl}/documents/view?id=${recordId}`;
+ const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
+ query: { id: recordId },
+ });
// Render the template body, plus quick links to the web page and the PDF.
// Links open in a new tab so they don't navigate the embedded component.
diff --git a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx
index ac2e61fa95..9e6ae750b2 100644
--- a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx
+++ b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/http-routes.mdx
@@ -9,15 +9,10 @@ The same handler can also answer HTTP requests. We'll add two routes:
- a **POST** endpoint the UI calls to generate a document, and
- a public **GET** endpoint that renders a document as a printable web page.
-Both use `httpRouteTriggerSettings`. On the local dev server, app routes are
-served under the `/s` prefix (e.g. `http://localhost:2020/s/documents/generate`).
-
-
-On Twenty Cloud, routes are served on the workspace's dedicated functions domain
-— the URL Twenty injects as `TWENTY_FUNCTIONS_URL`, with no `/s` prefix. The `/s`
-prefix is deprecated there and only remains for self-hosted and local instances.
-See [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
-
+Both use `httpRouteTriggerSettings`. App routes are addressed with an `/s/` path; on local
+and self-hosted instances, that path is served by your Twenty server
+(e.g. `http://localhost:2020/s/documents/generate`). On Twenty Cloud, `RestApiClient`
+resolves the path through the workspace's functions domain.
## POST route — generate on demand