Repository: apollographql/apollo-server
Pull request #7803 (2023-12-14 23:53:13)

feat: allow `stringifyResult` to return a `Promise<string>`
Previously `stringifyResult` had to be synchronous, despite only ever being called from the async context of `runHttpQuery`. This is an issue when the custom stringification function provided by the user through the constructor has to be async, for example when working with very large POJOs one could use [json-stream-stringify](https://github.com/Faleij/json-stream-stringify) because `JSON.stringify` would throw a `RangeError` with `Invalid string length`, the implementation using this library would be:

```ts
    stringifyResult: async (value) => {
      const stringifyStream = new JsonStreamStringify(value);
      let stringified = '';

      for await (const chunk of stringifyStream) {
        stringified += chunk;
      }

      return `${stringified}\n`;
    }
```

As can be seen here, this requires an async context within the callback function. @apollo/server can easily support this by awaiting the result of `internals.stringifyResult`.

Furthermore I also changed `errorResponse` to use the same `internals.stringifyResult` for consistency of how the JSON that will be sent to the requester is stringified.

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy request for *apollo-server-docs* pending review.
[Visit the deploys page to approve it](https://app.netlify.com/sites/apollo-server-docs/deploys)

|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 372d5b85da1cc09e7fd601a88c489799d098e402 |
apollo-cla: @favna: Thank you for submitting a pull request! Before we can merge it, you'll need to sign the Apollo Contributor License Agreement here: https://contribute.apollographql.com/
codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/apollographql/apollo-server/pr/7803/builds/449981) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 372d5b85da1cc09e7fd601a88c489799d098e402:

|Sandbox| Source |
|--|--|
|[Apollo Server Typescript](https://codesandbox.io/s/apollo-server-typescript-mlwm6x)| Configuration |
|[Apollo Server](https://codesandbox.io/s/apollo-server-ljj7w8)| Configuration |


Patch:
diff --git a/.changeset/cuddly-gorillas-rescue.md b/.changeset/cuddly-gorillas-rescue.md
new file mode 100644
index 000000000..d8c758528
--- /dev/null
+++ b/.changeset/cuddly-gorillas-rescue.md
@@ -0,0 +1,5 @@
+---
+'@apollo/server': patch
+---
+
+General availability of subscription callback protocol
diff --git a/.changeset/thirty-hairs-change.md b/.changeset/thirty-hairs-change.md
new file mode 100644
index 000000000..a9645f896
--- /dev/null
+++ b/.changeset/thirty-hairs-change.md
@@ -0,0 +1,7 @@
+---
+"@apollo/server": minor
+---
+
+allow `stringifyResult` to return a `Promise<string>`
+
+Users who implemented the `stringifyResult` hook can now expect error responses to be formatted with the hook as well. Please take care when updating to this version to ensure this is the desired behavior, or implement the desired behavior accordingly in your `stringifyResult` hook. This was considered a non-breaking change as we consider that it was an oversight in the original PR that introduced `stringifyResult` hook.
diff --git a/packages/server/src/ApolloServer.ts b/packages/server/src/ApolloServer.ts
index 4ed6eee45..a78dceee4 100644
--- a/packages/server/src/ApolloServer.ts
+++ b/packages/server/src/ApolloServer.ts
@@ -1,27 +1,29 @@
+import type { GatewayExecutor } from '@apollo/server-gateway-interface';
 import { isNodeLike } from '@apollo/utils.isnodelike';
+import {
+  InMemoryLRUCache,
+  PrefixingKeyValueCache,
+  type KeyValueCache,
+} from '@apollo/utils.keyvaluecache';
 import type { Logger } from '@apollo/utils.logger';
+import type { WithRequired } from '@apollo/utils.withrequired';
 import { makeExecutableSchema } from '@graphql-tools/schema';
 import resolvable, { type Resolvable } from '@josephg/resolvable';
 import {
+  GraphQLError,
   assertValidSchema,
+  print,
+  printSchema,
   type DocumentNode,
-  GraphQLError,
+  type FormattedExecutionResult,
   type GraphQLFieldResolver,
   type GraphQLFormattedError,
   type GraphQLSchema,
   type ParseOptions,
-  print,
-  printSchema,
   type TypedQueryDocumentNode,
   type ValidationContext,
   type ValidationRule,
-  type FormattedExecutionResult,
 } from 'graphql';
-import {
-  type KeyValueCache,
-  InMemoryLRUCache,
-  PrefixingKeyValueCache,
-} from '@apollo/utils.keyvaluecache';
 import loglevel from 'loglevel';
 import Negotiator from 'negotiator';
 import { newCachePolicy } from './cachePolicy.js';
@@ -35,45 +37,43 @@ import {
   ApolloServerErrorCode,
   ApolloServerValidationErrorCode,
 } from './errors/index.js';
+import type { ApolloServerOptionsWithStaticSchema } from './externalTypes/constructor.js';
 import type {
+  ExecuteOperationOptions,
+  VariableValues,
+} from './externalTypes/graphql.js';
+import type {
+  ApolloConfig,
+  ApolloServerOptions,
   ApolloServerPlugin,
   BaseContext,
+  ContextThunk,
+  DocumentStore,
   GraphQLRequest,
+  GraphQLRequestContext,
   GraphQLResponse,
-  GraphQLServerListener,
   GraphQLServerContext,
+  GraphQLServerListener,
+  HTTPGraphQLHead,
   HTTPGraphQLRequest,
   HTTPGraphQLResponse,
   LandingPage,
-  ApolloConfig,
-  ApolloServerOptions,
-  DocumentStore,
   PersistedQueryOptions,
-  ContextThunk,
-  GraphQLRequestContext,
-  HTTPGraphQLHead,
 } from './externalTypes/index.js';
 import { runPotentiallyBatchedHttpQuery } from './httpBatching.js';
-import { type InternalPluginId, pluginIsInternal } from './internalPlugin.js';
+import type { GraphQLExperimentalIncrementalExecutionResults } from './incrementalDeliveryPolyfill.js';
+import { pluginIsInternal, type InternalPluginId } from './internalPlugin.js';
 import {
   preventCsrf,
   recommendedCsrfPreventionRequestHeaders,
 } from './preventCsrf.js';
 import { APQ_CACHE_PREFIX, processGraphQLRequest } from './requestPipeline.js';
 import { newHTTPGraphQLHead, prettyJSONStringify } from './runHttpQuery.js';
-import { SchemaManager } from './utils/schemaManager.js';
-import { isDefined } from './utils/isDefined.js';
+import { HeaderMap } from './utils/HeaderMap.js';
 import { UnreachableCaseError } from './utils/UnreachableCaseError.js';
 import { computeCoreSchemaHash } from './utils/computeCoreSchemaHash.js';
-import type { WithRequired } from '@apollo/utils.withrequired';
-import type { ApolloServerOptionsWithStaticSchema } from './externalTypes/constructor.js';
-import type { GatewayExecutor } from '@apollo/server-gateway-interface';
-import type { GraphQLExperimentalIncrementalExecutionResults } from './incrementalDeliveryPolyfill.js';
-import { HeaderMap } from './utils/HeaderMap.js';
-import type {
-  ExecuteOperationOptions,
-  VariableValues,
-} from './externalTypes/graphql.js';
+import { isDefined } from './utils/isDefined.js';
+import { SchemaManager } from './utils/schemaManager.js';
 
 const NoIntrospection: ValidationRule = (context: ValidationContext) => ({
   Field(node) {
@@ -180,7 +180,9 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
   // flip default behavior.
   status400ForVariableCoercionErrors?: boolean;
   __testing_incrementalExecutionResults?: GraphQLExperimentalIncrementalExecutionResults;
-  stringifyResult: (value: FormattedExecutionResult) => string;
+  stringifyResult: (
+    value: FormattedExecutionResult,
+  ) => string | Promise<string>;
 }
 
 function defaultLogger(): Logger {
@@ -1015,7 +1017,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
         // This is typically either the masked error from when background startup
         // failed, or related to invoking this function before startup or
         // during/after shutdown (due to lack of draining).
-        return this.errorResponse(error, httpGraphQLRequest);
+        return await this.errorResponse(error, httpGraphQLRequest);
       }
 
       if (
@@ -1031,7 +1033,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
           } catch (maybeError: unknown) {
             const error = ensureError(maybeError);
             this.logger.error(`Landing page \`html\` function threw: ${error}`);
-            return this.errorResponse(error, httpGraphQLRequest);
+            return await this.errorResponse(error, httpGraphQLRequest);
           }
         }
 
@@ -1076,7 +1078,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
         // If some random function threw, add a helpful prefix when converting
         // to GraphQLError. If it was already a GraphQLError, trust that the
         // message was chosen thoughtfully and leave off the prefix.
-        return this.errorResponse(
+        return await this.errorResponse(
           ensureGraphQLError(error, 'Context creation failed: '),
           httpGraphQLRequest,
         );
@@ -1108,14 +1110,14 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
           );
         }
       }
-      return this.errorResponse(maybeError, httpGraphQLRequest);
+      return await this.errorResponse(maybeError, httpGraphQLRequest);
     }
   }
 
