Dialogflow Webhook with Fastify and PostgreSQL

Build a webhook API with Fastify, AJV validation, and raw PostgreSQL queries.

Introduction

30 Second Summary

When you ask a chatbot to "add milk to my grocery list," something has to receive that message, figure out what action to take, and store the item somewhere. That something is a webhook.

In this project, you will build a webhook API from scratch using TypeScript, Fastify, and PostgreSQL. You'll validate payloads with AJV, parse only the fields you need, route actions, and evolve the implementation from in-memory state to real database persistence with raw SQL.

What You'll Build

A Fastify TypeScript REST API that receives Dialogflow-shaped JSON payloads, validates them at runtime with AJV, routes on action strings, and manages a grocery list through in-memory state and then PostgreSQL persistence via raw SQL queries.

By the end of this project, you'll have:

  • A Fastify TypeScript project with a single POST /webhook route that parses nested Dialogflow JSON and returns correctly structured fulfillment responses.
  • Runtime payload validation using Fastify's built-in AJV engine, rejecting malformed requests before your handler runs.
  • Raw SQL persistence using the pg driver with parameterized queries, a connection pool, and a manually created schema.
  • Secret Mission: Add a filtered GET endpoint that queries the database directly with optional name filtering via SQL ILIKE.

Are there any prerequisites?

This project assumes familiarity with TypeScript syntax and concepts. You should also have PostgreSQL installed and running locally, along with Node.js and npm.

Set Up the TypeScript Fastify Project

Every webhook starts with a server that can receive HTTP requests. Before you can parse Dialogflow payloads or manage data, you need a running Fastify instance that accepts connections.

In this step, you will scaffold a TypeScript project from scratch, install your dependencies, and spin up a minimal server with a health-check route. By the end, a single curl command will prove your server is alive.

In this step, get ready to:
  • Create a Node.js project with TypeScript and ESM support.
  • Install Fastify and development dependencies.
  • Start a dev server and verify it responds to requests.
Create the project directory and install dependencies

You need a dedicated folder on your Desktop where all project files will live. Starting from a known location keeps things easy to find later.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a new Node.js project by running these commands:
mkdir dialogflow-webhook
cd dialogflow-webhook
npm init -y

What does this do?

mkdir creates a new folder called dialogflow-webhook. cd moves your terminal into that folder. npm init -y generates a default package.json file that tracks your project's name, version, and dependencies.

  • Confirm the folder and file exist by running:
ls

You should see package.json listed in the output.

Now open this folder in VS Code so you can edit the project files.

  • Switch back to VS Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the dialogflow-webhook folder, and click Open.

Node.js needs to know your project uses modern ESM imports (the import/export syntax) instead of the older CommonJS require() style. You signal this with a single field in package.json.

  • In the VS Code Explorer sidebar, click package.json to open it.
  • Find this line:
"version": "1.0.0",
  • Add a new line directly below it with "type": "module", so it looks like this:
"version": "1.0.0",
"type": "module",
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).

Next, install the packages your project depends on. Fastify is the web framework. TypeScript, tsx, and @types/node are development tools that let you write and run TypeScript code.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on both macOS and Windows).
  • Install Fastify as a production dependency by running:
npm install fastify
  • Install the development dependencies by running:
npm install -D typescript tsx @types/node

What are these packages?

  • fastify is a fast, low-overhead web framework for Node.js. It handles HTTP routing and request/response lifecycle.
  • typescript adds static type checking to your JavaScript code, catching errors before runtime.
  • tsx compiles and runs TypeScript files directly without a separate build step. The --watch flag auto-restarts your server when files change.
  • @types/node provides TypeScript type definitions for Node.js built-in modules.
  • Verify your dependencies installed correctly by running:
ls node_modules

You should see a long list of folders. This confirms npm downloaded your packages successfully.

Seeing permission errors or EACCES?

Make sure your terminal is inside the dialogflow-webhook folder. Run pwd to check your current location. If you are somewhere else, run cd ~/Desktop/dialogflow-webhook and try the install again.

Help me fix my npm install error

TypeScript needs a configuration file to know how to compile your code. This file tells it which module system to use and which JavaScript version to target.

  • Create a new file called tsconfig.json in the root of your dialogflow-webhook folder by clicking the new file icon in the VS Code Explorer sidebar.
  • Paste the following configuration into tsconfig.json:
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2023",
    "strict": true,
    "esModuleInterop": true,
    "ignoreDeprecations": "6.0"
  }
}

What do these settings do?

  • "module": "ESNext" tells TypeScript to use modern ES module import/export syntax.
  • "moduleResolution": "bundler" resolves imports the same way modern bundlers and runners like tsx do.
  • "target": "ES2023" sets the JavaScript output level, keeping modern features available.
  • "strict": true enables all strict type-checking options to catch more bugs at compile time.
  • Save tsconfig.json.

