diff --git a/config/install/salesforce.settings.yml b/config/install/salesforce.settings.yml
index 6ed932c9529b8e084c435ece97617ae95ae91712..87347f3b1fa964fb08ab16bb8bb4239299cc44c2 100644
--- a/config/install/salesforce.settings.yml
+++ b/config/install/salesforce.settings.yml
@@ -6,3 +6,4 @@ use_latest: true
 global_push_limit: 100000
 pull_max_queue_size: 100000
 show_all_objects: false
+standalone: false
diff --git a/config/schema/salesforce.schema.yml b/config/schema/salesforce.schema.yml
index 53afd14331427e8a8ab7b617b4f74a81a67a40de..54b319853d115b015fb1440ae8e0d5c5d155a349 100644
--- a/config/schema/salesforce.schema.yml
+++ b/config/schema/salesforce.schema.yml
@@ -2,6 +2,12 @@ salesforce.settings:
   type: config_object
   label: 'Salesforce Settings'
   mapping:
+    standalone:
+      type: boolean
+      label: 'Provide standalone push queue processing endpoint.'
+    show_all_objects:
+      type: boolean
+      label: 'Show all Salesforce objects in mapping UI, including system and non-writeable tables'
     use_latest:
       type: boolean
       label: 'Use latest REST API Version by default'
