Back to News for Developers

Booking Appointments with WhatsApp Flows: Creating a Node.js Back End

February 27, 2024ByGafi G & Iryna Wagner

With WhatsApp Flows, you can build interactive messages for users to complete actions directly on WhatsApp. Flows allow you to create screens for user interaction. For example, you can create simple input forms for collecting leads or submitting reviews. Additionally, you can design complex Flows across multiple screens for scheduling appointments.

This guide will walk you through building a Node.js app that allows users to book appointments through WhatsApp Flows. You’ll create a Flow on the WhatsApp Business Platform, then configure a webhook to receive the Flow’s response and book the appointment.

Prerequisites

To follow this tutorial, ensure you have:

Creating a WhatsApp Flow

There are two ways to create a WhatsApp Flow: the Flows Builder, accessible via WhatsApp Manager, and the Flows API. This tutorial uses the Flows Builder.

Create a Flow

On the left-side menu of your WhatsApp Manager dashboard, select Account tools. Then, click Flows.

WhatsApp Manager graphic

Click Create Flow, located in the top-right corner.

create flow graphic

In the dialog box that appears, fill in the details for the appointment Flow:

  • Name — Type BookAppointment, or choose another name you like.
  • Categories — Select Appointment booking.
  • Template — Choose Book an appointment. You’ll use the template because it contains the necessary elements for booking an appointment. These elements include screens for the appointment details, user details entry, appointment summary, and company terms display. You can further customize the template to suit your use case.
Book an appointment graphic

Click Submit to create the Flow.

You can preview the Flow on the right of the Builder UI. The appointment screen allows the user to choose the appointment details, like location and date. The details screen is where the user will enter their information. The summary screen displays the appointment booking summary. The last screen shows the company’s terms.

The Flow remains in the draft state as you edit it. You can currently share it with your team for testing purposes only. To share it with a large audience, you’d need to publish it. However, you can’t edit the Flow once you publish. Since you will still need to add the endpoint URL for this appointment Flow, leave it as a draft for now and proceed to the next step, where you’ll configure the endpoint.

Configuring the Flow’s Endpoint

WhatsApp Flows lets you connect to an external endpoint. This endpoint can provide dynamic data for your Flow and control routing. It also receives user-submitted responses from the Flow.

For testing purposes, this article uses Glitch to host the endpoint. Using Glitch is entirely optional, and not required to use Flows. You can clone the endpoint code from GitHub and run it in any environment you prefer.

Access the endpoint code in Glitch and remix it to get your unique domain. To remix it, click Remix at the top of the page. A unique domain will appear as a placeholder in the input element on the right side of the Glitch page.

Before proceeding, let’s walk through the code. There are four JavaScript files in the src directory: encryption.js, flow.js, keyGenerator.js, and server.js. The entry file is server.js, so let’s look at it first.

server.js

The server.js file starts by configuring the Express application to use the express.json middleware to parse incoming JSON requests. Then, it loads the environment variables needed for the endpoint.

const { APP_SECRET, PRIVATE_KEY, PASSPHRASE, PORT = "3000" } = process.env;

APP_SECRET is used in signature verification. It helps you check whether a message is coming via WhatsApp and, therefore, is safe to process. You’ll add it to the .env file.

To access your APP_SECRET, navigate to your dashboard on the app on Meta for Developers. In the left navigation pane under App settings, choose Basic. Click Show under App secret and copy the secret. Then, return to Glitch, open the .env file, and create a variable named APP_SECRET with the value of the secret you copied.

PRIVATE_KEY helps decrypt the messages received. The PASSPHRASE will be used to verify the private key. Along with the private key, you also need its corresponding public key, which you’ll upload later. Never use the private keys for your production accounts here. Create a temporary private key for testing on Glitch, and then replace it with your production key in your own infrastructure.

Generate the public-private key pair by running the command below in the Glitch terminal. Replace <your-passphrase> with your designated passphrase. Access the Glitch terminal by clicking the TERMINAL tab at the bottom of the page.

node src/keyGenerator.js <your-passphrase>

Copy the passphrase and private key and paste them to the .env file. Click on the file labeled .env on the left sidebar, then click on ✏️ Plain text on top. Do not edit it directly from the UI, as it will break your key formatting.

After you set the environment variables, copy the public key that you generated and upload the public key via the Graph API.