✔️ Awesome, I've got everything!

Great. Make sure you have saved tsconfig.json before moving on.

ⓧ I'd like to double check the full code

Here is the complete tsconfig.json for comparison:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2023",
    "strict": true,
    "esModuleInterop": true,
    "ignoreDeprecations": "6.0"
  }
}
Create the Fastify server file

With the project scaffolded, you can write the actual server. This file creates a Fastify instance, defines a route, and starts listening for HTTP requests.

  • Create a folder called src inside your dialogflow-webhook folder by right-clicking in the Explorer sidebar and selecting New Folder.
  • Inside the src folder, create a new file called server.ts.
  • Paste the following code into src/server.ts:
import Fastify from "fastify";

// Create a Fastify instance with logging enabled
const server = Fastify({ logger: true });

// Health-check route to confirm the server is running
server.get("/", async () => {
  return { status: "ok" };
});

// Start the server on port 3000
const start = async () => {
  try {
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();

What does this code do?

  • Fastify({ logger: true }) creates a server instance with built-in request logging powered by Pino. Every incoming request gets logged automatically.
  • server.get("/", ...) registers a GET route at the root path. It returns a simple JSON object as a health check.
  • server.listen({ port: 3000 }) starts accepting connections on port 3000. If something goes wrong, the error is logged and the process exits.
  • Save src/server.ts.

You will not see any visible change yet. The server file is ready but not running.

✔️ Awesome, I've got everything!

Great. Make sure src/server.ts is saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete src/server.ts for comparison:

import Fastify from "fastify";

const server = Fastify({ logger: true });

server.get("/", async () => {
  return { status: "ok" };
});

const start = async () => {
  try {
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();
Add the dev script and verify the server

You need a convenient way to start your server during development. An npm script wraps the long tsx command into a short npm run dev you can type each time.

  • Open package.json in VS Code.
  • Find the "scripts" section. It currently looks like this:
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},
  • Replace the entire "scripts" block with this:
"scripts": {
  "dev": "npx tsx --watch src/server.ts"
},

What does this script do?

npx tsx --watch src/server.ts compiles and runs your TypeScript server file directly. The --watch flag monitors your files for changes and automatically restarts the server when you save.

  • Save package.json.
  • In your VS Code terminal, start the dev server by running:
npm run dev

You should see log output that includes Server listening at http://127.0.0.1:3000. This means Fastify is running and ready to accept requests.

Seeing EADDRINUSE or port already in use?

Another process is already using port 3000. Close any other dev servers running on that port, or find and kill the process. On macOS/Linux, run lsof -i :3000 to see what is using it.

Help me fix my port conflict

Now confirm the server responds correctly with a test request.

  • Open a second terminal in VS Code by clicking the + icon in the terminal panel.
  • Send a GET request to your server by running:
curl http://localhost:3000/

You should see this response:

{"status":"ok"}

Your server is alive and responding. The first terminal (where npm run dev is running) also shows a log entry for the incoming request.

Getting connection refused?

Make sure npm run dev is still running in your first terminal. If the server crashed, check the error output there. A common cause is a typo in src/server.ts. Compare your file against the double-check tab below.

Help me debug my Fastify server

✔️ Awesome, I've got everything!

Great. Your server is confirmed working. Keep npm run dev running in the first terminal for the rest of the project.

ⓧ I'd like to double check the full code

Here is the complete package.json (the key fields to check):

{
  "name": "dialogflow-webhook",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "npx tsx --watch src/server.ts"
  },
  "dependencies": {
    "fastify": "5.8.5"
  },
  "devDependencies": {
    "@types/node": "26.0.0",
    "tsx": "4.22.4",
    "typescript": "6.0.3"
  }
}

Your version numbers may have a ^ prefix (e.g. ^5.8.5). That is normal and does not affect functionality.

And here is src/server.ts:

import Fastify from "fastify";

const server = Fastify({ logger: true });

server.get("/", async () => {
  return { status: "ok" };
});

const start = async () => {
  try {
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();

Leave the dev server running

For the rest of this project, keep npm run dev running in the first terminal. The --watch flag auto-restarts the server every time you save a file. Use the second terminal for curl commands.

Your Fastify server is live and confirmed working. Next up, you will teach it to understand Dialogflow webhook payloads and return properly structured fulfillment responses.

Parse the Dialogflow Payload and Return Fulfillment JSON

Your Fastify server is live and responding to basic GET requests. Now it's time to make it understand the complex JSON that a webhook receives from a chatbot platform.

Dialogflow sends deeply nested JSON payloads to fulfillment endpoints. In this step, you'll define TypeScript types that describe that payload shape, then build a POST route that parses the incoming data and returns a properly structured response.

In this step, get ready to:
  • Define TypeScript interfaces for the Dialogflow request and response shapes.
  • Create a POST /webhook route that extracts action and parameters from nested JSON.
  • Test the route with a curl command and verify console logging works.
Define the TypeScript interfaces

The webhook receives a specific JSON structure from Dialogflow. TypeScript interfaces let you describe that structure so the compiler catches shape mismatches before your code runs.

  • In src/server.ts, find the line that says const server = Fastify({ logger: true });.
  • Copy the following code below that line:
// Describes the shape of a Dialogflow v1 webhook request
// Only result.action and result.parameters are required for our logic
interface DialogflowRequest {
  result: {
    action: string;
    parameters: Record<string, string>;
    queryText?: string;
    languageCode?: string;
    allRequiredParamsPresent?: boolean;
    intent?: {
      name: string;
      displayName: string;
    };
    queryResult?: number;
    fulfillmentMessages?: Array<{ text: { text: string[] } }>;
  };
  responseId?: string;
  originalDetectIntentRequest?: {
    source: string;
    payload: Record<string, unknown>;
  };
  session?: string;
}

// Describes the shape of the response we send back
interface DialogflowResponse {
  speech: string;
  displayText: string;
}

What do these interfaces do?

DialogflowRequest maps the nested JSON that Dialogflow sends. The key fields are result.action (which action the chatbot matched) and result.parameters (the values the user provided, like an item name). Everything else is marked optional with ? because your handler doesn't depend on it.

This "parse only what you need" pattern keeps your types honest. If Dialogflow ever stops sending a field you don't use, your code won't break.

DialogflowResponse defines the fulfillment format Dialogflow v1 expects back: a speech field for voice output and a displayText field for screen display.

  • Save src/server.ts.
  • Your dev server restarts automatically. You won't see a visible change yet because no route uses the interfaces.

Seeing a TypeScript error in your terminal?

Check that the interface code is placed between the const server line and the server.get route. Interfaces must be declared before they're used.

Also verify every nested brace and semicolon matches the code above exactly.

Help me fix a TypeScript interface error in my Fastify server

Add the POST webhook route

With the types defined, you can now build the route that receives and parses the Dialogflow payload. This route will extract the action and parameters, log both the incoming request and outgoing response, then return a hardcoded fulfillment response.

  • In src/server.ts, find the closing }); of the GET / route.
  • Copy the following code below it:
server.post<{ Body: DialogflowRequest }>("/webhook", {
  schema: {
    body: {
      type: "object",
      required: ["result"],
      properties: {
        result: {
          type: "object",
          required: ["action", "parameters"],
          properties: {
            action: { type: "string" },
            parameters: { type: "object" },
          },
        },
      },
    },
  },
}, async (request, reply) => {
  // Extract the action and parameters from the nested payload
  const body = request.body;
  const action = body.result.action;
  const parameters = body.result.parameters;

  // Log the full incoming request for debugging
  server.log.info({ requestBody: body }, "Incoming webhook request");

  // Build a hardcoded fulfillment response
  const response: DialogflowResponse = {
    speech: `Received action: ${action}`,
    displayText: `Received action: ${action}`,
  };

  // Log the outgoing response before sending it back
  server.log.info({ response }, "Outgoing webhook response");
  return response;
});

What does this route do?

Two layers of safety are working together here. The schema object is a JSON Schema that Fastify passes to its built-in AJV validator. Any request missing result.action or result.parameters gets a 400 response automatically, before your handler code runs.

The generic <{ Body: DialogflowRequest }> gives you compile-time autocomplete and type-checking when accessing nested fields like body.result.action. Together, you get both runtime rejection of bad payloads and compile-time safety.

The two server.log.info calls use Fastify's built-in Pino logger. They print structured JSON to your terminal so you can inspect exactly what arrives and leaves.

  • Save src/server.ts.
  • The dev server restarts. Now test the route by sending a mock Dialogflow payload with curl.
  • Open a second terminal window (keep the dev server running in the first one). Send a test request by running this command:
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "responseId": "test-id",
    "result": {
      "action": "item.add",
      "parameters": { "item": "apple" },
      "queryText": "add apple to my list",
      "languageCode": "en",
      "allRequiredParamsPresent": true,
      "intent": { "name": "projects/test/agent/intents/abc", "displayName": "AddItem" },
      "queryResult": 1,
      "fulfillmentMessages": [{ "text": { "text": [""] } }]
    },
    "originalDetectIntentRequest": { "source": "test", "payload": {} },
    "session": "projects/test/agent/sessions/123"
  }'

What should I see?

In the terminal where you ran curl, you should see this JSON response:

{"speech":"Received action: item.add","displayText":"Received action: item.add"}

In the terminal running your dev server, you should see structured log lines containing Incoming webhook request (with the full request body) and Outgoing webhook response (with the response object).

🙋‍♀️ Getting an empty response or connection refused?

Make sure your dev server is still running in the other terminal. You should see Fastify's startup log line showing Server listening at http://127.0.0.1:3000.

If you see a parse error, double-check that your curl command includes -H "Content-Type: application/json". Without it, Fastify won't parse the body as JSON.

Help me debug my curl request to the Fastify webhook route

✔️ Awesome, I've got everything!

Great. Double check you've saved src/server.ts and confirmed the curl response matches the expected output above.

ⓧ I'd like to double check the full code

Here is the complete src/server.ts at this point:

import Fastify from "fastify";

const server = Fastify({ logger: true });

interface DialogflowRequest {
  result: {
    action: string;
    parameters: Record<string, string>;
    queryText?: string;
    languageCode?: string;
    allRequiredParamsPresent?: boolean;
    intent?: {
      name: string;
      displayName: string;
    };
    queryResult?: number;
    fulfillmentMessages?: Array<{ text: { text: string[] } }>;
  };
  responseId?: string;
  originalDetectIntentRequest?: {
    source: string;
    payload: Record<string, unknown>;
  };
  session?: string;
}

interface DialogflowResponse {
  speech: string;
  displayText: string;
}

server.get("/", async () => {
  return { status: "ok" };
});

server.post<{ Body: DialogflowRequest }>("/webhook", {
  schema: {
    body: {
      type: "object",
      required: ["result"],
      properties: {
        result: {
          type: "object",
          required: ["action", "parameters"],
          properties: {
            action: { type: "string" },
            parameters: { type: "object" },
          },
        },
      },
    },
  },
}, async (request, reply) => {
  const body = request.body;
  const action = body.result.action;
  const parameters = body.result.parameters;

  server.log.info({ requestBody: body }, "Incoming webhook request");

  const response: DialogflowResponse = {
    speech: `Received action: ${action}`,
    displayText: `Received action: ${action}`,
  };

  server.log.info({ response }, "Outgoing webhook response");
  return response;
});

const start = async () => {
  try {
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();

Your server now parses nested Dialogflow JSON and returns a properly structured fulfillment response. Next up, you'll replace the hardcoded response with real action routing that manages a grocery list.

Add In-Memory Action Routing

Your webhook can parse Dialogflow payloads and return a response. But right now, it ignores the action entirely and returns the same hardcoded message every time.

A real fulfillment endpoint routes on the action field and manages state across requests. In this step, you will add an in-memory array to store grocery items and a switch statement that adds, removes, and lists items based on the incoming action.

In this step, get ready to:
  • Add a module-level items array to hold state between requests.
  • Implement a switch statement that routes on three actions: add, remove, and list.
  • Verify correct behavior with sequential curl commands.
Add the items array and action routing switch

The webhook needs two things: a place to store items across requests, and logic that decides what to do based on the incoming action. A module-level array persists for as long as the server process runs.

  • In src/server.ts, find this line near the top of the file:
const server = Fastify({ logger: true });
  • Add the items array directly below it, so the two lines look like this:
const server = Fastify({ logger: true });

const items: string[] = [];

Now you need to replace the hardcoded response logic inside the POST handler with a switch statement that routes on the action value.

  • Inside the POST /webhook handler, find these lines:
  const response: DialogflowResponse = {
    speech: `Received action: ${action}`,
    displayText: `Received action: ${action}`,
  };
  • Replace that entire block with the following switch statement:
  let response: DialogflowResponse;

  switch (action) {
    case "item.add": {
      // Extract the item name from the parameters object
      const itemName = parameters.item;
      items.push(itemName);
      response = {
        speech: `Added ${itemName} to your list`,
        displayText: `Added ${itemName} to your list`,
      };
      break;
    }
    case "item.remove": {
      // Find and remove the first occurrence of the item
      const itemName = parameters.item;
      const index = items.indexOf(itemName);
      if (index !== -1) {
        items.splice(index, 1);
        response = {
          speech: `Removed ${itemName} from your list`,
          displayText: `Removed ${itemName} from your list`,
        };
      } else {
        response = {
          speech: `${itemName} is not in your list`,
          displayText: `${itemName} is not in your list`,
        };
      }
      break;
    }
    case "item.list": {
      // Join all item names into a comma-separated string
      const names = items.join(", ");
      const text = names.length > 0 ? `Your items: ${names}` : "Your list is empty";
      response = {
        speech: text,
        displayText: text,
      };
      break;
    }
    default: {
      response = {
        speech: `Unknown action: ${action}`,
        displayText: `Unknown action: ${action}`,
      };
    }
  }

What does this code do?

The switch statement checks the value of action and runs the matching case block.

  • item.add pushes the item name onto the items array, building up the list over time.
  • item.remove finds the first occurrence with indexOf and removes it with splice. If the item is not found, it returns a helpful message instead.
  • item.list joins all stored items into a comma-separated string. If the array is empty, it returns a fallback message.
  • The default case catches any unrecognized action and tells the caller what went wrong.

Notice that response is declared with let instead of const because each case assigns a different value to it.

  • Save src/server.ts.
  • Check your terminal. The tsx --watch process should automatically restart and print a new Server listening log line.

Server not restarting?

  • Make sure you saved the file. Unsaved changes are invisible to the watcher.
  • If you see a TypeScript error, check that you changed const response to let response. The switch assigns response in each case, which requires let.
  • Confirm that const items: string[] = []; is outside the route handler, at the module level (between the server declaration and the interfaces).

Still stuck? Help me debug a TypeScript error in my Fastify webhook switch statement

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on to testing.

ⓧ I'd like to double check the full code

import Fastify from "fastify";

const server = Fastify({ logger: true });

const items: string[] = [];

interface DialogflowRequest {
  result: {
    action: string;
    parameters: Record<string, string>;
    queryText?: string;
    languageCode?: string;
    allRequiredParamsPresent?: boolean;
    intent?: {
      name: string;
      displayName: string;
    };
    queryResult?: number;
    fulfillmentMessages?: Array<{ text: { text: string[] } }>;
  };
  responseId?: string;
  originalDetectIntentRequest?: {
    source: string;
    payload: Record<string, unknown>;
  };
  session?: string;
}

interface DialogflowResponse {
  speech: string;
  displayText: string;
}

server.get("/", async () => {
  return { status: "ok" };
});

server.post<{ Body: DialogflowRequest }>("/webhook", {
  schema: {
    body: {
      type: "object",
      required: ["result"],
      properties: {
        result: {
          type: "object",
          required: ["action", "parameters"],
          properties: {
            action: { type: "string" },
            parameters: { type: "object" },
          },
        },
      },
    },
  },
}, async (request, reply) => {
  const body = request.body;
  const action = body.result.action;
  const parameters = body.result.parameters;

  server.log.info({ requestBody: body }, "Incoming webhook request");

  let response: DialogflowResponse;

  switch (action) {
    case "item.add": {
      const itemName = parameters.item;
      items.push(itemName);
      response = {
        speech: `Added ${itemName} to your list`,
        displayText: `Added ${itemName} to your list`,
      };
      break;
    }
    case "item.remove": {
      const itemName = parameters.item;
      const index = items.indexOf(itemName);
      if (index !== -1) {
        items.splice(index, 1);
        response = {
          speech: `Removed ${itemName} from your list`,
          displayText: `Removed ${itemName} from your list`,
        };
      } else {
        response = {
          speech: `${itemName} is not in your list`,
          displayText: `${itemName} is not in your list`,
        };
      }
      break;
    }
    case "item.list": {
      const names = items.join(", ");
      const text = names.length > 0 ? `Your items: ${names}` : "Your list is empty";
      response = {
        speech: text,
        displayText: text,
      };
      break;
    }
    default: {
      response = {
        speech: `Unknown action: ${action}`,
        displayText: `Unknown action: ${action}`,
      };
    }
  }

  server.log.info({ response }, "Outgoing webhook response");
  return response;
});

const start = async () => {
  try {
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();
Test all three actions with curl

Your action routing is in place. Time to prove it works by running a sequence of curl commands that add items, list them, remove one, and list again.

  • Open a second terminal (keep the dev server running in the first one).
  • Add "apple" to your list by running this command:
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"responseId":"test-1","result":{"action":"item.add","parameters":{"item":"apple"},"queryText":"add apple","languageCode":"en","allRequiredParamsPresent":true,"intent":{"name":"test","displayName":"AddItem"},"queryResult":1,"fulfillmentMessages":[]},"originalDetectIntentRequest":{"source":"test","payload":{}},"session":"test"}'

You should see: {"speech":"Added apple to your list","displayText":"Added apple to your list"}

  • Add "banana" by running:
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"responseId":"test-2","result":{"action":"item.add","parameters":{"item":"banana"},"queryText":"add banana","languageCode":"en","allRequiredParamsPresent":true,"intent":{"name":"test","displayName":"AddItem"},"queryResult":1,"fulfillmentMessages":[]},"originalDetectIntentRequest":{"source":"test","payload":{}},"session":"test"}'

You should see: {"speech":"Added banana to your list","displayText":"Added banana to your list"}

  • List all items by running:
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"responseId":"test-3","result":{"action":"item.list","parameters":{},"queryText":"list items","languageCode":"en","allRequiredParamsPresent":true,"intent":{"name":"test","displayName":"ListItems"},"queryResult":1,"fulfillmentMessages":[]},"originalDetectIntentRequest":{"source":"test","payload":{}},"session":"test"}'

You should see: {"speech":"Your items: apple, banana","displayText":"Your items: apple, banana"}

  • Remove "apple" by running:
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"responseId":"test-4","result":{"action":"item.remove","parameters":{"item":"apple"},"queryText":"remove apple","languageCode":"en","allRequiredParamsPresent":true,"intent":{"name":"test","displayName":"RemoveItem"},"queryResult":1,"fulfillmentMessages":[]},"originalDetectIntentRequest":{"source":"test","payload":{}},"session":"test"}'

You should see: {"speech":"Removed apple from your list","displayText":"Removed apple from your list"}

  • List again to confirm only banana remains:
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"responseId":"test-5","result":{"action":"item.list","parameters":{},"queryText":"list items","languageCode":"en","allRequiredParamsPresent":true,"intent":{"name":"test","displayName":"ListItems"},"queryResult":1,"fulfillmentMessages":[]},"originalDetectIntentRequest":{"source":"test","payload":{}},"session":"test"}'

