diff --git a/apps/docs/docs/api.mdx b/apps/docs/docs/api.mdx
index 85bff8e012..6d3da992cc 100644
--- a/apps/docs/docs/api.mdx
+++ b/apps/docs/docs/api.mdx
@@ -45,54 +45,42 @@ curl -i -X GET https://typebot.io/api/typebots \
}
```
-### GET /api/typebots/typebotId/webhookSteps
+### GET /api/typebots/typebotId/webhookBlocks
-List webhook steps in a typebot. These are the steps you can, later on, register your Webhook URL:
+List webhook blocks in a typebot. These are the blocks you can register a Webhook URL:
```bash title="Try it yourself"
-curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/webhookSteps \
+curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/webhookBlocks \
-H 'Authorization: Bearer ${TOKEN}'
```
```json title="Response 200 OK"
{
- "steps": [
+ "blocks": [
{
"blockId": "blockId",
- "id": "stepId",
- "name": "Block #2 > stepId"
+ "name": "Group #2 > blockId",
+ "url": "https://my-webhook.com/webhook"
}
]
}
```
-### GET /api/typebots/typebotId/blocks/blockId/steps/stepId/sampleResult
+### GET /api/typebots/typebotId/blocks/blockId/sampleResult
Get a sample of what the webhook body will look like when triggered
```bash title="Try it yourself"
-curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/steps/$STEP_ID/sampleResult \
+curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/sampleResult \
-H 'Authorization: Bearer ${TOKEN}'
```
-```json title="Response 200 OK"
-{
- "steps": [
- {
- "blockId": "blockId",
- "id": "stepId",
- "name": "Block #2 > stepId"
- }
- ]
-}
-```
-
-### POST /api/typebots/typebotId/blocks/blockId/steps/stepId/subscribeWebhook
+### POST /api/typebots/typebotId/blocks/blockId/subscribeWebhook
-Subscribe the step to a specified webhook URL
+Subscribe the block to a specified webhook URL
```bash title="Try it yourself"
-curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/webhookSteps \
+curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/subscribeWebhook \
-H 'Authorization: Bearer ${TOKEN}'\
--header 'Content-Type: application/json' \
--data '{"url": "https://domain.com/my-webhook"}'
@@ -114,12 +102,12 @@ The url you want to subscribe to.
-### POST /api/typebots/typebotId/blocks/blockId/steps/stepId/unsubscribeWebhook
+### POST /api/typebots/typebotId/blocks/blockId/unsubscribeWebhook
-Unsubscribe the current webhook on step
+Unsubscribe the current webhook on block
```bash title="Try it yourself"
-curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/webhookSteps \
+curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/unsubscribeWebhook \
-H 'Authorization: Bearer ${TOKEN}'\
```
diff --git a/apps/docs/src/js/api-helpers.js b/apps/docs/src/js/api-helpers.js
index 298ddc3c64..6684e51366 100644
--- a/apps/docs/src/js/api-helpers.js
+++ b/apps/docs/src/js/api-helpers.js
@@ -1,5 +1,6 @@
// Taken from https://github.com/plausible/docs/blob/master/src/js/api-helpers.js 💙
import React from 'react'
+import { useColorMode } from '@docusaurus/theme-common'
export const Required = () => (
(
)
export const Tag = ({ children, color }) => {
- let backgroundColor = '#CBD5E0'
+ const { isDarkTheme } = useColorMode()
+ let backgroundColor = isDarkTheme ? '#2d60b4' : '#CBD5E0'
switch (color) {
case 'green':
backgroundColor = '#68D391'
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts
new file mode 100644
index 0000000000..0c05edd77d
--- /dev/null
+++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts
@@ -0,0 +1,204 @@
+import prisma from 'libs/prisma'
+import {
+ defaultWebhookAttributes,
+ HttpMethod,
+ KeyValue,
+ PublicTypebot,
+ ResultValues,
+ Typebot,
+ Variable,
+ Webhook,
+ WebhookOptions,
+ WebhookResponse,
+ WebhookStep,
+} from 'models'
+import { parseVariables } from 'bot-engine'
+import { NextApiRequest, NextApiResponse } from 'next'
+import got, { Method, Headers, HTTPError } from 'got'
+import {
+ byId,
+ initMiddleware,
+ methodNotAllowed,
+ notFound,
+ parseAnswers,
+} from 'utils'
+import { stringify } from 'qs'
+import { withSentry } from '@sentry/nextjs'
+import Cors from 'cors'
+import { parseSampleResult } from 'services/api/webhooks'
+
+const cors = initMiddleware(Cors())
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ await cors(req, res)
+ if (req.method === 'POST') {
+ const typebotId = req.query.typebotId.toString()
+ const stepId = req.query.blockId.toString()
+ const { resultValues, variables } = (
+ typeof req.body === 'string' ? JSON.parse(req.body) : req.body
+ ) as {
+ resultValues: ResultValues | undefined
+ variables: Variable[]
+ }
+ const typebot = (await prisma.typebot.findUnique({
+ where: { id: typebotId },
+ include: { webhooks: true },
+ })) as unknown as (Typebot & { webhooks: Webhook[] }) | null
+ if (!typebot) return notFound(res)
+ const step = typebot.blocks
+ .flatMap((b) => b.steps)
+ .find(byId(stepId)) as WebhookStep
+ const webhook = typebot.webhooks.find(byId(step.webhookId))
+ if (!webhook)
+ return res
+ .status(404)
+ .send({ statusCode: 404, data: { message: `Couldn't find webhook` } })
+ const preparedWebhook = prepareWebhookAttributes(webhook, step.options)
+ const result = await executeWebhook(typebot)(
+ preparedWebhook,
+ variables,
+ step.blockId,
+ resultValues
+ )
+ return res.status(200).send(result)
+ }
+ return methodNotAllowed(res)
+}
+
+const prepareWebhookAttributes = (
+ webhook: Webhook,
+ options: WebhookOptions
+): Webhook => {
+ if (options.isAdvancedConfig === false) {
+ return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes }
+ } else if (options.isCustomBody === false) {
+ return { ...webhook, body: '{{state}}' }
+ }
+ return webhook
+}
+
+const executeWebhook =
+ (typebot: Typebot) =>
+ async (
+ webhook: Webhook,
+ variables: Variable[],
+ blockId: string,
+ resultValues?: ResultValues
+ ): Promise => {
+ if (!webhook.url || !webhook.method)
+ return {
+ statusCode: 400,
+ data: { message: `Webhook doesn't have url or method` },
+ }
+ const basicAuth: { username?: string; password?: string } = {}
+ const basicAuthHeaderIdx = webhook.headers.findIndex(
+ (h) =>
+ h.key?.toLowerCase() === 'authorization' &&
+ h.value?.toLowerCase()?.includes('basic')
+ )
+ const isUsernamePasswordBasicAuth =
+ basicAuthHeaderIdx !== -1 &&
+ webhook.headers[basicAuthHeaderIdx].value?.includes(':')
+ if (isUsernamePasswordBasicAuth) {
+ const [username, password] =
+ webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? []
+ basicAuth.username = username
+ basicAuth.password = password
+ webhook.headers.splice(basicAuthHeaderIdx, 1)
+ }
+ const headers = convertKeyValueTableToObject(webhook.headers, variables) as
+ | Headers
+ | undefined
+ const queryParams = stringify(
+ convertKeyValueTableToObject(webhook.queryParams, variables)
+ )
+ const contentType = headers ? headers['Content-Type'] : undefined
+ const body =
+ webhook.method !== HttpMethod.GET
+ ? getBodyContent(typebot)({
+ body: webhook.body,
+ resultValues,
+ blockId,
+ })
+ : undefined
+ try {
+ const response = await got(
+ parseVariables(variables)(
+ webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
+ ),
+ {
+ method: webhook.method as Method,
+ headers,
+ ...basicAuth,
+ json:
+ contentType !== 'x-www-form-urlencoded' && body
+ ? JSON.parse(parseVariables(variables)(body))
+ : undefined,
+ form:
+ contentType === 'x-www-form-urlencoded' && body
+ ? JSON.parse(parseVariables(variables)(body))
+ : undefined,
+ }
+ )
+ return {
+ statusCode: response.statusCode,
+ data: parseBody(response.body),
+ }
+ } catch (error) {
+ if (error instanceof HTTPError) {
+ return {
+ statusCode: error.response.statusCode,
+ data: parseBody(error.response.body as string),
+ }
+ }
+ console.error(error)
+ return {
+ statusCode: 500,
+ data: { message: `Error from Typebot server: ${error}` },
+ }
+ }
+ }
+
+const getBodyContent =
+ (typebot: Pick) =>
+ ({
+ body,
+ resultValues,
+ blockId,
+ }: {
+ body?: string | null
+ resultValues?: ResultValues
+ blockId: string
+ }): string | undefined => {
+ if (!body) return
+ return body === '{{state}}'
+ ? JSON.stringify(
+ resultValues
+ ? parseAnswers(typebot)(resultValues)
+ : parseSampleResult(typebot)(blockId)
+ )
+ : body
+ }
+
+const parseBody = (body: string) => {
+ try {
+ return JSON.parse(body)
+ } catch (err) {
+ return body
+ }
+}
+
+const convertKeyValueTableToObject = (
+ keyValues: KeyValue[] | undefined,
+ variables: Variable[]
+) => {
+ if (!keyValues) return
+ return keyValues.reduce((object, item) => {
+ if (!item.key) return {}
+ return {
+ ...object,
+ [item.key]: parseVariables(variables)(item.value ?? ''),
+ }
+ }, {})
+}
+
+export default withSentry(handler)
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts
new file mode 100644
index 0000000000..85f400c858
--- /dev/null
+++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts
@@ -0,0 +1,27 @@
+import prisma from 'libs/prisma'
+import { Typebot } from 'models'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { authenticateUser } from 'services/api/utils'
+import { parseSampleResult } from 'services/api/webhooks'
+import { methodNotAllowed } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === 'GET') {
+ const user = await authenticateUser(req)
+ if (!user) return res.status(401).json({ message: 'Not authenticated' })
+ const typebotId = req.query.typebotId.toString()
+ const stepId = req.query.blockId.toString()
+ const typebot = (await prisma.typebot.findUnique({
+ where: { id_ownerId: { id: typebotId, ownerId: user.id } },
+ })) as unknown as Typebot | undefined
+ if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
+ const step = typebot.blocks
+ .flatMap((b) => b.steps)
+ .find((s) => s.id === stepId)
+ if (!step) return res.status(404).send({ message: 'Block not found' })
+ return res.send(parseSampleResult(typebot)(step.blockId))
+ }
+ methodNotAllowed(res)
+}
+
+export default handler
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts
new file mode 100644
index 0000000000..fba8c2f345
--- /dev/null
+++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts
@@ -0,0 +1,42 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { Typebot, WebhookStep } from 'models'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { authenticateUser } from 'services/api/utils'
+import { byId, methodNotAllowed } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === 'POST') {
+ const user = await authenticateUser(req)
+ if (!user) return res.status(401).json({ message: 'Not authenticated' })
+ const body = req.body as Record
+ if (!('url' in body))
+ return res.status(403).send({ message: 'url is missing in body' })
+ const { url } = body
+ const typebotId = req.query.typebotId.toString()
+ const stepId = req.query.blockId.toString()
+ const typebot = (await prisma.typebot.findUnique({
+ where: { id_ownerId: { id: typebotId, ownerId: user.id } },
+ })) as unknown as Typebot | undefined
+ if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
+ try {
+ const { webhookId } = typebot.blocks
+ .flatMap((b) => b.steps)
+ .find(byId(stepId)) as WebhookStep
+ await prisma.webhook.upsert({
+ where: { id: webhookId },
+ update: { url, body: '{{state}}', method: 'POST' },
+ create: { url, body: '{{state}}', method: 'POST', typebotId },
+ })
+
+ return res.send({ message: 'success' })
+ } catch (err) {
+ return res
+ .status(400)
+ .send({ message: "blockId doesn't point to a Webhook step" })
+ }
+ }
+ return methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts
new file mode 100644
index 0000000000..f0bdd480ca
--- /dev/null
+++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts
@@ -0,0 +1,37 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { Typebot, WebhookStep } from 'models'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { authenticateUser } from 'services/api/utils'
+import { byId, methodNotAllowed } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === 'POST') {
+ const user = await authenticateUser(req)
+ if (!user) return res.status(401).json({ message: 'Not authenticated' })
+ const typebotId = req.query.typebotId.toString()
+ const stepId = req.query.blockId.toString()
+ const typebot = (await prisma.typebot.findUnique({
+ where: { id_ownerId: { id: typebotId, ownerId: user.id } },
+ })) as unknown as Typebot | undefined
+ if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
+ try {
+ const { webhookId } = typebot.blocks
+ .flatMap((b) => b.steps)
+ .find(byId(stepId)) as WebhookStep
+ await prisma.webhook.update({
+ where: { id: webhookId },
+ data: { url: null },
+ })
+
+ return res.send({ message: 'success' })
+ } catch (err) {
+ return res
+ .status(400)
+ .send({ message: "blockId doesn't point to a Webhook step" })
+ }
+ }
+ return methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts
new file mode 100644
index 0000000000..804e397837
--- /dev/null
+++ b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts
@@ -0,0 +1,37 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { Block, WebhookStep } from 'models'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { authenticateUser } from 'services/api/utils'
+import { byId, isWebhookStep, methodNotAllowed } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === 'GET') {
+ const user = await authenticateUser(req)
+ if (!user) return res.status(401).json({ message: 'Not authenticated' })
+ const typebotId = req.query.typebotId.toString()
+ const typebot = await prisma.typebot.findUnique({
+ where: { id_ownerId: { id: typebotId, ownerId: user.id } },
+ select: { blocks: true, webhooks: true },
+ })
+ const emptyWebhookSteps = (typebot?.blocks as Block[]).reduce<
+ { blockId: string; name: string; url: string | undefined }[]
+ >((emptyWebhookSteps, block) => {
+ const steps = block.steps.filter((step) =>
+ isWebhookStep(step)
+ ) as WebhookStep[]
+ return [
+ ...emptyWebhookSteps,
+ ...steps.map((s) => ({
+ blockId: s.id,
+ name: `${block.title} > ${s.id}`,
+ url: typebot?.webhooks.find(byId(s.webhookId))?.url ?? undefined,
+ })),
+ ]
+ }, [])
+ return res.send({ blocks: emptyWebhookSteps })
+ }
+ return methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/viewer/playwright/tests/api.spec.ts b/apps/viewer/playwright/tests/api.spec.ts
index 76e781f3dd..181818880c 100644
--- a/apps/viewer/playwright/tests/api.spec.ts
+++ b/apps/viewer/playwright/tests/api.spec.ts
@@ -36,19 +36,18 @@ test('can list typebots', async ({ request }) => {
test('can get webhook steps', async ({ request }) => {
expect(
- (await request.get(`/api/typebots/${typebotId}/webhookSteps`)).status()
+ (await request.get(`/api/typebots/${typebotId}/webhookBlocks`)).status()
).toBe(401)
const response = await request.get(
- `/api/typebots/${typebotId}/webhookSteps`,
+ `/api/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: 'Bearer userToken' },
}
)
- const { steps } = await response.json()
- expect(steps).toHaveLength(1)
- expect(steps[0]).toEqual({
- id: 'webhookStep',
- blockId: 'webhookBlock',
+ const { blocks } = await response.json()
+ expect(blocks).toHaveLength(1)
+ expect(blocks[0]).toEqual({
+ blockId: 'webhookStep',
name: 'Webhook > webhookStep',
})
})
@@ -57,13 +56,13 @@ test('can subscribe webhook', async ({ request }) => {
expect(
(
await request.post(
- `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`,
+ `/api/typebots/${typebotId}/blocks/webhookStep/subscribeWebhook`,
{ data: { url: 'https://test.com' } }
)
).status()
).toBe(401)
const response = await request.post(
- `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`,
+ `/api/typebots/${typebotId}/blocks/webhookStep/subscribeWebhook`,
{
headers: {
Authorization: 'Bearer userToken',
@@ -81,12 +80,12 @@ test('can unsubscribe webhook', async ({ request }) => {
expect(
(
await request.post(
- `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`
+ `/api/typebots/${typebotId}/blocks/webhookStep/unsubscribeWebhook`
)
).status()
).toBe(401)
const response = await request.post(
- `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`,
+ `/api/typebots/${typebotId}/blocks/webhookStep/unsubscribeWebhook`,
{
headers: { Authorization: 'Bearer userToken' },
}
@@ -101,12 +100,12 @@ test('can get a sample result', async ({ request }) => {
expect(
(
await request.get(
- `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/sampleResult`
+ `/api/typebots/${typebotId}/blocks/webhookStep/sampleResult`
)
).status()
).toBe(401)
const response = await request.get(
- `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/sampleResult`,
+ `/api/typebots/${typebotId}/blocks/webhookStep/sampleResult`,
{
headers: { Authorization: 'Bearer userToken' },
}