Website changes docs playground (#10413)
From #10376 (extracting website related changes to deploy them separately, later) --------- Co-authored-by: oliver <8559757+oliverqx@users.noreply.github.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@ -1,13 +0,0 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const GraphQlPlayground = dynamic(
|
||||
() => import('@/app/_components/playground/graphql-playground'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const CoreGraphql = () => {
|
||||
return <GraphQlPlayground subDoc={'core'} />;
|
||||
};
|
||||
|
||||
export default CoreGraphql;
|
||||
@ -1,13 +0,0 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const GraphQlPlayground = dynamic(
|
||||
() => import('@/app/_components/playground/graphql-playground'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const MetadataGraphql = () => {
|
||||
return <GraphQlPlayground subDoc={'metadata'} />;
|
||||
};
|
||||
|
||||
export default MetadataGraphql;
|
||||
@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Playground from '@/app/_components/playground/playground';
|
||||
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
|
||||
|
||||
const RestApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = <RestApiWrapper openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100vw' }}>
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="core"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestApi;
|
||||
@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Playground from '@/app/_components/playground/playground';
|
||||
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
|
||||
|
||||
const restApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = <RestApiWrapper openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="metadata"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default restApi;
|
||||
@ -1,71 +0,0 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { explorerPlugin } from '@graphiql/plugin-explorer';
|
||||
import { createGraphiQLFetcher } from '@graphiql/toolkit';
|
||||
import { GraphiQL } from 'graphiql';
|
||||
|
||||
import { SubDoc } from '@/app/_components/playground/token-form';
|
||||
|
||||
import Playground from './playground';
|
||||
|
||||
import 'graphiql/graphiql.css';
|
||||
import '@graphiql/plugin-explorer/dist/style.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const SubDocToPath = {
|
||||
core: 'graphql',
|
||||
metadata: 'metadata',
|
||||
};
|
||||
|
||||
const GraphQlComponent = ({ token, baseUrl, path }: any) => {
|
||||
const explorer = explorerPlugin({
|
||||
showAttribution: true,
|
||||
});
|
||||
|
||||
const fetcher = createGraphiQLFetcher({
|
||||
url: baseUrl + '/' + path,
|
||||
});
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<GraphiQL
|
||||
plugins={[explorer]}
|
||||
fetcher={fetcher}
|
||||
defaultHeaders={JSON.stringify({ Authorization: `Bearer ${token}` })}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const GraphQlPlayground = ({ subDoc }: { subDoc: SubDoc }) => {
|
||||
const [token, setToken] = useState<string>();
|
||||
const [baseUrl, setBaseUrl] = useState<string>();
|
||||
|
||||
const children = (
|
||||
<GraphQlComponent
|
||||
token={token}
|
||||
baseUrl={baseUrl}
|
||||
path={SubDocToPath[subDoc]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<Playground
|
||||
children={children}
|
||||
setToken={setToken}
|
||||
setBaseUrl={setBaseUrl}
|
||||
subDoc={subDoc}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default GraphQlPlayground;
|
||||
@ -1,80 +0,0 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import { TbLoader2 } from 'react-icons/tb';
|
||||
|
||||
import TokenForm, { TokenFormProps } from './token-form';
|
||||
|
||||
const Playground = ({
|
||||
children,
|
||||
setOpenApiJson,
|
||||
setToken,
|
||||
setBaseUrl,
|
||||
subDoc,
|
||||
}: Partial<React.PropsWithChildren> &
|
||||
Omit<
|
||||
TokenFormProps,
|
||||
'isTokenValid' | 'setIsTokenValid' | 'setLoadingState'
|
||||
>) => {
|
||||
const [isTokenValid, setIsTokenValid] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
paddingTop: 15,
|
||||
}}
|
||||
>
|
||||
<TokenForm
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
setToken={setToken}
|
||||
setBaseUrl={setBaseUrl}
|
||||
isTokenValid={isTokenValid}
|
||||
setIsTokenValid={setIsTokenValid}
|
||||
subDoc={subDoc}
|
||||
setLoadingState={setIsLoading}
|
||||
/>
|
||||
{!isTokenValid && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexFlow: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2,
|
||||
background: 'rgba(23,23,23, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '50%',
|
||||
background: 'rgba(23,23,23, 0.8)',
|
||||
color: 'white',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
A token is required as APIs are dynamically generated for each
|
||||
workspace based on their unique metadata. <br /> Generate your token
|
||||
under{' '}
|
||||
<a className="link" href="https://app.twenty.com/settings/apis">
|
||||
Settings > APIs
|
||||
</a>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="loader-container">
|
||||
<TbLoader2 className="loader" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
||||
@ -1,33 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import { API } from '@stoplight/elements';
|
||||
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css';
|
||||
|
||||
export const RestApiWrapper = ({ openApiJson }: { openApiJson: any }) => {
|
||||
// We load spotlightTheme style using useEffect as it breaks remaining docs style
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.innerHTML = spotlightTheme.toString();
|
||||
document.head.append(styleElement);
|
||||
|
||||
return () => styleElement.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - var(--ifm-navbar-height) - 45px)',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<API
|
||||
apiDescriptionDocument={JSON.stringify(openApiJson)}
|
||||
hideSchemas={true}
|
||||
router="hash"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,146 +0,0 @@
|
||||
.form-container {
|
||||
height: 45px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--ifm-color-secondary-light);
|
||||
position: sticky;
|
||||
top: var(--ifm-navbar-height) + 10;
|
||||
padding: 0px 8px;
|
||||
background: var(--ifm-color-secondary-contrast-background);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
height: 45px;
|
||||
gap: 10px;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
flex: 0.7;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 6px;
|
||||
margin: 5px 0 5px 0;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #f3f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding-left:30px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.input[disabled] {
|
||||
color: rgb(153, 153, 153)
|
||||
}
|
||||
|
||||
[data-theme='dark'] .input {
|
||||
background-color: #16233f;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
padding: 5px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .inputIcon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 6px;
|
||||
margin: 5px 0 5px 0;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #f3f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .select {
|
||||
background-color: #16233f;
|
||||
}
|
||||
|
||||
|
||||
.invalid {
|
||||
border: 1px solid #f83e3e;
|
||||
}
|
||||
|
||||
.token-invalid {
|
||||
color: #f83e3e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.not-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: #16233f;
|
||||
font-size: 2rem;
|
||||
animation: animate 2s infinite;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .loader {
|
||||
color: #a3c0f8;
|
||||
}
|
||||
|
||||
@keyframes animate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
|
||||
.backButton {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
left: 8px;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #999999;
|
||||
|
||||
&:hover {
|
||||
color: #16233f;
|
||||
}
|
||||
}
|
||||
@ -1,212 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TbApi, TbChevronLeft, TbLink } from 'react-icons/tb';
|
||||
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import tokenForm from '!css-loader!./token-form.css';
|
||||
|
||||
export type SubDoc = 'core' | 'metadata';
|
||||
export type TokenFormProps = {
|
||||
setOpenApiJson?: (json: object) => void;
|
||||
setToken?: (token: string) => void;
|
||||
setBaseUrl?: (baseUrl: string) => void;
|
||||
isTokenValid?: boolean;
|
||||
setIsTokenValid?: (arg: boolean) => void;
|
||||
setLoadingState?: (arg: boolean) => void;
|
||||
subDoc?: SubDoc;
|
||||
};
|
||||
|
||||
const TokenForm = ({
|
||||
setOpenApiJson,
|
||||
setToken,
|
||||
setBaseUrl: submitBaseUrl,
|
||||
isTokenValid,
|
||||
setIsTokenValid,
|
||||
subDoc,
|
||||
setLoadingState,
|
||||
}: TokenFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [locationSetting, setLocationSetting] = useState(
|
||||
(typeof window !== 'undefined' &&
|
||||
window.localStorage.getItem('baseUrl') &&
|
||||
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')
|
||||
?.locationSetting) ??
|
||||
'production',
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState(
|
||||
(typeof window !== 'undefined' &&
|
||||
window.localStorage.getItem('baseUrl') &&
|
||||
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')?.baseUrl) ??
|
||||
'https://api.twenty.com',
|
||||
);
|
||||
|
||||
const tokenLocal = (
|
||||
typeof window !== 'undefined'
|
||||
? window?.localStorage?.getItem?.('TryIt_securitySchemeValues')
|
||||
: '{}'
|
||||
) as string;
|
||||
|
||||
const token = JSON.parse(tokenLocal)?.bearerAuth ?? '';
|
||||
|
||||
const updateLoading = (loading = false) => {
|
||||
setIsLoading(loading);
|
||||
setLoadingState?.(!!loading);
|
||||
};
|
||||
|
||||
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
window.localStorage.setItem(
|
||||
'TryIt_securitySchemeValues',
|
||||
JSON.stringify({ bearerAuth: event.target.value }),
|
||||
);
|
||||
await submitToken(event.target.value);
|
||||
};
|
||||
|
||||
const updateBaseUrl = (baseUrl: string, locationSetting: string) => {
|
||||
let url: string;
|
||||
if (locationSetting === 'production') {
|
||||
url = 'https://api.twenty.com';
|
||||
} else if (locationSetting === 'next') {
|
||||
url = 'https://api.twenty-next.com';
|
||||
} else if (locationSetting === 'localhost') {
|
||||
url = 'http://localhost:3000';
|
||||
} else {
|
||||
url = baseUrl;
|
||||
}
|
||||
|
||||
setBaseUrl(url);
|
||||
setLocationSetting(locationSetting);
|
||||
submitBaseUrl?.(url);
|
||||
window.localStorage.setItem(
|
||||
'baseUrl',
|
||||
JSON.stringify({ baseUrl: url, locationSetting }),
|
||||
);
|
||||
};
|
||||
|
||||
const validateToken = (openApiJson: any) => {
|
||||
setIsTokenValid?.(!!openApiJson.tags);
|
||||
};
|
||||
|
||||
const getJson = async (token: string) => {
|
||||
updateLoading(true);
|
||||
|
||||
return await fetch(baseUrl + '/open-api/' + (subDoc ?? 'core'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
validateToken(result);
|
||||
updateLoading(false);
|
||||
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
updateLoading(false);
|
||||
setIsTokenValid?.(false);
|
||||
});
|
||||
};
|
||||
|
||||
const submitToken = async (token: any) => {
|
||||
if (isLoading) return;
|
||||
|
||||
const json = await getJson(token);
|
||||
|
||||
setToken && setToken(token);
|
||||
|
||||
setOpenApiJson && setOpenApiJson(json);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
updateBaseUrl(baseUrl, locationSetting);
|
||||
await submitToken(token);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// We load playground style using useEffect as it breaks remaining docs style
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.innerHTML = tokenForm.toString();
|
||||
document.head.append(styleElement);
|
||||
|
||||
return () => styleElement.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="form-container">
|
||||
<form className="form">
|
||||
<div className="backButton" onClick={() => router.back()}>
|
||||
<TbChevronLeft size={18} />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<div className="inputWrapper">
|
||||
<select
|
||||
className="select"
|
||||
onChange={(event) => {
|
||||
updateBaseUrl(baseUrl, event.target.value);
|
||||
}}
|
||||
value={locationSetting}
|
||||
>
|
||||
<option value="production">Production API</option>
|
||||
<option value="demo">Demo API</option>
|
||||
<option value="localhost">Localhost</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="inputWrapper">
|
||||
<div className="inputIcon" title="Base URL">
|
||||
<TbLink size={20} />
|
||||
</div>
|
||||
<input
|
||||
className={'input'}
|
||||
type="text"
|
||||
readOnly={isLoading}
|
||||
disabled={locationSetting !== 'other'}
|
||||
placeholder="Base URL"
|
||||
value={baseUrl}
|
||||
onChange={(event) =>
|
||||
updateBaseUrl(event.target.value, locationSetting)
|
||||
}
|
||||
onBlur={() => submitToken(token)}
|
||||
/>
|
||||
</div>
|
||||
<div className="inputWrapper">
|
||||
<div className="inputIcon" title="Api Key">
|
||||
<TbApi size={20} />
|
||||
</div>
|
||||
<input
|
||||
className={!isTokenValid && !isLoading ? 'input invalid' : 'input'}
|
||||
type="text"
|
||||
readOnly={isLoading}
|
||||
placeholder="API Key"
|
||||
defaultValue={token}
|
||||
onChange={updateToken}
|
||||
/>
|
||||
</div>
|
||||
<div className="inputWrapper" style={{ maxWidth: '100px' }}>
|
||||
<select
|
||||
className="select"
|
||||
onChange={(event) =>
|
||||
router.replace(
|
||||
pathname.split('/').slice(0, -1).join('/') +
|
||||
'/' +
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
value={pathname.split('/').at(-1)}
|
||||
>
|
||||
<option value="core">Core</option>
|
||||
<option value="metadata">Metadata</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenForm;
|
||||
@ -0,0 +1,47 @@
|
||||
---
|
||||
title: API
|
||||
icon: IconApi
|
||||
image: /images/docs/getting-started/api.png
|
||||
info: Discover how to use our APIs.
|
||||
---
|
||||
|
||||
## Overview
|
||||
The Twenty API allows developers to interact programmatically with the Twenty CRM platform. Using the API, you can integrate Twenty with other systems, automate data synchronization, and build custom solutions around your customer data. The API provides endpoints to **create, read, update, and delete** core CRM objects (such as people and companies) as well as access metadata configuration.
|
||||
|
||||
**API Playground:** You can now access the API Playground within the app's settings. To try out API calls in real-time, log in to your Twenty workspace and navigate to **Settings > API & Webhooks** (under **Developers**). This will open the in-app API Playground and settings for API keys. _(If you don’t see the API & Webhooks section in Settings, enable “Advanced mode” in your app settings to reveal developer options.)_
|
||||
**[Go to API Settings](https://app.twenty.com/settings)**
|
||||
|
||||
## Authentication
|
||||
Twenty’s API uses API keys for authentication. Every request to protected endpoints must include an API key in the header.
|
||||
|
||||
* **API Keys:** You can generate a new API key from your Twenty app’s **API settings** page. Each API key is a secret token that grants access to your CRM data, so keep it safe. If a key is compromised, revoke it from the settings and generate a new one.
|
||||
* **Auth Header:** Once you have an API key, include it in the `Authorization` header of your HTTP requests. Use the Bearer token scheme. For example:
|
||||
```
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
Replace `YOUR_API_KEY` with the key you obtained. This header must be present on **all API requests**. If the token is missing or invalid, the API will respond with an authentication error (HTTP 401 Unauthorized).
|
||||
|
||||
## API Endpoints
|
||||
All resources can be accessed and via REST or GraphQL.
|
||||
|
||||
* **Cloud:** `https://api.twenty.com/` or your custom domain / sub-domain
|
||||
* **Self-Hosted Instances:** If you are running Twenty on your own server, use your own domain in place of `api.twenty.com` (for example, `https://<your-domain>/rest/`).
|
||||
|
||||
Endpoints are grouped into two categories: **Core API** and **Metadata API**. The **Core API** deals with primary CRM data (e.g. people, companies, notes, tasks), while the **Metadata API** covers configuration data (like custom fields or object definitions). Most integrations will primarily use the Core API.
|
||||
|
||||
### Core API
|
||||
Accessed on `/rest/` or `/graphql/`.
|
||||
The **Core API** serves as a unified interface for managing core CRM entities (people, companies, notes, tasks) and their relationships, offering **both REST and GraphQL** interaction models.
|
||||
|
||||
### Metadata API
|
||||
Accessed on `/rest/metadata/` or `/metadata/`.
|
||||
The Metadata API endpoints allow you to retrieve information about your schema and settings. For instance, you can fetch definitions of custom fields, object schemas, etc.
|
||||
|
||||
* **Example Endpoints:**
|
||||
|
||||
* `GET /rest/metadata/objects` – List all object types and their metadata (fields, relationships).
|
||||
* `GET /rest/metadata/objects/{objectName}` – Get metadata for a specific object (e.g., `people`, `companies`).
|
||||
* `GET /rest/metadata/picklists` – Retrieve picklist (dropdown) field options defined in the CRM.
|
||||
|
||||
Typically, the metadata endpoints are used to understand the structure of data (for dynamic integrations or form-building) rather than to manage actual records. They are read-only in most cases. Authentication is required for these as well (use your API key).
|
||||
@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Webhooks
|
||||
icon: IconApi
|
||||
image: /images/docs/getting-started/webhooks.png
|
||||
info: Discover how to use our Webhooks.
|
||||
---
|
||||
|
||||
## Overview
|
||||
Webhooks in Twenty complement the API by enabling **real-time notifications** to your own applications when certain events happen in your CRM. Instead of continuously polling the API for changes, you can set up webhooks to have Twenty **push** data to your system whenever specific events occur (for example, when a new record is created or an existing record is updated). This helps keep external systems in sync with Twenty instantly and efficiently.
|
||||
|
||||
With webhooks, Twenty will send an HTTP POST request to a URL you specify, containing details about the event. You can then handle that data in your application (e.g., to update your external database, trigger workflows, or send alerts).
|
||||
|
||||
## Setting Up a Webhook
|
||||
To create a webhook in Twenty, use the **API & Webhooks** settings in your Twenty app (Developer settings):
|
||||
|
||||
1. **Navigate to Settings:** In your Twenty application, go to **Settings** (ensure Advanced mode is enabled to see developer options).
|
||||
2. **Create a Webhook:** Under **Webhooks** click on **+ Create webhook**.
|
||||
3. **Enter URL:** Provide the endpoint URL on your server where you want Twenty to send webhook requests. This should be a publicly accessible URL that can handle POST requests.
|
||||
4. **Save:** Click **Save** to create the webhook. The new webhook will be active immediately.
|
||||
|
||||
You can create multiple webhooks if you need to send different events to different endpoints. Each webhook is essentially a subscription for all relevant events (at this time, Twenty sends all event types to the given URL; filtering specific event types may be configurable in the UI). If you ever need to remove a webhook, you can delete it from the same settings page (select the webhook and choose delete).
|
||||
|
||||
## Events and Payloads
|
||||
Once a webhook is set up, Twenty will send an HTTP POST request to your specified URL whenever a trigger event occurs in your CRM data. Common events that trigger webhooks include:
|
||||
|
||||
* **Record Created:** e.g. a new person is added (`person.created`), a new company is created (`company.created`), a note is created (`note.created`), etc.
|
||||
* **Record Updated:** e.g. an existing person's information is updated (`person.updated`), a company record is edited (`company.updated`), etc.
|
||||
* **Record Deleted:** e.g. a person or company is deleted (`person.deleted`, `company.deleted`).
|
||||
* **Other Events:** If applicable, other object events or custom triggers (for instance, if tasks or other objects are updated, similar event types would be used like `task.created`, `note.updated`, etc.).
|
||||
|
||||
The webhook POST request contains a JSON payload in its body. The payload will generally include at least two things: the type of event, and the data related to that event (often the record that was created/updated). For example, a webhook for a newly created person might send a payload like:
|
||||
|
||||
```
|
||||
{
|
||||
"event": "person.created",
|
||||
"data": {
|
||||
"id": "abc12345",
|
||||
"firstName": "Alice",
|
||||
"lastName": "Doe",
|
||||
"email": "alice@example.com",
|
||||
"createdAt": "2025-02-10T15:30:45Z",
|
||||
"createdBy": "user_123"
|
||||
},
|
||||
"timestamp": "2025-02-10T15:30:50Z"
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
* `"event"` specifies what happened (`person.created`).
|
||||
* `"data"` contains the new record's details (the same information you would get if you requested that person via the API).
|
||||
* `"timestamp"` is when the event occurred (in UTC).
|
||||
|
||||
Your endpoint should be prepared to receive such JSON data via POST. Typically, you'll parse the JSON, look at the `"event"` type to understand what happened, and then use the `"data"` accordingly (e.g., create a new contact in your system, or update an existing one).
|
||||
|
||||
**Note:** It's important to respond with a **2xx HTTP status** from your webhook endpoint to acknowledge successful receipt. If the Twenty webhook sender does not get a 2xx response, it may consider the delivery failed. (In the future, retry logic might attempt to resend failed webhooks, so always strive to return a 200 OK as quickly as possible after processing the data.)
|
||||
|
||||
## Webhook Validation
|
||||
|
||||
To ensure the security of your webhook endpoints, Twenty includes a signature in the `X-Twenty-Webhook-Signature` header.
|
||||
|
||||
This signature is an HMAC SHA256 hash of the request payload, computed using your webhook secret.
|
||||
|
||||
To validate the signature, you'll need to:
|
||||
1. Concatenate the timestamp (from `X-Twenty-Webhook-Timestamp` header), a colon, and the JSON string of the payload
|
||||
2. Compute the HMAC SHA256 hash using your webhook secret as the key ()
|
||||
3. Compare the resulting hex digest with the signature header
|
||||
|
||||
Here's an example in Node.js:
|
||||
|
||||
```javascript
|
||||
const crypto = require("crypto");
|
||||
const timestamp = "1735066639761";
|
||||
const payload = JSON.stringify({...});
|
||||
const secret = "your-secret";
|
||||
const stringToSign = `${timestamp}:${JSON.stringify(payload)}`;
|
||||
const signature = crypto.createHmac("sha256", secret)
|
||||
.update(stringToSign)
|
||||
.digest("hex");
|
||||
```
|
||||
@ -9,18 +9,7 @@ export const DOCS_INDEX = {
|
||||
{ fileName: 'cloud-providers' },
|
||||
{ fileName: 'troubleshooting' },
|
||||
],
|
||||
},
|
||||
Extending: {
|
||||
'Rest APIs': [
|
||||
{ fileName: 'rest-apis' },
|
||||
{ fileName: 'core-api-rest' },
|
||||
{ fileName: 'metadata-api-rest' },
|
||||
],
|
||||
'GraphQL APIs': [
|
||||
{ fileName: 'graphql-apis' },
|
||||
{ fileName: 'core-api-graphql' },
|
||||
{ fileName: 'metadata-api-graphql' },
|
||||
],
|
||||
'API and Webhooks': [{ fileName: 'api' }, { fileName: 'webhooks' }],
|
||||
},
|
||||
Contributing: {
|
||||
'Bugs and Requests': [{ fileName: 'bug-and-requests' }],
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
---
|
||||
title: Core API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/import-export-data/cloud.png
|
||||
---
|
||||
@ -1,5 +0,0 @@
|
||||
---
|
||||
title: Metadata API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
---
|
||||
@ -1,5 +0,0 @@
|
||||
---
|
||||
title: Core API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/import-export-data/cloud.png
|
||||
---
|
||||
@ -1,5 +0,0 @@
|
||||
---
|
||||
title: Metadata API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
---
|
||||
Reference in New Issue
Block a user