You should see: {"speech":"Your items: banana","displayText":"Your items: banana"}

Why does the state persist across curl calls?

The items array lives at the module level, outside any function. It stays in memory for the entire lifetime of the server process. Each incoming request mutates the same array.

This means restarting the server (or saving a file change that triggers tsx --watch) resets the list to empty. That limitation is exactly why you will migrate to a database in the next step.

🙋‍♀️ Seeing an empty list or wrong items?

  • If a file save triggered a server restart between your curl calls, the items array was wiped. Run the full sequence again without saving any files in between.
  • Make sure each curl payload uses the correct result.action value. A typo like item_add instead of item.add will hit the default case.
  • Confirm the parameters object uses the key item (not name or items). The code reads parameters.item specifically.

Still stuck? Help me debug why my curl tests are returning unexpected results from the webhook switch statement

Your webhook now manages a grocery list entirely in memory. It routes on three distinct actions and keeps state across requests. Next up, you will replace this temporary array with a real PostgreSQL database using raw SQL queries, so your data survives server restarts.

Migrate to PostgreSQL with Prisma

Migrate to PostgreSQL with Raw SQL

Your webhook handles all three actions correctly, but the data lives in memory. Every time you restart the server, the list resets to empty.

In this step, you'll connect your API to a real PostgreSQL database using the pg driver directly. No ORM, no generated code. You'll write SQL by hand with parameterized queries that prevent injection attacks.

