Repository: Yoctol/bottender
Pull request #664 (2020-02-25 16:50:57)

feat: add new channel whatsapp
#322

In the PR, I've got `whatsapp` channel implemented by using [Twilio API for WhatsApp](https://www.twilio.com/whatsapp). It includes:

- An hello world example for WhatsApp: `whatsapp-hello-world`
- `create-bottender-app` support
- WhatsApp routes: `whatsapp`, `whatsapp.message`, `whatsapp.media`, `whatsapp.received`, `whatsapp.sent`, `whatsapp.delivered`, and `whatsapp.read` 

To enable `whatsapp` in an existed project:

1. Adding those values to your `.env` file:

```
ACCOUNT_SID=
AUTH_TOKEN=
```

2. Adding `whatsapp` config to your `channels` setting in `bottender.config.js`:

```js
module.exports = {
  channels: {
    whatsapp: {
      enabled: true,
      path: '/webhooks/whatsapp',
      accountSid: process.env.ACCOUNT_SID,
      authToken: process.env.AUTH_TOKEN,
    },
  },
};
```

codecov-io: # [Codecov](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=h1) Report
> Merging [#664](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=desc) into [release/1.3](https://codecov.io/gh/Yoctol/bottender/commit/74c1ee8a4a46b0e36ca8501b6f885d88a2ceb739?src=pr&el=desc) will **increase** coverage by `0.16%`.
> The diff coverage is `87.17%`.

[![Impacted file tree graph](https://codecov.io/gh/Yoctol/bottender/pull/664/graphs/tree.svg?width=650&token=JX3EGtxYH3&height=150&src=pr)](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=tree)

```diff
@@               Coverage Diff               @@
##           release/1.3     #664      +/-   ##
===============================================
+ Coverage        80.76%   80.92%   +0.16%     
===============================================
  Files              102      108       +6     
  Lines             4362     4478     +116     
  Branches          1084     1109      +25     
===============================================
+ Hits              3523     3624     +101     
- Misses             794      807      +13     
- Partials            45       47       +2
```


| [Impacted Files](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [...s/create-bottender-app/src/utils/generateConfig.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvY3JlYXRlLWJvdHRlbmRlci1hcHAvc3JjL3V0aWxzL2dlbmVyYXRlQ29uZmlnLnRz) | `0% <ø> (ø)` | :arrow_up: |
| [packages/create-bottender-app/src/index.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvY3JlYXRlLWJvdHRlbmRlci1hcHAvc3JjL2luZGV4LnRz) | `0% <ø> (ø)` | :arrow_up: |
| [packages/bottender/src/server/Server.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9zZXJ2ZXIvU2VydmVyLnRz) | `10.25% <ø> (ø)` | :arrow_up: |
| [packages/bottender-express/src/registerRoutes.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyLWV4cHJlc3Mvc3JjL3JlZ2lzdGVyUm91dGVzLnRz) | `84.21% <100%> (+0.87%)` | :arrow_up: |
| [packages/bottender/src/whatsapp/routes.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy93aGF0c2FwcC9yb3V0ZXMudHM=) | `100% <100%> (ø)` | |
| [packages/bottender/src/whatsapp/WhatsappBot.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy93aGF0c2FwcC9XaGF0c2FwcEJvdC50cw==) | `100% <100%> (ø)` | |
| [packages/bottender/src/getClient.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9nZXRDbGllbnQudHM=) | `55.55% <100%> (+2.61%)` | :arrow_up: |
| [packages/bottender/src/shared/getChannelSchema.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9zaGFyZWQvZ2V0Q2hhbm5lbFNjaGVtYS50cw==) | `100% <100%> (ø)` | :arrow_up: |
| [packages/bottender/src/whatsapp/WhatsappEvent.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy93aGF0c2FwcC9XaGF0c2FwcEV2ZW50LnRz) | `100% <100%> (ø)` | |
| [packages/bottender/src/types.ts](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy90eXBlcy50cw==) | `100% <100%> (ø)` | :arrow_up: |
| ... and [10 more](https://codecov.io/gh/Yoctol/bottender/pull/664/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=footer). Last update [74c1ee8...74ee30e](https://codecov.io/gh/Yoctol/bottender/pull/664?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/examples/whatsapp-hello-world/.env b/examples/whatsapp-hello-world/.env
new file mode 100644
index 00000000..3d1bc932
--- /dev/null
+++ b/examples/whatsapp-hello-world/.env
@@ -0,0 +1,2 @@
+ACCOUNT_SID=
+AUTH_TOKEN=
diff --git a/examples/whatsapp-hello-world/README.md b/examples/whatsapp-hello-world/README.md
new file mode 100644
index 00000000..e157edd3
--- /dev/null
+++ b/examples/whatsapp-hello-world/README.md
@@ -0,0 +1,33 @@
+# WhatsApp Hello World
+
+## Install and Run
+
+Download this example or clone [bottender](https://github.com/Yoctol/bottender).
+
+```sh
+curl https://codeload.github.com/Yoctol/bottender/tar.gz/master | tar -xz --strip=2 bottender-master/examples/whatsapp-hello-world
+cd whatsapp-hello-world
+```
+
+Install dependencies:
+
+```sh
+npm install
+```
+
+You must put `accountSid` and `authToken` into `bottender.config.js`.
+
+After that, you can run the bot with this npm script:
+
+```sh
+npm run dev
+```
+
+This command will start server for bot developing at `http://localhost:5000`.
+
+If you successfully start the server, you will get a webhook URL like `https://xxxxxxxx.ngrok.io/webhooks/whatsapp` from command line.
+
+## Idea of this Example
+
+This example is a simple bot running on [Twilio API for WhatsApp](https://www.twilio.com/whatsapp).
+For more information, check our [WhatsApp guides](https://bottender.js.org/docs/channel-whatsapp-setup).
diff --git a/examples/whatsapp-hello-world/bottender.config.js b/examples/whatsapp-hello-world/bottender.config.js
new file mode 100644
index 00000000..60d1e75d
--- /dev/null
+++ b/examples/whatsapp-hello-world/bottender.config.js
@@ -0,0 +1,10 @@
+module.exports = {
+  channels: {
+    whatsapp: {
+      enabled: true,
+      path: '/webhooks/whatsapp',
+      accountSid: process.env.ACCOUNT_SID,
+      authToken: process.env.AUTH_TOKEN,
+    },
+  },
+};
diff --git a/examples/whatsapp-hello-world/index.js b/examples/whatsapp-hello-world/index.js
new file mode 100644
index 00000000..8c8bef90
--- /dev/null
+++ b/examples/whatsapp-hello-world/index.js
@@ -0,0 +1,5 @@
+module.exports = async function App(context) {
+  if (context.event.isText) {
+    await context.sendText('Hello World');
+  }
+};
diff --git a/examples/whatsapp-hello-world/package.json b/examples/whatsapp-hello-world/package.json
new file mode 100644
index 00000000..8642479a
--- /dev/null
+++ b/examples/whatsapp-hello-world/package.json
@@ -0,0 +1,9 @@
+{
+  "scripts": {
+    "dev": "bottender dev",
+    "start": "bottender start"
+  },
+  "dependencies": {
+    "bottender": "next"
+  }
+}
diff --git a/packages/bottender-express/src/registerRoutes.ts b/packages/bottender-express/src/registerRoutes.ts
index 1164dcff..ba7042cd 100644
--- a/packages/bottender-express/src/registerRoutes.ts
+++ b/packages/bottender-express/src/registerRoutes.ts
@@ -17,8 +17,11 @@ function registerRoutes(
       return;
     }
 
+    const url = `https://${req.get('host')}${req.originalUrl}`;
+
     const { shouldNext, response } = bot.connector.preprocess({
       method: req.method,
+      url,
       headers: req.headers,
       query: req.query,
       rawBody,
diff --git a/packages/bottender/package.json b/packages/bottender/package.json
index ecb6792e..a19cb09a 100644
--- a/packages/bottender/package.json
+++ b/packages/bottender/package.json
@@ -44,6 +44,7 @@
     "@types/warning": "^3.0.0",
     "arg": "^4.1.1",
     "axios": "^0.19.0",
+    "axios-error": "^1.0.0-beta.5",
     "chalk": "~2.4.2",
     "cli-table3": "^0.5.1",
     "date-fns": "^2.7.0",
diff --git a/packages/bottender/src/getClient.ts b/packages/bottender/src/getClient.ts
index f2c66ab9..d4521279 100644
--- a/packages/bottender/src/getClient.ts
+++ b/packages/bottender/src/getClient.ts
@@ -3,6 +3,7 @@ import MessengerBot from './messenger/MessengerBot';
 import SlackBot from './slack/SlackBot';
 import TelegramBot from './telegram/TelegramBot';
 import ViberBot from './viber/ViberBot';
+import WhatsappBot from './whatsapp/WhatsappBot';
 import getBottenderConfig from './shared/getBottenderConfig';
 import getSessionStore from './getSessionStore';
 import { Channel } from './types';
@@ -13,6 +14,7 @@ const BOT_MAP = {
   slack: SlackBot,
   telegram: TelegramBot,
   viber: ViberBot,
+  whatsapp: WhatsappBot,
 };
 
 function getClient(channel: Channel) {
diff --git a/packages/bottender/src/initializeServer.ts b/packages/bottender/src/initializeServer.ts
index 8e72da37..cc9943a9 100644
--- a/packages/bottender/src/initializeServer.ts
+++ b/packages/bottender/src/initializeServer.ts
@@ -10,6 +10,7 @@ import MessengerBot from './messenger/MessengerBot';
 import SlackBot from './slack/SlackBot';
 import TelegramBot from './telegram/TelegramBot';
 import ViberBot from './viber/ViberBot';
+import WhatsappBot from './whatsapp/WhatsappBot';
 import getBottenderConfig from './shared/getBottenderConfig';
 import getSessionStore from './getSessionStore';
 import { Action, BottenderConfig, Channel, Plugin } from './types';
@@ -20,6 +21,7 @@ const BOT_MAP = {
   slack: SlackBot,
   telegram: TelegramBot,
   viber: ViberBot,
+  whatsapp: WhatsappBot,
 };
 
 function initializeServer({
diff --git a/packages/bottender/src/server/Server.ts b/packages/bottender/src/server/Server.ts
index ec665b02..fe060c67 100644
--- a/packages/bottender/src/server/Server.ts
+++ b/packages/bottender/src/server/Server.ts
@@ -122,7 +122,7 @@ class Server {
     }
 
     const { pathname, searchParams } = new url.URL(
-      `http://${req.headers.host}${req.url}`
+      `https://${req.headers.host}${req.url}`
     );
 
     const query = fromEntries(searchParams.entries());
@@ -133,6 +133,7 @@ class Server {
       if (pathname === webhookPath) {
         const result = (bot.connector as any).preprocess({
           method: req.method,
+          url: `https://${req.headers.host}${req.url}`,
           headers: req.headers,
           query,
           rawBody: (req as any).rawBody,
diff --git a/packages/bottender/src/shared/getChannelSchema.ts b/packages/bottender/src/shared/getChannelSchema.ts
index 53844c44..0475ef2a 100644
--- a/packages/bottender/src/shared/getChannelSchema.ts
+++ b/packages/bottender/src/shared/getChannelSchema.ts
@@ -84,7 +84,7 @@ const slackSchema = Joi.object().keys({
   verificationToken: Joi.string(),
 });
 
-const viberSchmea = Joi.object().keys({
+const viberSchema = Joi.object().keys({
   enabled: Joi.boolean(),
   path: Joi.string(),
   accessToken: Joi.string().required(),
@@ -94,12 +94,20 @@ const viberSchmea = Joi.object().keys({
   }),
 });
 
+const whatsappSchema = Joi.object().keys({
+  enabled: Joi.boolean(),
+  path: Joi.string(),
+  accountSid: Joi.string().required(),
+  authToken: Joi.string().required(),
+});
+
 export default function getChannelSchema(channel: Channel): Schema {
   return {
     messenger: messengerSchema,
     line: lineSchema,
     telegram: telegramSchema,
     slack: slackSchema,
-    viber: viberSchmea,
+    viber: viberSchema,
+    whatsapp: whatsappSchema,
   }[channel];
 }
diff --git a/packages/bottender/src/types.ts b/packages/bottender/src/types.ts
index c31294c5..23cf8f01 100644
--- a/packages/bottender/src/types.ts
+++ b/packages/bottender/src/types.ts
@@ -10,13 +10,16 @@ import LineEvent from './line/LineEvent';
 import MessengerEvent from './messenger/MessengerEvent';
 import SlackEvent from './slack/SlackEvent';
 import TelegramEvent from './telegram/TelegramEvent';
+import TwilioClient from './whatsapp/TwilioClient';
 import ViberEvent from './viber/ViberEvent';
+import WhatsappEvent from './whatsapp/WhatsappEvent';
 import { ConsoleClient } from './console/ConsoleClient';
 import { LineRequestBody } from './line/LineConnector';
 import { MessengerRequestBody } from './messenger/MessengerConnector';
 import { SlackRequestBody } from './slack/SlackConnector';
 import { TelegramRequestBody } from './telegram/TelegramConnector';
 import { ViberRequestBody } from './viber/ViberConnector';
+import { WhatsappRequestBody } from './whatsapp/WhatsappConnector';
 
 export type Client =
   | ConsoleClient
@@ -24,7 +27,8 @@ export type Client =
   | LineClient
   | SlackOAuthClient
   | TelegramClient
-  | ViberClient;
+  | ViberClient
+  | TwilioClient;
 
 export type Event =
   | ConsoleEvent
@@ -32,7 +36,8 @@ export type Event =
   | LineEvent
   | SlackEvent
   | TelegramEvent
-  | ViberEvent;
+  | ViberEvent
+  | WhatsappEvent;
 
 export type Body =
   | ConsoleRawEvent
@@ -40,7 +45,8 @@ export type Body =
   | LineRequestBody
   | SlackRequestBody
   | TelegramRequestBody
-  | ViberRequestBody;
+  | ViberRequestBody
+  | WhatsappRequestBody;
 
 export type Action<C extends Client, E extends Event> = (
   context: Context<C, E>,
@@ -63,6 +69,7 @@ export enum Channel {
   Slack = 'slack',
   Telegram = 'telegram',
   Viber = 'viber',
+  Whatsapp = 'whatsapp',
 }
 
 export enum SessionDriver {
@@ -134,6 +141,12 @@ export type BottenderConfig = {
         name: string;
       };
     };
+    [Channel.Whatsapp]: {
+      enabled: boolean;
+      path: string;
+      accountSid: string;
+      authToken: string;
+    };
   };
 };
 
diff --git a/packages/bottender/src/whatsapp/TwilioClient.ts b/packages/bottender/src/whatsapp/TwilioClient.ts
new file mode 100644
index 00000000..251fc1c7
--- /dev/null
+++ b/packages/bottender/src/whatsapp/TwilioClient.ts
@@ -0,0 +1,110 @@
+import qs from 'querystring';
+
+import AxiosError from 'axios-error';
+import axios, { AxiosInstance } from 'axios';
+import get from 'lodash/get';
+import {
+  OnRequestFunction,
+  camelcaseKeys,
+  createRequestInterceptor,
+  pascalcaseKeys,
+} from 'messaging-api-common';
+
+type ClientConfig = {
+  accountSid: string;
+  authToken: string;
+  origin?: string;
+};
+
+function handleError(err: AxiosError): never {
+  if (err.response && err.response.data) {
+    const error = get(err, 'response.data', {});
+    const msg = `WhatsApp API - ${error.code} ${error.message} ${error.more_info}`;
+    throw new AxiosError(msg, err);
+  }
+  throw new AxiosError(err.message, err);
+}
+
+export default class TwilioClient {
+  static connect(config: ClientConfig): TwilioClient {
+    return new TwilioClient(config);
+  }
+
+  _onRequest: OnRequestFunction | undefined;
+
+  _axios: AxiosInstance;
+
+  _authToken: string;
+
+  constructor(config: ClientConfig) {
+    const twilioOrigin = `https://${config.accountSid}:${config.authToken}@api.twilio.com`;
+
+    this._authToken = config.authToken;
+
+    this._axios = axios.create({
+      baseURL: `${config.origin || twilioOrigin}/2010-04-01/Accounts/${
+        config.accountSid
+      }/`,
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+      },
+    });
+
+    this._axios.interceptors.request.use(
+      createRequestInterceptor({
+        onRequest: this._onRequest,
+      })
+    );
+  }
+
+  get axios(): AxiosInstance {
+    return this._axios;
+  }
+
+  get authToken(): string {
+    return this._authToken;
+  }
+
+  async createMessage(message: {
+    from: string;
+    body: string;
+    to: string;
+    maxPrice?: number;
+    provideFeedback?: boolean;
+    validityPeriod?: number;
+    forceDelivery?: boolean;
+    smartEncoded?: boolean;
+    persistentAction?: string[];
+    mediaUrl?: string[];
+  }) {
+    try {
+      const { data } = await this._axios.post<{
+        sid: string;
+        date_created: string;
+        date_updated: string;
+        date_sent: null;
+        account_sid: string;
+        to: string;
+        from: string;
+        messaging_service_sid: null;
+        body: string;
+        status: string;
+        num_segments: string;
+        num_media: string;
+        direction: string;
+        api_version: string;
+        price: null;
+        price_unit: null;
+        error_code: null;
+        error_message: null;
+        uri: string;
+        subresource_uris: {
+          uri: string;
+        };
+      }>('/Messages.json', qs.stringify(pascalcaseKeys(message)));
+      return camelcaseKeys(data);
+    } catch (err) {
+      handleError(err);
+    }
+  }
+}
diff --git a/packages/bottender/src/whatsapp/WhatsappBot.ts b/packages/bottender/src/whatsapp/WhatsappBot.ts
new file mode 100644
index 00000000..3ae572c0
--- /dev/null
+++ b/packages/bottender/src/whatsapp/WhatsappBot.ts
@@ -0,0 +1,33 @@
+import Bot from '../bot/Bot';
+import SessionStore from '../session/SessionStore';
+
+import TwilioClient from './TwilioClient';
+import WhatsappConnector, { WhatsappRequestBody } from './WhatsappConnector';
+import WhatsappEvent from './WhatsappEvent';
+
+export default class WhatsappBot extends Bot<
+  WhatsappRequestBody,
+  TwilioClient,
+  WhatsappEvent
+> {
+  constructor({
+    accountSid,
+    authToken,
+    sessionStore,
+    sync,
+    origin,
+  }: {
+    accountSid: string;
+    authToken: string;
+    sessionStore?: SessionStore;
+    sync?: boolean;
+    origin?: string;
+  }) {
+    const connector = new WhatsappConnector({
+      accountSid,
+      authToken,
+      origin,
+    });
+    super({ connector, sessionStore, sync });
+  }
+}
diff --git a/packages/bottender/src/whatsapp/WhatsappConnector.ts b/packages/bottender/src/whatsapp/WhatsappConnector.ts
new file mode 100644
index 00000000..5f248394
--- /dev/null
+++ b/packages/bottender/src/whatsapp/WhatsappConnector.ts
@@ -0,0 +1,181 @@
+import EventEmitter from 'events';
+import crypto from 'crypto';
+
+import Session from '../session/Session';
+import { Connector } from '../bot/Connector';
+import { RequestContext } from '../types';
+
+import TwilioClient from './TwilioClient';
+import WhatsappContext from './WhatsappContext';
+import WhatsappEvent from './WhatsappEvent';
+
+export type WhatsappRequestBody = any;
+
+type ConstructorOptionsWithoutClient = {
+  accountSid: string;
+  authToken: string;
+  origin?: string;
+};
+
+type ConstructorOptionsWithClient = {
+  client: TwilioClient;
+  origin?: string;
+};
+
+type ConstructorOptions =
+  | ConstructorOptionsWithoutClient
+  | ConstructorOptionsWithClient;
+
+function getExpectedTwilioSignature(
+  authToken: string,
+  url: string,
+  params: Record<string, any> = {}
+) {
+  const data = Object.keys(params)
+    .sort()
+    .reduce((acc, key) => acc + key + params[key], url);
+
+  return crypto
+    .createHmac('sha1', authToken)
+    .update(Buffer.from(data, 'utf-8'))
+    .digest('base64');
+}
+
+export default class WhatsappConnector
+  implements Connector<WhatsappRequestBody, TwilioClient> {
+  _client: TwilioClient;
+
+  constructor(options: ConstructorOptions) {
+    if ('client' in options) {
+      this._client = options.client;
+    } else {
+      const { accountSid, authToken, origin } = options;
+
+      this._client = TwilioClient.connect({
+        accountSid,
+        authToken,
+        origin,
+      });
+    }
+  }
+
+  get platform(): string {
+    return 'whatsapp';
+  }
+
+  get client(): TwilioClient {
+    return this._client;
+  }
+
+  getUniqueSessionKey(body: WhatsappRequestBody): string {
+    return body.smsStatus === 'received' ? body.from : body.to;
+  }
+
+  async updateSession(
+    session: Session,
+    body: WhatsappRequestBody
+  ): Promise<void> {
+    const userId = body.smsStatus === 'received' ? body.from : body.to;
+
+    session.user = {
+      _updatedAt: new Date().toISOString(),
+      id: userId,
+    };
+
+    Object.freeze(session.user);
+    Object.defineProperty(session, 'user', {
+      configurable: false,
+      enumerable: true,
+      writable: false,
+      value: session.user,
+    });
+  }
+
+  mapRequestToEvents(body: WhatsappRequestBody): WhatsappEvent[] {
+    return [new WhatsappEvent(body)];
+  }
+
+  createContext(params: {
+    event: WhatsappEvent;
+    session: Session | null;
+    initialState?: Record<string, any> | null;
+    requestContext?: RequestContext;
+    emitter?: EventEmitter | null;
+  }): WhatsappContext {
+    return new WhatsappContext({
+      ...params,
+      client: this._client,
+    });
+  }
+
+  verifySignature({
+    body,
+    url,
+    headers,
+  }: {
+    headers: Record<string, any>;
+    url: string;
+    body: Record<string, any>;
+  }): boolean {
+    const authToken = this._client.authToken;
+
+    const bufferFromActualSignature = Buffer.from(
+      headers['x-twilio-signature']
+    );
+
+    const bufferFromExpectedSignature = Buffer.from(
+      getExpectedTwilioSignature(authToken, url, body)
+    );
+
+    // return early here if buffer lengths are not equal since timingSafeEqual
+    // will throw if buffer lengths are not equal
+    if (
+      bufferFromActualSignature.length !== bufferFromExpectedSignature.length
+    ) {
+      return false;
+    }
+
+    return crypto.timingSafeEqual(
+      bufferFromActualSignature,
+      bufferFromExpectedSignature
+    );
+  }
+
+  preprocess({
+    url,
+    headers,
+    rawBody,
+    body,
+  }: {
+    method: string;
+    url: string;
+    headers: Record<string, any>;
+    query: Record<string, any>;
+    rawBody: string;
+    body: Record<string, any>;
+  }) {
+    if (this.verifySignature({ body, url, headers })) {
+      return {
+        shouldNext: true,
+      };
+    }
+
+    const error = {
+      message: 'WhatsApp Signature Validation Failed!',
+      request: {
+        rawBody,
+        headers: {
+          'x-twilio-signature': headers['x-twilio-signature'],
+        },
+      },
+    };
+
+    return {
+      shouldNext: false,
+      response: {
+        status: 400,
+        body: { error },
+      },
+    };
+  }
+}
diff --git a/packages/bottender/src/whatsapp/WhatsappContext.ts b/packages/bottender/src/whatsapp/WhatsappContext.ts
new file mode 100644
index 00000000..51f03b74
--- /dev/null
+++ b/packages/bottender/src/whatsapp/WhatsappContext.ts
@@ -0,0 +1,49 @@
+import Context from '../context/Context';
+
+import TwilioClient from './TwilioClient';
+import WhatsappEvent from './WhatsappEvent';
+
+class WhatsappContext extends Context<TwilioClient, WhatsappEvent> {
+  /**
+   * The name of the platform.
+   *
+   */
+  get platform(): string {
+    return 'whatsapp';
+  }
+
+  /**
+   * Send text to the owner of then session.
+   *
+   */
+  async sendText(
+    text: string,
+    options?: {
+      maxPrice?: number;
+      provideFeedback?: boolean;
+      validityPeriod?: number;
+      forceDelivery?: boolean;
+      smartEncoded?: boolean;
+      persistentAction?: string[];
+      mediaUrl?: string[];
+    }
+  ): Promise<any> {
+    const from =
+      this._event.rawEvent.smsStatus === 'received'
+        ? this._event.rawEvent.to
+        : this._event.rawEvent.from;
+    const to =
+      this._event.rawEvent.smsStatus === 'received'
+        ? this._event.rawEvent.from
+        : this._event.rawEvent.to;
+
+    return this._client.createMessage({
+      from,
+      to,
+      body: text,
+      ...options,
+    });
+  }
+}
+
+export default WhatsappContext;
diff --git a/packages/bottender/src/whatsapp/WhatsappEvent.ts b/packages/bottender/src/whatsapp/WhatsappEvent.ts
new file mode 100644
index 00000000..7760e41a
--- /dev/null
+++ b/packages/bottender/src/whatsapp/WhatsappEvent.ts
@@ -0,0 +1,149 @@
+import { Event } from '../context/Event';
+
+import {
+  MediaMessageReceived,
+  MessageDelivered,
+  MessageRead,
+  MessageReceived,
+  MessageSent,
+  WhatsappRawEvent,
+} from './WhatsappTypes';
+
+export default class WhatsappEvent implements Event<WhatsappRawEvent> {
+  _rawEvent: WhatsappRawEvent;
+
+  constructor(rawEvent: WhatsappRawEvent) {
+    this._rawEvent = rawEvent;
+  }
+
+  /**
+   * Underlying raw event from WhatsApp.
+   *
+   */
+  get rawEvent(): WhatsappRawEvent {
+    return this._rawEvent;
+  }
+
+  /**
+   * Determine if the event is a message event.
+   *
+   */
+  get isMessage(): boolean {
+    return this._rawEvent.smsStatus === 'received';
+  }
+
+  /**
+   * The message object from Messenger raw event.
+   *
+   */
+  get message(): MessageReceived | null {
+    return this.isMessage ? (this._rawEvent as MessageReceived) : null;
+  }
+
+  /**
+   * Determine if the event is a message event which includes text.
+   *
+   */
+  get isText(): boolean {
+    return (
+      this._rawEvent.smsStatus === 'received' && this._rawEvent.numMedia === '0'
+    );
+  }
+
+  /**
+   * The text string from Messenger raw event.
+   *
+   */
+  get text(): string | null {
+    return (this._rawEvent as MessageReceived).body || null;
+  }
+
+  /**
+   * Determine if the event is a message event which includes media.
+   *
+   */
+  get isMedia(): boolean {
+    return (
+      this._rawEvent.smsStatus === 'received' && this._rawEvent.numMedia === '1'
+    );
+  }
+
+  /**
+   * The media object from Messenger raw event.
+   *
+   */
+  get media(): { contentType: string; url: string } | null {
+    if (!this.isMedia) return null;
+
+    const rawEvent = this._rawEvent as MediaMessageReceived;
+
+    return {
+      contentType: rawEvent.mediaContentType0,
+      url: rawEvent.mediaUrl0,
+    };
+  }
+
+  /**
+   * Determine if the event is a message received event.
+   *
+   */
+  get isReceived(): boolean {
+    return this._rawEvent.smsStatus === 'received';
+  }
+
+  /**
+   * The received object from WhatsApp raw event.
+   *
+   */
+  get received(): MessageReceived | null {
+    return this.isReceived ? (this._rawEvent as MessageReceived) : null;
+  }
+
+  /**
+   * Determine if the event is a message sent event.
+   *
+   */
+  get isSent(): boolean {
+    return this._rawEvent.smsStatus === 'sent';
+  }
+
+  /**
+   * The sent object from WhatsApp raw event.
+   *
+   */
+  get sent(): MessageSent | null {
+    return this.isSent ? (this._rawEvent as MessageSent) : null;
+  }
+
+  /**
+   * Determine if the event is a message delivered event.
+   *
+   */
+  get isDelivered(): boolean {
+    return this._rawEvent.smsStatus === 'delivered';
+  }
+
+  /**
+   * The delivered object from WhatsApp raw event.
+   *
+   */
+  get delivered(): MessageDelivered | null {
+    return this.isDelivered ? (this._rawEvent as MessageDelivered) : null;
+  }
+
+  /**
+   * Determine if the event is a message read event.
+   *
+   */
+  get isRead(): boolean {
+    return this._rawEvent.smsStatus === 'read';
+  }
+
+  /**
+   * The read object from WhatsApp raw event.
+   *
+   */
+  get read(): MessageRead | null {
+    return this.isRead ? (this._rawEvent as MessageRead) : null;
+  }
+}
diff --git a/packages/bottender/src/whatsapp/WhatsappTypes.ts b/packages/bottender/src/whatsapp/WhatsappTypes.ts
new file mode 100644
index 00000000..e1eb12c8
--- /dev/null
+++ b/packages/bottender/src/whatsapp/WhatsappTypes.ts
@@ -0,0 +1,131 @@
+export type MessageReceivedCommon = {
+  /**
+   * A 34 character unique identifier for the message. May be used to later retrieve this message from the REST API.
+   */
+  messageSid: string;
+  /**
+   * A 34 character unique identifier for the message. May be used to later retrieve this message from the REST API.
+   */
+  smsMessageSid: string;
+  /**
+   * Same value as MessageSid. Deprecated and included for backward compatibility.
+   */
+  smsSid: string;
+  /**
+   * The delivery status of the message.
+   */
+  smsStatus: 'received';
+  /**
+   * The 34 character id of the Account this message is associated with.
+   */
+  accountSid: string;
+  /**
+   * The phone number or Channel address that sent this message.
+   */
+  from: string;
+  /**
+   * The phone number or Channel address of the recipient.
+   */
+  to: string;
+  /**
+   * The text body of the message. Up to 1600 characters long.
+   */
+  body: string;
+  /**
+   * For outbound messages, this property indicates the number of SMS messages it took to deliver the body of the message.
+   */
+  numSegments: string;
+  /**
+   * The version of the API.
+   */
+  apiVersion: '2010-04-01';
+  /**
+   * The city of the sender
+   */
+  fromCity?: string;
+  /**
+   * The state or province of the sender.
+   */
+  fromState?: string;
+  /**
+   * The postal code of the called sender.
+   */
+  fromZip?: string;
+  /**
+   * The country of the called sender.
+   */
+  fromCountry?: string;
+  /**
+   * The city of the recipient.
+   */
+  toCity?: string;
+  /**
+   * The state or province of the recipient.
+   */
+  toState?: string;
+  /**
+   * The postal code of the recipient.
+   */
+  toZip?: string;
+  /**
+   * The country of the recipient.
+   */
+  toCountry?: string;
+};
+
+export type TextMessageReceived = MessageReceivedCommon & {
+  /**
+   * The number of media items associated with your message
+   */
+  numMedia: '0';
+};
+
+/**
+ * Twilio sends form variables named MediaUrlX, where X is a zero-based index.
+ * WhatsApp messages will only contain one media file per incoming message, so you can access the file at MediaUrl0 on the incoming request from Twilio to your webhook URL.
+ */
+export type MediaMessageReceived = MessageReceivedCommon & {
+  numMedia: '1';
+  /**
+   * The ContentTypes for the Media stored at MediaUrl0.
+   */
+  mediaContentType0: string;
+  /**
+   * A URL referencing the content of the media received in the Message.
+   */
+  mediaUrl0: string;
+};
+
+export type MessageReceived = TextMessageReceived | MediaMessageReceived;
+
+export type MessageStatusCommon<S extends string> = {
+  smsStatus: S;
+  messageStatus: S;
+  smsSid: string;
+  channelToAddress: string;
+  to: string;
+  channelPrefix: 'whatsapp';
+  messageSid: string;
+  accountSid: string;
+  from: string;
+  apiVersion: '2010-04-01';
+  channelInstallSid: string;
+};
+
+export type MessageSent = MessageStatusCommon<'sent'> & {
+  structuredMessage: 'false';
+};
+
+export type MessageDelivered = MessageStatusCommon<'delivered'> & {
+  eventType: 'DELIVERED';
+};
+
+export type MessageRead = MessageStatusCommon<'read'> & {
+  eventType: 'READ';
+};
+
+export type WhatsappRawEvent =
+  | MessageReceived
+  | MessageSent
+  | MessageDelivered
+  | MessageRead;
diff --git a/packages/bottender/src/whatsapp/routes.ts b/packages/bottender/src/whatsapp/routes.ts
new file mode 100644
index 00000000..b576d91e
--- /dev/null
+++ b/packages/bottender/src/whatsapp/routes.ts
@@ -0,0 +1,99 @@
+import Context from '../context/Context';
+import { Action, Client, Event } from '../types';
+import { RoutePredicate, route } from '../router';
+
+type Route = <C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) => {
+  predicate: RoutePredicate<C, E>;
+  action: Action<C, E>;
+};
+
+type Whatsapp = Route & {
+  message: Route;
+  media: Route;
+  received: Route;
+  sent: Route;
+  delivered: Route;
+  read: Route;
+};
+
+const whatsapp: Whatsapp = <C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) => {
+  return route(context => context.platform === 'whatsapp', action);
+};
+
+function message<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'whatsapp' && context.event.isMessage,
+    action
+  );
+}
+
+whatsapp.message = message;
+
+function media<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'whatsapp' && context.event.isMedia,
+    action
+  );
+}
+
+whatsapp.media = media;
+
+function received<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'whatsapp' && context.event.isReceived,
+    action
+  );
+}
+
+whatsapp.received = received;
+
+function sent<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'whatsapp' && context.event.isSent,
+    action
+  );
+}
+
+whatsapp.sent = sent;
+
+function delivered<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'whatsapp' && context.event.isDelivered,
+    action
+  );
+}
+
+whatsapp.delivered = delivered;
+
+function read<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'whatsapp' && context.event.isRead,
+    action
+  );
+}
+
+whatsapp.read = read;
+
+export default whatsapp;
diff --git a/packages/create-bottender-app/src/index.ts b/packages/create-bottender-app/src/index.ts
index 34c1c93e..b758b7d8 100644
--- a/packages/create-bottender-app/src/index.ts
+++ b/packages/create-bottender-app/src/index.ts
@@ -74,7 +74,7 @@ const getQuestions = (): Record<string, any>[] => [
     name: 'platforms',
     message: 'What platform of bot do you want to create?',
     type: 'checkbox',
-    choices: ['messenger', 'line', 'slack', 'telegram', 'viber'],
+    choices: ['messenger', 'whatsapp', 'line', 'slack', 'telegram', 'viber'],
   },
   {
     name: 'session',
diff --git a/packages/create-bottender-app/src/types.ts b/packages/create-bottender-app/src/types.ts
index f22e0c4e..ed9d8442 100644
--- a/packages/create-bottender-app/src/types.ts
+++ b/packages/create-bottender-app/src/types.ts
@@ -1,4 +1,10 @@
-export type Platform = 'messenger' | 'line' | 'slack' | 'telegram' | 'viber';
+export type Platform =
+  | 'messenger'
+  | 'whatsapp'
+  | 'line'
+  | 'slack'
+  | 'telegram'
+  | 'viber';
 export type Session = 'memory' | 'file' | 'redis' | 'mongo';
 export type Answer = {
   name: string;
diff --git a/packages/create-bottender-app/src/utils/generateConfig.ts b/packages/create-bottender-app/src/utils/generateConfig.ts
index 9c30fafa..84a27ded 100644
--- a/packages/create-bottender-app/src/utils/generateConfig.ts
+++ b/packages/create-bottender-app/src/utils/generateConfig.ts
@@ -37,6 +37,12 @@ const generateConfig = (session: Session, platforms: Platform[]): string =>
       appSecret: process.env.MESSENGER_APP_SECRET,
       verifyToken: process.env.MESSENGER_VERIFY_TOKEN,
     },
+    whatsapp: {
+      enabled: ${platforms.includes('whatsapp')},
+      path: '/webhooks/whatsapp',
+      accountSid: process.env.WHATSAPP_ACCOUNT_SID,
+      authToken: process.env.WHATSAPP_AUTH_TOKEN,
+    },
     line: {
       enabled: ${platforms.includes('line')},
       path: '/webhooks/line',
diff --git a/packages/create-bottender-app/template-typescript/.env.example b/packages/create-bottender-app/template-typescript/.env.example
index 6f0706fd..a670ac45 100644
--- a/packages/create-bottender-app/template-typescript/.env.example
+++ b/packages/create-bottender-app/template-typescript/.env.example
@@ -4,6 +4,9 @@ MESSENGER_APP_ID=
 MESSENGER_APP_SECRET=
 MESSENGER_VERIFY_TOKEN=
 
+WHATSAPP_ACCOUNT_SID=
+WHATSAPP_AUTH_TOKEN=
+
 LINE_ACCESS_TOKEN=
 LINE_CHANNEL_SECRET=
 
diff --git a/packages/create-bottender-app/template/.env.example b/packages/create-bottender-app/template/.env.example
index 5aa76b36..8876dbe0 100644
--- a/packages/create-bottender-app/template/.env.example
+++ b/packages/create-bottender-app/template/.env.example
@@ -4,6 +4,9 @@ MESSENGER_APP_ID=
 MESSENGER_APP_SECRET=
 MESSENGER_VERIFY_TOKEN=
 
+WHATSAPP_ACCOUNT_SID=
+WHATSAPP_AUTH_TOKEN=
+
 LINE_ACCESS_TOKEN=
 LINE_CHANNEL_SECRET=
 


Test patch:
diff --git a/packages/bottender/src/whatsapp/__tests__/WhatsappBot.spec.ts b/packages/bottender/src/whatsapp/__tests__/WhatsappBot.spec.ts
new file mode 100644
index 00000000..32095fdc
--- /dev/null
+++ b/packages/bottender/src/whatsapp/__tests__/WhatsappBot.spec.ts
@@ -0,0 +1,14 @@
+import WhatsappBot from '../WhatsappBot';
+import WhatsappConnector from '../WhatsappConnector';
+
+it('should construct bot with WhatsappConnector', () => {
+  const bot = new WhatsappBot({
+    accountSid: 'ACCOUNT_SID',
+    authToken: 'AUTH_TOKEN',
+  });
+  expect(bot).toBeDefined();
+  expect(bot.onEvent).toBeDefined();
+  expect(bot.createRequestHandler).toBeDefined();
+  expect(bot.connector).toBeDefined();
+  expect(bot.connector).toBeInstanceOf(WhatsappConnector);
+});
diff --git a/packages/bottender/src/whatsapp/__tests__/WhatsappConnector.spec.ts b/packages/bottender/src/whatsapp/__tests__/WhatsappConnector.spec.ts
new file mode 100644
index 00000000..621ffd53
--- /dev/null
+++ b/packages/bottender/src/whatsapp/__tests__/WhatsappConnector.spec.ts
@@ -0,0 +1,275 @@
+import TwilioClient from '../TwilioClient';
+import WhatsappConnector from '../WhatsappConnector';
+import WhatsappContext from '../WhatsappContext';
+import WhatsappEvent from '../WhatsappEvent';
+import {
+  MessageDelivered,
+  MessageRead,
+  MessageSent,
+  TextMessageReceived,
+} from '../WhatsappTypes';
+
+const messageReceived: TextMessageReceived = {
+  smsMessageSid: 'SM7cd85aed706d25735d1c8019234572df',
+  numMedia: '0',
+  smsSid: 'SM7cd85aed706d25735d1c8019234572df',
+  smsStatus: 'received',
+  body: 'hi',
+  to: 'whatsapp:+14155238886',
+  numSegments: '1',
+  messageSid: 'SM7cd85aed706d25735d1c8019234572df',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+886123456789',
+  apiVersion: '2010-04-01',
+};
+
+const messageSent: MessageSent = {
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'sent',
+  messageStatus: 'sent',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  structuredMessage: 'false',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XEcc20d939f803ee381f2442185d0XXXXX',
+};
+
+const messageDelivered: MessageDelivered = {
+  eventType: 'DELIVERED',
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'delivered',
+  messageStatus: 'delivered',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XE85c014c372541a32e1102eb1631XXXXX',
+};
+
+const messageRead: MessageRead = {
+  eventType: 'READ',
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'read',
+  messageStatus: 'read',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XE85c014c372541a32e1102eb1631XXXXX',
+};
+
+function setup() {
+  const connector = new WhatsappConnector({
+    accountSid: 'ACCOUNT_SID',
+    authToken: 'AUTH_TOKEN',
+  });
+
+  return {
+    connector,
+  };
+}
+
+describe('#platform', () => {
+  it('should be whatsapp', () => {
+    const { connector } = setup();
+    expect(connector.platform).toBe('whatsapp');
+  });
+});
+
+describe('#client', () => {
+  it('should be client', () => {
+    const { connector } = setup();
+    expect(connector.client).toBeInstanceOf(TwilioClient);
+  });
+
+  it('support custom client', () => {
+    const client = TwilioClient.connect({
+      accountSid: 'ACCOUNT_SID',
+      authToken: 'AUTH_TOKEN',
+    });
+    const connector = new WhatsappConnector({ client });
+    expect(connector.client).toBe(client);
+  });
+});
+
+describe('#getUniqueSessionKey', () => {
+  it('should work with the received event', () => {
+    const { connector } = setup();
+
+    const sessionKey = connector.getUniqueSessionKey(messageReceived);
+
+    expect(sessionKey).toEqual('whatsapp:+886123456789');
+  });
+
+  it('should work with the sent event', () => {
+    const { connector } = setup();
+
+    const sessionKey = connector.getUniqueSessionKey(messageSent);
+
+    expect(sessionKey).toEqual('whatsapp:+886123456789');
+  });
+
+  it('should work with the delivered event', () => {
+    const { connector } = setup();
+
+    const sessionKey = connector.getUniqueSessionKey(messageDelivered);
+
+    expect(sessionKey).toEqual('whatsapp:+886123456789');
+  });
+
+  it('should work with the read event', () => {
+    const { connector } = setup();
+
+    const sessionKey = connector.getUniqueSessionKey(messageRead);
+
+    expect(sessionKey).toEqual('whatsapp:+886123456789');
+  });
+});
+
+describe('#updateSession', () => {
+  it('should update user id on session to `from` string when the message is received', async () => {
+    const { connector } = setup();
+
+    const session = {};
+    await connector.updateSession(session, messageReceived);
+
+    expect(session).toEqual({
+      user: {
+        _updatedAt: expect.any(String),
+        id: 'whatsapp:+886123456789',
+      },
+    });
+  });
+
+  it('should update user id on session to `to` string when the message is sent', async () => {
+    const { connector } = setup();
+
+    const session = {};
+    await connector.updateSession(session, messageSent);
+
+    expect(session).toEqual({
+      user: {
+        _updatedAt: expect.any(String),
+        id: 'whatsapp:+886123456789',
+      },
+    });
+  });
+
+  it('should update user id on session to `to` string when the message is delivered', async () => {
+    const { connector } = setup();
+
+    const session = {};
+    await connector.updateSession(session, messageDelivered);
+
+    expect(session).toEqual({
+      user: {
+        _updatedAt: expect.any(String),
+        id: 'whatsapp:+886123456789',
+      },
+    });
+  });
+
+  it('should update user id on session to `to` string when the message is read', async () => {
+    const { connector } = setup();
+
+    const session = {};
+    await connector.updateSession(session, messageRead);
+
+    expect(session).toEqual({
+      user: {
+        _updatedAt: expect.any(String),
+        id: 'whatsapp:+886123456789',
+      },
+    });
+  });
+});
+
+describe('#mapRequestToEvents', () => {
+  it('should map request to WhatsappEvent', () => {
+    const { connector } = setup();
+    const events = connector.mapRequestToEvents(messageReceived);
+
+    expect(events).toHaveLength(1);
+    expect(events[0]).toBeInstanceOf(WhatsappEvent);
+  });
+});
+
+describe('#createContext', () => {
+  it('should create WhatsappContext', async () => {
+    const { connector } = setup();
+    const event = new WhatsappEvent(messageReceived);
+    const session = {};
+
+    const context = await connector.createContext({
+      event,
+      session,
+    });
+
+    expect(context).toBeDefined();
+    expect(context).toBeInstanceOf(WhatsappContext);
+  });
+});
+
+describe('#preprocess', () => {
+  it('should return shouldNext: true if signature match', () => {
+    const { connector } = setup();
+
+    expect(
+      connector.preprocess({
+        method: 'post',
+        url: 'http://xxxxxxxx.ngrok.io/webhooks/whatsapp',
+        headers: {
+          'x-twilio-signature': 'Aw+rhj2Zc+2pBslFUXMhzqUnmaM=',
+        },
+        query: {},
+        rawBody: '{}',
+        body: {},
+      })
+    ).toEqual({
+      shouldNext: true,
+    });
+  });
+
+  it('should return shouldNext: false and error if signature does not match', () => {
+    const { connector } = setup();
+
+    expect(
+      connector.preprocess({
+        method: 'post',
+        url: 'http://xxxxxxxx.ngrok.io/webhooks/whatsapp',
+        headers: {
+          'x-twilio-signature': 'hu/qdx0P1E0D7WJNUIRLKWDbvsI=',
+        },
+        query: {},
+        rawBody: '{}',
+        body: {},
+      })
+    ).toEqual({
+      shouldNext: false,
+      response: {
+        body: {
+          error: {
+            message: 'WhatsApp Signature Validation Failed!',
+            request: {
+              headers: {
+                'x-twilio-signature': 'hu/qdx0P1E0D7WJNUIRLKWDbvsI=',
+              },
+              rawBody: '{}',
+            },
+          },
+        },
+        status: 400,
+      },
+    });
+  });
+});
diff --git a/packages/bottender/src/whatsapp/__tests__/WhatsappContext.spec.ts b/packages/bottender/src/whatsapp/__tests__/WhatsappContext.spec.ts
new file mode 100644
index 00000000..1b3122c9
--- /dev/null
+++ b/packages/bottender/src/whatsapp/__tests__/WhatsappContext.spec.ts
@@ -0,0 +1,84 @@
+import Context from '../../context/Context';
+import TwilioClient from '../TwilioClient';
+import WhatsappContext from '../WhatsappContext';
+import WhatsappEvent from '../WhatsappEvent';
+
+const event = new WhatsappEvent({
+  smsMessageSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  numMedia: '0',
+  smsSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  smsStatus: 'received',
+  body: 'hi',
+  to: 'whatsapp:+14155238886',
+  numSegments: '1',
+  messageSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+886123456789',
+  apiVersion: '2010-04-01',
+});
+
+function setup() {
+  const client = new TwilioClient({
+    accountSid: 'ACCOUNT_SID',
+    authToken: 'AUTH_TOKEN',
+  });
+
+  client.createMessage = jest.fn();
+
+  const context = new WhatsappContext({
+    client,
+    event,
+  });
+
+  return {
+    client,
+    context,
+  };
+}
+
+it('#platform to be `whatsapp`', () => {
+  const { context } = setup();
+
+  expect(context.platform).toBe('whatsapp');
+});
+
+describe('#sendText', () => {
+  it('should call client.createMessage', async () => {
+    const { client, context } = setup();
+
+    await context.sendText('hi');
+
+    expect(client.createMessage).toBeCalledWith({
+      body: 'hi',
+      from: 'whatsapp:+14155238886',
+      to: 'whatsapp:+886123456789',
+    });
+  });
+
+  it('should accept other options', async () => {
+    const { client, context } = setup();
+
+    await context.sendText('hi', {
+      maxPrice: 10,
+      provideFeedback: true,
+      validityPeriod: 10,
+      forceDelivery: true,
+      smartEncoded: true,
+      persistentAction: [],
+      mediaUrl: [],
+    });
+
+    expect(client.createMessage).toBeCalledWith({
+      body: 'hi',
+      from: 'whatsapp:+14155238886',
+      to: 'whatsapp:+886123456789',
+      maxPrice: 10,
+      provideFeedback: true,
+      validityPeriod: 10,
+      forceDelivery: true,
+      smartEncoded: true,
+      persistentAction: [],
+      mediaUrl: [],
+    });
+  });
+});
diff --git a/packages/bottender/src/whatsapp/__tests__/WhatsappEvent.spec.ts b/packages/bottender/src/whatsapp/__tests__/WhatsappEvent.spec.ts
new file mode 100644
index 00000000..835ecf42
--- /dev/null
+++ b/packages/bottender/src/whatsapp/__tests__/WhatsappEvent.spec.ts
@@ -0,0 +1,224 @@
+import WhatsappEvent from '../WhatsappEvent';
+import {
+  MediaMessageReceived,
+  MessageDelivered,
+  MessageRead,
+  MessageSent,
+  TextMessageReceived,
+} from '../WhatsappTypes';
+
+const textMessageReceived: TextMessageReceived = {
+  smsMessageSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  numMedia: '0',
+  smsSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  smsStatus: 'received',
+  body: 'hi',
+  to: 'whatsapp:+14155238886',
+  numSegments: '1',
+  messageSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+886123456789',
+  apiVersion: '2010-04-01',
+};
+
+const imageMessageReceived: MediaMessageReceived = {
+  mediaContentType0: 'image/jpeg',
+  smsMessageSid: 'MMad0463f6e2a946b3fc91d9a04a2XXXXX',
+  numMedia: '1',
+  smsSid: 'MMad0463f6e2a946b3fc91d9a04a2XXXXX',
+  smsStatus: 'received',
+  body: 'xd',
+  to: 'whatsapp:+14155238886',
+  numSegments: '1',
+  messageSid: 'MMad0463f6e2a946b3fc91d9a04a2XXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+886123456789',
+  mediaUrl0:
+    'https://api.twilio.com/2010-04-01/Accounts/ACf19dfb164f82b2c9d6178c6ada3XXXXX/Messages/MMad0463f6e2a946b3fc91d9a04a2XXXXX/Media/MEfaf3decca478ebeb4924fe523ff7fdb2',
+  apiVersion: '2010-04-01',
+};
+
+const messageSent: MessageSent = {
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'sent',
+  messageStatus: 'sent',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  structuredMessage: 'false',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XEcc20d939f803ee381f2442185d0XXXXX',
+};
+
+const messageDelivered: MessageDelivered = {
+  eventType: 'DELIVERED',
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'delivered',
+  messageStatus: 'delivered',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XE85c014c372541a32e1102eb1631XXXXX',
+};
+
+const messageRead: MessageRead = {
+  eventType: 'READ',
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'read',
+  messageStatus: 'read',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XE85c014c372541a32e1102eb1631XXXXX',
+};
+
+it('#rawEvent', () => {
+  expect(new WhatsappEvent(textMessageReceived).rawEvent).toEqual(
+    textMessageReceived
+  );
+  expect(new WhatsappEvent(imageMessageReceived).rawEvent).toEqual(
+    imageMessageReceived
+  );
+  expect(new WhatsappEvent(messageSent).rawEvent).toEqual(messageSent);
+  expect(new WhatsappEvent(messageDelivered).rawEvent).toEqual(
+    messageDelivered
+  );
+  expect(new WhatsappEvent(messageRead).rawEvent).toEqual(messageRead);
+});
+
+it('#isMessage', () => {
+  expect(new WhatsappEvent(textMessageReceived).isMessage).toEqual(true);
+  expect(new WhatsappEvent(imageMessageReceived).isMessage).toEqual(true);
+  expect(new WhatsappEvent(messageSent).isMessage).toEqual(false);
+  expect(new WhatsappEvent(messageDelivered).isMessage).toEqual(false);
+  expect(new WhatsappEvent(messageRead).isMessage).toEqual(false);
+});
+
+it('#message', () => {
+  expect(new WhatsappEvent(textMessageReceived).message).toEqual(
+    textMessageReceived
+  );
+  expect(new WhatsappEvent(imageMessageReceived).message).toEqual(
+    imageMessageReceived
+  );
+  expect(new WhatsappEvent(messageSent).message).toEqual(null);
+  expect(new WhatsappEvent(messageDelivered).message).toEqual(null);
+  expect(new WhatsappEvent(messageRead).message).toEqual(null);
+});
+
+it('#isText', () => {
+  expect(new WhatsappEvent(textMessageReceived).isText).toEqual(true);
+  expect(new WhatsappEvent(imageMessageReceived).isText).toEqual(false);
+  expect(new WhatsappEvent(messageSent).isText).toEqual(false);
+  expect(new WhatsappEvent(messageDelivered).isText).toEqual(false);
+  expect(new WhatsappEvent(messageRead).isText).toEqual(false);
+});
+
+it('#text', () => {
+  expect(new WhatsappEvent(textMessageReceived).text).toEqual('hi');
+  expect(new WhatsappEvent(imageMessageReceived).text).toEqual('xd');
+  expect(new WhatsappEvent(messageSent).text).toEqual(null);
+  expect(new WhatsappEvent(messageDelivered).text).toEqual(null);
+  expect(new WhatsappEvent(messageRead).text).toEqual(null);
+});
+
+it('#isMedia', () => {
+  expect(new WhatsappEvent(textMessageReceived).isMedia).toEqual(false);
+  expect(new WhatsappEvent(imageMessageReceived).isMedia).toEqual(true);
+  expect(new WhatsappEvent(messageSent).isMedia).toEqual(false);
+  expect(new WhatsappEvent(messageDelivered).isMedia).toEqual(false);
+  expect(new WhatsappEvent(messageRead).isMedia).toEqual(false);
+});
+
+it('#media', () => {
+  expect(new WhatsappEvent(textMessageReceived).media).toEqual(null);
+  expect(new WhatsappEvent(imageMessageReceived).media).toEqual({
+    contentType: 'image/jpeg',
+    url:
+      'https://api.twilio.com/2010-04-01/Accounts/ACf19dfb164f82b2c9d6178c6ada3XXXXX/Messages/MMad0463f6e2a946b3fc91d9a04a2XXXXX/Media/MEfaf3decca478ebeb4924fe523ff7fdb2',
+  });
+  expect(new WhatsappEvent(messageSent).media).toEqual(null);
+  expect(new WhatsappEvent(messageDelivered).media).toEqual(null);
+  expect(new WhatsappEvent(messageRead).media).toEqual(null);
+});
+
+it('#isReceived', () => {
+  expect(new WhatsappEvent(textMessageReceived).isReceived).toEqual(true);
+  expect(new WhatsappEvent(imageMessageReceived).isReceived).toEqual(true);
+  expect(new WhatsappEvent(messageSent).isReceived).toEqual(false);
+  expect(new WhatsappEvent(messageDelivered).isReceived).toEqual(false);
+  expect(new WhatsappEvent(messageRead).isReceived).toEqual(false);
+});
+
+it('#received', () => {
+  expect(new WhatsappEvent(textMessageReceived).received).toEqual(
+    textMessageReceived
+  );
+  expect(new WhatsappEvent(imageMessageReceived).received).toEqual(
+    imageMessageReceived
+  );
+  expect(new WhatsappEvent(messageSent).received).toEqual(null);
+  expect(new WhatsappEvent(messageDelivered).received).toEqual(null);
+  expect(new WhatsappEvent(messageRead).received).toEqual(null);
+});
+
+it('#isSent', () => {
+  expect(new WhatsappEvent(textMessageReceived).isSent).toEqual(false);
+  expect(new WhatsappEvent(imageMessageReceived).isSent).toEqual(false);
+  expect(new WhatsappEvent(messageSent).isSent).toEqual(true);
+  expect(new WhatsappEvent(messageDelivered).isSent).toEqual(false);
+  expect(new WhatsappEvent(messageRead).isSent).toEqual(false);
+});
+
+it('#sent', () => {
+  expect(new WhatsappEvent(textMessageReceived).sent).toEqual(null);
+  expect(new WhatsappEvent(imageMessageReceived).sent).toEqual(null);
+  expect(new WhatsappEvent(messageSent).sent).toEqual(messageSent);
+  expect(new WhatsappEvent(messageDelivered).sent).toEqual(null);
+  expect(new WhatsappEvent(messageRead).sent).toEqual(null);
+});
+
+it('#isDelivered', () => {
+  expect(new WhatsappEvent(textMessageReceived).isDelivered).toEqual(false);
+  expect(new WhatsappEvent(imageMessageReceived).isDelivered).toEqual(false);
+  expect(new WhatsappEvent(messageSent).isDelivered).toEqual(false);
+  expect(new WhatsappEvent(messageDelivered).isDelivered).toEqual(true);
+  expect(new WhatsappEvent(messageRead).isDelivered).toEqual(false);
+});
+
+it('#delivered', () => {
+  expect(new WhatsappEvent(textMessageReceived).delivered).toEqual(null);
+  expect(new WhatsappEvent(imageMessageReceived).delivered).toEqual(null);
+  expect(new WhatsappEvent(messageSent).delivered).toEqual(null);
+  expect(new WhatsappEvent(messageDelivered).delivered).toEqual(
+    messageDelivered
+  );
+  expect(new WhatsappEvent(messageRead).delivered).toEqual(null);
+});
+
+it('#isRead', () => {
+  expect(new WhatsappEvent(textMessageReceived).isRead).toEqual(false);
+  expect(new WhatsappEvent(imageMessageReceived).isRead).toEqual(false);
+  expect(new WhatsappEvent(messageSent).isRead).toEqual(false);
+  expect(new WhatsappEvent(messageDelivered).isRead).toEqual(false);
+  expect(new WhatsappEvent(messageRead).isRead).toEqual(true);
+});
+
+it('#read', () => {
+  expect(new WhatsappEvent(textMessageReceived).read).toEqual(null);
+  expect(new WhatsappEvent(imageMessageReceived).read).toEqual(null);
+  expect(new WhatsappEvent(messageSent).read).toEqual(null);
+  expect(new WhatsappEvent(messageDelivered).read).toEqual(null);
+  expect(new WhatsappEvent(messageRead).read).toEqual(messageRead);
+});
diff --git a/packages/bottender/src/whatsapp/__tests__/routes.spec.ts b/packages/bottender/src/whatsapp/__tests__/routes.spec.ts
new file mode 100644
index 00000000..c0c32f24
--- /dev/null
+++ b/packages/bottender/src/whatsapp/__tests__/routes.spec.ts
@@ -0,0 +1,258 @@
+import Context from '../../context/Context';
+import WhatsappContext from '../WhatsappContext';
+import WhatsappEvent from '../WhatsappEvent';
+import router from '../../router';
+import whatsapp from '../routes';
+import { run } from '../../bot/Bot';
+
+const whatsappEventTextMessageReceived = new WhatsappEvent({
+  smsMessageSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  numMedia: '0',
+  smsSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  smsStatus: 'received',
+  body: 'hi',
+  to: 'whatsapp:+14155238886',
+  numSegments: '1',
+  messageSid: 'SM7cd85aed706d25735d1c8019234XXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+886123456789',
+  apiVersion: '2010-04-01',
+});
+
+const whatsappEventMediaMessageReceived = new WhatsappEvent({
+  mediaContentType0: 'image/jpeg',
+  smsMessageSid: 'MMad0463f6e2a946b3fc91d9a04a2XXXXX',
+  numMedia: '1',
+  smsSid: 'MMad0463f6e2a946b3fc91d9a04a2XXXXX',
+  smsStatus: 'received',
+  body: 'xd',
+  to: 'whatsapp:+14155238886',
+  numSegments: '1',
+  messageSid: 'MMad0463f6e2a946b3fc91d9a04a2XXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+886123456789',
+  mediaUrl0:
+    'https://api.twilio.com/2010-04-01/Accounts/ACf19dfb164f82b2c9d6178c6ada3XXXXX/Messages/MMad0463f6e2a946b3fc91d9a04a2XXXXX/Media/MEfaf3decca478ebeb4924fe523ff7fdb2',
+  apiVersion: '2010-04-01',
+});
+
+const whatsappEventMessageSent = new WhatsappEvent({
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'sent',
+  messageStatus: 'sent',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  structuredMessage: 'false',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XEcc20d939f803ee381f2442185d0XXXXX',
+});
+
+const whatsappEventMessageDelivered = new WhatsappEvent({
+  eventType: 'DELIVERED',
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'delivered',
+  messageStatus: 'delivered',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XE85c014c372541a32e1102eb1631XXXXX',
+});
+
+const whatsappEventMessageRead = new WhatsappEvent({
+  eventType: 'READ',
+  smsSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  smsStatus: 'read',
+  messageStatus: 'read',
+  channelToAddress: '+886123456789',
+  to: 'whatsapp:+886123456789',
+  channelPrefix: 'whatsapp',
+  messageSid: 'SM338ac551ecd04d698821b50ea5dXXXXX',
+  accountSid: 'ACf19dfb164f82b2c9d6178c6ada3XXXXX',
+  from: 'whatsapp:+14155238886',
+  apiVersion: '2010-04-01',
+  channelInstallSid: 'XE85c014c372541a32e1102eb1631XXXXX',
+});
+
+async function Action(context) {
+  await context.sendText('hello');
+}
+
+async function expectRouteMatchContext({ route, context }) {
+  const Router = router([route]);
+
+  const app = run(Router);
+
+  context.sendText = jest.fn();
+
+  await app(context);
+
+  expect(context.sendText).toBeCalledWith('hello');
+}
+
+async function expectRouteMatchWhatsappEvent({ route, event }) {
+  const context = new WhatsappContext({
+    client: {} as any,
+    event,
+  });
+
+  await expectRouteMatchContext({
+    route,
+    context,
+  });
+}
+
+async function expectRouteNotMatchContext({ route, context }) {
+  const Router = router([route]);
+
+  const app = run(Router);
+
+  context.sendText = jest.fn();
+
+  await app(context);
+
+  expect(context.sendText).not.toBeCalledWith('hello');
+}
+
+async function expectRouteNotMatchWhatsappEvent({ route, event }) {
+  const context = new WhatsappContext({
+    client: {} as any,
+    event,
+  });
+
+  await expectRouteNotMatchContext({
+    route,
+    context,
+  });
+}
+
+class TestContext extends Context<any, any> {
+  get platform() {
+    return 'test';
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-empty-function
+  sendText() {}
+}
+
+describe('#whatsapp', () => {
+  it('should call action when it receives a whatsapp event', async () => {
+    await expectRouteMatchWhatsappEvent({
+      route: whatsapp(Action),
+      event: whatsappEventTextMessageReceived,
+    });
+  });
+
+  it('should not call action when it receives a non-whatsapp event', async () => {
+    await expectRouteNotMatchContext({
+      route: whatsapp(Action),
+      context: new TestContext({
+        client: {} as any,
+        event: {},
+      }),
+    });
+  });
+
+  describe('#whatsapp.message', () => {
+    it('should call action when it receives a whatsapp message event', async () => {
+      await expectRouteMatchWhatsappEvent({
+        route: whatsapp.message(Action),
+        event: whatsappEventTextMessageReceived,
+      });
+    });
+
+    it('should not call action when it receives a non-message event', async () => {
+      await expectRouteNotMatchWhatsappEvent({
+        route: whatsapp.message(Action),
+        event: whatsappEventMessageRead,
+      });
+    });
+  });
+
+  describe('#whatsapp.media', () => {
+    it('should call action when it receives a whatsapp media event', async () => {
+      await expectRouteMatchWhatsappEvent({
+        route: whatsapp.media(Action),
+        event: whatsappEventMediaMessageReceived,
+      });
+    });
+
+    it('should not call action when it receives a non-media event', async () => {
+      await expectRouteNotMatchWhatsappEvent({
+        route: whatsapp.media(Action),
+        event: whatsappEventTextMessageReceived,
+      });
+    });
+  });
+
+  describe('#whatsapp.received', () => {
+    it('should call action when it receives a whatsapp received event', async () => {
+      await expectRouteMatchWhatsappEvent({
+        route: whatsapp.received(Action),
+        event: whatsappEventTextMessageReceived,
+      });
+    });
+
+    it('should not call action when it receives a non-received event', async () => {
+      await expectRouteNotMatchWhatsappEvent({
+        route: whatsapp.received(Action),
+        event: whatsappEventMessageSent,
+      });
+    });
+  });
+
+  describe('#whatsapp.sent', () => {
+    it('should call action when it receives a whatsapp sent event', async () => {
+      await expectRouteMatchWhatsappEvent({
+        route: whatsapp.sent(Action),
+        event: whatsappEventMessageSent,
+      });
+    });
+
+    it('should not call action when it receives a non-sent event', async () => {
+      await expectRouteNotMatchWhatsappEvent({
+        route: whatsapp.sent(Action),
+        event: whatsappEventTextMessageReceived,
+      });
+    });
+  });
+
+  describe('#whatsapp.delivered', () => {
+    it('should call action when it receives a whatsapp delivered event', async () => {
+      await expectRouteMatchWhatsappEvent({
+        route: whatsapp.delivered(Action),
+        event: whatsappEventMessageDelivered,
+      });
+    });
+
+    it('should not call action when it receives a non-delivered event', async () => {
+      await expectRouteNotMatchWhatsappEvent({
+        route: whatsapp.delivered(Action),
+        event: whatsappEventTextMessageReceived,
+      });
+    });
+  });
+
+  describe('#whatsapp.read', () => {
+    it('should call action when it receives a whatsapp read event', async () => {
+      await expectRouteMatchWhatsappEvent({
+        route: whatsapp.read(Action),
+        event: whatsappEventMessageRead,
+      });
+    });
+
+    it('should not call action when it receives a non-read event', async () => {
+      await expectRouteNotMatchWhatsappEvent({
+        route: whatsapp.read(Action),
+        event: whatsappEventTextMessageReceived,
+      });
+    });
+  });
+});