diff --git a/src/App.spec.ts b/src/App.spec.ts
index af4a203ef..cb294cc13 100644
--- a/src/App.spec.ts
+++ b/src/App.spec.ts
@@ -432,25 +432,24 @@ describe('App', () => {
                 team: {},
                 view: {
                   callback_id: 'view_callback_id',
-                }
+                },
+              },
+              respond: noop,
+              ack: noop,
+            },
+            {
+              body: {
+                type: 'view_closed',
+                channel: {},
+                user: {},
+                team: {},
+                view: {
+                  callback_id: 'view_callback_id',
+                },
               },
               respond: noop,
               ack: noop,
             },
-            // TODO: https://github.com/slackapi/bolt/issues/263
-            // {
-            //   body: {
-            //     type: 'view_closed',
-            //     channel: {},
-            //     user: {},
-            //     team: {},
-            //     view: {
-            //       callback_id: 'view_callback_id',
-            //     }
-            //   },
-            //   respond: noop,
-            //   ack: noop,
-            // },
           ];
         }
 
@@ -467,11 +466,12 @@ describe('App', () => {
           // Act
           const app = new App({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) });
           app.use((_args) => { ackFn(); });
-          app.action('block_action_id', ({ }) => { actionFn(); })
-          app.action({ callback_id: 'message_action_callback_id' }, ({ }) => { actionFn(); })
-          app.action({ callback_id: 'interactive_message_callback_id' }, ({ }) => { actionFn(); })
-          app.action({ callback_id: 'dialog_submission_callback_id' }, ({ }) => { actionFn(); })
-          app.view('view_callback_id', ({ }) => { viewFn(); })
+          app.action('block_action_id', ({ }) => { actionFn(); });
+          app.action({ callback_id: 'message_action_callback_id' }, ({ }) => { actionFn(); });
+          app.action({ callback_id: 'interactive_message_callback_id' }, ({ }) => { actionFn(); });
+          app.action({ callback_id: 'dialog_submission_callback_id' }, ({ }) => { actionFn(); });
+          app.view('view_callback_id', ({ }) => { viewFn(); });
+          app.view({ callback_id: 'view_callback_id', type: 'view_closed' }, ({ }) => { viewFn(); });
           app.options('external_select_action_id', ({ }) => { optionsFn(); });
           app.options({ callback_id: 'dialog_suggestion_callback_id' }, ({ }) => { optionsFn(); });
 
@@ -481,7 +481,7 @@ describe('App', () => {
 
           // Assert
           assert.equal(actionFn.callCount, 4);
-          assert.equal(viewFn.callCount, 1);
+          assert.equal(viewFn.callCount, 2);
           assert.equal(optionsFn.callCount, 2);
           assert.equal(ackFn.callCount, dummyReceiverEvents.length);
           assert(fakeErrorHandler.notCalled);
diff --git a/src/App.ts b/src/App.ts
index 0429a5c3c..2ce68996f 100644
--- a/src/App.ts
+++ b/src/App.ts
@@ -12,8 +12,7 @@ import {
   onlyEvents,
   matchEventType,
   matchMessage,
-  onlyViewSubmits,
-  matchCallbackId,
+  onlyViewActions,
 } from './middleware/builtin';
 import { processMiddleware } from './middleware/process';
 import { ConversationStore, conversationContext, MemoryStore } from './conversation-store';
@@ -33,6 +32,7 @@ import {
   OptionsSource,
   BlockAction,
   InteractiveMessage,
+  SlackViewAction,
   Receiver,
   ReceiverEvent,
 } from './types';
@@ -92,6 +92,11 @@ export interface ActionConstraints {
   callback_id?: string | RegExp;
 }
 
+export interface ViewConstraints {
+  callback_id?: string | RegExp;
+  type?: 'view_closed' | 'view_submission';
+}
+
 export interface ErrorHandler {
   (error: CodedError): void;
 }
@@ -318,9 +323,39 @@ export default class App {
     );
   }
 
-  public view(callbackId: string | RegExp, ...listeners: Middleware<SlackViewMiddlewareArgs>[]): void {
+  public view<ViewActionType extends SlackViewAction = SlackViewAction>(
+    callbackId: string | RegExp,
+    ...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]
+  ): void;
+  public view<ViewActionType extends SlackViewAction = SlackViewAction>(
+    constraints: ViewConstraints,
+    ...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]
+  ): void;
+  public view<ViewActionType extends SlackViewAction = SlackViewAction>(
+    callbackIdOrConstraints: string | RegExp | ViewConstraints,
+    ...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]): void {
+    const constraints: ViewConstraints =
+      (typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints)) ?
+      { callback_id: callbackIdOrConstraints, type: 'view_submission' } : callbackIdOrConstraints;
+    // Fail early if the constraints contain invalid keys
+    const unknownConstraintKeys = Object.keys(constraints)
+      .filter(k => (k !== 'callback_id' && k !== 'type'));
+    if (unknownConstraintKeys.length > 0) {
+      this.logger.error(
+        `View listener cannot be attached using unknown constraint keys: ${unknownConstraintKeys.join(', ')}`,
+      );
+      return;
+    }
+
+    if (constraints.type !== undefined && !validViewTypes.includes(constraints.type)) {
+      this.logger.error(
+        `View listener cannot be attached using unknown view event type: ${constraints.type}`,
+      );
+      return;
+    }
+
     this.listeners.push(
-      [onlyViewSubmits, matchCallbackId(callbackId), ...listeners] as Middleware<AnyMiddlewareArgs>[],
+      [onlyViewActions, matchConstraints(constraints), ...listeners] as Middleware<AnyMiddlewareArgs>[],
     );
   }
 