diff --git a/modules/salesforce_example/salesforce_example-apex_endpoint.php b/modules/salesforce_example/salesforce_example-apex_endpoint.php
new file mode 100644
index 0000000000000000000000000000000000000000..1c6eda6abe3fda6129e4b8a639ef7f713cdbe923
--- /dev/null
+++ b/modules/salesforce_example/salesforce_example-apex_endpoint.php
@@ -0,0 +1,64 @@
+<?php
+
+// This line for "security" purposes:
+exit;
+
+// Include the exception class:
+use Drupal\salesforce\Rest\RestException;
+
+// Your api path should NOT include a domain, but should include any query
+// string params. The leading slash, "/", on your path tells apiCall() that
+// this is a custom endpoint:
+$path = '/services/apexrest/MyEndpoint?getParam1=getValue1&getParam2=getValue2';
+
+// Create your POST body appropriately, if necessary.
+// This must be an array, which will be json-encoded before POSTing
+$payload = ['postParam1' => 'postValue1', 'postParam2' => 'postValue2', ... ];
+
+$returnObject = FALSE;
+// Uncomment the following line to get Drupal\salesforce\Rest\RestResponse object instead of json-decoded value:
+// $returnObject = TRUE;
+
+// Instantiate the client so we can reference the response later if necessary:
+/** @var Drupal\salesforce\Rest\RestClient **/
+$client = \Drupal::service('salesforce.client');
+
+$method = 'POST';
+
+try {
+  // apiCall() method will pre-pend the appropriate instance URL, send request
+  // with OAuth headers, and will automatically retry ONCE if SF responds with
+  // status code 401.
+  // $response_data is json-decoded response body.
+  // (or RestResponse if $returnObject is TRUE).
+  /** @var mixed array | Drupal\salesforce\Rest\RestResponse **/
+  $response_data = $client->apiCall($path, $payload, $method, $returnObject);
+}
+catch (RestException $e) {
+  // RestException will be raised if:
+    // - SF responds with 300+ status code, or if Response 
+    // - SF response body is not valid JSON
+    // - SF response body is empty
+    // - SF response contains an 'error' element
+    // - SF response contains an 'errorCode' element
+
+  /** @var Psr\Http\Message\ResponseInterface **/
+  $response = $e->getResponse();
+
+  // Convenience wrapper for $response->getBody()->getContents()
+  /** @var string **/
+  $responseBody = $e->getResponseBody();
+
+  /** @var int **/
+  $statusCode = $response->getStatusCode();
+
+  // insert exception handling here.
+  // ...
+}
+catch (\Exception $e) {
+  // Another exception may be thrown, e.g. for a network error, missing OAuth credentials, invalid params, etc.
+  // see GuzzleHttp\Client
+}
+
+```
+
diff --git a/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml b/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml
index f78a584ff8371f35b576170622f03923d029ecbd..2130f12705a282ed11782f4ee975bfff2bc1b151 100644
--- a/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml
+++ b/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml
@@ -28,6 +28,9 @@ salesforce_mapping.salesforce_mapping.*:
     async:
       type: boolean
       label: 'Push async'
+    push_standalone:
+      type: boolean
+      label: 'Standalone push queue processing'
     pull_trigger_date:
       type: string
       label: 'Pull Trigger Date Field'
diff --git a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php
index 0316b8180b301b7a3529af39aa584ab5f03688f9..71957c550cd949e2cae86bc6b2d0fb0628a8655d 100644
--- a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php
+++ b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php
@@ -48,6 +48,7 @@ use Drupal\salesforce_mapping\MappingConstants;
  *    "type",
  *    "key",
  *    "async",
+ *    "push_standalone",
  *    "pull_trigger_date",
  *    "pull_where_clause",
  *    "sync_triggers",
@@ -130,6 +131,13 @@ class SalesforceMapping extends ConfigEntityBase implements SalesforceMappingInt
    */
   protected $async = FALSE;
 
+  /**
+   * Whether a standalone push endpoint is enabled for this mapping.
+   *
+   * @var bool
+   */
+  protected $push_standalone = FALSE;
+
   /**
    * The Salesforce field to use for determining whether or not to pull.
    *
@@ -398,6 +406,13 @@ class SalesforceMapping extends ConfigEntityBase implements SalesforceMappingInt
     return $this->pull_trigger_date;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function doesPushStandalone() {
+    return $this->push_standalone;
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php b/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php
index da8b242b920297aba5075576e9ea1316502090f5..65e49c75f1ecbf16833db4f6e92d99477c18ec13 100644
--- a/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php
+++ b/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php
@@ -76,6 +76,12 @@ interface SalesforceMappingInterface extends ConfigEntityInterface {
    */
   public function getPullTriggerDate();
 
+  /**
+   * Return TRUE if this mapping is set to process push queue via a standalone
+   * endpoint instead of during cron.
+   */
+  public function doesPushStandalone();
+
   /**
    * Checks mappings for any push operation positive
    *
diff --git a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php
index 720df0fe88b9300f6e8dbeb654db7cd1aee60311..cedae00157a8e45652c948f60f90d8e221dbc200 100644
--- a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php
+++ b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php
@@ -153,6 +153,9 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase {
         salesforce_push and salesforce_pull modules.'
       ),
     ];
+    if (empty($trigger_options)) {
+      $form['sync_triggers']['#description'] += ' ' . t('<em>No trigger options are available when Salesforce Push and Pull modules are disabled. Enable one or both modules to allow Push or Pull processing.');
+    }
 
     foreach ($trigger_options as $option => $label) {
       $form['sync_triggers'][$option] = [
@@ -162,30 +165,39 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase {
       ];
     }
 
-    // @TODO should push and pull settings get moved into push and pull modules?
-    $form['pull'] = [
-      '#title' => t('Pull Settings'),
-      '#type' => 'details',
-      '#description' => t(''),
-      '#open' => TRUE,
-      '#tree' => FALSE,
-      '#states' => [
-        'visible' => [
-          ':input[name^="sync_triggers[pull"]' => array('checked' => TRUE),
+    if ($this->moduleHandler->moduleExists('salesforce_pull')) {
+      // @TODO should push and pull settings get moved into push and pull modules?
+      $form['pull'] = [
+        '#title' => t('Pull Settings'),
+        '#type' => 'details',
+        '#description' => t(''),
+        '#open' => TRUE,
+        '#tree' => FALSE,
+        '#states' => [
+          'visible' => [
+            ':input[name^="sync_triggers[pull"]' => array('checked' => TRUE),
+          ]
         ]
-      ]
-    ];
+      ];
 
-    if (!$mapping->isNew()) {
-      // This doesn't work until after mapping gets saved.
-      // @TODO figure out best way to alert admins about this, or AJAX-ify it.
-      $form['pull']['pull_trigger_date'] = [
-        '#type' => 'select',
-        '#title' => t('Date field to trigger pull'),
-        '#description' => t('Poll Salesforce for updated records based on the given date field. Defaults to "Last Modified Date".'),
-        '#required' => $mapping->salesforce_object_type,
-        '#default_value' => $mapping->pull_trigger_date,
-        '#options' => $this->get_pull_trigger_options($salesforce_object_type),
+      if (!$mapping->isNew()) {
+        // This doesn't work until after mapping gets saved.
+        // @TODO figure out best way to alert admins about this, or AJAX-ify it.
+        $form['pull']['pull_trigger_date'] = [
+          '#type' => 'select',
+          '#title' => t('Date field to trigger pull'),
+          '#description' => t('Poll Salesforce for updated records based on the given date field. Defaults to "Last Modified Date".'),
+          '#required' => $mapping->salesforce_object_type,
+          '#default_value' => $mapping->pull_trigger_date,
+          '#options' => $this->get_pull_trigger_options($salesforce_object_type),
+        ];
+      }
+
+      $form['pull']['pull_where_clause'] = [
+        '#title' => t('Pull query SOQL "Where" clause'),
+        '#type' => 'textarea',
+        '#description' => t('Add a "where" SOQL condition clause to limit records pulled from Salesforce. e.g. Email != \'\' AND RecordType.DevelopName = \'ExampleRecordType\''),
+        '#default_value' => $mapping->pull_where_clause,
       ];
     }
 
@@ -203,57 +215,92 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase {
       '#description' => t('Enter a frequency, in seconds, for how often this mapping should be used to pull data to Drupal. Enter 0 to pull as often as possible. FYI: 1 hour = 3600; 1 day = 86400. <em>NOTE: pull frequency is shared per-Salesforce Object. The setting is exposed here for convenience.</em>'),
     ];
 
-    $form['push'] = [
-      '#title' => t('Push Settings'),
-      '#type' => 'details',
-      '#description' => t('The asynchronous push queue is always enabled in Drupal 8: real-time push fails are queued for async push. Alternatively, you can choose to disable real-time push and use async-only.'),
-      '#open' => TRUE,
-      '#tree' => FALSE,
-      '#states' => [
-        'visible' => [
-          ':input[name^="sync_triggers[push"]' => array('checked' => TRUE),
+    if ($this->moduleHandler->moduleExists('salesforce_push')) {
+      $form['push'] = [
+        '#title' => t('Push Settings'),
+        '#type' => 'details',
+        '#description' => t('The asynchronous push queue is always enabled in Drupal 8: real-time push fails are queued for async push. Alternatively, you can choose to disable real-time push and use async-only.'),
+        '#open' => TRUE,
+        '#tree' => FALSE,
+        '#states' => [
+          'visible' => [
+            ':input[name^="sync_triggers[push"]' => array('checked' => TRUE),
+          ]
         ]
-      ]
-    ];
+      ];
 
-    $form['push']['async'] = [
-      '#title' => t('Disable real-time push'),
-      '#type' => 'checkbox',
-      '#description' => t('When real-time push is disabled, enqueue changes and push to Salesforce asynchronously during cron. When disabled, push changes immediately upon entity CRUD, and only enqueue failures for async push.'),
-      '#default_value' => $mapping->async,
-    ];
+      $form['push']['async'] = [
+        '#title' => t('Disable real-time push'),
+        '#type' => 'checkbox',
+        '#description' => t('When real-time push is disabled, enqueue changes and push to Salesforce asynchronously during cron. When disabled, push changes immediately upon entity CRUD, and only enqueue failures for async push.'),
+        '#default_value' => $mapping->async,
+      ];
 
-    $form['push']['push_frequency'] = [
-      '#title' => t('Push Frequency'),
-      '#type' => 'number',
-      '#default_value' => $mapping->push_frequency,
-      '#description' => t('Enter a frequency, in seconds, for how often this mapping should be used to push data to Salesforce. Enter 0 to push as often as possible. FYI: 1 hour = 3600; 1 day = 86400.'),
-      '#min' => 0,
-    ];
+      $form['push']['push_frequency'] = [
+        '#title' => t('Push Frequency'),
+        '#type' => 'number',
+        '#default_value' => $mapping->push_frequency,
+        '#description' => t('Enter a frequency, in seconds, for how often this mapping should be used to push data to Salesforce. Enter 0 to push as often as possible. FYI: 1 hour = 3600; 1 day = 86400.'),
+        '#min' => 0,
+      ];
 
-    $form['push']['push_limit'] = [
-      '#title' => t('Push Limit'),
-      '#type' => 'number',
-      '#default_value' => $mapping->push_limit,
-      '#description' => t('Enter the maximum number of records to be pushed to Salesforce during a single queue batch. Enter 0 to process as many records as possible, subject to the global push queue limit.'),
-      '#min' => 0,
-    ];
+      $form['push']['push_limit'] = [
+        '#title' => t('Push Limit'),
+        '#type' => 'number',
+        '#default_value' => $mapping->push_limit,
+        '#description' => t('Enter the maximum number of records to be pushed to Salesforce during a single queue batch. Enter 0 to process as many records as possible, subject to the global push queue limit.'),
+        '#min' => 0,
+      ];
 
-    $form['push']['push_retries'] = [
-      '#title' => t('Push Retries'),
-      '#type' => 'number',
-      '#default_value' => $mapping->push_retries,
-      '#description' => t('Enter the maximum number of attempts to push a record to Salesforce before it\'s considered failed. Enter 0 for no limit.'),
-      '#min' => 0,
-    ];
+      $form['push']['push_retries'] = [
+        '#title' => t('Push Retries'),
+        '#type' => 'number',
+        '#default_value' => $mapping->push_retries,
+        '#description' => t('Enter the maximum number of attempts to push a record to Salesforce before it\'s considered failed. Enter 0 for no limit.'),
+        '#min' => 0,
+      ];
 
-    $form['push']['weight'] = [
-      '#title' => t('Weight'),
-      '#type' => 'select',
-      '#options' => array_combine(range(-50,50), range(-50,50)),
-      '#description' => t('Not yet in use. During cron, mapping weight determines in which order items will be pushed. Lesser weight items will be pushed before greater weight items.'),
-      '#default_value' => $mapping->weight,
-    ];
+      $form['push']['weight'] = [
+        '#title' => t('Weight'),
+        '#type' => 'select',
+        '#options' => array_combine(range(-50,50), range(-50,50)),
+        '#description' => t('Not yet in use. During cron, mapping weight determines in which order items will be pushed. Lesser weight items will be pushed before greater weight items.'),
+        '#default_value' => $mapping->weight,
+      ];
+
+      $standalone_url = Url::fromRoute(
+          'salesforce_push.endpoint.salesforce_mapping',
+          [
+            'salesforce_mapping' => $mapping->id(),
+            'key' => \Drupal::state()->get('system.cron_key')
+          ],
+          ['absolute' => TRUE])
+        ->toString();
+
+      $form['push']['push_standalone'] = [
+        '#title' => t('Enable standalone push queue processing'),
+        '#type' => 'checkbox',
+        '#description' => t('Check this box to disable cron push processing for this mapping, and allow standalone processing via this URL: <a href="@url">@url</a>', ['@url' => $standalone_url]),
+        '#default_value' => $mapping->push_standalone,
+      ];
+
+      // If global standalone is enabled, then we force this mapping's
+      // standalone property to true.
+      if ($this->config('salesforce.settings')->get('standalone')) {
+        $settings_url = Url::fromRoute('salesforce.global_settings');
+        $form['push']['push_standalone']['#default_value'] = TRUE;
+        $form['push']['push_standalone']['#disabled'] = TRUE;
+        $form['push']['push_standalone']['#description'] .= ' ' . t('See also <a href="@url">global standalone processing settings</a>.', ['@url' => $settings_url]);
+      }
+
+      $form['push']['weight'] = [
+        '#title' => t('Weight'),
+        '#type' => 'select',
+        '#options' => array_combine(range(-50,50), range(-50,50)),
+        '#description' => t('Not yet in use. During cron, mapping weight determines in which order items will be pushed. Lesser weight items will be pushed before greater weight items.'),
+        '#default_value' => $mapping->weight,
+      ];
+    }
 
     $form['meta'] = [
       '#type' => 'details',
@@ -398,14 +445,22 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase {
    *   label as the value.
    */
   protected function get_sync_trigger_options() {
-    return [
-      MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_CREATE => t('Drupal entity create (push)'),
-      MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_UPDATE => t('Drupal entity update (push)'),
-      MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE => t('Drupal entity delete (push)'),
-      MappingConstants::SALESFORCE_MAPPING_SYNC_SF_CREATE => t('Salesforce object create (pull)'),
-      MappingConstants::SALESFORCE_MAPPING_SYNC_SF_UPDATE => t('Salesforce object update (pull)'),
-      MappingConstants::SALESFORCE_MAPPING_SYNC_SF_DELETE => t('Salesforce object delete (pull)'),
-    ];
+    $options = [];
+    if ($this->moduleHandler->moduleExists('salesforce_push')) {
+      $options += [
+        MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_CREATE => t('Drupal entity create (push)'),
+        MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_UPDATE => t('Drupal entity update (push)'),
+        MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE => t('Drupal entity delete (push)'),
+      ];
+    }
+    if ($this->moduleHandler->moduleExists('salesforce_pull')) {
+      $options += [
+        MappingConstants::SALESFORCE_MAPPING_SYNC_SF_CREATE => t('Salesforce object create (pull)'),
+        MappingConstants::SALESFORCE_MAPPING_SYNC_SF_UPDATE => t('Salesforce object update (pull)'),
+        MappingConstants::SALESFORCE_MAPPING_SYNC_SF_DELETE => t('Salesforce object delete (pull)'),
+      ];
+    }
+    return $options;
   }
 
   /**
diff --git a/modules/salesforce_mapping/src/SalesforceMappingStorage.php b/modules/salesforce_mapping/src/SalesforceMappingStorage.php
index 5f90ad4c2f68d89f3c3b5a8e9128b715071fb70b..cc9570017d232f7bdc45b0a5ca707d1823d59f75 100644
--- a/modules/salesforce_mapping/src/SalesforceMappingStorage.php
+++ b/modules/salesforce_mapping/src/SalesforceMappingStorage.php
@@ -82,12 +82,26 @@ class SalesforceMappingStorage extends ConfigEntityStorage {
    * @return array
    */
   public function loadPushMappings($entity_type_id = NULL) {
-    $push_mappings = [];
     $properties = empty($entity_type_id)
       ? []
       : ["drupal_entity_type" => $entity_type_id];
-    $mappings = $this->loadByProperties($properties);
+    return $this->loadPushMappingsByProperties($properties);
+  }
+
+  /**
+   * Return an array of SalesforceMapping entities who are push-enabled.
+   *
+   * @param string $entity_type_id
+   *
+   * @return array
+   */
+  public function loadCronPushMappings() {
+    $properties["push_standalone"] = FALSE;
+    return $this->loadPushMappingsByProperties($properties);
+  }
 
+  protected function loadPushMappingsByProperties($properties) {
+    $mappings = $this->loadByProperties($properties);
     foreach ($mappings as $key => $mapping) {
       if (!$mapping->doesPush()) {
         continue;
@@ -97,7 +111,7 @@ class SalesforceMappingStorage extends ConfigEntityStorage {
     if (empty($push_mappings)) {
       return [];
     }
-    return $push_mappings;
+    return $push_mappings;    
   }
 
   /**
diff --git a/modules/salesforce_pull/src/QueueHandler.php b/modules/salesforce_pull/src/QueueHandler.php
index cad96dc43cb8fa1cc741be56ce5aca9a7e949285..37e61e5ac88c9d05cbcab78c05e9facda238567b 100644
--- a/modules/salesforce_pull/src/QueueHandler.php
+++ b/modules/salesforce_pull/src/QueueHandler.php
@@ -99,10 +99,14 @@ class QueueHandler {
 
     // Iterate over each field mapping to determine our query parameters.
     foreach ($this->mappings as $mapping) {
+      if (!$mapping->doesPull()) {
+        continue;
+      }
       if ($mapping->getNextPullTime() > $this->time->getRequestTime()) {
         // Skip this mapping, based on pull frequency.
         continue;
       }
+
       $results = $this->doSfoQuery($mapping);
       if ($results) {
         $this->enqueueAllResults($mapping, $results);
@@ -125,7 +129,6 @@ class QueueHandler {
    */
   protected function doSfoQuery(SalesforceMappingInterface $mapping) {
     // @TODO figure out the new way to build the query.
-
     // Execute query.
     try {
       $soql = $mapping->getPullQuery();
@@ -151,7 +154,16 @@ class QueueHandler {
    */
   public function enqueueAllResults(SalesforceMappingInterface $mapping, SelectQueryResult $results) {
     while (!$this->enqueueResultSet($mapping, $results)) {
-      $results = $this->sfapi->queryMore($results);
+      try {
+        $results = $this->sfapi->queryMore($results);
+      }
+      catch (\Exception $e) {
+        $message = '%type: @message in %function (line %line of %file).';
+        $args = Error::decodeException($e);
+        $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e, $message, $args));
+        // @TODO do we really want to eat this exception here?
+        return;
+      }
     }
   }
 
diff --git a/modules/salesforce_push/salesforce_push.module b/modules/salesforce_push/salesforce_push.module
index 7d66b394fa9b6e2e8a1f84904474c6e76a16b715..db4a8193920cd3842b420bec5c20be4deeed44f5 100644
--- a/modules/salesforce_push/salesforce_push.module
+++ b/modules/salesforce_push/salesforce_push.module
@@ -210,8 +210,19 @@ function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappin
 function salesforce_push_cron() {
   $queue = \Drupal::service('queue.salesforce_push');
   $queue->garbageCollection();
+  if (\Drupal::config('salesforce.settings')->get('standalone')) {
+    // If global standalone processing is enabled, stop here.
+    return;
+  }
   try {
-    $queue->processQueues();
+    // Process mappings only for those which are not marked standalone.
+    $mappings = \Drupal::service('entity.manager')
+      ->getStorage('salesforce_mapping')
+      ->loadCronPushMappings();
+    if (empty($mappings)) {
+      return;
+    }
+    $queue->processQueues($mappings);
   }
   catch (\Exception $e) {
     \Drupal::service('event_dispatcher')->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
diff --git a/modules/salesforce_push/salesforce_push.routing.yml b/modules/salesforce_push/salesforce_push.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7b43dc6c186399da0ad0b12e8a81a0607dce889a
--- /dev/null
+++ b/modules/salesforce_push/salesforce_push.routing.yml
@@ -0,0 +1,17 @@
+salesforce_push.endpoint:
+  path: '/salesforce_push/endpoint/{key}'
+  defaults:
+    _controller: '\Drupal\salesforce_push\PushController::endpoint'
+  options:
+    no_cache: TRUE
+  requirements:
+    _access_system_cron: 'TRUE'
+
+salesforce_push.endpoint.salesforce_mapping:
+  path: '/salesforce_push/{salesforce_mapping}/endpoint/{key}'
+  defaults:
+    _controller: '\Drupal\salesforce_push\PushController::mappingEndpoint'
+  options:
+    no_cache: TRUE
+  requirements:
+    _access_system_cron: 'TRUE'
diff --git a/modules/salesforce_push/src/PushController.php b/modules/salesforce_push/src/PushController.php
new file mode 100644
index 0000000000000000000000000000000000000000..950e249787cf86b041a4eb939f507270e732c425
--- /dev/null
+++ b/modules/salesforce_push/src/PushController.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\salesforce_push;
+
+use Drupal\Core\Controller\ControllerBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Response;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+  
+class PushController extends ControllerBase {
+
+  protected $pushQueue;
+  protected $mappingStorage;
+
+  public function __construct(PushQueue $pushQueue, EntityManagerInterface $entity_manager) {
+    $this->pushQueue = $pushQueue;
+    $this->mappingStorage = $entity_manager->getStorage('salesforce_mapping');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('queue.salesforce_push'),
+      $container->get('entity.manager')
+    );
+  }
+
+  /**
+   * Page callback to process the entire push queue.
+   */
+  public function endpoint() {
+    // "Access Denied" if standalone global config not enabled.
+    if (!$this->config('salesforce.settings')->get('standalone')) {
+      throw new AccessDeniedHttpException();
+    }
+    $this->pushQueue->processQueues();
+    return new Response('', 204);
+  }
+
+  /**
+   * Page callback to process push queue for a given mapping.
+   */
+  public function mappingEndpoint($salesforce_mapping) {
+    $mapping = $this->mappingStorage->load($salesforce_mapping);
+    // If standalone for this mapping is disabled, and global standalone is
+    // disabled, then "Access Denied" for this mapping.
+    if (!$mapping->doesPushStandalone()
+    && !\Drupal::config('salesforce.settings')->get('standalone')) {
+      throw new AccessDeniedHttpException();
+    }
+    $this->pushQueue->processQueue($mapping);
+    return new Response('', 204);
+  }
+
+}
\ No newline at end of file
diff --git a/modules/salesforce_push/src/PushQueue.php b/modules/salesforce_push/src/PushQueue.php
index 0b7edb2d58c861c5808209e088a5f6208dd0e797..191d769a2e177c2a1226df991352742060bfb348 100644
--- a/modules/salesforce_push/src/PushQueue.php
+++ b/modules/salesforce_push/src/PushQueue.php
@@ -17,6 +17,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 use Drupal\salesforce\Event\SalesforceEvents;
 use Drupal\Component\Datetime\TimeInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
+use Drupal\salesforce_mapping\Entity\SalesforceMappingInterface;
 
 /**
  * Salesforce push queue.
@@ -295,79 +296,97 @@ class PushQueue extends DatabaseQueue {
   /**
    * Process Salesforce queues.
    */
-  public function processQueues() {
-    $mappings = $this
-      ->mapping_storage
-      ->loadPushMappings();
+  public function processQueues($mappings = []) {
+    if (empty($mappings)) {
+      $mappings = $this
+        ->mapping_storage
+        ->loadPushMappings();
+    }
     if (empty($mappings)) {
       return $this;
     }
+
     $i = 0;
+    foreach ($mappings as $mapping) {
+      $i += $this->processQueue($mapping);
+      if ($i >= $this->global_limit) {
+        break;
+      }
+    }
+    return $this;
+  }
 
-    // @TODO push queue processor could be set globally, or per-mapping. Exposing some UI setting would probably be better than this:
-    $plugin_name = $this->state->get('salesforce.push_queue_processor', static::DEFAULT_QUEUE_PROCESSOR);
+  /**
+   * Given a salesforce mapping, process all its push queue entries.
+   *
+   * @param SalesforceMapping $mapping 
+   *
+   * @return int
+   *   The number of items procesed, or -1 if there was any error, And also
+   *   dispatches a SalesforceEvents::ERROR event.
+   */
+  public function processQueue(SalesforceMappingInterface $mapping) {
+    static $queue_processor = FALSE;
+    // Check mapping frequency before proceeding.
+    if ($mapping->getNextPushTime() > $this->time->getRequestTime()) {
+      return;
+    }
 
-    $queue_processor = $this->queueManager->createInstance($plugin_name);
+    if (!$queue_processor) {
+      // @TODO push queue processor could be set globally, or per-mapping. Exposing some UI setting would probably be better than this:
+      $plugin_name = $this->state->get('salesforce.push_queue_processor', static::DEFAULT_QUEUE_PROCESSOR);
+      $queue_processor = $this->queueManager->createInstance($plugin_name);
+    }
 
-    foreach ($mappings as $mapping) {
-      $j = 0;
-      // Check mapping frequency before proceeding.
-      if ($mapping->getNextPushTime() > $this->time->getRequestTime()) {
-        continue;
-      }
+    $i = 0;
+    // Set the queue name, which is the mapping id.
+    $this->setName($mapping->id());
 
-      // Set the queue name, which is the mapping id.
-      $this->setName($mapping->id());
-
-      // Iterate through items in this queue (mapping) until we run out or hit
-      // the mapping limit, then move to the next queue. If we hit the global
-      // limit, return immediately.
-      while (TRUE) {
-        // Claim as many items as we can from this queue and advance our counter. If this queue is empty, move to the next mapping.
-        $items = $this->claimItems($mapping->push_limit, $mapping->push_retries);
-        if (empty($items)) {
-          $mapping->setLastPushTime($this->time->getRequestTime());
-          continue 2;
-        }
+    // Iterate through items in this queue (mapping) until we run out or hit
+    // the mapping limit, then move to the next queue. If we hit the global
+    // limit, return immediately.
+    while (TRUE) {
+      // Claim as many items as we can from this queue and advance our counter. If this queue is empty, move to the next mapping.
+      $items = $this->claimItems($mapping->push_limit, $mapping->push_retries);
+      if (empty($items)) {
+        $mapping->setLastPushTime($this->time->getRequestTime());
+        return $i;
+      }
 
-        // Hand them to the queue processor.
-        try {
-          $queue_processor->process($items);
-        }
-        catch (RequeueException $e) {
-          // Getting a Requeue here is weird for a group of items, but we'll
-          // deal with it.
-          $this->releaseItems($items);
-          $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
-        }
-        catch (SuspendQueueException $e) {
-          // Getting a SuspendQueue is more likely, e.g. because of a network
-          // or authorization error. Release items and move on to the next
-          // mapping in this case.
-          $this->releaseItems($items);
-          $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
-          continue 2;
-        }
-        catch (\Exception $e) {
-          // In case of any other kind of exception, log it and leave the item
-          // in the queue to be processed again later.
-          // @TODO: this is how Cron.php queue works, but I don't really understand why it doesn't get re-queued.
-          $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
-        }
-        finally {
-          // If we've reached our limit, we're done. Otherwise, continue to next items.
-          $i += count($items);
-          $j += count($items);
-          if ($i >= $this->global_limit) {
-            return $this;
-          }
-        }
-        if ($mapping_limit && $j > $mapping_limit) {
-          continue 2;
+      // Hand them to the queue processor.
+      try {
+        $queue_processor->process($items);
+      }
+      catch (RequeueException $e) {
+        // Getting a Requeue here is weird for a group of items, but we'll
+        // deal with it.
+        $this->releaseItems($items);
+        $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
+        continue;
+      }
+      catch (SuspendQueueException $e) {
+        // Getting a SuspendQueue is more likely, e.g. because of a network
+        // or authorization error. Release items and move on to the next
+        // mapping in this case.
+        $this->releaseItems($items);
+        $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
+        return $i;
+      }
+      catch (\Exception $e) {
+        // In case of any other kind of exception, log it and leave the item
+        // in the queue to be processed again later.
+        // @TODO: this is how Cron.php queue works, but I don't really understand why it doesn't get re-queued.
+        $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
+      }
+      finally {
+        // If we've reached our limit, we're done. Otherwise, continue to next items.
+        $i += count($items);
+        if ($i >= $this->global_limit) {
+          return $i;
         }
       }
     }
-    return $this;
+    return $i;
   }
 
   /**
diff --git a/salesforce.drush.inc b/salesforce.drush.inc
index 2e47a42d8ede2637e8bc86a07a7bf76592aa8c99..fc606bcf0409743e9044e54608130f9185df1714 100644
--- a/salesforce.drush.inc
+++ b/salesforce.drush.inc
@@ -145,6 +145,20 @@ raw: Display the complete, raw describe response."
     ],
   ];
 
+  $items['sf-push-queue'] = [
+    'description' => 'Process push queues (as though during cron) for one or all Salesforce Mappings.',
+    'aliases' => ['sfpushq', 'sfpm'],
+    'arguments' => [
+      'name' => [
+        'description' => 'Machine name of the Salesforce Mapping for which to process push queue. If omitted, process all queues.',
+      ],
+    ],
+    'examples' => [
+      'drush sfpushq' => 'Process all push queue items',
+      'drush sfpushq foo' => 'Process push queue items for mapping "foo"',
+    ],
+  ];
+
   return $items;
 }
 
@@ -624,3 +638,18 @@ function _salesforce_drush_get_pull_query(SalesforceMappingInterface $mapping) {
   $soql->conditions = $new_conditions;
   return $soql;
 }
+
+function drush_salesforce_sf_push_queue($name = NULL) {
+  $queue = \Drupal::service('queue.salesforce_push');
+  if ($name !== NULL) {
+    if (!($mapping = _salesforce_drush_get_mapping($name))) {
+      return;
+    }
+    // Process one mapping queue
+    $queue->processQueue($mapping);
+  }
+  else {
+    // Process all queues
+    $queue->processQueues();
+  }
+}
\ No newline at end of file
diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php
index 9e8ca868b3480390d25c0112336348f573b9f38f..f66c53a3eada3d2f155c00f85f70e171715f80be 100644
--- a/src/Form/SettingsForm.php
+++ b/src/Form/SettingsForm.php
@@ -13,6 +13,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 use Drupal\salesforce\Event\SalesforceEvents;
 use Drupal\salesforce\Event\SalesforceErrorEvent;
+use Drupal\Core\Url;
 
 /**
  * Creates authorization form for Salesforce.
@@ -136,6 +137,28 @@ class SettingsForm extends ConfigFormBase {
       '#default_value' => $config->get('show_all_objects'),
     ];
 
+    $form['standalone'] = [
+      '#title' => $this->t('Standalone Push Processing'),
+      '#description' => $this->t('Enable standalone push processing, and do not process push mappings during cron. Note: when enabled, you must set up your own service to query this endpoint.'),
+      '#type' => 'checkbox',
+      '#default_value' => $config->get('standalone'),
+    ];
+
+    $standalone_url = Url::fromRoute(
+        'salesforce_push.endpoint',
+        ['key' => \Drupal::state()->get('system.cron_key')],
+        ['absolute' => TRUE]);
+    $form['standalone_url'] = [
+      '#type' => 'item',
+      '#title' => $this->t('Standalone URL'),
+      '#markup' => $this->t('<a href="@url">@url</a>', ['@url' => $standalone_url->toString()]),
+      '#states' => [
+        'visible' => [
+          ':input#edit-standalone' => ['checked' => TRUE],
+        ],
+      ],
+    ];
+
     $form = parent::buildForm($form, $form_state);
     $form['creds']['actions'] = $form['actions'];
     unset($form['actions']);
@@ -147,35 +170,17 @@ class SettingsForm extends ConfigFormBase {
    * {@inheritdoc}
    */
   public function submitForm(array &$form, FormStateInterface $form_state) {
-    $values = $form_state->getValues();
-    $this->sf_client->setConsumerKey($values['consumer_key']);
-    $this->sf_client->setConsumerSecret($values['consumer_secret']);
-    $this->sf_client->setLoginUrl($values['login_url']);
-
-    try {
-      $path = $this->sf_client->getAuthEndpointUrl();
-      $query = [
-        'redirect_uri' => $this->sf_client->getAuthCallbackUrl(),
-        'response_type' => 'code',
-        'client_id' => $values['consumer_key'],
-      ];
-
-      // Send the user along to the Salesforce OAuth login form. If successful,
-      // the user will be redirected to {redirect_uri} to complete the OAuth
-      // handshake.
-      $form_state->setResponse(new TrustedRedirectResponse($path . '?' . http_build_query($query), 302));
-    }
-    catch (RequestException $e) {
-      drupal_set_message(t("Error during authorization: %message", ['%message' => $e->getMessage()]), 'error');
-      $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e));
-    }
-
-    $this->sf_client->setApiVersion($form_state->getValue('use_latest'), $form_state->getValue('rest_api_version'));
-
     $config = $this->config('salesforce.settings');
     $config->set('show_all_objects', $form_state->getValue('show_all_objects'));
+    $config->set('standalone', $form_state->getValue('standalone'));
+    $use_latest = $form_state->getValue('use_latest');
+    $config->set('use_latest', $use_latest);
+    if (!$use_latest) {
+      $versions = $this->sf_client->getVersions();
+      $version = $versions[$form_state->getValue('rest_api_version')];
+      $config->set('rest_api_version', $version);
+    }
     $config->save();
-
     parent::submitForm($form, $form_state);
   }
 
diff --git a/src/Rest/RestClient.php b/src/Rest/RestClient.php
index a7b6d477aa4132faa74354562afbd7770dc4607d..b3c510aa9cd82635e7f9a8b1666bedbd057ed158 100644
--- a/src/Rest/RestClient.php
+++ b/src/Rest/RestClient.php
@@ -109,8 +109,6 @@ class RestClient implements RestClientInterface {
 
   /**
    * Determine if this SF instance is fully configured.
-   *
-   * @TODO: Consider making a test API call.
    */
   public function isAuthorized() {
     return $this->getConsumerKey() && $this->getConsumerSecret() && $this->getRefreshToken();
@@ -124,8 +122,15 @@ class RestClient implements RestClientInterface {
       $this->refreshToken();
     }
 
+    if (strpos($path, '/') === 0) {
+      $url = $this->getInstanceUrl() . $path;
+    }
+    else {
+      $url = $this->getApiEndPoint() . $path;
+    }
+
     try {
-      $this->response = new RestResponse($this->apiHttpRequest($path, $params, $method));
+      $this->response = new RestResponse($this->apiHttpRequest($url, $params, $method));
     }
     catch (RequestException $e) {
       // RequestException gets thrown for any response status but 2XX.
@@ -143,7 +148,7 @@ class RestClient implements RestClientInterface {
       // throws anything but a RequestException, let it bubble up.
       $this->refreshToken();
       try {
-        $this->response = new RestResponse($this->apiHttpRequest($path, $params, $method));
+        $this->response = new RestResponse($this->apiHttpRequest($url, $params, $method));
       }
       catch (RequestException $e) {
         $this->response = $e->getResponse();
@@ -167,8 +172,9 @@ class RestClient implements RestClientInterface {
   /**
    * Private helper to issue an SF API request.
    *
-   * @param string $path
-   *   Path to resource.
+   * @param string $url
+   *   Fully-qualified URL to resource.
+   *
    * @param array $params
    *   Parameters to provide.
    * @param string $method
@@ -177,11 +183,10 @@ class RestClient implements RestClientInterface {
    * @return GuzzleHttp\Psr7\Response
    *   Response object.
    */
-  protected function apiHttpRequest($path, array $params, $method) {
+  protected function apiHttpRequest($url, array $params, $method) {
     if (!$this->getAccessToken()) {
       throw new \Exception('Missing OAuth Token');
     }
-    $url = $this->getApiEndPoint() . $path;
 
     $headers = [
       'Authorization' => 'OAuth ' . $this->getAccessToken(),
@@ -395,11 +400,9 @@ class RestClient implements RestClientInterface {
   }
 
   /**
-   * Refresh access token based on the refresh token.
-   *
-   * @throws Exception
+   * {@inheritdoc}
    */
-  protected function refreshToken() {
+  public function refreshToken() {
     $refresh_token = $this->getRefreshToken();
     if (empty($refresh_token)) {
       throw new \Exception(t('There is no refresh token.'));
diff --git a/src/Rest/RestClientInterface.php b/src/Rest/RestClientInterface.php
index 8b151ed8310d78e67240beca1f13c89272dc5d4c..758b4f54640b1f59a48f42e90ff9893ca4258e89 100644
--- a/src/Rest/RestClientInterface.php
+++ b/src/Rest/RestClientInterface.php
@@ -24,6 +24,14 @@ interface RestClientInterface {
    *
    * @param string $path
    *   Path to resource.
+   *
+   *   If $path begins with a slash, the resource will be considered absolute,
+   *   and only the instance URL will be pre-pended. This can be used, for
+   *   example, to issue an API call to a custom Apex Rest endpoint.
+   *
+   *   If $path does not begin with a slash, the resource will be considered
+   *   relative and the Rest API Endpoint will be pre-pended.
+   *
    * @param array $params
    *   Parameters to provide.
    * @param string $method
@@ -99,6 +107,13 @@ interface RestClientInterface {
    */
   public function setAccessToken($token);
 
+  /**
+   * Refresh access token based on the refresh token.
+   *
+   * @throws Exception
+   */
+  public function refreshToken();
+
   /**
    * Helper callback for OAuth handshake, and refreshToken()
    *