The server.js file also contains a POST endpoint that performs different steps:

  • Checks that the private key is present:
       if (!PRIVATE_KEY) {
         throw new Error('Private key is empty. Please check your env variable 
"PRIVATE_KEY".');
       }
  • Validates the request signature using the isRequestSignatureValid function found at the bottom of the file:
if(!isRequestSignatureValid(req)) {
        // Return status code 432 if request signature does not match.
        // To learn more about return error codes visit: 
    https://developers.facebook.com/docs/whatsapp/flows/reference/error-codes#endpoint_error_codes
         return res.status(432).send();
       }
  • Decrypts incoming messages using the decryptRequest function found in the encryption.js file:
      let decryptedRequest = null;
      try {
         decryptedRequest = decryptRequest(req.body, PRIVATE_KEY, PASSPHRASE);
      } catch (err) {
      console.error(err);
      if (err instanceof FlowEndpointException) {
         return res.status(err.statusCode).send();
      }
      return res.status(500).send();
      }

     const { aesKeyBuffer, initialVectorBuffer, decryptedBody } = decryptedRequest;
     console.log("💬 Decrypted Request:", decryptedBody);
  • Decides what Flow screen to display to the user. You’ll look at the getNextScreen function in detail later.

const screenResponse = await getNextScreen(decryptedBody);

       console.log("👉 Response to Encrypt:", screenResponse);
  • Encrypts the response to be sent to the user:
res.send(encryptResponse(screenResponse, aesKeyBuffer, initialVectorBuffer));

encryption.js

This file contains the logic for encrypting and decrypting messages exchanged for security purposes. This tutorial won’t focus on the workings of the file.

keyGenerator.js

This file helps generate the private and public keys, as you saw earlier. As with the encryption.js file, this tutorial won’t explore the keyGenerator.js file in detail.

flow.js

The logic for handling the Flow is housed in this file. It starts with an object assigned the name SCREEN_RESPONSES. The object contains screen IDs with their corresponding details, such as the preset data used in the data exchanges. This object is generated from Flow Builder under "..." > Endpoint > Snippets > Responses. In the same object, you also have another ID, SUCCESS, that is sent back to the client device when the Flow is successfully completed. This closes the Flow.

The getNextScreen function contains the logic that guides the endpoint on what Flow data to display to the user. It starts by extracting the necessary data from the decrypted message.

const { screen, data, version, action, flow_token } = decryptedBody;

WhatsApp Flows endpoints usually receive three requests:

You can find their details in the endpoint documentation.

The function handles the health check and error notifications using if statements and responds accordingly, as shown in the snippet below:

// handle health check request
if (action === "ping") {
    return {
        version,
        data: {
            status: "active",
        },
    };
}

// handle error notification
if (data?.error) {
    console.warn("Received client error:", data);
    return {
        version,
        data: {
            acknowledged: true,
        },
    };
}
        

When a user clicks the Flow’s call to action (CTA) button, an INIT action fires. This action returns the appointment screen together with the data. It also disables the location, date, and time drop-downs to ensure the user fills out all fields.

For instance, the date drop-down is enabled only when the location drop-down is filled. The enabling and disabling of the fields are handled when a data_exchange request is received.

// handle initial request when opening the flow and display APPOINTMENT screen
if (action === "INIT") {
    return {
        ...SCREEN_RESPONSES.APPOINTMENT,
        data: {
            ...SCREEN_RESPONSES.APPOINTMENT.data,
            // these fields are disabled initially. Each field is enabled when previous fields are selected
            is_location_enabled: false,
            is_date_enabled: false,
            is_time_enabled: false,
        },
    };
}

For data_exchange actions, a switch case structure is used to determine what data to send back based on the screen ID. If the screen ID is APPOINTMENT, the drop-down fields are enabled only when the preceding drop-downs are selected.

// Each field is enabled only when previous fields are selected
 is_location_enabled: Boolean(data.department),
 is_date_enabled: Boolean(data.department) && Boolean(data.location),
 is_time_enabled: Boolean(data.department) && Boolean(data.location) && Boolean(data.date)

For the DETAILS screen, the titles of data object properties, such as location and department, are extracted from the SCREEN_RESPONSES.APPOINTMENT.data object. This code assumes there’s a valid match, so note that it may throw an error if no matching object is found.

Now, take an instance of the location object. The selection of the specific location object is determined by matching the id property of the objects in the array with the value of data.location.

const departmentName = SCREEN_RESPONSES.APPOINTMENT.data.department.find(
    (dept) => dept.id === data.department
).title;
const locationName = SCREEN_RESPONSES.APPOINTMENT.data.location.find(
    (loc) => loc.id === data.location
).title;
const dateName = SCREEN_RESPONSES.APPOINTMENT.data.date.find(
    (date) => date.id === data.date

).title;

The values are then concatenated and returned in the response to render the SUMMARY screen.

const appointment = `${departmentName} at ${locationName}
${dateName} at ${data.time}`;

const details = `Name: ${data.name}
Email: ${data.email}
Phone: ${data.phone}
"${data.more_details}"`;

return {
    ...SCREEN_RESPONSES.SUMMARY,
    data: {
        appointment,
        details,
        // return the same fields sent from client back to submit in the next step
        ...data,
    },
};
        

After the SUMMARY screen is submitted from the client, a success response is sent to the client device to mark the Flow as complete. The flow_token is a unique identifier that you can set when sending the Flow to the user.

// send success response to complete and close the flow
return {
    ...SCREEN_RESPONSES.SUCCESS,
    data: {
        extension_message_response: {
            params: {
                flow_token,
            },
        },
    },
};

The TERMS screen has no data to be exchanged, so the endpoint doesn’t handle it.

Add the Endpoint to the Flow

At the top right of the Glitch page, you can copy the URL by clicking on the kebab menu icon and selecting Copy Link. You can also get the link by clicking Share on the top right.

Head to the Flow Editor. Click Set up in the brown banner that appears at the top of the editor.

A popup will appear, allowing you to configure the endpoint URI, business phone number, and app on Meta for Developers. After making the necessary configurations, perform a health check. First, run the interactive preview and ensure that you select Request data, under Request data on first screen, in the interactive preview settings. This sends a request to the endpoint to retrieve data for the first screen, verifying that the endpoint is available and that you’ve implemented a health check.

Then, publish the Flow by clicking the meatballs (...) menu and choosing Publish. This will send a health check request to your endpoint with action === "ping" to verify the endpoint is set up before publishing.

Endpoint graphic

Test Out the Flow

After you complete the configurations, toggle the interactive preview again in the WhatsApp Builder UI to test the Flow. In the popup that appears, select the phone number and choose the Request data option under Request data on first screen. Close the Flow by clicking the X icon to start testing the Flow afresh from the CTA button.

Open the Glitch log by clicking the LOGS tab. Clear it by clicking Clear. Then, return to the WhatsApp Builder UI preview. Click Preview flow. You’ll see something like the following:

Preview flow graphic

Now, return to the Glitch logs. You’ll see an INIT action, the Flow token, and other details under the decrypted request. There is also a response to encrypt sent back to the user Flow once the department drop-down is selected.

decrypted request graphic

Proceed to select the department. Notice how the is_location_enabled is set to true and the action has changed to data_exchange.

data_exchange graphic

Keep trying out the Flow and observe the data changes in the Glitch logs. Similar logs will be generated when users interact with the Flow from their mobile devices.

In the next section, you’ll create a webhook that sends a confirmation message to the user when they book an appointment.

Setting Up the Webhook

When a user completes the Flow, a message marking the Flow completion is sent to the subscribed webhook. From this webhook, you’ll notify the user about their successful appointment booking with a message in the chat. Similar to the endpoint, you’ll also use Glitch for testing. You can access the code and remix it here.

Using Glitch is entirely optional — it’s not required to use Flows. You can clone the webhook code from GitHub and run it in any environment you prefer.

Setting Environment Variables

To set the environment variables, open the .env file on Glitch. Set VERIFY_TOKEN to any string you prefer, FLOW_ID with your Flow’s ID, and GRAPH_API_TOKEN to your WhatsApp Business account’s access token. You can get the access token from the dashboard of your app on Meta for Developers when you click API Setup under the WhatsApp section on the left navigation pane.

API Setup graphic

On the page that renders, click the Copy button under the Temporary access token card. Paste the key in your .env file.

Subscribing the Webhook on the Meta Dashboard

In your account on Meta for Developers, click the Configuration menu under WhatsApp in the left navigation pane.

configuration graphic

In the Webhook card, click Edit. In the dialog that opens, paste your copied Glitch URL, and append /webhook to it in the Callback URL field. For the Verify token field, add the token from the VERIFY_TOKEN variable in your .env file. When finished, click Verify and save. The dialog will close and return to the main screen. Click Manage and check the messages field. Your webhook is now ready.

Webhook Code Walkthrough

The code contains two routes: POST /webhook and GET /webhook. The GET route handles webhook verification requests by checking the provided token against a predefined verify token and responding with the appropriate status codes and a challenge token.

const verify_token = process.env.VERIFY_TOKEN;

// Parse params from the webhook verification request
let mode = req.query["hub.mode"];
let token = req.query["hub.verify_token"];
let challenge = req.query["hub.challenge"];

if (mode && token) {
    if (mode === "subscribe" && token === verify_token) {
        console.log("WEBHOOK_VERIFIED");
        res.status(200).send(challenge);
    } else {
        res.sendStatus(403);
    }
}

The POST /webhook route processes incoming webhook notifications. Webhook requests can have different payloads. So, the code below reads the message and the business phone number by accessing the request fields safely in case they are undefined.

const message = req.body.entry?.[0]?.changes[0]?.value?.messages?.[0];
const business_phone_number_id =

req.body.entry?.[0].changes?.[0].value?.metadata?.phone_number_id;

It then checks if the incoming request is for a "text" type message that contains the word “appointment.” If the message contains this word, the Flow is sent to the user. The Flow message is sent with flow_action: "data_exchange," which means the Flow will make an INIT request to the endpoint when launched to get the initial screen and data.

if (
    message.type === "text" &&
    // for demo purposes, send the flow message whenever a user sends a message containing "appointment"
    message.text.body.toLowerCase().includes("appointment")
) {
    // send flow message as per the docs here https://developers.facebook.com/docs/whatsapp/flows/gettingstarted/sendingaflow#interactive-message-parameters
    await axios({
        method: "POST",
        url: `https://graph.facebook.com/v18.0/${business_phone_number_id}/messages`,
        headers: {
            Authorization: `Bearer ${GRAPH_API_TOKEN}`,
        },
        data: {
            messaging_product: "whatsapp",
            to: message.from,
            type: "interactive",
            interactive: {
                type: "flow",
                header: {
                    type: "text",
                    text: "Hello there 👋",
                },
                body: {
                    text: "Ready to transform your space? Schedule a personalized consultation with our expert team!",
                },
                footer: {
                    text: "Click the button below to proceed",
                },
                action: {
                    name: "flow",
                    parameters: {
                        flow_id: FLOW_ID,
                        flow_message_version: "3",
                        // replace flow_token with a unique identifier for this flow message to track it in your endpoint & webhook
                        flow_token: "<FLOW_TOKEN_PLACEHOLDER>",
                        flow_cta: "Book an appointment",
                        flow_action: "data_exchange",
                    },
                },
            },
        },
    });
}
...

If the incoming message type is not "text", the code checks if the message type is "interactive." An interactive type "nfm_reply" signifies that the incoming message is a Flow response. Then, it sends back a “You’ve successfully booked an appointment” message to the user.

...
if (
    message.type === "interactive" &&
    message.interactive?.type === "nfm_reply"
) {
    // send confirmation message
    await axios({
        method: "POST",
        url: `https://graph.facebook.com/v18.0/${business_phone_number_id}/messages`,
        headers: {
            Authorization: `Bearer ${GRAPH_API_TOKEN}`,
        },
        data: {
            messaging_product: "whatsapp",
            to: message.from,
            text: { body: "You've successfully booked an appointment" },
        },
    });
}
...

It then marks the incoming message as read so the user will see the blue ticks.

...
// mark incoming message as read
await axios({
    method: "POST",
    url: `https://graph.facebook.com/v18.0/${business_phone_number_id}/messages`,
    headers: {
        Authorization: `Bearer ${GRAPH_API_TOKEN}`,
    },
    data: {
        messaging_product: "whatsapp",
        status: "read",
        message_id: message.id,
    },
});
...
        

The User Experience

In this example, the user sends a message to your number containing the word “appointment,” and then receives the Flow message. You can also choose to send the Flow after a different interaction or as a message template.

The user will receive a Flow message with a CTA button for booking an appointment, where they can fill in their details. Then, they’ll receive a confirmation message when they complete the Flow.

Sending the Flow to the User graphic

In this guide, you learned how to set up a WhatsApp Flow for seamless appointment scheduling. Using the Flow Builder UI, you crafted a form to collect appointment details from users.

Flows eliminate the need to redirect users to an external website for appointment bookings, enhancing the customer experience. The straightforward process enables users to complete bookings directly within WhatsApp. Beyond scheduling appointments, you can use WhatsApp Flows to collect customer service feedback, or help users sign up for promotions or mailing lists. WhatsApp Flows also offer the flexibility to connect with external APIs or other apps in your endpoint.

Creating WhatsApp Flows is easy with the Flow Builder UI. However, you can also use the Flow API to build Flows programmatically. For more information, refer to the WhatsApp Flows documentation.