In this step, get ready to:
  • Install the pg driver and create a connection pool.
  • Write a SQL migration script that creates the items table.
  • Replace the in-memory array with parameterized INSERT, SELECT, and DELETE queries.
Install the pg driver and create the database module

The pg package gives you a connection pool that manages multiple database connections efficiently. You'll create a thin wrapper module that the rest of your server imports.

  • Stop your running dev server by pressing Ctrl+C in the terminal.
  • Install the database dependencies by running this command:
npm install pg dotenv && npm install -D @types/pg

What does this command do?

  • pg is the Node.js driver for PostgreSQL. It provides a connection pool and supports parameterized queries out of the box.
  • dotenv loads environment variables from a .env file so your database URL stays out of source code.
  • @types/pg provides TypeScript type definitions for the pg package.
  • Create a .env file in the root of your dialogflow-webhook folder and add your database connection string:
DATABASE_URL="postgresql://username:password@localhost:5432/dialogflow_webhook"
  • Replace username with your PostgreSQL username (often postgres).
  • Replace password with your PostgreSQL password.
  • Save .env.

Keep .env out of version control

The .env file contains your database credentials. If you use Git, add .env to your .gitignore file so it never gets committed.

🙋‍♀️ Not sure what your PostgreSQL credentials are?

If you installed PostgreSQL with the default settings, your username is often postgres and you chose a password during installation. On macOS with Homebrew, the default username is your OS username with no password.