@@ -384,7 +419,7 @@ export default class App {
         payload:
           (type === IncomingEventType.Event) ?
             (bodyArg as SlackEventMiddlewareArgs['body']).event :
-          (type === IncomingEventType.ViewSubmitAction) ?
+          (type === IncomingEventType.ViewAction) ?
             (bodyArg as SlackViewMiddlewareArgs['body']).view :
           (type === IncomingEventType.Action &&
             isBlockActionOrInteractiveMessageBody(bodyArg as SlackActionMiddlewareArgs['body'])) ?
@@ -412,7 +447,7 @@ export default class App {
     } else if (type === IncomingEventType.Options) {
       const optionListenerArgs = listenerArgs as SlackOptionsMiddlewareArgs<OptionsSource>;
       optionListenerArgs.options = optionListenerArgs.payload;
-    } else if (type === IncomingEventType.ViewSubmitAction) {
+    } else if (type === IncomingEventType.ViewAction) {
       const viewListenerArgs = listenerArgs as SlackViewMiddlewareArgs;
       viewListenerArgs.view = viewListenerArgs.payload;
     }
@@ -476,6 +511,8 @@ export default class App {
 const tokenUsage = 'Apps used in one workspace should be initialized with a token. Apps used in many workspaces ' +
   'should be initialized with a authorize.';
 
+const validViewTypes = ['view_closed', 'view_submission'];
+
 /**
  * Helper which builds the data structure the authorize hook uses to provide tokens for the context.
  */
@@ -491,11 +528,11 @@ function buildSource(
   const source: AuthorizeSourceData = {
     teamId:
       ((type === IncomingEventType.Event || type === IncomingEventType.Command) ? (body as (SlackEventMiddlewareArgs | SlackCommandMiddlewareArgs)['body']).team_id as string :
-       (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.id as string :
+       (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.id as string :
        assertNever(type)),
     enterpriseId:
       ((type === IncomingEventType.Event || type === IncomingEventType.Command) ? (body as (SlackEventMiddlewareArgs | SlackCommandMiddlewareArgs)['body']).enterprise_id as string :
-       (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.enterprise_id as string :
+       (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.enterprise_id as string :
        undefined),
     userId:
       ((type === IncomingEventType.Event) ?
@@ -504,7 +541,7 @@ function buildSource(
          ((body as SlackEventMiddlewareArgs['body']).event.channel !== undefined && (body as SlackEventMiddlewareArgs['body']).event.channel.creator !== undefined) ? (body as SlackEventMiddlewareArgs['body']).event.channel.creator as string :
          ((body as SlackEventMiddlewareArgs['body']).event.subteam !== undefined && (body as SlackEventMiddlewareArgs['body']).event.subteam.created_by !== undefined) ? (body as SlackEventMiddlewareArgs['body']).event.subteam.created_by as string :
          undefined) :
-       (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).user.id as string :
+       (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).user.id as string :
        (type === IncomingEventType.Command) ? (body as SlackCommandMiddlewareArgs['body']).user_id as string :
        undefined),
     conversationId: channelId,
diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts
index c6bdc2ba7..1b036d6a5 100644
--- a/src/ExpressReceiver.ts
+++ b/src/ExpressReceiver.ts
@@ -270,7 +270,7 @@ function parseRequestBody(
       // Parse this body anyway
       return JSON.parse(stringBody);
     } catch (e) {
-      logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`)
+      logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`);
       throw e;
     }
   }
diff --git a/src/helpers.spec.ts b/src/helpers.spec.ts
index eb0038f18..ad27217ac 100644
--- a/src/helpers.spec.ts
+++ b/src/helpers.spec.ts
@@ -77,6 +77,21 @@ describe('getTypeAndConversation()', () => {
     });
   });
 
+  describe('view types', () => {
+    // Arrange
+    const dummyViewBodies = createFakeViews();
+
+    dummyViewBodies.forEach((viewBody) => {
+      it(`should find Action type for ${viewBody.type}`, () => {
+        // Act
+        const typeAndConversation = getTypeAndConversation(viewBody);
+
+        // Assert
+        assert(typeAndConversation.type === IncomingEventType.ViewAction);
+      });
+    });
+  });
+
   describe('invalid events', () => {
     // Arrange
     const fakeEventBody = {
@@ -150,3 +165,18 @@ function createFakeOptions(conversationId: string): any[] {
     },
   ];
 }
+
+function createFakeViews(): any[] {
+  return [
+    // Body for a view_submission event
+    {
+      type: 'view_submission',
+      view: { id: 'V123' },
+    },
+    // Body for a view_closed event
+    {
+      type: 'view_closed',
+      view: { id: 'V456' },
+    },
+  ];
+}
diff --git a/src/helpers.ts b/src/helpers.ts
index f427732f6..a6415d891 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -15,7 +15,7 @@ export enum IncomingEventType {
   Action,
   Command,
   Options,
-  ViewSubmitAction,
+  ViewAction,
 }
 
 /**
@@ -54,9 +54,9 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType, c
       conversationId: actionBody.channel !== undefined ? actionBody.channel.id : undefined,
     };
   }
-  if (body.type === 'view_submission') {
+  if (body.type === 'view_submission' || body.type === 'view_closed') {
     return {
-      type: IncomingEventType.ViewSubmitAction,
+      type: IncomingEventType.ViewAction,
     };
   }
   return {};
diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts
index 75abbc69d..8054191b6 100644
--- a/src/middleware/builtin.ts
+++ b/src/middleware/builtin.ts
@@ -5,20 +5,21 @@ import {
   SlackCommandMiddlewareArgs,
   SlackEventMiddlewareArgs,
   SlackOptionsMiddlewareArgs,
+  SlackViewMiddlewareArgs,
   SlackEvent,
   SlackAction,
   SlashCommand,
   ViewSubmitAction,
+  ViewClosedAction,
   OptionsRequest,
   InteractiveMessage,
   DialogSubmitAction,
   MessageAction,
   BlockElementAction,
   ContextMissingPropertyError,
-  SlackViewMiddlewareArgs,
-  ViewOutput,
+  SlackViewAction,
 } from '../types';
-import { ActionConstraints } from '../App';
+import { ActionConstraints, ViewConstraints } from '../App';
 import { ErrorCode, errorWithCode } from '../errors';
 
 /**
@@ -74,107 +75,92 @@ export const onlyEvents: Middleware<AnyMiddlewareArgs & { event?: SlackEvent }>
 };
 
 /**
- * Middleware that filters out any event that isn't a view_submission
+ * Middleware that filters out any event that isn't a view_submission or view_closed event
  */
-export const onlyViewSubmits: Middleware<AnyMiddlewareArgs & { view?: ViewSubmitAction }> = ({ view, next }) => {
-  // Filter out anything that isn't a view_submission
-  if (view === undefined) {
-    return;
-  }
-
-  // It matches so we should continue down this middleware listener chain
-  next();
-};
-
-// TODO: is there a way to consolidate the next two methods without too much type refactoring?
-export function matchCallbackId(
-    callbackId: string | RegExp,
-): Middleware<SlackViewMiddlewareArgs> {
-  return ({ payload, next, context }) => {
-    let tempMatches: RegExpMatchArray | null;
-
-    if (!isCallbackIdentifiedBody(payload)) {
+export const onlyViewActions: Middleware<AnyMiddlewareArgs &
+  { view?: (ViewSubmitAction | ViewClosedAction) }> = ({ view, next }) => {
+    // Filter out anything that doesn't have a view
+    if (view === undefined) {
       return;
     }
-    if (typeof callbackId === 'string') {
-      if (payload.callback_id !== callbackId) {
-        return;
-      }
-    } else {
-      tempMatches = payload.callback_id.match(callbackId);
-
-      if (tempMatches !== null) {
-        context['callbackIdMatches'] = tempMatches;
-      } else {
-        return;
-      }
-    }
 
+    // It matches so we should continue down this middleware listener chain
     next();
   };
-}
 
 /**
  * Middleware that checks for matches given constraints
  */
 export function matchConstraints(
-  constraints: ActionConstraints,
-): Middleware<SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs> {
+    constraints: ActionConstraints | ViewConstraints,
+  ): Middleware<SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs> {
   return ({ payload, body, next, context }) => {
     // TODO: is putting matches in an array actually helpful? there's no way to know which of the regexps contributed
     // which matches (and in which order)
     let tempMatches: RegExpMatchArray | null;
 
-    if (constraints.block_id !== undefined) {
+    // Narrow type for ActionConstraints
+    if ('block_id' in constraints || 'action_id' in constraints) {
       if (!isBlockPayload(payload)) {
         return;
       }
 
-      if (typeof constraints.block_id === 'string') {
-        if (payload.block_id !== constraints.block_id) {
-          return;
+      // Check block_id
+      if (constraints.block_id !== undefined) {
+
+        if (typeof constraints.block_id === 'string') {
+          if (payload.block_id !== constraints.block_id) {
+            return;
+          }
+        } else {
+          tempMatches = payload.block_id.match(constraints.block_id);
+
+          if (tempMatches !== null) {
+            context['blockIdMatches'] = tempMatches;
+          } else {
+            return;
+          }
         }
-      } else {
-        tempMatches = payload.block_id.match(constraints.block_id);
+      }
 
-        if (tempMatches !== null) {
-          context['blockIdMatches'] = tempMatches;
+      // Check action_id
+      if (constraints.action_id !== undefined) {
+        if (typeof constraints.action_id === 'string') {
+          if (payload.action_id !== constraints.action_id) {
+            return;
+          }
         } else {
-          return;
+          tempMatches = payload.action_id.match(constraints.action_id);
+
+          if (tempMatches !== null) {
+            context['actionIdMatches'] = tempMatches;
+          } else {
+            return;
+          }
         }
       }
     }
 
-    if (constraints.action_id !== undefined) {
-      if (!isBlockPayload(payload)) {
-        return;
-      }
+    // Check callback_id
+    if ('callback_id' in constraints && constraints.callback_id !== undefined) {
+      let callbackId: string = '';
 
-      if (typeof constraints.action_id === 'string') {
-        if (payload.action_id !== constraints.action_id) {
-          return;
-        }
+      if (isViewBody(body)) {
+        callbackId = body['view']['callback_id'];
       } else {
-        tempMatches = payload.action_id.match(constraints.action_id);
-
-        if (tempMatches !== null) {
-          context['actionIdMatches'] = tempMatches;
+        if (isCallbackIdentifiedBody(body)) {
+          callbackId = body['callback_id'];
         } else {
           return;
         }
       }
-    }
 
-    if (constraints.callback_id !== undefined) {
-      if (!isCallbackIdentifiedBody(body)) {
-        return;
-      }
       if (typeof constraints.callback_id === 'string') {
-        if (body.callback_id !== constraints.callback_id) {
+        if (callbackId !== constraints.callback_id) {
           return;
         }
       } else {
-        tempMatches = body.callback_id.match(constraints.callback_id);
+        tempMatches = callbackId.match(constraints.callback_id);
 
         if (tempMatches !== null) {
           context['callbackIdMatches'] = tempMatches;
@@ -184,6 +170,11 @@ export function matchConstraints(
       }
     }
 
+    // Check type
+    if ('type' in constraints) {
+      if (body.type !== constraints.type) return;
+    }
+
     next();
   };
 }
@@ -335,7 +326,10 @@ export function directMention(): Middleware<SlackEventMiddlewareArgs<'message'>>
 }
 
 function isBlockPayload(
-  payload: SlackActionMiddlewareArgs['payload'] | SlackOptionsMiddlewareArgs['payload'],
+  payload:
+    | SlackActionMiddlewareArgs['payload']
+    | SlackOptionsMiddlewareArgs['payload']
+    | SlackViewMiddlewareArgs['payload'],
 ): payload is BlockElementAction | OptionsRequest<'block_suggestion'> {
   return (payload as BlockElementAction | OptionsRequest<'block_suggestion'>).action_id !== undefined;
 }
@@ -343,16 +337,24 @@ function isBlockPayload(
 type CallbackIdentifiedBody =
   | InteractiveMessage
   | DialogSubmitAction
-  | ViewOutput
   | MessageAction
   | OptionsRequest<'interactive_message' | 'dialog_suggestion'>;
 
 function isCallbackIdentifiedBody(
-  body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'] | SlackViewMiddlewareArgs['payload'],
+  body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'],
 ): body is CallbackIdentifiedBody {
   return (body as CallbackIdentifiedBody).callback_id !== undefined;
 }
 
+function isViewBody(
+  body:
+    SlackActionMiddlewareArgs['body']
+    | SlackOptionsMiddlewareArgs['body']
+    | SlackViewMiddlewareArgs['body'],
+): body is SlackViewAction {
+  return (body as SlackViewAction).view !== undefined;
+}
+
 function isEventArgs(
   args: AnyMiddlewareArgs,
 ): args is SlackEventMiddlewareArgs {
diff --git a/src/types/view/index.ts b/src/types/view/index.ts
index 901b3e55a..411df4d51 100644
--- a/src/types/view/index.ts
+++ b/src/types/view/index.ts
@@ -1,13 +1,18 @@
 import { StringIndexed } from '../helpers';
 import { RespondArguments, AckFn } from '../utilities';
 
+/**
+ * Known view action types
+ */
+export type SlackViewAction = ViewSubmitAction | ViewClosedAction;
+// <ViewAction extends SlackViewAction = ViewSubmitAction>
 /**
  * Arguments which listeners and middleware receive to process a view submission event from Slack.
  */
-export interface SlackViewMiddlewareArgs {
+export interface SlackViewMiddlewareArgs<ViewActionType extends SlackViewAction = SlackViewAction> {
   payload: ViewOutput;
   view: this['payload'];
-  body: ViewSubmitAction;
+  body: ViewActionType;
   ack: AckFn<string | RespondArguments>;
 }
 
@@ -40,6 +45,30 @@ export interface ViewSubmitAction {
   token: string;
 }
 
+/**
+ * A Slack view_closed event wrapped in the standard metadata.
+ *
+ * This describes the entire JSON-encoded body of a view_closed event.
+ */
+export interface ViewClosedAction {
+  type: 'view_closed';
+  team: {
+    id: string;
+    domain: string;
+    enterprise_id?: string; // undocumented
+    enterprise_name?: string; // undocumented
+  };
+  user: {
+    id: string;
+    name: string;
+    team_id?: string; // undocumented
+  };
+  view: ViewOutput;
+  api_app_id: string;
+  token: string;
+  is_cleared: boolean;
+}
+
 export interface ViewOutput {
   id: string;
   callback_id: string;