Add callAppRoute to RestApiClient (#22863)

Adds a `callAppRoute` method to `RestApiClient` in
`twenty-client-sdk/rest`. It calls one of the app's own HTTP routes
using the injected `TWENTY_FUNCTIONS_URL`, resolved internally the same
way the client already resolves `TWENTY_API_URL`, so app code no longer
reads env vars or knows how function routes are hosted.

Both app runtimes already go through `RestApiClient` for route calls
(logic functions and front components), so both get this in one place;
front components keep the existing 401 token-refresh flow.

Pairs with #22825, which makes the injected `TWENTY_FUNCTIONS_URL`
callable in every topology (app custom domain -> workspace isolated
functions domain -> `SERVER_URL/s`).

Once this ships in an SDK release, Call Recorder's own-route plumbing
(logic-function and front-component utils) drops its URL resolution and
calls `client.callAppRoute(path, body)`.

---------

Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
nitin
2026-07-15 23:37:16 +05:30
committed by GitHub
parent 67ed2689ce
commit 79f3a5243a
11 changed files with 262 additions and 120 deletions
@@ -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 <div style={styles.empty}>{loading ? 'Loading…' : 'Open a document to preview it here.'}</div>;
}
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
query: { id: recordId },
});
return (
<div style={styles.scroll}>
@@ -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<HTMLElement>): string | undefined => {
return object.detail?.value ?? object.target?.value;
};
const callAppRoute = async <TResponse,>(
path: string,
method: 'GET' | 'POST',
body?: Record<string, unknown>,
): Promise<TResponse> => {
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<TResponse>;
};
const styles: Record<string, CSSProperties> = {
container: {
fontFamily: theme.fontFamily,
@@ -236,9 +202,8 @@ const GenerateDocumentForm = () => {
setSubmitting(true);
try {
const result = await callAppRoute<GenerateResponse>(
'/documents/generate',
'POST',
const result = await new RestApiClient().post<GenerateResponse>(
'/s/documents/generate',
{ templateId, recordId },
);
@@ -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'] },
);
});
@@ -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<GenerateSummariesResponse>(
isNonEmptyString(functionsBaseUrl)
? GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH
: `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
const response = await new RestApiClient().post<GenerateSummariesResponse>(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds },
);
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
+45 -2
View File
@@ -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 = <T>(value: T): value is NonNullable<T> =>
@@ -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<TResponse>('GET', path, undefined, options);
}
resolveUrl(
path: string,
requestOptions?: Pick<RestApiRequestOptions, 'query'>,
): string {
const target = this.resolveTarget(path);
return buildRequestUrl(target.baseUrl, target.path, requestOptions?.query);
}
post<TResponse = unknown>(
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<TResponse> {
const target = this.resolveTarget(path);
const url = buildRequestUrl(
this.resolveBaseUrl(),
path,
target.baseUrl,
target.path,
requestOptions?.query,
);
const token = this.resolveToken();
@@ -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://<your-workspace-subdomain>.withtwenty.com<path>` — 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.
<Warning>
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.
</Warning>
> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.withtwenty.com<path>`. 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({
```
<Note>
`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.
</Note>
### 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://<your-workspace-subdomain>.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 `<TWENTY_API_URL>/s` when the variable is not set. Use `resolveUrl('/s/<path>')` 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`:
@@ -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`
<Warning>
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 `<server-url>/s/<path>` otherwise.
</Warning>
- **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 `<server-url>/s` when it is not set).
<Note>
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).
@@ -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/<path>` 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` |
@@ -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.
@@ -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`).
<Note>
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).
</Note>
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