help me find my local PostgreSQL credentials

  • Create the src/lib/ directory by running:
mkdir -p src/lib
  • Create a new file at src/lib/db.ts and add the following code:
import "dotenv/config";
import { Pool } from "pg";

// Create a connection pool using the DATABASE_URL from .env
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

// Export a helper that runs a parameterized query on the pool
export const query = (text: string, params?: unknown[]) => {
  return pool.query(text, params);
};

export { pool };

What does this module do?

  • import "dotenv/config" loads your .env file so DATABASE_URL is available on process.env.
  • Pool manages a set of reusable database connections. You don't open and close connections per request.
  • The exported query helper runs any SQL statement with parameterized values ($1, $2, etc.) to prevent SQL injection.

💡 Why raw SQL instead of an ORM?

An ORM like Prisma or Drizzle adds a layer between you and the database. For interviews and production debugging, knowing how to write parameterized queries, handle result sets, and design schemas by hand is a core skill that ORMs abstract away.

The pg driver gives you direct control with minimal dependencies. You write real SQL, so you understand exactly what runs on the server.

  • Save src/lib/db.ts.
Create the items table with a migration script

Without an ORM generating migrations for you, you'll write SQL directly. A simple startup script ensures the table exists before the server accepts requests.

  • Create a new file at src/lib/migrate.ts and add the following code:
import { query } from "./db.js";

// Create the items table if it does not already exist
export async function migrate() {
  await query(`
    CREATE TABLE IF NOT EXISTS items (
      id SERIAL PRIMARY KEY,
      name TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT NOW()
    )
  `);
}

What does this migration do?

  • CREATE TABLE IF NOT EXISTS is idempotent. Running it multiple times won't fail or duplicate the table.
  • SERIAL auto-increments the id column on each insert.
  • TIMESTAMPTZ DEFAULT NOW() records when each item was added.
  • Save src/lib/migrate.ts.
Replace in-memory logic with SQL queries

The database layer is ready. Now you'll swap out the in-memory items array for real SQL operations inside your webhook route.

  • Open src/server.ts.
  • Find this line near the top of the file:
const items: string[] = [];
  • Delete that line entirely.
  • Now find the import at the very top:
import Fastify from "fastify";
  • Add the database imports directly below it, so the first lines read:
import Fastify from "fastify";
import { query } from "./lib/db.js";
import { migrate } from "./lib/migrate.js";
  • Now find the start function at the bottom of the file and add the migrate() call before server.listen, so it looks like this:
const start = async () => {
  try {
    // Run the migration to ensure the items table exists
    await migrate();
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();
  • Now replace the entire switch block inside the POST /webhook route with this updated version:
  switch (action) {
    case "item.add": {
      const itemName = parameters.item;
      // Insert a new row into the items table
      await query("INSERT INTO items(name) VALUES($1)", [itemName]);
      response = {
        speech: `Added ${itemName} to your list`,
        displayText: `Added ${itemName} to your list`,
      };
      break;
    }
    case "item.remove": {
      const itemName = parameters.item;
      // Find the first item matching the name and delete it by id
      const found = await query(
        "SELECT id FROM items WHERE name = $1 LIMIT 1",
        [itemName]
      );
      if (found.rows.length > 0) {
        await query("DELETE FROM items WHERE id = $1", [found.rows[0].id]);
        response = {
          speech: `Removed ${itemName} from your list`,
          displayText: `Removed ${itemName} from your list`,
        };
      } else {
        response = {
          speech: `${itemName} is not in your list`,
          displayText: `${itemName} is not in your list`,
        };
      }
      break;
    }
    case "item.list": {
      // Fetch all items from the database
      const allItems = await query("SELECT name FROM items ORDER BY created_at");
      const names = allItems.rows.map((r) => r.name).join(", ");
      const text = names.length > 0 ? `Your items: ${names}` : "Your list is empty";
      response = {
        speech: text,
        displayText: text,
      };
      break;
    }
    default: {
      response = {
        speech: `Unknown action: ${action}`,
        displayText: `Unknown action: ${action}`,
      };
    }
  }

What changed in each case?

  • item.add now runs INSERT INTO items(name) VALUES($1) with the item name as a parameterized value. No string concatenation, no injection risk.
  • item.remove first runs a SELECT with LIMIT 1 to find the first matching row, then DELETEs by id. This mirrors the old splice-first-occurrence behavior.
  • item.list runs SELECT name FROM items and maps over result.rows to join the names into a string.
  • Save src/server.ts.
  • Start the dev server again by running:
npm run dev
  • You should see the Fastify server listening on port 3000 in your terminal output.
  • Open a second terminal tab and test all three actions with these curl commands.
  • Add an apple to the list:
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"result":{"action":"item.add","parameters":{"item":"apple"}}}'
  • Add a banana to the list:
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"result":{"action":"item.add","parameters":{"item":"banana"}}}'
  • List all items:
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"result":{"action":"item.list","parameters":{}}}'
  • The list response should show {"speech":"Your items: apple, banana","displayText":"Your items: apple, banana"}.
  • Test the remove action:
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"result":{"action":"item.remove","parameters":{"item":"apple"}}}'
  • You should see {"speech":"Removed apple from your list","displayText":"Removed apple from your list"}.