-  private errorResponse(
+  private async errorResponse(
     error: unknown,
     requestHead: HTTPGraphQLHead,
-  ): HTTPGraphQLResponse {
+  ): Promise<HTTPGraphQLResponse> {
     const { formattedErrors, httpFromErrors } = normalizeAndFormatErrors(
       [error],
       {
@@ -1144,7 +1146,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
       ]),
       body: {
         kind: 'complete',
-        string: prettyJSONStringify({
+        string: await this.internals.stringifyResult({
           errors: formattedErrors,
         }),
       },
@@ -1354,6 +1356,8 @@ export function isImplicitlyInstallablePlugin<TContext extends BaseContext>(
 
 export const MEDIA_TYPES = {
   APPLICATION_JSON: 'application/json; charset=utf-8',
+  APPLICATION_JSON_GRAPHQL_CALLBACK:
+    'application/json; callbackSpec=1.0; charset=utf-8',
   APPLICATION_GRAPHQL_RESPONSE_JSON:
     'application/graphql-response+json; charset=utf-8',
   // We do *not* currently support this content-type; we will once incremental
@@ -1378,6 +1382,7 @@ export function chooseContentTypeForSingleResultResponse(
     }).mediaType([
       MEDIA_TYPES.APPLICATION_JSON,
       MEDIA_TYPES.APPLICATION_GRAPHQL_RESPONSE_JSON,
+      MEDIA_TYPES.APPLICATION_JSON_GRAPHQL_CALLBACK,
     ]);
     if (preferred) {
       return preferred;
diff --git a/packages/server/src/externalTypes/constructor.ts b/packages/server/src/externalTypes/constructor.ts
index 04cf0653d..839a2daae 100644
--- a/packages/server/src/externalTypes/constructor.ts
+++ b/packages/server/src/externalTypes/constructor.ts
@@ -89,7 +89,9 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
   includeStacktraceInErrorResponses?: boolean;
   logger?: Logger;
   allowBatchedHttpRequests?: boolean;
-  stringifyResult?: (value: FormattedExecutionResult) => string;
+  stringifyResult?: (
+    value: FormattedExecutionResult,
+  ) => string | Promise<string>;
   introspection?: boolean;
   plugins?: ApolloServerPlugin<TContext>[];
   persistedQueries?: PersistedQueryOptions | false;
diff --git a/packages/server/src/plugin/subscriptionCallback/index.ts b/packages/server/src/plugin/subscriptionCallback/index.ts
index 460f6c73c..02c07f83a 100644
--- a/packages/server/src/plugin/subscriptionCallback/index.ts
+++ b/packages/server/src/plugin/subscriptionCallback/index.ts
@@ -1,13 +1,12 @@
 import type { Logger } from '@apollo/utils.logger';
-import { type GraphQLError, subscribe, type ExecutionResult } from 'graphql';
+import retry from 'async-retry';
+import { subscribe, type ExecutionResult, type GraphQLError } from 'graphql';
 import fetch, { type Response } from 'node-fetch';
 import { ensureError, ensureGraphQLError } from '../../errorNormalize.js';
 import type { ApolloServerPlugin } from '../../externalTypes/index.js';
 import { HeaderMap } from '../../utils/HeaderMap.js';
-import retry from 'async-retry';
 
 export interface ApolloServerPluginSubscriptionCallbackOptions {
-  heartbeatIntervalMs?: number;
   maxConsecutiveHeartbeatFailures?: number;
   logger?: Logger;
   retry?: retry.Options;
@@ -26,11 +25,19 @@ export function ApolloServerPluginSubscriptionCallback(
       const subscriptionExtension = request?.extensions?.subscription;
       // If it's not a callback subscription, ignore the request.
       if (!subscriptionExtension) return;
-      const {
-        callback_url: callbackUrl,
-        subscription_id: id,
+      let {
+        callbackUrl,
+        subscriptionId: id,
         verifier,
+        heartbeatIntervalMs,
       } = subscriptionExtension;
+      // The GA version of callback protocol use camelCase, this is to keep backward compatibility
+      callbackUrl = callbackUrl || subscriptionExtension.callback_url;
+      id = id || subscriptionExtension.subscription_id;
+      heartbeatIntervalMs =
+        heartbeatIntervalMs ??
+        subscriptionExtension.heartbeat_interval_ms ??
+        5000;
 
       return {
         // Implementing `responseForOperation` is the only hook that allows us
@@ -44,7 +51,7 @@ export function ApolloServerPluginSubscriptionCallback(
           return {
             http: {
               status: 200,
-              headers: new HeaderMap(),
+              headers: new HeaderMap([['content-type', 'application/json']]),
             },
             body: {
               kind: 'single',
@@ -153,6 +160,7 @@ export function ApolloServerPluginSubscriptionCallback(
               callbackUrl,
               id,
               verifier,
+              heartbeatIntervalMs,
             });
 
             subscriptionManager.startConsumingSubscription({
@@ -196,14 +204,11 @@ interface SubscriptionObject {
 // router requests. It keeps track of in flight requests so we can await them
 // during server cleanup.
 class SubscriptionManager {
-  private heartbeatIntervalMs: number;
   private maxConsecutiveHeartbeatFailures: number;
   private logger?: ReturnType<typeof prefixedLogger>;
   private retryConfig?: retry.Options;
   private requestsInFlight: Set<Promise<any>> = new Set();
-  // A map of information about subscriptions for a given callback url. For each
-  // url, this tracks its single heartbeat interval (with relevant heartbeat
-  // request info) and active subscriptions.
+  // A map of information about subscription for a given callback url including the subscription id.
   private subscriptionInfoByCallbackUrl: Map<
     string,
     {
@@ -213,12 +218,11 @@ class SubscriptionManager {
         interval: NodeJS.Timeout;
         queue: Promise<void>[];
       };
-      subscriptionsById: Map<string, SubscriptionObject>;
+      subscription?: SubscriptionObject;
     }
   > = new Map();
 
   constructor(options: ApolloServerPluginSubscriptionCallbackOptions) {
-    this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 5000;
     this.maxConsecutiveHeartbeatFailures =
       options.maxConsecutiveHeartbeatFailures ?? 5;
     this.retryConfig = {
@@ -239,6 +243,7 @@ class SubscriptionManager {
     verifier,
     payload,
     errors,
+    headers,
   }: {
     url: string;
     action: 'check' | 'next' | 'complete';
@@ -246,6 +251,7 @@ class SubscriptionManager {
     verifier: string;
     payload?: ExecutionResult;
     errors?: readonly GraphQLError[];
+    headers?: Record<string, string>;
   }) {
     let response: Promise<Response> | undefined;
     try {
@@ -258,7 +264,10 @@ class SubscriptionManager {
         async (bail) => {
           response = fetch(url, {
             method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
+            headers: {
+              'content-type': 'application/json',
+              ...headers,
+            },
             body: JSON.stringify({
               kind: 'subscription',
               action,
@@ -295,7 +304,7 @@ class SubscriptionManager {
                 this.logger?.debug(errMsg, id);
                 bail(new Error(errMsg));
               }
-              this.terminateSubscriptions([id], url);
+              this.terminateSubscription(id, url);
               return result;
             }
           }
@@ -335,6 +344,7 @@ class SubscriptionManager {
       action: 'check',
       id,
       verifier,
+      headers: { 'subscription-protocol': 'callback/1.0' },
     });
   }
 
@@ -346,23 +356,20 @@ class SubscriptionManager {
     callbackUrl,
     id,
     verifier,
+    heartbeatIntervalMs,
   }: {
     callbackUrl: string;
     id: string;
     verifier: string;
+    heartbeatIntervalMs: number;
   }) {
     if (!this.subscriptionInfoByCallbackUrl.has(callbackUrl)) {
-      this.subscriptionInfoByCallbackUrl.set(callbackUrl, {
-        subscriptionsById: new Map(),
-      });
+      this.subscriptionInfoByCallbackUrl.set(callbackUrl, {});
     }
 
-    // Skip interval creation if one already exists for this url
-    if (this.subscriptionInfoByCallbackUrl.get(callbackUrl)?.heartbeat) {
-      this.logger?.debug(
-        `Heartbeat interval already exists for ${callbackUrl}, reusing existing interval`,
-        id,
-      );
+    if (heartbeatIntervalMs === 0) {
+      // Heartbeat has been disabled on the router
+      this.logger?.debug(`Heartbeat disabled for ${callbackUrl}`, id);
       return;
     }
 
@@ -410,61 +417,41 @@ class SubscriptionManager {
 
       // Send the heartbeat request
       try {
-        const ids = Array.from(
-          existingSubscriptionInfo.subscriptionsById.keys() ?? [],
-        );
         this.logger?.debug(
-          `Sending \`heartbeat\` request to ${callbackUrl} for IDs: [${ids.join(
-            ',',
-          )}]`,
+          `Sending \`check\` request to ${callbackUrl} for ID: ${id}`,
         );
 
         heartbeatRequest = fetch(callbackUrl, {
           method: 'POST',
           body: JSON.stringify({
             kind: 'subscription',
-            action: 'heartbeat',
-            id: existingHeartbeat.id ?? id,
-            verifier: existingHeartbeat.verifier ?? verifier,
-            ids,
+            action: 'check',
+            id,
+            verifier,
           }),
-          headers: { 'Content-Type': 'application/json' },
+          headers: {
+            'content-type': 'application/json',
+            'subscription-protocol': 'callback/1.0',
+          },
         });
         this.requestsInFlight.add(heartbeatRequest);
 
-        // The heartbeat response might contain updates for us to act upon, so we
-        // need to await it
+        // heartbeat response might be a 4xx, in which case we'd need to
+        // terminate the subscription, so we need to await it
         const result = await heartbeatRequest;
 
-        this.logger?.debug(
-          `Heartbeat received response for IDs: [${ids.join(',')}]`,
-        );
-
+        this.logger?.debug(`Heartbeat received response for ID: ${id}`);
         if (result.ok) {
-          this.logger?.debug(
-            `Heartbeat request successful, IDs: [${ids.join(',')}]`,
-          );
+          this.logger?.debug(`Heartbeat request successful, ID: ${id}`);
         } else if (result.status === 400) {
-          const body = await result.json();
-          this.logger?.debug(
-            `Heartbeat request received invalid IDs: [${body.invalid_ids.join(
-              ',',
-            )}]`,
-          );
-          // Some of the IDs are invalid, so we need to update the id and
-          // verifier for future heartbeat requests (both provided by the router
-          // in the response body)
-          existingHeartbeat.id = body.id;
-          existingHeartbeat.verifier = body.verifier;
+          this.logger?.debug(`Heartbeat request received invalid ID: ${id}`);
 
-          this.terminateSubscriptions(body.invalid_ids, callbackUrl);
+          this.terminateSubscription(id, callbackUrl);
         } else if (result.status === 404) {
-          // all ids we sent are invalid
-          this.logger?.debug(
-            `Heartbeat request received invalid IDs: [${ids.join(',')}]`,
-          );
+          // the id we sent is invalid
+          this.logger?.debug(`Heartbeat request received invalid ID: ${id}`);
           // This will also handle cleaning up the heartbeat interval
-          this.terminateSubscriptions(ids, callbackUrl);
+          this.terminateSubscription(id, callbackUrl);
         } else {
           // We'll catch this below and log it with the expectation that we'll
           // retry this request some number of times before giving up
@@ -495,14 +482,7 @@ class SubscriptionManager {
           // If we've failed 5 times in a row, we should terminate all
           // subscriptions for this callback url. This will also handle
           // cleaning up the heartbeat interval.
-          this.terminateSubscriptions(
-            Array.from(
-              this.subscriptionInfoByCallbackUrl
-                .get(callbackUrl)
-                ?.subscriptionsById.keys() ?? [],
-            ),
-            callbackUrl,
-          );
+          this.terminateSubscription(id, callbackUrl);
         }
         return;
       } finally {
@@ -513,7 +493,7 @@ class SubscriptionManager {
         existingHeartbeat?.queue.shift();
         resolveHeartbeatPromise!();
       }
-    }, this.heartbeatIntervalMs);
+    }, heartbeatIntervalMs);
 
     // Add the heartbeat interval to the map of intervals
     const subscriptionInfo =
@@ -530,8 +510,8 @@ class SubscriptionManager {
   // there are no active subscriptions after this, we also clean up the
   // heartbeat interval. This does not handle sending the `complete` request to
   // the router.
-  private terminateSubscriptions(ids: string[], callbackUrl: string) {
-    this.logger?.debug(`Terminating subscriptions for IDs: [${ids.join(',')}]`);
+  private terminateSubscription(id: string, callbackUrl: string) {
+    this.logger?.debug(`Terminating subscriptions for ID: ${id}`);
     const subscriptionInfo =
       this.subscriptionInfoByCallbackUrl.get(callbackUrl);
     if (!subscriptionInfo) {
@@ -540,22 +520,16 @@ class SubscriptionManager {
       );
       return;
     }
-    const { subscriptionsById, heartbeat } = subscriptionInfo;
-    for (const id of ids) {
-      const subscription = subscriptionsById.get(id);
-      if (subscription) {
-        subscription.cancelled = true;
-      }
-      subscriptionsById.delete(id);
-      // if the list is empty now we can clean up everything for this callback url
-      if (subscriptionsById?.size === 0) {
-        this.logger?.debug(
-          `Terminating heartbeat interval, no more subscriptions for ${callbackUrl}`,
-        );
-        if (heartbeat) clearInterval(heartbeat.interval);
-        this.subscriptionInfoByCallbackUrl.delete(callbackUrl);
-      }
+    const { subscription, heartbeat } = subscriptionInfo;
+    if (subscription) {
+      subscription.cancelled = true;
+    }
+    // cleanup heartbeat for subscription
+    if (heartbeat) {
+      this.logger?.debug(`Terminating heartbeat interval for ${callbackUrl}`);
+      clearInterval(heartbeat.interval);
     }
+    this.subscriptionInfoByCallbackUrl.delete(callbackUrl);
   }
 
   // Consumes the AsyncIterable returned by `graphql-js`'s `subscribe` function.
@@ -606,7 +580,7 @@ class SubscriptionManager {
               `\`next\` request failed, terminating subscription: ${originalError.message}`,
               id,
             );
-            self.terminateSubscriptions([id], callbackUrl);
+            self.terminateSubscription(id, callbackUrl);
           }
         }
         // The subscription ended without errors, send the `complete` request to
@@ -635,7 +609,7 @@ class SubscriptionManager {
             id,
           );
         } finally {
-          self.terminateSubscriptions([id], callbackUrl);
+          self.terminateSubscription(id, callbackUrl);
         }
       },
     };
@@ -647,8 +621,9 @@ class SubscriptionManager {
       this.logger?.error(
         `No existing heartbeat found for ${callbackUrl}, skipping subscription`,
       );
+    } else {
+      subscriptionInfo.subscription = subscriptionObject;
     }
-    subscriptionInfo?.subscriptionsById.set(id, subscriptionObject);
   }
 
   // Sends the `complete` request to the router.
@@ -674,8 +649,10 @@ class SubscriptionManager {
 
   collectAllSubscriptions() {
     return Array.from(this.subscriptionInfoByCallbackUrl.values()).reduce(
-      (subscriptions, { subscriptionsById }) => {
-        subscriptions.push(...Array.from(subscriptionsById.values()));
+      (subscriptions, { subscription }) => {
+        if (subscription) {
+          subscriptions.push(subscription);
+        }
         return subscriptions;
       },
       [] as SubscriptionObject[],
diff --git a/packages/server/src/runHttpQuery.ts b/packages/server/src/runHttpQuery.ts
index a22df8aa2..06341dd1b 100644
--- a/packages/server/src/runHttpQuery.ts
+++ b/packages/server/src/runHttpQuery.ts
@@ -260,7 +260,7 @@ export async function runHttpQuery<TContext extends BaseContext>({
       ...graphQLResponse.http,
       body: {
         kind: 'complete',
-        string: internals.stringifyResult(
+        string: await internals.stringifyResult(
           orderExecutionResultFields(graphQLResponse.body.singleResult),
         ),
       },


Test patch:
diff --git a/packages/server/src/__tests__/ApolloServer.test.ts b/packages/server/src/__tests__/ApolloServer.test.ts
index 224948ec9..460a7d5cc 100644
--- a/packages/server/src/__tests__/ApolloServer.test.ts
+++ b/packages/server/src/__tests__/ApolloServer.test.ts
@@ -1,22 +1,22 @@
-import { ApolloServer, HeaderMap } from '..';
-import type { ApolloServerOptions } from '..';
+import type { GatewayInterface } from '@apollo/server-gateway-interface';
+import { makeExecutableSchema } from '@graphql-tools/schema';
+import { describe, expect, it, jest } from '@jest/globals';
+import assert from 'assert';
 import {
   FormattedExecutionResult,
   GraphQLError,
   GraphQLSchema,
-  parse,
   TypedQueryDocumentNode,
+  parse,
 } from 'graphql';
+import gql from 'graphql-tag';
+import type { ApolloServerOptions } from '..';
+import { ApolloServer, HeaderMap } from '..';
 import type { ApolloServerPlugin, BaseContext } from '../externalTypes';
+import type { GraphQLResponseBody } from '../externalTypes/graphql';
 import { ApolloServerPluginCacheControlDisabled } from '../plugin/disabled/index.js';
 import { ApolloServerPluginUsageReporting } from '../plugin/usageReporting/index.js';
-import { makeExecutableSchema } from '@graphql-tools/schema';
 import { mockLogger } from './mockLogger.js';
-import gql from 'graphql-tag';
-import type { GatewayInterface } from '@apollo/server-gateway-interface';
-import { jest, describe, it, expect } from '@jest/globals';
-import type { GraphQLResponseBody } from '../externalTypes/graphql';
-import assert from 'assert';
 
 const typeDefs = gql`
   type Query {
@@ -176,6 +176,100 @@ describe('ApolloServer construction', () => {
       `);
       await server.stop();
     });
+
+    it('async stringifyResult', async () => {
+      const server = new ApolloServer({
+        typeDefs,
+        resolvers,
+        stringifyResult: async (value: FormattedExecutionResult) => {
+          let result = await Promise.resolve(
+            JSON.stringify(value, null, 10000),
+          );
+          result = result.replace('world', 'stringifyResults works!'); // replace text with something custom
+          return result;
+        },
+      });
+
+      await server.start();
+
+      const request = {
+        httpGraphQLRequest: {
+          method: 'POST',
+          headers: new HeaderMap([['content-type', 'application-json']]),
+          body: { query: '{ hello }' },
+          search: '',
+        },
+        context: async () => ({}),
+      };
+
+      const { body } = await server.executeHTTPGraphQLRequest(request);
+      assert(body.kind === 'complete');
+      expect(body.string).toMatchInlineSnapshot(`
+      "{
+                "data": {
+                          "hello": "stringifyResults works!"
+                }
+      }"
+      `);
+      await server.stop();
+    });
+
+    it('throws the custom parsed error from stringifyResult', async () => {
+      const server = new ApolloServer({
+        typeDefs,
+        resolvers,
+        stringifyResult: (_: FormattedExecutionResult) => {
+          throw new Error('A custom synchronous error');
+        },
+      });
+
+      await server.start();
+
+      const request = {
+        httpGraphQLRequest: {
+          method: 'POST',
+          headers: new HeaderMap([['content-type', 'application-json']]),
+          body: { query: '{ error }' },
+          search: '',
+        },
+        context: async () => ({}),
+      };
+
+      await expect(
+        server.executeHTTPGraphQLRequest(request),
+      ).rejects.toThrowErrorMatchingInlineSnapshot(
+        `"A custom synchronous error"`,
+      );
+
+      await server.stop();
+    });
+
+    it('throws the custom parsed error from async stringifyResult', async () => {
+      const server = new ApolloServer({
+        typeDefs,
+        resolvers,
+        stringifyResult: async (_: FormattedExecutionResult) =>
+          Promise.reject('A custom asynchronous error'),
+      });
+
+      await server.start();
+
+      const request = {
+        httpGraphQLRequest: {
+          method: 'POST',
+          headers: new HeaderMap([['content-type', 'application-json']]),
+          body: { query: '{ error }' },
+          search: '',
+        },
+        context: async () => ({}),
+      };
+
+      await expect(
+        server.executeHTTPGraphQLRequest(request),
+      ).rejects.toMatchInlineSnapshot(`"A custom asynchronous error"`);
+
+      await server.stop();
+    });
   });
 
   it('throws when an API key is not a valid header value', () => {
@@ -501,7 +595,7 @@ describe('ApolloServer executeOperation', () => {
 
     const { body, http } = await server.executeOperation({
       query: `#graphql
-        query NeedsArg($arg: CompoundInput!) { needsCompoundArg(aCompound: $arg) }
+      query NeedsArg($arg: CompoundInput!) { needsCompoundArg(aCompound: $arg) }
       `,
       // @ts-expect-error for `null` case
       variables,
diff --git a/packages/server/src/__tests__/plugin/subscriptionCallback/index.test.ts b/packages/server/src/__tests__/plugin/subscriptionCallback/index.test.ts
index 8d1d00e6f..4dc843ead 100644
--- a/packages/server/src/__tests__/plugin/subscriptionCallback/index.test.ts
+++ b/packages/server/src/__tests__/plugin/subscriptionCallback/index.test.ts
@@ -2,6 +2,7 @@ import {
   ApolloServer,
   ApolloServerOptionsWithTypeDefs,
   BaseContext,
+  HTTPGraphQLRequest,
   HeaderMap,
 } from '@apollo/server';
 import { ApolloServerPluginSubscriptionCallback } from '@apollo/server/plugin/subscriptionCallback';
@@ -56,6 +57,22 @@ describe('SubscriptionCallbackPlugin', () => {
     jest.useRealTimers();
   });
 
+  function buildHTTPGraphQLRequest(options: Partial<HTTPGraphQLRequest>) {
+    return {
+      httpGraphQLRequest: {
+        method: 'POST',
+        search: '',
+        body: '',
+        headers: new HeaderMap([
+          ['accept', 'application/json;callbackSpec=1.0'],
+          ['content-type', 'application/json'],
+        ]),
+        ...options,
+      },
+      context: async () => ({}),
+    };
+  }
+
   it('simple happy path', async () => {
     const server = await startSubscriptionServer({ logger });
 
@@ -65,26 +82,30 @@ describe('SubscriptionCallbackPlugin', () => {
     // Start the subscription; this triggers the initial check request and
     // starts the heartbeat interval. This simulates an incoming subscription
     // request from the router.
-    const result = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: '1234-cats',
-          verifier: 'my-verifier-token',
+    const result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: 'http://mock-router-url.com',
+              subscriptionId: '1234-cats',
+              verifier: 'my-verifier-token',
+            },
+          },
         },
-      },
-    });
+      }),
+    );
 
-    expect(result.http.status).toEqual(200);
+    expect(result.status).toEqual(200);
 
     // Mock the heartbeat response from the router. We'll trigger it once below
     // after the subscription is initialized to make sure it works.
-    const firstHeartbeat = mockRouterHeartbeatResponse();
+    const firstHeartbeat = mockRouterCheckResponse();
     // Advance timers to trigger the heartbeat once. This consumes the one
     // heartbeat mock from above.
     jest.advanceTimersByTime(5000);
@@ -101,22 +122,30 @@ describe('SubscriptionCallbackPlugin', () => {
 
     // Trigger a couple updates. These send `next` requests to the router.
     logger.debug('TESTING: Triggering first update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
 
     logger.debug('TESTING: Triggering second update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
     await updates;
 
     // When we shutdown the server, we'll stop listening for subscription
@@ -141,9 +170,9 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
         "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
         "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-        "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
         "TESTING: Triggering first update",
         "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
         "TESTING: Triggering second update",
@@ -153,110 +182,199 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
         "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
         "SubscriptionManager[1234-cats]: \`complete\` request successful",
-        "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-        "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
         "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
       ]
     `);
   });
 
-  it('handles multiple callback urls', async () => {
+  it('simple happy path without heartbeat', async () => {
     const server = await startSubscriptionServer({ logger });
-    const router2 = {
-      url: 'http://mock-router-url-2.com',
-      id: '5678-dogs',
-      verifier: 'another-verifier-token',
-    };
-    // Mock the initial check response from the 2 callback urls.
+
+    // Mock the initial check response from the router.
     mockRouterCheckResponse();
-    mockRouterCheckResponse({ requestOpts: router2 });
 
-    // Mock the heartbeat response from the routers. We'll trigger it once below
-    // after the subscriptions are initialized to make sure it works.
-    const heartbeats = Promise.all([
-      mockRouterHeartbeatResponse(),
-      mockRouterHeartbeatResponse({ requestOptions: router2 }),
+    // Start the subscription; this triggers the initial check request and
+    // starts the heartbeat interval. This simulates an incoming subscription
+    // request from the router.
+    const result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: 'http://mock-router-url.com',
+              subscriptionId: '1234-cats',
+              verifier: 'my-verifier-token',
+              heartbeatIntervalMs: 0,
+            },
+          },
+        },
+      }),
+    );
+
+    expect(result.status).toEqual(200);
+
+    // Advance timers to check heartbeat has been disabled.
+    jest.advanceTimersByTime(5000);
+
+    // Next we'll trigger some subscription events. In advance, we'll mock the 2
+    // router responses.
+    const updates = Promise.all([
+      mockRouterNextResponse({ payload: { count: 1 } }),
+      mockRouterNextResponse({
+        payload: { count: 2 },
+      }),
     ]);
 
-    // Start the subscriptions.
-    const router1Result = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: '1234-cats',
-          verifier: 'my-verifier-token',
+    // Trigger a couple updates. These send `next` requests to the router.
+    logger.debug('TESTING: Triggering first update');
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
         },
-      },
-    });
-    const router2Result = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: router2.url,
-          subscription_id: router2.id,
-          verifier: router2.verifier,
+      }),
+    );
+
+    logger.debug('TESTING: Triggering second update');
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
         },
-      },
-    });
-    expect(router1Result.http.status).toEqual(200);
-    expect(router2Result.http.status).toEqual(200);
+      }),
+    );
+    await updates;
 
-    // Advance timers to trigger the heartbeat once. This consumes the
-    // heartbeat mocks from above (one per router).
+    // When we shutdown the server, we'll stop listening for subscription
+    // updates, await unresolved requests, and send a `complete` request to the
+    // router for each active subscription.
+    const completeRequest = mockRouterCompleteResponse();
+
+    await server.stop();
+    await completeRequest;
+
+    expect(logger.orderOfOperations).toMatchInlineSnapshot(`
+      [
+        "SubscriptionCallback[1234-cats]: Received new subscription request",
+        "SubscriptionManager[1234-cats]: Sending \`check\` request to router",
+        "SubscriptionManager[1234-cats]: \`check\` request successful",
+        "SubscriptionCallback[1234-cats]: Starting graphql-js subscription",
+        "SubscriptionCallback[1234-cats]: graphql-js subscription successful",
+        "SubscriptionManager[1234-cats]: Heartbeat disabled for http://mock-router-url.com",
+        "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
+        "SubscriptionCallback[1234-cats]: Responding to original subscription request",
+        "TESTING: Triggering first update",
+        "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
+        "TESTING: Triggering second update",
+        "SubscriptionManager[1234-cats]: \`next\` request successful",
+        "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
+        "SubscriptionManager[1234-cats]: \`next\` request successful",
+        "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
+        "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
+        "SubscriptionManager[1234-cats]: \`complete\` request successful",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
+      ]
+    `);
+  });
+
+  it('simple happy path with deprecated field names in snake_case', async () => {
+    const server = await startSubscriptionServer({ logger });
+
+    // Mock the initial check response from the router.
+    mockRouterCheckResponse();
+
+    // Start the subscription; this triggers the initial check request and
+    // starts the heartbeat interval. This simulates an incoming subscription
+    // request from the router.
+    const result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+          subscription {
+            count
+          }
+        `,
+          extensions: {
+            subscription: {
+              callback_url: 'http://mock-router-url.com',
+              subscription_id: '1234-cats',
+              verifier: 'my-verifier-token',
+            },
+          },
+        },
+      }),
+    );
+
+    expect(result.status).toEqual(200);
+
+    // Mock the heartbeat response from the router. We'll trigger it once below
+    // after the subscription is initialized to make sure it works.
+    const firstHeartbeat = mockRouterCheckResponse();
+    // Advance timers to trigger the heartbeat once. This consumes the one
+    // heartbeat mock from above.
     jest.advanceTimersByTime(5000);
-    await heartbeats;
+    await firstHeartbeat;
 
     // Next we'll trigger some subscription events. In advance, we'll mock the 2
     // router responses.
-    const firstUpdate = Promise.all([
+    const updates = Promise.all([
       mockRouterNextResponse({ payload: { count: 1 } }),
-      mockRouterNextResponse({ payload: { count: 1 }, ...router2 }),
-    ]);
-    const secondUpdate = Promise.all([
-      mockRouterNextResponse({ payload: { count: 2 } }),
-      mockRouterNextResponse({ payload: { count: 2 }, ...router2 }),
+      mockRouterNextResponse({
+        payload: { count: 2 },
+      }),
     ]);
 
     // Trigger a couple updates. These send `next` requests to the router.
     logger.debug('TESTING: Triggering first update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
-    await firstUpdate;
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
 
     logger.debug('TESTING: Triggering second update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
-    await secondUpdate;
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
+    await updates;
 
     // When we shutdown the server, we'll stop listening for subscription
     // updates, await unresolved requests, and send a `complete` request to the
     // router for each active subscription.
-    const completeRequests = Promise.all([
-      mockRouterCompleteResponse(),
-      mockRouterCompleteResponse(router2),
-    ]);
+    const completeRequest = mockRouterCompleteResponse();
 
     await server.stop();
-    await completeRequests;
+    await completeRequest;
 
     // The heartbeat should be cleaned up at this point. There is no second
     // heartbeat mock, so if it ticks again it'll throw an error.
@@ -272,192 +390,257 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
         "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
         "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-        "SubscriptionCallback[5678-dogs]: Received new subscription request",
-        "SubscriptionManager[5678-dogs]: Sending \`check\` request to router",
-        "SubscriptionManager[5678-dogs]: \`check\` request successful",
-        "SubscriptionCallback[5678-dogs]: Starting graphql-js subscription",
-        "SubscriptionCallback[5678-dogs]: graphql-js subscription successful",
-        "SubscriptionManager[5678-dogs]: Starting new heartbeat interval for http://mock-router-url-2.com",
-        "SubscriptionManager[5678-dogs]: Listening to graphql-js subscription",
-        "SubscriptionCallback[5678-dogs]: Responding to original subscription request",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url-2.com for IDs: [5678-dogs]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-        "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
-        "SubscriptionManager: Heartbeat received response for IDs: [5678-dogs]",
-        "SubscriptionManager: Heartbeat request successful, IDs: [5678-dogs]",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
         "TESTING: Triggering first update",
         "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
-        "SubscriptionManager[5678-dogs]: Sending \`next\` request to router",
-        "SubscriptionManager[1234-cats]: \`next\` request successful",
-        "SubscriptionManager[5678-dogs]: \`next\` request successful",
         "TESTING: Triggering second update",
+        "SubscriptionManager[1234-cats]: \`next\` request successful",
         "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
-        "SubscriptionManager[5678-dogs]: Sending \`next\` request to router",
         "SubscriptionManager[1234-cats]: \`next\` request successful",
-        "SubscriptionManager[5678-dogs]: \`next\` request successful",
         "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
         "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
-        "SubscriptionManager[5678-dogs]: Sending \`complete\` request to router",
         "SubscriptionManager[1234-cats]: \`complete\` request successful",
-        "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-        "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
-        "SubscriptionManager[5678-dogs]: \`complete\` request successful",
-        "SubscriptionManager: Terminating subscriptions for IDs: [5678-dogs]",
-        "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url-2.com",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
         "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
       ]
     `);
   });
 
-  it('updates id/verifier on heartbeat response with 400 + invalid IDs', async () => {
+  it('simple happy path with custom heartbeat interval', async () => {
     const server = await startSubscriptionServer({ logger });
-    const secondSubscription = {
-      id: '5678-dogs',
-      verifier: 'another-verifier-token',
-    };
 
+    // Mock the initial check response from the router.
     mockRouterCheckResponse();
-    mockRouterCheckResponse({ requestOpts: secondSubscription });
 
-    // Start the subscriptions; this triggers the initial check request and
+    // Start the subscription; this triggers the initial check request and
     // starts the heartbeat interval. This simulates an incoming subscription
     // request from the router.
-    const firstResult = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: '1234-cats',
-          verifier: 'my-verifier-token',
+    const result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: 'http://mock-router-url.com',
+              subscriptionId: '1234-cats',
+              verifier: 'my-verifier-token',
+              heartbeatIntervalMs: 2000,
+            },
+          },
         },
-      },
-    });
+      }),
+    );
 
-    expect(firstResult.http.status).toEqual(200);
+    expect(result.status).toEqual(200);
 
-    const secondResult = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: secondSubscription.id,
-          verifier: secondSubscription.verifier,
+    // Mock the heartbeat response from the router. We'll trigger it once below
+    // after the subscription is initialized to make sure it works.
+    const firstHeartbeat = mockRouterCheckResponse();
+    // Advance timers to trigger the heartbeat once. This consumes the one
+    // heartbeat mock from above.
+    jest.advanceTimersByTime(2000);
+    await firstHeartbeat;
+
+    // Next we'll trigger some subscription events. In advance, we'll mock the 2
+    // router responses.
+    const updates = Promise.all([
+      mockRouterNextResponse({ payload: { count: 1 } }),
+      mockRouterNextResponse({
+        payload: { count: 2 },
+      }),
+    ]);
+
+    // Trigger a couple updates. These send `next` requests to the router.
+    logger.debug('TESTING: Triggering first update');
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
         },
-      },
-    });
+      }),
+    );
 
-    expect(secondResult.http.status).toEqual(200);
+    logger.debug('TESTING: Triggering second update');
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
+    await updates;
 
-    // Trigger a heartbeat once to make sure it's working
-    const firstHeartbeat = mockRouterHeartbeatResponse({
-      requestOptions: {
-        ids: ['1234-cats', secondSubscription.id],
-      },
-    });
+    // When we shutdown the server, we'll stop listening for subscription
+    // updates, await unresolved requests, and send a `complete` request to the
+    // router for each active subscription.
+    const completeRequest = mockRouterCompleteResponse();
+
+    await server.stop();
+    await completeRequest;
+
+    // The heartbeat should be cleaned up at this point. There is no second
+    // heartbeat mock, so if it ticks again it'll throw an error.
     jest.advanceTimersByTime(5000);
-    await firstHeartbeat;
+
+    expect(logger.orderOfOperations).toMatchInlineSnapshot(`
+      [
+        "SubscriptionCallback[1234-cats]: Received new subscription request",
+        "SubscriptionManager[1234-cats]: Sending \`check\` request to router",
+        "SubscriptionManager[1234-cats]: \`check\` request successful",
+        "SubscriptionCallback[1234-cats]: Starting graphql-js subscription",
+        "SubscriptionCallback[1234-cats]: graphql-js subscription successful",
+        "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
+        "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
+        "SubscriptionCallback[1234-cats]: Responding to original subscription request",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
+        "TESTING: Triggering first update",
+        "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
+        "TESTING: Triggering second update",
+        "SubscriptionManager[1234-cats]: \`next\` request successful",
+        "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
+        "SubscriptionManager[1234-cats]: \`next\` request successful",
+        "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
+        "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
+        "SubscriptionManager[1234-cats]: \`complete\` request successful",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
+        "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
+      ]
+    `);
+  });
+
+  it('handles multiple callback urls', async () => {
+    const server = await startSubscriptionServer({ logger });
+    const router2 = {
+      url: 'http://mock-router-url-2.com',
+      id: '5678-dogs',
+      verifier: 'another-verifier-token',
+    };
+    // Mock the initial check response from the 2 callback urls.
+    mockRouterCheckResponse();
+    mockRouterCheckResponse({ requestOpts: router2 });
+
+    // Mock the heartbeat response from the routers. We'll trigger it once below
+    // after the subscriptions are initialized to make sure it works.
+    const heartbeats = Promise.all([
+      mockRouterCheckResponse(),
+      mockRouterCheckResponse({ requestOpts: router2 }),
+    ]);
+
+    // Start the subscriptions.
+    const router1Result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: 'http://mock-router-url.com',
+              subscriptionId: '1234-cats',
+              verifier: 'my-verifier-token',
+            },
+          },
+        },
+      }),
+    );
+    const router2Result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: router2.url,
+              subscriptionId: router2.id,
+              verifier: router2.verifier,
+            },
+          },
+        },
+      }),
+    );
+    expect(router1Result.status).toEqual(200);
+    expect(router2Result.status).toEqual(200);
+
+    // Advance timers to trigger the heartbeat once. This consumes the
+    // heartbeat mocks from above (one per router).
+    jest.advanceTimersByTime(5000);
+    await heartbeats;
 
     // Next we'll trigger some subscription events. In advance, we'll mock the 2
     // router responses.
     const firstUpdate = Promise.all([
       mockRouterNextResponse({ payload: { count: 1 } }),
-      mockRouterNextResponse({ payload: { count: 1 }, ...secondSubscription }),
+      mockRouterNextResponse({ payload: { count: 1 }, ...router2 }),
     ]);
-
-    // This promise is a testing detail - we need to wait for the second update
-    // to fully resolve or else the server will stop and clean everything up
-    // before the mock is consumed. It resolves when the mock replies.
     const secondUpdate = Promise.all([
       mockRouterNextResponse({ payload: { count: 2 } }),
-      mockRouterNextResponse({ payload: { count: 2 }, ...secondSubscription }),
+      mockRouterNextResponse({ payload: { count: 2 }, ...router2 }),
     ]);
 
     // Trigger a couple updates. These send `next` requests to the router.
     logger.debug('TESTING: Triggering first update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
     await firstUpdate;
 
     logger.debug('TESTING: Triggering second update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
     await secondUpdate;
 
-    // We've established two subscriptions are functional at this point. Now
-    // let's have the router invalidate one with a 400 heartbeat.
-    const heartbeatWithInvalidIds = mockRouterHeartbeatResponse({
-      requestOptions: {
-        id: '1234-cats',
-        ids: ['1234-cats', secondSubscription.id],
-        verifier: 'my-verifier-token',
-      },
-      statusCode: 400,
-      responseBody: {
-        id: 'updated-subscription-id',
-        ids: ['1234-cats'],
-        invalid_ids: [secondSubscription.id],
-        verifier: 'updated-verifier-token',
-      },
-    });
-
-    jest.advanceTimersByTime(5000);
-    await heartbeatWithInvalidIds;
-
-    // A subsequent heartbeat should use the updated id/verifier, and the second
-    // subscription should be excluded.
-    const updatedHeartbeat = mockRouterHeartbeatResponse({
-      requestOptions: {
-        id: 'updated-subscription-id',
-        ids: ['1234-cats'],
-        verifier: 'updated-verifier-token',
-      },
-    });
-
-    jest.advanceTimersByTime(5000);
-    await updatedHeartbeat;
-
-    const thirdUpdate = mockRouterNextResponse({
-      payload: { count: 3 },
-    });
-    // Trigger a 3rd update to make sure the second subscription is
-    // excluded.
-    logger.debug('TESTING: Triggering third update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
-
-    await thirdUpdate;
     // When we shutdown the server, we'll stop listening for subscription
     // updates, await unresolved requests, and send a `complete` request to the
     // router for each active subscription.
-    mockRouterCompleteResponse();
+    const completeRequests = Promise.all([
+      mockRouterCompleteResponse(),
+      mockRouterCompleteResponse(router2),
+    ]);
 
     await server.stop();
+    await completeRequests;
 
-    // The heartbeat should be cleaned up at this point. There is no additional
+    // The heartbeat should be cleaned up at this point. There is no second
     // heartbeat mock, so if it ticks again it'll throw an error.
     jest.advanceTimersByTime(5000);
 
@@ -476,12 +659,15 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[5678-dogs]: \`check\` request successful",
         "SubscriptionCallback[5678-dogs]: Starting graphql-js subscription",
         "SubscriptionCallback[5678-dogs]: graphql-js subscription successful",
-        "SubscriptionManager[5678-dogs]: Heartbeat interval already exists for http://mock-router-url.com, reusing existing interval",
+        "SubscriptionManager[5678-dogs]: Starting new heartbeat interval for http://mock-router-url-2.com",
         "SubscriptionManager[5678-dogs]: Listening to graphql-js subscription",
         "SubscriptionCallback[5678-dogs]: Responding to original subscription request",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats,5678-dogs]",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url-2.com for ID: 5678-dogs",
+        "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
+        "SubscriptionManager: Heartbeat received response for ID: 5678-dogs",
+        "SubscriptionManager: Heartbeat request successful, ID: 5678-dogs",
         "TESTING: Triggering first update",
         "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
         "SubscriptionManager[5678-dogs]: Sending \`next\` request to router",
@@ -492,85 +678,90 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[5678-dogs]: Sending \`next\` request to router",
         "SubscriptionManager[1234-cats]: \`next\` request successful",
         "SubscriptionManager[5678-dogs]: \`next\` request successful",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat request received invalid IDs: [5678-dogs]",
-        "SubscriptionManager: Terminating subscriptions for IDs: [5678-dogs]",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-        "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
-        "TESTING: Triggering third update",
-        "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
-        "SubscriptionManager[5678-dogs]: Subscription already cancelled, ignoring current and future payloads",
-        "SubscriptionManager[1234-cats]: \`next\` request successful",
         "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
         "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
+        "SubscriptionManager[5678-dogs]: Sending \`complete\` request to router",
         "SubscriptionManager[1234-cats]: \`complete\` request successful",
-        "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-        "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
+        "SubscriptionManager[5678-dogs]: \`complete\` request successful",
+        "SubscriptionManager: Terminating subscriptions for ID: 5678-dogs",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url-2.com",
         "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
       ]
     `);
   });
 
-  it('cancels heartbeat on 404 (all IDs invalid)', async () => {
+  it('cancels heartbeat on 404', async () => {
     const server = await startSubscriptionServer({ logger });
     const secondSubscription = {
       id: '5678-dogs',
       verifier: 'another-verifier-token',
+      callbackUrl: 'http://mock-router-url.com/5678-dogs',
+      path: '/5678-dogs',
     };
 
     mockRouterCheckResponse();
-    mockRouterCheckResponse({ requestOpts: secondSubscription });
+    mockRouterCheckResponse({
+      requestOpts: secondSubscription,
+    });
     // Mock the heartbeat response from the router. We'll trigger it once below
     // after the subscription is initialized to make sure it works.
-    const firstHeartbeat = mockRouterHeartbeatResponse({
-      requestOptions: {
-        ids: ['1234-cats', secondSubscription.id],
-      },
-    });
+    const heartbeats = Promise.all([
+      mockRouterCheckResponse(),
+      mockRouterCheckResponse({
+        requestOpts: secondSubscription,
+      }),
+    ]);
 
     // Start the subscriptions; this triggers the initial check request and
     // starts the heartbeat interval. This simulates an incoming subscription
     // request from the router.
-    const firstResult = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: '1234-cats',
-          verifier: 'my-verifier-token',
+    const firstResult = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: 'http://mock-router-url.com',
+              subscriptionId: '1234-cats',
+              verifier: 'my-verifier-token',
+            },
+          },
         },
-      },
-    });
+      }),
+    );
 
-    expect(firstResult.http.status).toEqual(200);
+    expect(firstResult.status).toEqual(200);
 
-    const secondResult = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          count
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: secondSubscription.id,
-          verifier: secondSubscription.verifier,
+    const secondResult = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              count
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: secondSubscription.callbackUrl,
+              subscriptionId: secondSubscription.id,
+              verifier: secondSubscription.verifier,
+            },
+          },
         },
-      },
-    });
-
-    expect(secondResult.http.status).toEqual(200);
+      }),
+    );
+    expect(secondResult.status).toEqual(200);
 
     // Advance timers to trigger the heartbeat. This consumes the one
     // heartbeat mock from above.
     jest.advanceTimersByTime(5000);
-    await firstHeartbeat;
+    await heartbeats;
 
     // Next we'll trigger some subscription events. In advance, we'll mock the
     // router responses.
@@ -586,50 +777,61 @@ describe('SubscriptionCallbackPlugin', () => {
 
     // Trigger a couple updates. These send `next` requests to the router.
     logger.debug('TESTING: Triggering first update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
 
     await firstUpdate;
 
     logger.debug('TESTING: Triggering second update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
 
     await secondUpdate;
 
     // We've established two subscriptions are functional at this point. Now
     // let's have the router invalidate them with a 404 heartbeat.
-    const secondHeartbeat = mockRouterHeartbeatResponse({
-      requestOptions: {
-        id: '1234-cats',
-        ids: ['1234-cats', secondSubscription.id],
-        verifier: 'my-verifier-token',
-      },
-      statusCode: 404,
-    });
+    const secondHeartbeats = Promise.all([
+      mockRouterCheckResponse({ statusCode: 404 }),
+      mockRouterCheckResponse({
+        requestOpts: secondSubscription,
+        statusCode: 404,
+      }),
+    ]);
 
     jest.advanceTimersByTime(5000);
-    await secondHeartbeat;
+    await secondHeartbeats;
 
     // Trigger a 3rd update to make sure both subscriptions are cancelled.
     logger.debug('TESTING: Triggering third update');
-    await server.executeOperation({
-      query: `#graphql
-        mutation {
-          addOne
-        }
-      `,
-    });
+    await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+        },
+      }),
+    );
 
     // The heartbeat should be cleaned up at this point. There is no remaining
     // heartbeat mock, so if it ticks again it'll throw an error.
@@ -652,12 +854,15 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[5678-dogs]: \`check\` request successful",
         "SubscriptionCallback[5678-dogs]: Starting graphql-js subscription",
         "SubscriptionCallback[5678-dogs]: graphql-js subscription successful",
-        "SubscriptionManager[5678-dogs]: Heartbeat interval already exists for http://mock-router-url.com, reusing existing interval",
+        "SubscriptionManager[5678-dogs]: Starting new heartbeat interval for http://mock-router-url.com/5678-dogs",
         "SubscriptionManager[5678-dogs]: Listening to graphql-js subscription",
         "SubscriptionCallback[5678-dogs]: Responding to original subscription request",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats,5678-dogs]",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com/5678-dogs for ID: 5678-dogs",
+        "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
+        "SubscriptionManager: Heartbeat received response for ID: 5678-dogs",
+        "SubscriptionManager: Heartbeat request successful, ID: 5678-dogs",
         "TESTING: Triggering first update",
         "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
         "SubscriptionManager[5678-dogs]: Sending \`next\` request to router",
@@ -668,11 +873,16 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[5678-dogs]: Sending \`next\` request to router",
         "SubscriptionManager[1234-cats]: \`next\` request successful",
         "SubscriptionManager[5678-dogs]: \`next\` request successful",
-        "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat received response for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Heartbeat request received invalid IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats,5678-dogs]",
-        "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+        "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com/5678-dogs for ID: 5678-dogs",
+        "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+        "SubscriptionManager: Heartbeat request received invalid ID: 1234-cats",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
+        "SubscriptionManager: Heartbeat received response for ID: 5678-dogs",
+        "SubscriptionManager: Heartbeat request received invalid ID: 5678-dogs",
+        "SubscriptionManager: Terminating subscriptions for ID: 5678-dogs",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com/5678-dogs",
         "TESTING: Triggering third update",
         "SubscriptionManager[1234-cats]: Subscription already cancelled, ignoring current and future payloads",
         "SubscriptionManager[5678-dogs]: Subscription already cancelled, ignoring current and future payloads",
@@ -699,22 +909,26 @@ describe('SubscriptionCallbackPlugin', () => {
     // Start the subscription; this triggers the initial check request and
     // starts the heartbeat interval. This simulates an incoming subscription
     // request from the router.
-    const result = await server.executeOperation({
-      query: `#graphql
-        subscription {
-          terminatesSuccessfully
-        }
-      `,
-      extensions: {
-        subscription: {
-          callback_url: 'http://mock-router-url.com',
-          subscription_id: '1234-cats',
-          verifier: 'my-verifier-token',
+    const result = await server.executeHTTPGraphQLRequest(
+      buildHTTPGraphQLRequest({
+        body: {
+          query: `#graphql
+            subscription {
+              terminatesSuccessfully
+            }
+          `,
+          extensions: {
+            subscription: {
+              callbackUrl: 'http://mock-router-url.com',
+              subscriptionId: '1234-cats',
+              verifier: 'my-verifier-token',
+            },
+          },
         },
-      },
-    });
+      }),
+    );
     // The response to the router's initial request should be status 200
-    expect(result.http.status).toEqual(200);
+    expect(result.status).toEqual(200);
 
     await completeRequest;
 
@@ -743,8 +957,8 @@ describe('SubscriptionCallbackPlugin', () => {
         "SubscriptionManager[1234-cats]: Subscription completed without errors",
         "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
         "SubscriptionManager[1234-cats]: \`complete\` request successful",
-        "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-        "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+        "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+        "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
         "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
         "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
       ]
@@ -762,8 +976,8 @@ describe('SubscriptionCallbackPlugin', () => {
       });
 
       // This triggers the check request which will fail.
-      const result = await server.executeHTTPGraphQLRequest({
-        httpGraphQLRequest: {
+      const result = await server.executeHTTPGraphQLRequest(
+        buildHTTPGraphQLRequest({
           body: {
             query: `#graphql
             subscription {
@@ -772,18 +986,14 @@ describe('SubscriptionCallbackPlugin', () => {
           `,
             extensions: {
               subscription: {
-                callback_url: 'http://mock-router-url.com',
-                subscription_id: '1234-cats',
+                callbackUrl: 'http://mock-router-url.com',
+                subscriptionId: '1234-cats',
                 verifier: 'my-verifier-token',
               },
             },
           },
-          headers: new HeaderMap([['content-type', 'application/json']]),
-          method: 'POST',
-          search: '',
-        },
-        context: async () => ({}),
-      });
+        }),
+      );
 
       expect(result.status).toEqual(500);
       assert(result.body.kind === 'complete');
@@ -810,7 +1020,7 @@ describe('SubscriptionCallbackPlugin', () => {
           "SubscriptionCallback[1234-cats]: Received new subscription request",
           "SubscriptionManager[1234-cats]: Sending \`check\` request to router",
           "SubscriptionManager[1234-cats]: \`check\` request failed with unexpected status code: 400, terminating subscription",
-          "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
+          "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
           "ERROR: SubscriptionManager: No subscriptions found for http://mock-router-url.com, skipping termination",
           "ERROR: SubscriptionCallback[1234-cats]: \`check\` request failed: \`check\` request failed with unexpected status code: 400, terminating subscription",
           "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
@@ -836,8 +1046,8 @@ describe('SubscriptionCallbackPlugin', () => {
       });
 
       // Trigger an invalid subscription
-      const response = await server.executeHTTPGraphQLRequest({
-        httpGraphQLRequest: {
+      const response = await server.executeHTTPGraphQLRequest(
+        buildHTTPGraphQLRequest({
           body: {
             query: `#graphql
             subscription {
@@ -846,18 +1056,14 @@ describe('SubscriptionCallbackPlugin', () => {
           `,
             extensions: {
               subscription: {
-                callback_url: 'http://mock-router-url.com',
-                subscription_id: '1234-cats',
+                callbackUrl: 'http://mock-router-url.com',
+                subscriptionId: '1234-cats',
                 verifier: 'my-verifier-token',
               },
             },
           },
-          headers: new HeaderMap([['content-type', 'application/json']]),
-          method: 'POST',
-          search: '',
-        },
-        context: async () => ({}),
-      });
+        }),
+      );
       expect(response.status).toEqual(400);
       assert(response.body.kind === 'complete');
       expect(JSON.parse(response.body.string)).toEqual({
@@ -906,22 +1112,26 @@ describe('SubscriptionCallbackPlugin', () => {
       // Start the subscription; this triggers the initial check request and
       // starts the heartbeat interval. This simulates an incoming subscription
       // request from the router.
-      const result = await server.executeOperation({
-        query: `#graphql
-          subscription {
-            count
-          }
-        `,
-        extensions: {
-          subscription: {
-            callback_url: 'http://mock-router-url.com',
-            subscription_id: '1234-cats',
-            verifier: 'my-verifier-token',
+      const result = await server.executeHTTPGraphQLRequest(
+        buildHTTPGraphQLRequest({
+          body: {
+            query: `#graphql
+            subscription {
+              count
+            }
+          `,
+            extensions: {
+              subscription: {
+                callbackUrl: 'http://mock-router-url.com',
+                subscriptionId: '1234-cats',
+                verifier: 'my-verifier-token',
+              },
+            },
           },
-        },
-      });
+        }),
+      );
 
-      expect(result.http.status).toEqual(200);
+      expect(result.status).toEqual(200);
 
       // 5 failures is the limit before the heartbeat is cancelled. We expect to
       // see 5 errors and then a final error indicating the heartbeat was
@@ -932,10 +1142,9 @@ describe('SubscriptionCallbackPlugin', () => {
           .matchHeader('content-type', 'application/json')
           .post('/', {
             kind: 'subscription',
-            action: 'heartbeat',
+            action: 'check',
             id: '1234-cats',
             verifier: 'my-verifier-token',
-            ids: ['1234-cats'],
           })
           .replyWithError('network request error');
         // trigger heartbeat
@@ -954,20 +1163,20 @@ describe('SubscriptionCallbackPlugin', () => {
           "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
           "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
           "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
           "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (1 consecutive): request to http://mock-router-url.com/ failed, reason: network request error",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (2 consecutive): request to http://mock-router-url.com/ failed, reason: network request error",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (3 consecutive): request to http://mock-router-url.com/ failed, reason: network request error",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (4 consecutive): request to http://mock-router-url.com/ failed, reason: network request error",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (5 consecutive): request to http://mock-router-url.com/ failed, reason: network request error",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed 5 times, terminating subscriptions and heartbeat interval: request to http://mock-router-url.com/ failed, reason: network request error",
-          "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-          "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+          "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+          "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
           "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
         ]
       `);
@@ -982,22 +1191,26 @@ describe('SubscriptionCallbackPlugin', () => {
       // Start the subscription; this triggers the initial check request and
       // starts the heartbeat interval. This simulates an incoming subscription
       // request from the router.
-      const result = await server.executeOperation({
-        query: `#graphql
-          subscription {
-            count
-          }
-        `,
-        extensions: {
-          subscription: {
-            callback_url: 'http://mock-router-url.com',
-            subscription_id: '1234-cats',
-            verifier: 'my-verifier-token',
+      const result = await server.executeHTTPGraphQLRequest(
+        buildHTTPGraphQLRequest({
+          body: {
+            query: `#graphql
+            subscription {
+              count
+            }
+          `,
+            extensions: {
+              subscription: {
+                callbackUrl: 'http://mock-router-url.com',
+                subscriptionId: '1234-cats',
+                verifier: 'my-verifier-token',
+              },
+            },
           },
-        },
-      });
+        }),
+      );
 
-      expect(result.http.status).toEqual(200);
+      expect(result.status).toEqual(200);
 
       // 5 failures is the limit before the heartbeat is cancelled. We expect to
       // see 5 errors and then a final error indicating the heartbeat was
@@ -1008,10 +1221,9 @@ describe('SubscriptionCallbackPlugin', () => {
           .matchHeader('content-type', 'application/json')
           .post('/', {
             kind: 'subscription',
-            action: 'heartbeat',
+            action: 'check',
             id: '1234-cats',
             verifier: 'my-verifier-token',
-            ids: ['1234-cats'],
           })
           .reply(500);
         // trigger heartbeat
@@ -1030,25 +1242,25 @@ describe('SubscriptionCallbackPlugin', () => {
           "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
           "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
           "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
           "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
-          "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
+          "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (1 consecutive): Unexpected status code: 500",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-          "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+          "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (2 consecutive): Unexpected status code: 500",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-          "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+          "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (3 consecutive): Unexpected status code: 500",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-          "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+          "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (4 consecutive): Unexpected status code: 500",
-          "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-          "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
+          "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+          "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed (5 consecutive): Unexpected status code: 500",
           "ERROR: SubscriptionManager[1234-cats]: Heartbeat request failed 5 times, terminating subscriptions and heartbeat interval: Unexpected status code: 500",
-          "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-          "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+          "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+          "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
           "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
         ]
       `);
@@ -1067,26 +1279,30 @@ describe('SubscriptionCallbackPlugin', () => {
         // Start the subscription; this triggers the initial check request and
         // starts the heartbeat interval. This simulates an incoming subscription
         // request from the router.
-        const result = await server.executeOperation({
-          query: `#graphql
+        const result = await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
             subscription {
               count
             }
           `,
-          extensions: {
-            subscription: {
-              callback_url: 'http://mock-router-url.com',
-              subscription_id: '1234-cats',
-              verifier: 'my-verifier-token',
+              extensions: {
+                subscription: {
+                  callbackUrl: 'http://mock-router-url.com',
+                  subscriptionId: '1234-cats',
+                  verifier: 'my-verifier-token',
+                },
+              },
             },
-          },
-        });
+          }),
+        );
 
-        expect(result.http.status).toEqual(200);
+        expect(result.status).toEqual(200);
 
         // Mock the heartbeat response from the router. We'll trigger it once below
         // after the subscription is initialized to make sure it works.
-        const firstHeartbeat = mockRouterHeartbeatResponse();
+        const firstHeartbeat = mockRouterCheckResponse();
         // Advance timers to trigger the heartbeat once. This consumes the one
         // heartbeat mock from above.
         jest.advanceTimersByTime(5000);
@@ -1097,13 +1313,17 @@ describe('SubscriptionCallbackPlugin', () => {
 
         // Trigger a couple updates. These send `next` requests to the router.
         logger.debug('TESTING: Triggering first update');
-        await server.executeOperation({
-          query: `#graphql
-            mutation {
-              addOne
-            }
-          `,
-        });
+        await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
+                mutation {
+                  addOne
+                }
+              `,
+            },
+          }),
+        );
 
         await update;
 
@@ -1131,17 +1351,17 @@ describe('SubscriptionCallbackPlugin', () => {
             "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
             "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
             "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-            "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
+            "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
             "TESTING: Triggering first update",
             "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
             "SubscriptionManager[1234-cats]: \`next\` request successful",
             "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
             "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
             "SubscriptionManager[1234-cats]: \`complete\` request successful",
-            "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-            "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+            "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+            "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
             "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
           ]
         `);
@@ -1156,26 +1376,30 @@ describe('SubscriptionCallbackPlugin', () => {
         // Start the subscription; this triggers the initial check request and
         // starts the heartbeat interval. This simulates an incoming subscription
         // request from the router.
-        const result = await server.executeOperation({
-          query: `#graphql
-          subscription {
-            count
-          }
-        `,
-          extensions: {
-            subscription: {
-              callback_url: 'http://mock-router-url.com',
-              subscription_id: '1234-cats',
-              verifier: 'my-verifier-token',
+        const result = await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
+            subscription {
+              count
+            }
+          `,
+              extensions: {
+                subscription: {
+                  callbackUrl: 'http://mock-router-url.com',
+                  subscriptionId: '1234-cats',
+                  verifier: 'my-verifier-token',
+                },
+              },
             },
-          },
-        });
+          }),
+        );
 
-        expect(result.http.status).toEqual(200);
+        expect(result.status).toEqual(200);
 
         // Mock the heartbeat response from the router. We'll trigger it once below
         // after the subscription is initialized to make sure it works.
-        const firstHeartbeat = mockRouterHeartbeatResponse();
+        const firstHeartbeat = mockRouterCheckResponse();
         // Advance timers to trigger the heartbeat once. This consumes the one
         // heartbeat mock from above.
         jest.advanceTimersByTime(5000);
@@ -1198,22 +1422,30 @@ describe('SubscriptionCallbackPlugin', () => {
 
         // Trigger a couple updates. These send `next` requests to the router.
         logger.debug('TESTING: Triggering first update');
-        await server.executeOperation({
-          query: `#graphql
-          mutation {
-            addOne
-          }
-        `,
-        });
+        await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+            },
+          }),
+        );
 
         logger.debug('TESTING: Triggering second update');
-        await server.executeOperation({
-          query: `#graphql
-          mutation {
-            addOne
-          }
-        `,
-        });
+        await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+            },
+          }),
+        );
 
         await updates;
 
@@ -1239,9 +1471,9 @@ describe('SubscriptionCallbackPlugin', () => {
             "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
             "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
             "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-            "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
+            "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
             "TESTING: Triggering first update",
             "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
             "TESTING: Triggering second update",
@@ -1257,8 +1489,8 @@ describe('SubscriptionCallbackPlugin', () => {
             "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
             "SubscriptionManager[1234-cats]: Sending \`complete\` request to router",
             "SubscriptionManager[1234-cats]: \`complete\` request successful",
-            "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-            "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+            "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+            "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
             "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
           ]
         `);
@@ -1273,26 +1505,30 @@ describe('SubscriptionCallbackPlugin', () => {
         // Start the subscription; this triggers the initial check request and
         // starts the heartbeat interval. This simulates an incoming subscription
         // request from the router.
-        const result = await server.executeOperation({
-          query: `#graphql
+        const result = await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
             subscription {
               count
             }
           `,
-          extensions: {
-            subscription: {
-              callback_url: 'http://mock-router-url.com',
-              subscription_id: '1234-cats',
-              verifier: 'my-verifier-token',
+              extensions: {
+                subscription: {
+                  callbackUrl: 'http://mock-router-url.com',
+                  subscriptionId: '1234-cats',
+                  verifier: 'my-verifier-token',
+                },
+              },
             },
-          },
-        });
+          }),
+        );
 
-        expect(result.http.status).toEqual(200);
+        expect(result.status).toEqual(200);
 
         // Mock the heartbeat response from the router. We'll trigger it once below
         // after the subscription is initialized to make sure it works.
-        const firstHeartbeat = mockRouterHeartbeatResponse();
+        const firstHeartbeat = mockRouterCheckResponse();
         // Advance timers to trigger the heartbeat once. This consumes the one
         // heartbeat mock from above.
         jest.advanceTimersByTime(5000);
@@ -1303,13 +1539,17 @@ describe('SubscriptionCallbackPlugin', () => {
 
         // Trigger a couple updates. These send `next` requests to the router.
         logger.debug('TESTING: Triggering first update');
-        await server.executeOperation({
-          query: `#graphql
+        await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
             mutation {
               addOne
             }
           `,
-        });
+            },
+          }),
+        );
 
         await update;
 
@@ -1338,9 +1578,9 @@ describe('SubscriptionCallbackPlugin', () => {
             "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
             "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
             "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-            "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
+            "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
             "TESTING: Triggering first update",
             "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
             "SubscriptionManager[1234-cats]: \`next\` request successful",
@@ -1349,8 +1589,8 @@ describe('SubscriptionCallbackPlugin', () => {
             "WARN: SubscriptionManager[1234-cats]: Retrying \`complete\` request (attempt 1) due to error: \`complete\` request failed with unexpected status code: 500",
             "WARN: SubscriptionManager[1234-cats]: Retrying \`complete\` request (attempt 2) due to error: \`complete\` request failed with unexpected status code: 500",
             "SubscriptionManager[1234-cats]: \`complete\` request successful",
-            "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-            "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+            "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+            "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
             "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
           ]
         `);
@@ -1365,26 +1605,30 @@ describe('SubscriptionCallbackPlugin', () => {
         // Start the subscription; this triggers the initial check request and
         // starts the heartbeat interval. This simulates an incoming subscription
         // request from the router.
-        const result = await server.executeOperation({
-          query: `#graphql
+        const result = await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
             subscription {
               count
             }
           `,
-          extensions: {
-            subscription: {
-              callback_url: 'http://mock-router-url.com',
-              subscription_id: '1234-cats',
-              verifier: 'my-verifier-token',
+              extensions: {
+                subscription: {
+                  callbackUrl: 'http://mock-router-url.com',
+                  subscriptionId: '1234-cats',
+                  verifier: 'my-verifier-token',
+                },
+              },
             },
-          },
-        });
+          }),
+        );
 
-        expect(result.http.status).toEqual(200);
+        expect(result.status).toEqual(200);
 
         // Mock the heartbeat response from the router. We'll trigger it once below
         // after the subscription is initialized to make sure it works.
-        const firstHeartbeat = mockRouterHeartbeatResponse();
+        const firstHeartbeat = mockRouterCheckResponse();
         // Advance timers to trigger the heartbeat once. This consumes the one
         // heartbeat mock from above.
         jest.advanceTimersByTime(5000);
@@ -1395,13 +1639,17 @@ describe('SubscriptionCallbackPlugin', () => {
 
         // Trigger a couple updates. These send `next` requests to the router.
         logger.debug('TESTING: Triggering first update');
-        await server.executeOperation({
-          query: `#graphql
+        await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
             mutation {
               addOne
             }
           `,
-        });
+            },
+          }),
+        );
 
         await update;
 
@@ -1432,9 +1680,9 @@ describe('SubscriptionCallbackPlugin', () => {
             "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
             "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
             "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-            "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
+            "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
             "TESTING: Triggering first update",
             "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
             "SubscriptionManager[1234-cats]: \`next\` request successful",
@@ -1446,8 +1694,8 @@ describe('SubscriptionCallbackPlugin', () => {
             "WARN: SubscriptionManager[1234-cats]: Retrying \`complete\` request (attempt 4) due to error: \`complete\` request failed with unexpected status code: 500",
             "WARN: SubscriptionManager[1234-cats]: Retrying \`complete\` request (attempt 5) due to error: \`complete\` request failed with unexpected status code: 500",
             "ERROR: SubscriptionManager[1234-cats]: \`complete\` request failed: \`complete\` request failed with unexpected status code: 500",
-            "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-            "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+            "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+            "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
             "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
           ]
         `);
@@ -1462,26 +1710,30 @@ describe('SubscriptionCallbackPlugin', () => {
         // Start the subscription; this triggers the initial check request and
         // starts the heartbeat interval. This simulates an incoming subscription
         // request from the router.
-        const result = await server.executeOperation({
-          query: `#graphql
-          subscription {
-            count
-          }
-        `,
-          extensions: {
-            subscription: {
-              callback_url: 'http://mock-router-url.com',
-              subscription_id: '1234-cats',
-              verifier: 'my-verifier-token',
+        const result = await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
+            subscription {
+              count
+            }
+          `,
+              extensions: {
+                subscription: {
+                  callbackUrl: 'http://mock-router-url.com',
+                  subscriptionId: '1234-cats',
+                  verifier: 'my-verifier-token',
+                },
+              },
             },
-          },
-        });
+          }),
+        );
 