Server crashes on startup?

If you see a connection error, double-check that PostgreSQL is running and that the credentials in .env are correct. Also confirm the dialogflow_webhook database exists. You can create it manually by running createdb dialogflow_webhook in your terminal.

help me fix my PostgreSQL connection error

✔️ Awesome, I've got everything!

Great. Double check you've saved src/server.ts and your server is running without errors.

ⓧ I'd like to double check the full code

Here is src/lib/db.ts:

import "dotenv/config";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const query = (text: string, params?: unknown[]) => {
  return pool.query(text, params);
};

export { pool };

Here is src/lib/migrate.ts:

import { query } from "./db.js";

export async function migrate() {
  await query(`
    CREATE TABLE IF NOT EXISTS items (
      id SERIAL PRIMARY KEY,
      name TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT NOW()
    )
  `);
}

Here is the complete src/server.ts:

import Fastify from "fastify";
import { query } from "./lib/db.js";
import { migrate } from "./lib/migrate.js";

const server = Fastify({ logger: true });

interface DialogflowRequest {
  result: {
    action: string;
    parameters: Record<string, string>;
    queryText?: string;
    languageCode?: string;
    allRequiredParamsPresent?: boolean;
    intent?: {
      name: string;
      displayName: string;
    };
    queryResult?: number;
    fulfillmentMessages?: Array<{ text: { text: string[] } }>;
  };
  responseId?: string;
  originalDetectIntentRequest?: {
    source: string;
    payload: Record<string, unknown>;
  };
  session?: string;
}

interface DialogflowResponse {
  speech: string;
  displayText: string;
}

server.get("/", async () => {
  return { status: "ok" };
});

server.post<{ Body: DialogflowRequest }>("/webhook", {
  schema: {
    body: {
      type: "object",
      required: ["result"],
      properties: {
        result: {
          type: "object",
          required: ["action", "parameters"],
          properties: {
            action: { type: "string" },
            parameters: { type: "object" },
          },
        },
      },
    },
  },
}, async (request, reply) => {
  const body = request.body;
  const action = body.result.action;
  const parameters = body.result.parameters;

  server.log.info({ requestBody: body }, "Incoming webhook request");

  let response: DialogflowResponse;

  switch (action) {
    case "item.add": {
      const itemName = parameters.item;
      await query("INSERT INTO items(name) VALUES($1)", [itemName]);
      response = {
        speech: `Added ${itemName} to your list`,
        displayText: `Added ${itemName} to your list`,
      };
      break;
    }
    case "item.remove": {
      const itemName = parameters.item;
      const found = await query(
        "SELECT id FROM items WHERE name = $1 LIMIT 1",
        [itemName]
      );
      if (found.rows.length > 0) {
        await query("DELETE FROM items WHERE id = $1", [found.rows[0].id]);
        response = {
          speech: `Removed ${itemName} from your list`,
          displayText: `Removed ${itemName} from your list`,
        };
      } else {
        response = {
          speech: `${itemName} is not in your list`,
          displayText: `${itemName} is not in your list`,
        };
      }
      break;
    }
    case "item.list": {
      const allItems = await query("SELECT name FROM items ORDER BY created_at");
      const names = allItems.rows.map((r) => r.name).join(", ");
      const text = names.length > 0 ? `Your items: ${names}` : "Your list is empty";
      response = {
        speech: text,
        displayText: text,
      };
      break;
    }
    default: {
      response = {
        speech: `Unknown action: ${action}`,
        displayText: `Unknown action: ${action}`,
      };
    }
  }

  server.log.info({ response }, "Outgoing webhook response");
  return response;
});

const start = async () => {
  try {
    await migrate();
    await server.listen({ port: 3000 });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start();

Your webhook is now backed by a real database. Items survive server restarts, and you've written every SQL statement by hand with parameterized queries that prevent injection attacks.

Secret mission

Add a GET /items Endpoint with Query Filtering

Your webhook handles all item operations through POST requests. But what if you wanted a simple way to browse items directly in the browser? Add a dedicated GET endpoint that queries your database and supports optional name filtering using SQL ILIKE.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The dialogflow-webhook project folder on your Desktop.
  • The dialogflow_webhook PostgreSQL database.
  • The running dev server process (tsx --watch).

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue shortly.

  • Your dev server stays running and the database retains all your items.
  • Everything is local, so nothing is costing you money.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal and port 3000. Your project files and database stay intact for next time.

  • In the terminal running the dev server, press Ctrl+C to stop the process.
  • Your PostgreSQL database continues to exist and your files remain on disk. When you're ready to resume, run npm run dev from the dialogflow-webhook folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your project files and the database permanently.

  • In the terminal running the dev server, press Ctrl+C to stop the process.
  • Drop the PostgreSQL database by running:
dropdb dialogflow_webhook
  • Remove the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf dialogflow-webhook
  • On Windows, run:
cd ~\Desktop
rmdir /s /q dialogflow-webhook
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see dialogflow-webhook in the output.

Nice Work!

Nice Work!

You just built a production-style webhook API from scratch using Fastify, TypeScript, and raw SQL. No scaffolding tools, no templates, no ORM. Every layer of this server exists because you wrote it yourself.

You've learned how to:

  • Build a Fastify TypeScript server from scratch with AJV schema validation, parsing only the fields you need from deeply nested Dialogflow JSON payloads.
  • Implement action-based routing with three cases (add, remove, list) sharing a single POST endpoint.
  • Migrate from in-memory state to PostgreSQL persistence using the pg driver with hand-written parameterized SQL queries and a connection pool.
  • Secret Mission: Added a filtered GET /items endpoint querying the database directly with optional ILIKE filtering.

Ready to quiz yourself?