-        expect(result.http.status).toEqual(200);
+        expect(result.status).toEqual(200);
 
         // Mock the heartbeat response from the router. We'll trigger it once below
         // after the subscription is initialized to make sure it works.
-        const firstHeartbeat = mockRouterHeartbeatResponse();
+        const firstHeartbeat = mockRouterCheckResponse();
         // Advance timers to trigger the heartbeat once. This consumes the one
         // heartbeat mock from above.
         jest.advanceTimersByTime(5000);
@@ -1499,13 +1751,17 @@ describe('SubscriptionCallbackPlugin', () => {
 
         // Trigger a couple updates. These send `next` requests to the router.
         logger.debug('TESTING: Triggering first update');
-        await server.executeOperation({
-          query: `#graphql
-          mutation {
-            addOne
-          }
-        `,
-        });
+        await server.executeHTTPGraphQLRequest(
+          buildHTTPGraphQLRequest({
+            body: {
+              query: `#graphql
+            mutation {
+              addOne
+            }
+          `,
+            },
+          }),
+        );
 
         // After 5 failures, the plugin will terminate the subscriptions without
         // sending a `complete` request.
@@ -1529,9 +1785,9 @@ describe('SubscriptionCallbackPlugin', () => {
             "SubscriptionManager[1234-cats]: Starting new heartbeat interval for http://mock-router-url.com",
             "SubscriptionManager[1234-cats]: Listening to graphql-js subscription",
             "SubscriptionCallback[1234-cats]: Responding to original subscription request",
-            "SubscriptionManager: Sending \`heartbeat\` request to http://mock-router-url.com for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat received response for IDs: [1234-cats]",
-            "SubscriptionManager: Heartbeat request successful, IDs: [1234-cats]",
+            "SubscriptionManager: Sending \`check\` request to http://mock-router-url.com for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat received response for ID: 1234-cats",
+            "SubscriptionManager: Heartbeat request successful, ID: 1234-cats",
             "TESTING: Triggering first update",
             "SubscriptionManager[1234-cats]: Sending \`next\` request to router",
             "WARN: SubscriptionManager[1234-cats]: Retrying \`next\` request (attempt 1) due to error: \`next\` request failed with unexpected status code: 500",
@@ -1540,8 +1796,8 @@ describe('SubscriptionCallbackPlugin', () => {
             "WARN: SubscriptionManager[1234-cats]: Retrying \`next\` request (attempt 4) due to error: \`next\` request failed with unexpected status code: 500",
             "WARN: SubscriptionManager[1234-cats]: Retrying \`next\` request (attempt 5) due to error: \`next\` request failed with unexpected status code: 500",
             "ERROR: SubscriptionManager[1234-cats]: \`next\` request failed, terminating subscription: \`next\` request failed with unexpected status code: 500",
-            "SubscriptionManager: Terminating subscriptions for IDs: [1234-cats]",
-            "SubscriptionManager: Terminating heartbeat interval, no more subscriptions for http://mock-router-url.com",
+            "SubscriptionManager: Terminating subscriptions for ID: 1234-cats",
+            "SubscriptionManager: Terminating heartbeat interval for http://mock-router-url.com",
             "SubscriptionCallback: Server is shutting down. Cleaning up outstanding subscriptions and heartbeat intervals",
             "SubscriptionCallback: Successfully cleaned up outstanding subscriptions and heartbeat intervals.",
           ]
@@ -1635,6 +1891,7 @@ function mockRouterCheckResponse(opts?: {
     url?: string;
     id?: string;
     verifier?: string;
+    path?: string;
   };
   statusCode?: number;
   responseBody?: any;
@@ -1644,6 +1901,7 @@ function mockRouterCheckResponse(opts?: {
       url = 'http://mock-router-url.com',
       id = '1234-cats',
       verifier = 'my-verifier-token',
+      path = '/',
     } = {},
     statusCode = 204,
     responseBody = undefined,
@@ -1652,7 +1910,8 @@ function mockRouterCheckResponse(opts?: {
   return promisifyNock(
     nock(url)
       .matchHeader('content-type', 'application/json')
-      .post('/', {
+      .matchHeader('subscription-protocol', 'callback/1.0')
+      .post(path, {
         kind: 'subscription',
         action: 'check',
         id,
@@ -1667,6 +1926,7 @@ function mockRouterCheckResponseWithError(opts?: {
     url?: string;
     id?: string;
     verifier?: string;
+    path?: string;
   };
 }) {
   const {
@@ -1674,13 +1934,15 @@ function mockRouterCheckResponseWithError(opts?: {
       url = 'http://mock-router-url.com',
       id = '1234-cats',
       verifier = 'my-verifier-token',
+      path = '/',
     } = {},
   } = opts ?? {};
 
   return promisifyNock(
     nock(url)
       .matchHeader('content-type', 'application/json')
-      .post('/', {
+      .matchHeader('subscription-protocol', 'callback/1.0')
+      .post(path, {
         kind: 'subscription',
         action: 'check',
         id,
@@ -1690,47 +1952,10 @@ function mockRouterCheckResponseWithError(opts?: {
   );
 }
 
-function mockRouterHeartbeatResponse(opts?: {
-  requestOptions: {
-    url?: string;
-    id?: string;
-    verifier?: string;
-    ids?: string[];
-    invalidIds?: string[];
-  };
-  statusCode?: number;
-  responseBody?: any;
-}) {
-  const {
-    requestOptions: {
-      url = 'http://mock-router-url.com',
-      id = '1234-cats',
-      verifier = 'my-verifier-token',
-      ids = undefined,
-      invalidIds = undefined,
-    } = {},
-    statusCode = 200,
-    responseBody,
-  } = opts ?? {};
-
-  return promisifyNock(
-    nock(url)
-      .matchHeader('content-type', 'application/json')
-      .post('/', {
-        kind: 'subscription',
-        action: 'heartbeat',
-        id,
-        verifier,
-        ids: ids ?? [id],
-        ...(invalidIds && { invalid_ids: invalidIds }),
-      })
-      .reply(statusCode, responseBody),
-  );
-}
-
 function mockRouterNextResponse(requestOpts: {
   payload: Record<string, any>;
   url?: string;
+  path?: string;
   id?: string;
   verifier?: string;
   statusCode?: number;
@@ -1739,6 +1964,7 @@ function mockRouterNextResponse(requestOpts: {
     payload,
     url = 'http://mock-router-url.com',
     id = '1234-cats',
+    path = '/',
     verifier = 'my-verifier-token',
     statusCode = 200,
   } = requestOpts;
@@ -1746,7 +1972,7 @@ function mockRouterNextResponse(requestOpts: {
   return promisifyNock(
     nock(url)
       .matchHeader('content-type', 'application/json')
-      .post('/', {
+      .post(path, {
         kind: 'subscription',
         action: 'next',
         id,
@@ -1761,6 +1987,7 @@ function mockRouterCompleteResponse(requestOpts?: {
   errors?: any[];
   url?: string;
   id?: string;
+  path?: string;
   verifier?: string;
   statusCode?: number;
 }) {
@@ -1768,6 +1995,7 @@ function mockRouterCompleteResponse(requestOpts?: {
     errors = undefined,
     url = 'http://mock-router-url.com',
     id = '1234-cats',
+    path = '/',
     verifier = 'my-verifier-token',
     statusCode = 200,
   } = requestOpts ?? {};
@@ -1775,7 +2003,7 @@ function mockRouterCompleteResponse(requestOpts?: {
   return promisifyNock(
     nock(url)
       .matchHeader('content-type', 'application/json')
-      .post('/', {
+      .post(path, {
         kind: 'subscription',
         action: 'complete',
         id,