diff --git a/cms/envs/common.py b/cms/envs/common.py
index 42fe966ebd777108a619b58f5f077357481ddcd1..531721413e995912ea2dc284f18603b90cd3b903 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -1899,6 +1899,12 @@ derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION')
 RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5
 RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5
 
+# Software Secure request retry settings
+# Time in seconds before a retry of the task should be 60 mints.
+SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
+# Maximum of 6 retries before giving up.
+SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6
+
 ############## DJANGO-USER-TASKS ##############
 
 # How long until database records about the outcome of a task and its artifacts get deleted?
@@ -1951,6 +1957,8 @@ RECALCULATE_GRADES_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE
 # Queue to use for updating grades due to grading policy change
 POLICY_CHANGE_GRADES_ROUTING_KEY = 'edx.lms.core.default'
 
+SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = 'edx.lms.core.default'
+
 # Rate limit for regrading tasks that a grading policy change can kick off
 POLICY_CHANGE_TASK_RATE_LIMIT = '300/h'
 
diff --git a/cms/envs/production.py b/cms/envs/production.py
index 98fee62bf8e2900e9d83dd41a55a79bcaf966152..0d478ee089bcd2a5083b17beae3eadc9eb8e3275 100644
--- a/cms/envs/production.py
+++ b/cms/envs/production.py
@@ -504,6 +504,11 @@ POLICY_CHANGE_GRADES_ROUTING_KEY = ENV_TOKENS.get('POLICY_CHANGE_GRADES_ROUTING_
 # Rate limit for regrading tasks that a grading policy change can kick off
 POLICY_CHANGE_TASK_RATE_LIMIT = ENV_TOKENS.get('POLICY_CHANGE_TASK_RATE_LIMIT', POLICY_CHANGE_TASK_RATE_LIMIT)
 
+SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = ENV_TOKENS.get(
+    'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY',
+    HIGH_PRIORITY_QUEUE
+)
+
 # Event tracking
 TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
 EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 220927d02bb170ffc81bff0d2e73d09a761ad4cd..c5953682ce7267019dfc0388b0122846f4bbc909 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -32,7 +32,7 @@ from course_modes.models import CourseMode
 from course_modes.tests.factories import CourseModeFactory
 from lms.djangoapps.certificates.models import CertificateStatuses  # pylint: disable=import-error
 from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory  # pylint: disable=import-error
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
+from lms.djangoapps.verify_student.tests import TestVerificationBase
 from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory
 from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory, generate_course_run_key
 from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
@@ -291,7 +291,7 @@ class CourseEndingTest(TestCase):
 
 
 @ddt.ddt
-class DashboardTest(ModuleStoreTestCase):
+class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
     """
     Tests for dashboard utility functions
     """
@@ -314,9 +314,7 @@ class DashboardTest(ModuleStoreTestCase):
 
         if mode == 'verified':
             # Simulate a successful verification attempt
-            attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-            attempt.mark_ready()
-            attempt.submit()
+            attempt = self.create_and_submit_attempt_for_user(self.user)
             attempt.approve()
 
         response = self.client.get(reverse('dashboard'))
@@ -351,9 +349,7 @@ class DashboardTest(ModuleStoreTestCase):
 
         if mode == 'verified':
             # Simulate a successful verification attempt
-            attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-            attempt.mark_ready()
-            attempt.submit()
+            attempt = self.create_and_submit_attempt_for_user(self.user)
             attempt.approve()
 
         response = self.client.get(reverse('dashboard'))
diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py
index 1d71839e202514e34b9524e73a805ce7dc86a857..5cfa3667e6d29b1486503f3bc06831f61ea7c5aa 100644
--- a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py
+++ b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py
@@ -4,17 +4,14 @@ Tests for django admin commands in the verify_student module
 Lots of imports from verify_student's model tests, since they cover similar ground
 """
 
-
-import boto
 from django.conf import settings
 from django.core.management import call_command
-from django.core.management.base import CommandError
-from django.test import TestCase
 from mock import patch
 from testfixtures import LogCapture
 
 from common.test.utils import MockS3BotoMixin
 from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSPVerificationRetryConfig
+from lms.djangoapps.verify_student.tests import TestVerificationBase
 from lms.djangoapps.verify_student.tests.test_models import (
     FAKE_SETTINGS,
     mock_software_secure_post,
@@ -28,22 +25,10 @@ LOGGER_NAME = 'retry_photo_verification'
 # Lots of patching to stub in our own settings, and HTTP posting
 @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
 @patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post)
-class TestVerifyStudentCommand(MockS3BotoMixin, TestCase):
+class TestVerifyStudentCommand(MockS3BotoMixin, TestVerificationBase):
     """
     Tests for django admin commands in the verify_student module
     """
-    def create_and_submit(self, username):
-        """
-        Helper method that lets us create new SoftwareSecurePhotoVerifications
-        """
-        user = UserFactory.create()
-        attempt = SoftwareSecurePhotoVerification(user=user)
-        user.profile.name = username
-        attempt.upload_face_image("Fake Data")
-        attempt.upload_photo_id_image("More Fake Data")
-        attempt.mark_ready()
-        attempt.submit()
-        return attempt
 
     def test_retry_failed_photo_verifications(self):
         """
@@ -51,55 +36,58 @@ class TestVerifyStudentCommand(MockS3BotoMixin, TestCase):
         and re-submit them executes successfully
         """
         # set up some fake data to use...
-        self.create_and_submit("SuccessfulSally")
-        with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
-            self.create_and_submit("RetryRoger")
+        self.create_upload_and_submit_attempt_for_user()
         with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
-            self.create_and_submit("RetryRick")
+            self.create_upload_and_submit_attempt_for_user()
+            self.create_upload_and_submit_attempt_for_user()
+
         # check to make sure we had two successes and two failures; otherwise we've got problems elsewhere
-        assert len(SoftwareSecurePhotoVerification.objects.filter(status="submitted")) == 1
-        assert len(SoftwareSecurePhotoVerification.objects.filter(status='must_retry')) == 2
-        call_command('retry_failed_photo_verifications')
+        self.assertEqual(SoftwareSecurePhotoVerification.objects.filter(status="submitted").count(), 1)
+        self.assertEqual(SoftwareSecurePhotoVerification.objects.filter(status='must_retry').count(), 2)
+
+        with self.immediate_on_commit():
+            call_command('retry_failed_photo_verifications')
         attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(status='must_retry')
         assert not attempts_to_retry
 
+    def add_test_config_for_retry_verification(self):
+        """Setups verification retry configuration."""
+        config = SSPVerificationRetryConfig.current()
+        config.arguments = '--verification-ids 1 2 3'
+        config.enabled = True
+        config.save()
+
     def test_args_from_database(self):
         """Test management command arguments injected from config model."""
         # Nothing in the database, should default to disabled
-
         with LogCapture(LOGGER_NAME) as log:
             call_command('retry_failed_photo_verifications', '--args-from-database')
             log.check_present(
                 (
                     LOGGER_NAME, 'WARNING',
-                    u"SSPVerificationRetryConfig is disabled or empty, but --args-from-database was requested."
+                    'SSPVerificationRetryConfig is disabled or empty, but --args-from-database was requested.'
                 ),
             )
-        # Add a config
-        config = SSPVerificationRetryConfig.current()
-        config.arguments = '--verification-ids 1 2 3'
-        config.enabled = True
-        config.save()
-
+        self.add_test_config_for_retry_verification()
         with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
-            self.create_and_submit("RetryRoger")
-
+            self.create_upload_and_submit_attempt_for_user()
             with LogCapture(LOGGER_NAME) as log:
-                call_command('retry_failed_photo_verifications')
+                with self.immediate_on_commit():
+                    call_command('retry_failed_photo_verifications')
+                    log.check_present(
+                        (
+                            LOGGER_NAME, 'INFO',
+                            'Attempting to retry {0} failed PhotoVerification submissions'.format(1)
+                        ),
+                    )
+
+        with LogCapture(LOGGER_NAME) as log:
+            with self.immediate_on_commit():
+                call_command('retry_failed_photo_verifications', '--args-from-database')
 
                 log.check_present(
                     (
                         LOGGER_NAME, 'INFO',
-                        u"Attempting to retry {0} failed PhotoVerification submissions".format(1)
+                        'Fetching retry verification ids from config model'
                     ),
                 )
-
-        with LogCapture(LOGGER_NAME) as log:
-            call_command('retry_failed_photo_verifications', '--args-from-database')
-
-            log.check_present(
-                (
-                    LOGGER_NAME, 'INFO',
-                    u"Fetching retry verification ids from config model"
-                ),
-            )
diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py
index cdf9250ae8c7043765b79e747c135b6769df03a9..3f27affb7f9640dcdfb881dce87766adb5dbb623 100644
--- a/lms/djangoapps/verify_student/models.py
+++ b/lms/djangoapps/verify_student/models.py
@@ -9,7 +9,6 @@ of a student over a period of time. Right now, the only models are the abstract
 photo verification process as generic as possible.
 """
 
-
 import base64
 import codecs
 import functools
@@ -21,13 +20,12 @@ from datetime import timedelta
 from email.utils import formatdate
 
 import requests
-import simplejson
 import six
 from config_models.models import ConfigurationModel
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.core.files.base import ContentFile
-from django.db import models
+from django.db import models, transaction
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.functional import cached_property
@@ -46,7 +44,7 @@ from lms.djangoapps.verify_student.ssencrypt import (
 from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED
 from openedx.core.storage import get_storage
 
-from .utils import earliest_allowed_verification_date
+from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date
 
 log = logging.getLogger(__name__)
 
@@ -393,7 +391,7 @@ class PhotoVerification(IDVerificationAttempt):
         # student dashboard. But at this point, we lock the value into the
         # attempt.
         self.name = self.user.profile.name
-        self.status = "ready"
+        self.status = self.STATUS.ready
         self.save()
 
     @status_before_must_be("must_retry", "submitted", "approved", "denied")
@@ -426,7 +424,7 @@ class PhotoVerification(IDVerificationAttempt):
             logs. This should be a relatively rare occurrence.
         """
         # If someone approves an outdated version of this, the first one wins
-        if self.status == "approved":
+        if self.status == self.STATUS.approved:
             return
 
         log.info(u"Verification for user '{user_id}' approved by '{reviewer}'.".format(
@@ -436,7 +434,7 @@ class PhotoVerification(IDVerificationAttempt):
         self.error_code = ""  # reset, in case this attempt was denied before
         self.reviewing_user = user_id
         self.reviewing_service = service
-        self.status = "approved"
+        self.status = self.STATUS.approved
         self.save()
         # Emit signal to find and generate eligible certificates
         LEARNER_NOW_VERIFIED.send_robust(
@@ -447,6 +445,51 @@ class PhotoVerification(IDVerificationAttempt):
         message = u'LEARNER_NOW_VERIFIED signal fired for {user} from PhotoVerification'
         log.info(message.format(user=self.user.username))
 
+    @status_before_must_be("ready", "must_retry")
+    def mark_submit(self):
+        """
+        Submit this attempt.
+        Valid attempt statuses when calling this method:
+            `ready`, `must_retry`
+
+        Status after method completes: `submitted`
+
+        State Transitions:
+
+            → → → `must_retry`
+            ↑        ↑ ↓
+         `ready` → `submitted`
+        """
+        self.submitted_at = now()
+        self.status = self.STATUS.submitted
+        self.save()
+
+    @status_before_must_be("ready", "must_retry", "submitted")
+    def mark_must_retry(self, error=""):
+        """
+        Set the attempt status to `must_retry`.
+        Mark that this attempt could not be completed because of a system error.
+        Status should be moved to `must_retry`. For example, if Software Secure
+        service is down and we couldn't process the request even after retrying.
+
+        Valid attempt statuses when calling this method:
+            `ready`, `submitted`
+
+        Status after method completes: `must_retry`
+
+        State Transitions:
+
+            → → → `must_retry`
+            ↑        ↑ ↓
+          `ready` → `submitted`
+        """
+        if self.status == self.STATUS.must_retry:
+            return
+
+        self.status = self.STATUS.must_retry
+        self.error_msg = error
+        self.save()
+
     @status_before_must_be("must_retry", "submitted", "approved", "denied")
     def deny(self,
              error_msg,
@@ -490,7 +533,7 @@ class PhotoVerification(IDVerificationAttempt):
         self.error_code = error_code
         self.reviewing_user = reviewing_user
         self.reviewing_service = reviewing_service
-        self.status = "denied"
+        self.status = self.STATUS.denied
         self.save()
 
     @status_before_must_be("must_retry", "submitted", "approved", "denied")
@@ -505,14 +548,14 @@ class PhotoVerification(IDVerificationAttempt):
         reported to us that they couldn't process our submission because they
         couldn't decrypt the image we sent.
         """
-        if self.status in ["approved", "denied"]:
+        if self.status in [self.STATUS.approved, self.STATUS.denied]:
             return  # If we were already approved or denied, just leave it.
 
         self.error_msg = error_msg
         self.error_code = error_code
         self.reviewing_user = reviewing_user
         self.reviewing_service = reviewing_service
-        self.status = "must_retry"
+        self.status = self.STATUS.must_retry
         self.save()
 
     @classmethod
@@ -641,7 +684,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
         # developing and aren't interested in working on student identity
         # verification functionality. If you do want to work on it, you have to
         # explicitly enable these in your private settings.
-        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
+        if auto_verify_for_testing_enabled():
             return
 
         aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
@@ -671,7 +714,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
         # developing and aren't interested in working on student identity
         # verification functionality. If you do want to work on it, you have to
         # explicitly enable these in your private settings.
-        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
+        if auto_verify_for_testing_enabled():
             # fake photo id key is set only for initial verification
             self.photo_id_key = 'fake-photo-id-key'
             self.save()
@@ -703,27 +746,28 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
 
         Keyword Arguments:
             copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
-                data from this attempt.  This is used for reverification, in which new face photos
+                data from this attempt.  This is used for re-verification, in which new face photos
                 are sent with previously-submitted ID photos.
-
         """
-        try:
-            response = self.send_request(copy_id_photo_from=copy_id_photo_from)
-            if response.ok:
-                self.submitted_at = now()
-                self.status = "submitted"
-                self.save()
-            else:
-                self.status = "must_retry"
-                self.error_msg = response.text
-                self.save()
-        except Exception:       # pylint: disable=broad-except
-            log.exception(
-                u'Software Secure submission failed for user %s, setting status to must_retry',
-                self.user.username
+        from .tasks import send_request_to_ss_for_user
+        if auto_verify_for_testing_enabled():
+            self.mark_submit()
+            fake_response = requests.Response()
+            fake_response.status_code = 200
+            return fake_response
+
+        if copy_id_photo_from is not None:
+            log.info(
+                ('Software Secure attempt for user: %r and receipt ID: %r used the same photo ID data as the '
+                 'receipt with ID %r.'),
+                self.user.username,
+                self.receipt_id,
+                copy_id_photo_from.receipt_id,
             )
-            self.status = "must_retry"
-            self.save()
+        transaction.on_commit(
+            lambda:
+            send_request_to_ss_for_user.delay(user_verification_id=self.id, copy_id_photo_from=copy_id_photo_from)
+        )
 
     def parsed_error_msg(self):
         """
@@ -766,7 +810,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
                     parsed_errors.append(parsed_error)
                 else:
                     log.debug(u'Ignoring photo verification error message: %s', message)
-        except Exception:   # pylint: disable=broad-except
+        except Exception:  # pylint: disable=broad-except
             log.exception(u'Failed to parse error message for SoftwareSecurePhotoVerification %d', self.pk)
 
         return parsed_errors
@@ -845,7 +889,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
 
         Keyword Arguments:
             copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
-                data from this attempt.  This is used for reverification, in which new face photos
+                data from this attempt.  This is used for re-verification, in which new face photos
                 are sent with previously-submitted ID photos.
 
         Returns:
@@ -911,55 +955,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
 
         return header_txt + "\n\n" + body_txt
 
-    def send_request(self, copy_id_photo_from=None):
-        """
-        Assembles a submission to Software Secure and sends it via HTTPS.
-
-        Keyword Arguments:
-            copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
-                data from this attempt.  This is used for reverification, in which new face photos
-                are sent with previously-submitted ID photos.
-
-        Returns:
-            request.Response
-
-        """
-        # If AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING is True, we want to
-        # skip posting anything to Software Secure. We actually don't even
-        # create the message because that would require encryption and message
-        # signing that rely on settings.VERIFY_STUDENT values that aren't set
-        # in dev. So we just pretend like we successfully posted
-        if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'):
-            fake_response = requests.Response()
-            fake_response.status_code = 200
-            return fake_response
-
-        headers, body = self.create_request(copy_id_photo_from=copy_id_photo_from)
-
-        response = requests.post(
-            settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
-            headers=headers,
-            data=simplejson.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'),
-            verify=False
-        )
-
-        log.info(u"Sent request to Software Secure for receipt ID %s.", self.receipt_id)
-        if copy_id_photo_from is not None:
-            log.info(
-                (
-                    u"Software Secure attempt with receipt ID %s used the same photo ID "
-                    u"data as the receipt with ID %s"
-                ),
-                self.receipt_id, copy_id_photo_from.receipt_id
-            )
-
-        log.debug("Headers:\n{}\n\n".format(headers))
-        log.debug("Body:\n{}\n\n".format(body))
-        log.debug(u"Return code: {}".format(response.status_code))
-        log.debug(u"Return message:\n\n{}\n\n".format(response.text))
-
-        return response
-
     def should_display_status_to_user(self):
         """Whether or not the status from this attempt should be displayed to the user."""
         return True
diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py
index 3f7b2e28eac3f060b74b8b7fc063ef9a61e69e05..49d41120e8ba397c67dc3e34441231e6217ee9fc 100644
--- a/lms/djangoapps/verify_student/services.py
+++ b/lms/djangoapps/verify_student/services.py
@@ -2,7 +2,6 @@
 Implementation of abstraction layer for other parts of the system to make queries related to ID Verification.
 """
 
-
 import logging
 from itertools import chain
 
@@ -148,9 +147,11 @@ class IDVerificationService(object):
             'created_at__gte': earliest_allowed_verification_date()
         }
 
-        return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
-                SSOVerification.objects.filter(**filter_kwargs).exists() or
-                ManualVerification.objects.filter(**filter_kwargs).exists())
+        return (
+            SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
+            SSOVerification.objects.filter(**filter_kwargs).exists() or
+            ManualVerification.objects.filter(**filter_kwargs).exists()
+        )
 
     @classmethod
     def user_status(cls, user):
diff --git a/lms/djangoapps/verify_student/tasks.py b/lms/djangoapps/verify_student/tasks.py
index 705158acfa978b101b99eb93c27be7d2700f6576..a6371b971c3a5e84e10d50cc5cd0e9dff278b0de 100644
--- a/lms/djangoapps/verify_student/tasks.py
+++ b/lms/djangoapps/verify_student/tasks.py
@@ -2,20 +2,70 @@
 Django Celery tasks for service status app
 """
 
-
 import logging
 from smtplib import SMTPException
 
-from celery import task
+import requests
+import simplejson
+from celery import Task, task
+from celery.states import FAILURE
 from django.conf import settings
 from django.core.mail import EmailMessage
 from edxmako.shortcuts import render_to_string
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
 from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
 
 ACE_ROUTING_KEY = getattr(settings, 'ACE_ROUTING_KEY', None)
+SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = getattr(settings, 'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY', None)
 log = logging.getLogger(__name__)
 
 
+class BaseSoftwareSecureTask(Task):
+    """
+    Base task class for use with Software Secure request.
+
+    Permits updating information about user attempt in correspondence to submitting
+    request to software secure.
+    """
+    abstract = True
+
+    def on_success(self, response, task_id, args, kwargs):
+        """
+        Update SoftwareSecurePhotoVerification object corresponding to this
+        task with info about success.
+
+        Updates user verification attempt to "submitted" if the response was ok otherwise
+        set it to "must_retry".
+        """
+        user_verification_id = kwargs['user_verification_id']
+        user_verification = SoftwareSecurePhotoVerification.objects.get(id=user_verification_id)
+        if response.ok:
+            user_verification.mark_submit()
+            log.info(
+                'Sent request to Software Secure for user: %r and receipt ID %r.',
+                user_verification.user.username,
+                user_verification.receipt_id,
+            )
+            return user_verification
+
+        user_verification.mark_must_retry(response.text)
+
+    def after_return(self, status, retval, task_id, args, kwargs, einfo):
+        """
+        If max retries have reached and task status is still failing, mark user submission
+        with "must_retry" so that it can be retried latter.
+        """
+        if self.max_retries == self.request.retries and status == FAILURE:
+            user_verification_id = kwargs['user_verification_id']
+            user_verification = SoftwareSecurePhotoVerification.objects.get(id=user_verification_id)
+            user_verification.mark_must_retry()
+            log.error(
+                'Software Secure submission failed for user %r, setting status to must_retry',
+                user_verification.user.username,
+                exc_info=True
+            )
+
+
 @task(routing_key=ACE_ROUTING_KEY)
 def send_verification_status_email(context):
     """
@@ -35,3 +85,48 @@ def send_verification_status_email(context):
         msg.send(fail_silently=False)
     except SMTPException:
         log.warning(u"Failure in sending verification status e-mail to %s", dest_addr)
+
+
+@task(
+    base=BaseSoftwareSecureTask,
+    bind=True,
+    default_retry_delay=settings.SOFTWARE_SECURE_REQUEST_RETRY_DELAY,
+    max_retries=settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS,
+    routing_key=SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY,
+)
+def send_request_to_ss_for_user(self, user_verification_id, copy_id_photo_from):
+    """
+    Assembles a submission to Software Secure.
+
+    Keyword Arguments:
+        user_verification_id (int) SoftwareSecurePhotoVerification model object identifier.
+        copy_id_photo_from (SoftwareSecurePhotoVerification): If provided, re-send the ID photo
+                data from this attempt.  This is used for re-verification, in which new face photos
+                are sent with previously-submitted ID photos.
+    Returns:
+        request.Response
+    """
+    log.info('=>New Verification Task Received')  # todo -- remove before merge.
+    user_verification = SoftwareSecurePhotoVerification.objects.get(id=user_verification_id)
+    try:
+        headers, body = user_verification.create_request(copy_id_photo_from)
+        response = requests.post(
+            settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
+            headers=headers,
+            data=simplejson.dumps(body, indent=2, sort_keys=True, ensure_ascii=False).encode('utf-8'),
+            verify=False
+        )
+        return response
+    except Exception as exc:  # pylint: disable=bare-except
+        log.error(
+            (
+                'Retrying sending request to Software Secure for user: %r, Receipt ID: %r '
+                'attempt#: %s of %s'
+            ),
+            user_verification.user.username,
+            user_verification.receipt_id,
+            self.request.retries,
+            settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS,
+        )
+        log.error(str(exc))
+        self.retry()
diff --git a/lms/djangoapps/verify_student/tests/__init__.py b/lms/djangoapps/verify_student/tests/__init__.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e889ad708b5d15873fe7cc71a14e525cef39ae87 100644
--- a/lms/djangoapps/verify_student/tests/__init__.py
+++ b/lms/djangoapps/verify_student/tests/__init__.py
@@ -0,0 +1,92 @@
+from contextlib import contextmanager
+from datetime import timedelta
+from unittest import mock
+
+from django.conf import settings
+from django.db import DEFAULT_DB_ALIAS
+from django.test import TestCase
+from django.utils.timezone import now
+
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
+from student.tests.factories import UserFactory
+
+
+class TestVerificationBase(TestCase):
+    """
+    Common tests across all types of Verifications (e.g., SoftwareSecurePhotoVerification, SSOVerification)
+    """
+
+    @contextmanager
+    def immediate_on_commit(self, using=None):
+        """
+        Context manager executing transaction.on_commit() hooks immediately as
+        if the connection was in auto-commit mode. This is required when
+        using a subclass of django.test.TestCase as all tests are wrapped in
+        a transaction that never gets committed.
+
+        TODO: Remove when immediate_on_commit function is actually implemented
+        Django Ticket #: 30456, Link: https://code.djangoproject.com/ticket/30457#no1
+        """
+        immediate_using = DEFAULT_DB_ALIAS if using is None else using
+
+        def on_commit(func, using=None):
+            using = DEFAULT_DB_ALIAS if using is None else using
+            if using == immediate_using:
+                func()
+
+        with mock.patch('django.db.transaction.on_commit', side_effect=on_commit) as patch:
+            yield patch
+
+    def verification_active_at_datetime(self, attempt):
+        """
+        Tests to ensure the Verification is active or inactive at the appropriate datetimes.
+        """
+        # Not active before the created date
+        before = attempt.created_at - timedelta(seconds=1)
+        self.assertFalse(attempt.active_at_datetime(before))
+
+        # Active immediately after created date
+        after_created = attempt.created_at + timedelta(seconds=1)
+        self.assertTrue(attempt.active_at_datetime(after_created))
+
+        # Active immediately before expiration date
+        expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
+        before_expiration = expiration - timedelta(seconds=1)
+        self.assertTrue(attempt.active_at_datetime(before_expiration))
+
+        # Not active after the expiration date
+        attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
+        attempt.save()
+        self.assertFalse(attempt.active_at_datetime(now() + timedelta(days=1)))
+
+    def submit_attempt(self, attempt):
+        with self.immediate_on_commit():
+            attempt.submit()
+            attempt.refresh_from_db()
+        return attempt
+
+    def create_and_submit_attempt_for_user(self, user=None):
+        """
+        Create photo verification attempt without uploading photos
+        for a user.
+        """
+        if not user:
+            user = UserFactory.create()
+        attempt = SoftwareSecurePhotoVerification.objects.create(user=user)
+        attempt.mark_ready()
+        return self.submit_attempt(attempt)
+
+    def create_upload_and_submit_attempt_for_user(self, user=None):
+        """
+        Helper method to create a generic submission with photos for
+        a user and send it.
+        """
+        if not user:
+            user = UserFactory.create()
+        attempt = SoftwareSecurePhotoVerification(user=user)
+        user.profile.name = u"Rust\u01B4"
+
+        attempt.upload_face_image("Just pretend this is image data")
+        attempt.upload_photo_id_image("Hey, we're a photo ID")
+        attempt.mark_ready()
+        return self.submit_attempt(attempt)
diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py
index fe59f36f237e5044a9a53aeb324649573ee717e2..c008b01386fba6a22c47814692f6d72c9cddea70 100644
--- a/lms/djangoapps/verify_student/tests/test_models.py
+++ b/lms/djangoapps/verify_student/tests/test_models.py
@@ -1,30 +1,29 @@
 # -*- coding: utf-8 -*-
 
-
 import base64
-import simplejson as json
 from datetime import datetime, timedelta
 
 import ddt
 import mock
 import requests.exceptions
+import simplejson as json
 from django.conf import settings
-from django.test import TestCase
 from django.utils.timezone import now
 from freezegun import freeze_time
 from mock import patch
 from six.moves import range
-from student.tests.factories import UserFactory
-from testfixtures import LogCapture
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
 
 from common.test.utils import MockS3BotoMixin
 from lms.djangoapps.verify_student.models import (
+    ManualVerification,
+    PhotoVerification,
     SoftwareSecurePhotoVerification,
     SSOVerification,
-    ManualVerification,
-    VerificationException,
+    VerificationException
 )
+from student.tests.factories import UserFactory
+from verify_student.tests import TestVerificationBase
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
 
 FAKE_SETTINGS = {
     "SOFTWARE_SECURE": {
@@ -90,39 +89,11 @@ def mock_software_secure_post_unavailable(url, headers=None, data=None, **kwargs
     raise requests.exceptions.ConnectionError
 
 
-class TestVerification(TestCase):
-    """
-    Common tests across all types of Verications (e.g., SoftwareSecurePhotoVerication, SSOVerification)
-    """
-
-    def verification_active_at_datetime(self, attempt):
-        """
-        Tests to ensure the Verification is active or inactive at the appropriate datetimes.
-        """
-        # Not active before the created date
-        before = attempt.created_at - timedelta(seconds=1)
-        self.assertFalse(attempt.active_at_datetime(before))
-
-        # Active immediately after created date
-        after_created = attempt.created_at + timedelta(seconds=1)
-        self.assertTrue(attempt.active_at_datetime(after_created))
-
-        # Active immediately before expiration date
-        expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
-        before_expiration = expiration - timedelta(seconds=1)
-        self.assertTrue(attempt.active_at_datetime(before_expiration))
-
-        # Not active after the expiration date
-        attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
-        attempt.save()
-        self.assertFalse(attempt.active_at_datetime(now() + timedelta(days=1)))
-
-
 # Lots of patching to stub in our own settings, and HTTP posting
 @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
 @patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post)
 @ddt.ddt
-class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCase):
+class TestPhotoVerification(TestVerificationBase, MockS3BotoMixin, ModuleStoreTestCase):
 
     def test_state_transitions(self):
         """
@@ -138,44 +109,59 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
         """
         user = UserFactory.create()
         attempt = SoftwareSecurePhotoVerification(user=user)
-        self.assertEqual(attempt.status, "created")
+        self.assertEqual(attempt.status, PhotoVerification.STATUS.created)
 
         # These should all fail because we're in the wrong starting state.
         self.assertRaises(VerificationException, attempt.submit)
         self.assertRaises(VerificationException, attempt.approve)
         self.assertRaises(VerificationException, attempt.deny)
+        self.assertRaises(VerificationException, attempt.mark_must_retry)
+        self.assertRaises(VerificationException, attempt.mark_submit)
 
         # Now let's fill in some values so that we can pass the mark_ready() call
         attempt.mark_ready()
-        self.assertEqual(attempt.status, "ready")
+        self.assertEqual(attempt.status, PhotoVerification.STATUS.ready)
 
         # ready (can't approve or deny unless it's "submitted")
         self.assertRaises(VerificationException, attempt.approve)
         self.assertRaises(VerificationException, attempt.deny)
+        attempt.mark_must_retry()
+        attempt.mark_submit()
 
         DENY_ERROR_MSG = '[{"photoIdReasons": ["Not provided"]}]'
 
         # must_retry
-        attempt.status = "must_retry"
+        attempt.status = PhotoVerification.STATUS.must_retry
         attempt.system_error("System error")
+        attempt.mark_must_retry()  # no-op
+        attempt.mark_submit()
         attempt.approve()
-        attempt.status = "must_retry"
+
+        attempt.status = PhotoVerification.STATUS.must_retry
         attempt.deny(DENY_ERROR_MSG)
 
         # submitted
-        attempt.status = "submitted"
+        attempt.status = PhotoVerification.STATUS.submitted
         attempt.deny(DENY_ERROR_MSG)
-        attempt.status = "submitted"
+
+        attempt.status = PhotoVerification.STATUS.submitted
+        attempt.mark_must_retry()
+
+        attempt.status = PhotoVerification.STATUS.submitted
         attempt.approve()
 
         # approved
         self.assertRaises(VerificationException, attempt.submit)
+        self.assertRaises(VerificationException, attempt.mark_must_retry)
+        self.assertRaises(VerificationException, attempt.mark_submit)
         attempt.approve()  # no-op
         attempt.system_error("System error")  # no-op, something processed it without error
         attempt.deny(DENY_ERROR_MSG)
 
         # denied
         self.assertRaises(VerificationException, attempt.submit)
+        self.assertRaises(VerificationException, attempt.mark_must_retry)
+        self.assertRaises(VerificationException, attempt.mark_submit)
         attempt.deny(DENY_ERROR_MSG)  # no-op
         attempt.system_error("System error")  # no-op, something processed it without error
         attempt.approve()
@@ -198,39 +184,21 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
 
         self.assertEqual(u"Clyde \u01B4", attempt.name)
 
-    def create_and_submit(self):
-        """Helper method to create a generic submission and send it."""
-        user = UserFactory.create()
-        attempt = SoftwareSecurePhotoVerification(user=user)
-        user.profile.name = u"Rust\u01B4"
-
-        attempt.upload_face_image("Just pretend this is image data")
-        attempt.upload_photo_id_image("Hey, we're a photo ID")
-        attempt.mark_ready()
-        attempt.submit()
-
-        return attempt
-
     def test_submissions(self):
         """Test that we set our status correctly after a submission."""
         # Basic case, things go well.
-        attempt = self.create_and_submit()
-        self.assertEqual(attempt.status, "submitted")
+        attempt = self.create_upload_and_submit_attempt_for_user()
+        self.assertEqual(attempt.status, PhotoVerification.STATUS.submitted)
 
         # We post, but Software Secure doesn't like what we send for some reason
-        with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
-            attempt = self.create_and_submit()
-            self.assertEqual(attempt.status, "must_retry")
+        with patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_error):
+            attempt = self.create_upload_and_submit_attempt_for_user()
+            self.assertEqual(attempt.status, PhotoVerification.STATUS.must_retry)
 
         # We try to post, but run into an error (in this case a network connection error)
-        with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_unavailable):
-            with LogCapture('lms.djangoapps.verify_student.models') as logger:
-                attempt = self.create_and_submit()
-                self.assertEqual(attempt.status, "must_retry")
-                logger.check(
-                    ('lms.djangoapps.verify_student.models', 'ERROR',
-                     u'Software Secure submission failed for user %s, setting status to must_retry'
-                     % attempt.user.username))
+        with patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_unavailable):
+            attempt = self.create_upload_and_submit_attempt_for_user()
+            self.assertEqual(attempt.status, PhotoVerification.STATUS.must_retry)
 
     @mock.patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
     def test_submission_while_testing_flag_is_true(self):
@@ -238,21 +206,14 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
         initial verification when the feature flag 'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'
         is enabled.
         """
-        user = UserFactory.create()
-        attempt = SoftwareSecurePhotoVerification(user=user)
-        user.profile.name = "test-user"
-
-        attempt.upload_photo_id_image("Image data")
-        attempt.mark_ready()
-        attempt.submit()
-
+        attempt = self.create_upload_and_submit_attempt_for_user()
         self.assertEqual(attempt.photo_id_key, "fake-photo-id-key")
 
     # pylint: disable=line-too-long
     def test_parse_error_msg_success(self):
         user = UserFactory.create()
         attempt = SoftwareSecurePhotoVerification(user=user)
-        attempt.status = 'denied'
+        attempt.status = PhotoVerification.STATUS.denied
         attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}, {"photoIdReasons": ["Photo hidden/No photo", "ID name not provided"]}]'
         parsed_error_msg = attempt.parsed_error_msg()
         self.assertEqual(
@@ -288,7 +249,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
 
         # Make an initial verification with 'photo_id_key'
         attempt = SoftwareSecurePhotoVerification(user=user, photo_id_key="dummy_photo_id_key")
-        attempt.status = 'approved'
+        attempt.status = PhotoVerification.STATUS.approved
         attempt.save()
 
         # Check that method 'get_initial_verification' returns the correct
@@ -298,7 +259,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
 
         # Now create a second verification without 'photo_id_key'
         attempt = SoftwareSecurePhotoVerification(user=user)
-        attempt.status = 'submitted'
+        attempt.status = PhotoVerification.STATUS.submitted
         attempt.save()
 
         # Test method 'get_initial_verification' still returns the correct
@@ -332,7 +293,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
 
         # Populate Record
         attempt.mark_ready()
-        attempt.status = "submitted"
+        attempt.status = PhotoVerification.STATUS.submitted
         attempt.photo_id_image_url = "https://example.com/test/image/img.jpg"
         attempt.face_image_url = "https://example.com/test/face/img.jpg"
         attempt.photo_id_key = 'there_was_an_attempt'
@@ -379,7 +340,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
         for _ in range(2):
             # Make an approved verification
             attempt = SoftwareSecurePhotoVerification(user=user)
-            attempt.status = 'approved'
+            attempt.status = PhotoVerification.STATUS.approved
             attempt.expiry_date = datetime.now()
             attempt.save()
 
@@ -400,7 +361,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
         for _ in range(2):
             # Make an approved verification
             attempt = SoftwareSecurePhotoVerification(user=user)
-            attempt.status = 'approved'
+            attempt.status = PhotoVerification.STATUS.approved
             attempt.save()
 
         # Test method 'get_recent_verification' returns None
@@ -428,7 +389,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
         user = UserFactory.create()
         verification = SoftwareSecurePhotoVerification(user=user)
         verification.expiry_date = now() - timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR'])
-        verification.status = 'approved'
+        verification.status = PhotoVerification.STATUS.approved
         verification.save()
 
         self.assertIsNone(verification.expiry_email_date)
@@ -439,7 +400,7 @@ class TestPhotoVerification(TestVerification, MockS3BotoMixin, ModuleStoreTestCa
         self.assertIsNotNone(result.expiry_email_date)
 
 
-class SSOVerificationTest(TestVerification):
+class SSOVerificationTest(TestVerificationBase):
     """
     Tests for the SSOVerification model
     """
@@ -450,7 +411,7 @@ class SSOVerificationTest(TestVerification):
         self.verification_active_at_datetime(attempt)
 
 
-class ManualVerificationTest(TestVerification):
+class ManualVerificationTest(TestVerificationBase):
     """
     Tests for the ManualVerification model
     """
diff --git a/lms/djangoapps/verify_student/tests/test_tasks.py b/lms/djangoapps/verify_student/tests/test_tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd04a4d23b40193c5a836a24f08b4fc228c10a23
--- /dev/null
+++ b/lms/djangoapps/verify_student/tests/test_tasks.py
@@ -0,0 +1,38 @@
+# Lots of patching to stub in our own settings, and HTTP posting
+import ddt
+import mock
+from django.conf import settings
+from mock import patch
+
+from common.test.utils import MockS3BotoMixin
+from verify_student.tests import TestVerificationBase
+from verify_student.tests.test_models import FAKE_SETTINGS, mock_software_secure_post_unavailable
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+
+LOGGER_NAME = 'lms.djangoapps.verify_student.tasks'
+
+
+@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS)
+@ddt.ddt
+class TestPhotoVerificationTasks(TestVerificationBase, MockS3BotoMixin, ModuleStoreTestCase):
+
+    @mock.patch('lms.djangoapps.verify_student.tasks.log')
+    def test_logs_for_retry_until_failure(self, mock_log):
+        retry_max_attempts = settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS
+        with mock.patch('lms.djangoapps.verify_student.tasks.requests.post', new=mock_software_secure_post_unavailable):
+            attempt = self.create_and_submit_attempt_for_user()
+            username = attempt.user.username
+            mock_log.error.assert_called_with(
+                'Software Secure submission failed for user %r, setting status to must_retry',
+                username,
+                exc_info=True
+            )
+            for current_attempt in range(retry_max_attempts):
+                mock_log.error.assert_any_call(
+                    ('Retrying sending request to Software Secure for user: %r, Receipt ID: %r '
+                     'attempt#: %s of %s'),
+                    username,
+                    attempt.receipt_id,
+                    current_attempt,
+                    settings.SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS,
+                )
diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py
index 94db7dcc40ba1922011bf63aaa76f81bac1095c8..6e4b3ce3a0f174d823d65c9f8f97f86a9f636d57 100644
--- a/lms/djangoapps/verify_student/tests/test_views.py
+++ b/lms/djangoapps/verify_student/tests/test_views.py
@@ -3,7 +3,6 @@
 Tests of verify_student views.
 """
 
-
 from datetime import timedelta
 from uuid import uuid4
 
@@ -45,6 +44,7 @@ from shoppingcart.models import CertificateItem, Order
 from student.models import CourseEnrollment
 from student.tests.factories import CourseEnrollmentFactory, UserFactory
 from util.testing import UrlResetMixin
+from verify_student.tests import TestVerificationBase
 from xmodule.modulestore import ModuleStoreEnum
 from xmodule.modulestore.django import modulestore
 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -54,6 +54,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
 def mock_render_to_response(*args, **kwargs):
     return render_to_response(*args, **kwargs)
 
+
 render_mock = Mock(side_effect=mock_render_to_response)
 
 PAYMENT_DATA_KEYS = {'payment_processor_name', 'payment_page_url', 'payment_form_data'}
@@ -64,6 +65,7 @@ class StartView(TestCase):
     This view is for the first time student is
     attempting a Photo Verification.
     """
+
     def start_url(self, course_id=""):
         return "/verify_student/{0}".format(six.moves.urllib.parse.quote(course_id))
 
@@ -80,7 +82,7 @@ class StartView(TestCase):
 
 
 @ddt.ddt
-class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
+class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin, TestVerificationBase):
     """
     Tests for the payment and verification flow views.
     """
@@ -897,7 +899,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase, XssTestMixin):
 
         if status in ["submitted", "approved", "expired", "denied", "error"]:
             attempt.mark_ready()
-            attempt.submit()
+            attempt = self.submit_attempt(attempt)
 
         if status in ["approved", "expired"]:
             attempt.approve()
@@ -1112,6 +1114,7 @@ class CheckoutTestMixin(object):
     compatibility, the effect of using this endpoint is to choose a specific product
     (i.e. course mode) and trigger immediate checkout.
     """
+
     def setUp(self):
         """ Create a user and course. """
         super(CheckoutTestMixin, self).setUp()
@@ -1123,12 +1126,12 @@ class CheckoutTestMixin(object):
         self.client.login(username="test", password="test")
 
     def _assert_checked_out(
-            self,
-            post_params,
-            patched_create_order,
-            expected_course_key,
-            expected_mode_slug,
-            expected_status_code=200
+        self,
+        post_params,
+        patched_create_order,
+        expected_course_key,
+        expected_mode_slug,
+        expected_status_code=200
     ):
         """
         DRY helper.
@@ -1413,7 +1416,7 @@ class TestCreateOrderView(ModuleStoreTestCase):
 
 @ddt.ddt
 @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
-class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
+class TestSubmitPhotosForVerification(MockS3BotoMixin, TestVerificationBase):
     """
     Tests for submitting photos for verification.
     """
@@ -1571,6 +1574,7 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
         # Now the request should succeed
         self._submit_photos(face_image=self.IMAGE_DATA)
 
+    #
     def _submit_photos(self, face_image=None, photo_id_image=None, full_name=None, expected_status_code=200):
         """Submit photos for verification.
 
@@ -1596,7 +1600,8 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
         if full_name is not None:
             params['full_name'] = full_name
 
-        response = self.client.post(url, params)
+        with self.immediate_on_commit():
+            response = self.client.post(url, params)
         self.assertEqual(response.status_code, expected_status_code)
 
         return response
@@ -1637,10 +1642,11 @@ class TestSubmitPhotosForVerification(MockS3BotoMixin, TestCase):
         return json.loads(last_request.body)
 
 
-class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
+class TestPhotoVerificationResultsCallback(ModuleStoreTestCase, TestVerificationBase):
     """
     Tests for the results_callback view.
     """
+
     def setUp(self):
         super(TestPhotoVerificationResultsCallback, self).setUp()
 
@@ -1750,9 +1756,7 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
         expiry_date = now() + timedelta(
             days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
         )
-        verification = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-        verification.mark_ready()
-        verification.submit()
+        verification = self.create_and_submit_attempt_for_user(self.user)
         verification.approve()
         verification.expiry_date = now()
         verification.expiry_email_date = now()
@@ -1899,11 +1903,11 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
         self.assertContains(response, 'Result Unknown not understood', status_code=400)
 
 
-class TestReverifyView(TestCase):
+class TestReverifyView(TestVerificationBase):
     """
-    Tests for the reverification view.
+    Tests for the re-verification view.
 
-    Reverification occurs when a verification attempt is denied or expired,
+    Re-verification occurs when a verification attempt is denied or expired,
     and the student is given the option to resubmit.
     """
 
@@ -1918,30 +1922,26 @@ class TestReverifyView(TestCase):
 
     def test_reverify_view_can_do_initial_verification(self):
         """
-        Test that a User can use reverify link for initial verification.
+        Test that a User can use re-verify link for initial verification.
         """
         self._assert_can_reverify()
 
     def test_reverify_view_can_reverify_denied(self):
-        # User has a denied attempt, so can reverify
-        attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-        attempt.mark_ready()
-        attempt.submit()
+        # User has a denied attempt, so can re-verify
+        attempt = self.create_and_submit_attempt_for_user(self.user)
         attempt.deny("error")
         self._assert_can_reverify()
 
     def test_reverify_view_can_reverify_expired(self):
         # User has a verification attempt, but it's expired
-        attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-        attempt.mark_ready()
-        attempt.submit()
+        attempt = self.create_and_submit_attempt_for_user(self.user)
         attempt.approve()
 
         days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
         attempt.created_at = now() - timedelta(days=(days_good_for + 1))
         attempt.save()
 
-        # Allow the student to reverify
+        # Allow the student to re-verify
         self._assert_can_reverify()
 
     def test_reverify_view_can_reverify_pending(self):
@@ -1954,21 +1954,17 @@ class TestReverifyView(TestCase):
         """
 
         # User has submitted a verification attempt, but Software Secure has not yet responded
-        attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-        attempt.mark_ready()
-        attempt.submit()
+        attempt = self.create_and_submit_attempt_for_user(self.user)
 
         # Can re-verify because an attempt has already been submitted.
         self._assert_can_reverify()
 
     def test_reverify_view_cannot_reverify_approved(self):
         # Submitted attempt has been approved
-        attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-        attempt.mark_ready()
-        attempt.submit()
+        attempt = self.create_and_submit_attempt_for_user(self.user)
         attempt.approve()
 
-        # Cannot reverify because the user is already verified.
+        # Cannot re-verify because the user is already verified.
         self._assert_cannot_reverify()
 
     @override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10})
@@ -1979,10 +1975,7 @@ class TestReverifyView(TestCase):
         and learner can submit photos if verification is set to expire in
         EXPIRING_SOON_WINDOW(i.e here it is 10 days) or less days.
         """
-
-        attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
-        attempt.mark_ready()
-        attempt.submit()
+        attempt = self.create_and_submit_attempt_for_user(self.user)
         attempt.approve()
 
         # Can re-verify because verification is set to expired soon.
@@ -1990,21 +1983,21 @@ class TestReverifyView(TestCase):
 
     def _get_reverify_page(self):
         """
-        Retrieve the reverification page and return the response.
+        Retrieve the re-verification page and return the response.
         """
         url = reverse("verify_student_reverify")
         return self.client.get(url)
 
     def _assert_can_reverify(self):
         """
-        Check that the reverification flow is rendered.
+        Check that the re-verification flow is rendered.
         """
         response = self._get_reverify_page()
         self.assertContains(response, "reverify-container")
 
     def _assert_cannot_reverify(self):
         """
-        Check that the user is blocked from reverifying.
+        Check that the user is blocked from re-verifying.
         """
         response = self._get_reverify_page()
         self.assertContains(response, "reverify-blocked")
diff --git a/lms/djangoapps/verify_student/utils.py b/lms/djangoapps/verify_student/utils.py
index cbe4441c03000cd6a0c58bb7e096eed46c6e88af..111ae1bdb660e3b925c49443ad8dac944c89d725 100644
--- a/lms/djangoapps/verify_student/utils.py
+++ b/lms/djangoapps/verify_student/utils.py
@@ -101,3 +101,18 @@ def most_recent_verification(photo_id_verifications, sso_id_verifications, manua
     }
 
     return max(verifications_map, key=lambda k: verifications_map[k]) if verifications_map else None
+
+
+def auto_verify_for_testing_enabled(override=None):
+    """
+    If AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING is True, we want to skip posting
+    anything to Software Secure.
+
+    Bypass posting anything to Software Secure if auto verify feature for testing is enabled.
+    We actually don't even create the message because that would require encryption and message
+    signing that rely on settings.VERIFY_STUDENT values that aren't set in dev. So we just
+    pretend like we successfully posted.
+    """
+    if override is not None:
+        return override
+    return settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING')
diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py
index d6290eb06205bf3aa7e49efef13be10423d0e057..4441110ce5c4a588c3ca1b8b84d07cb68dd3d7db 100644
--- a/lms/djangoapps/verify_student/views.py
+++ b/lms/djangoapps/verify_student/views.py
@@ -239,7 +239,7 @@ class PayAndVerifyView(View):
 
         # Verify that the course exists
         if course is None:
-            log.warn(u"Could not find course with ID %s.", course_id)
+            log.warning(u"Could not find course with ID %s.", course_id)
             raise Http404
 
         # Check whether the user has access to this course
@@ -295,7 +295,7 @@ class PayAndVerifyView(View):
         else:
             # Otherwise, there has never been a verified/paid mode,
             # so return a page not found response.
-            log.warn(
+            log.warning(
                 u"No paid/verified course mode found for course '%s' for verification/payment flow request",
                 course_id
             )
@@ -818,12 +818,12 @@ def create_order(request):
         paid_modes = CourseMode.paid_modes_for_course(course_id)
         if paid_modes:
             if len(paid_modes) > 1:
-                log.warn(u"Multiple paid course modes found for course '%s' for create order request", course_id)
+                log.warning(u"Multiple paid course modes found for course '%s' for create order request", course_id)
             current_mode = paid_modes[0]
 
     # Make sure this course has a paid mode
     if not current_mode:
-        log.warn(u"Create order requested for course '%s' without a paid mode.", course_id)
+        log.warning(u"Create order requested for course '%s' without a paid mode.", course_id)
         return HttpResponseBadRequest(_("This course doesn't support paid certificates"))
 
     if CourseMode.is_professional_mode(current_mode):
@@ -899,7 +899,7 @@ class SubmitPhotosView(View):
 
         # Retrieve the image data
         # Validation ensures that we'll have a face image, but we may not have
-        # a photo ID image if this is a reverification.
+        # a photo ID image if this is a re-verification.
         face_image, photo_id_image, response = self._decode_image_data(
             params["face_image"], params.get("photo_id_image")
         )
diff --git a/lms/envs/common.py b/lms/envs/common.py
index de3a2ca75447f359850181d0af96c02e27f82af2..694a8c7d793e2b5b7393d6fc0f3d4a2704397ca6 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -511,6 +511,13 @@ XQUEUE_INTERFACE = {
 # Used with Email sending
 RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5
 RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5
+
+# Software Secure request retry settings
+# Time in seconds before a retry of the task should be 60 mints.
+SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
+# Maximum of 6 retries before giving up.
+SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6
+
 PASSWORD_RESET_EMAIL_RATE_LIMIT = {
     'no_of_emails': 1,
     'per_seconds': 60
@@ -2154,7 +2161,6 @@ CELERY_BROKER_TRANSPORT = 'amqp'
 CELERY_BROKER_HOSTNAME = 'localhost'
 CELERY_BROKER_USER = 'celery'
 CELERY_BROKER_PASSWORD = 'celery'
-CELERY_TIMEZONE = 'UTC'
 
 ################################ Block Structures ###################################
 
@@ -2837,6 +2843,8 @@ POLICY_CHANGE_GRADES_ROUTING_KEY = 'edx.lms.core.default'
 
 RECALCULATE_GRADES_ROUTING_KEY = 'edx.lms.core.default'
 
+SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = 'edx.lms.core.default'
+
 GRADES_DOWNLOAD = {
     'STORAGE_CLASS': 'django.core.files.storage.FileSystemStorage',
     'STORAGE_KWARGS': {
diff --git a/lms/envs/production.py b/lms/envs/production.py
index c8a1164db2fa41feee97fc80f1f75a6bcd1acd03..7ff5b138392d1771c645a4d7abe8ab76f66de87f 100644
--- a/lms/envs/production.py
+++ b/lms/envs/production.py
@@ -51,7 +51,7 @@ CONFIG_FILE = get_env_setting('LMS_CFG')
 with codecs.open(CONFIG_FILE, encoding='utf-8') as f:
     __config__ = yaml.safe_load(f)
 
-    # ENV_TOKENS and AUTH_TOKENS are included for reverse compatability.
+    # ENV_TOKENS and AUTH_TOKENS are included for reverse compatibility.
     # Removing them may break plugins that rely on them.
     ENV_TOKENS = __config__
     AUTH_TOKENS = __config__
@@ -966,6 +966,10 @@ CREDENTIALS_GENERATION_ROUTING_KEY = ENV_TOKENS.get('CREDENTIALS_GENERATION_ROUT
 
 # Queue to use for award program certificates
 PROGRAM_CERTIFICATES_ROUTING_KEY = ENV_TOKENS.get('PROGRAM_CERTIFICATES_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE)
+SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = ENV_TOKENS.get(
+    'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY',
+    HIGH_PRIORITY_QUEUE
+)
 
 API_ACCESS_MANAGER_EMAIL = ENV_TOKENS.get('API_ACCESS_MANAGER_EMAIL')
 API_ACCESS_FROM_EMAIL = ENV_TOKENS.get('API_ACCESS_FROM_EMAIL')
diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py
index a80a6572650043ed3fac627964e2c234e62a3e90..7eb499b348e86654b71f6f36ffb797c9aa5c7020 100644
--- a/openedx/tests/settings.py
+++ b/openedx/tests/settings.py
@@ -123,3 +123,10 @@ RETIREMENT_SERVICE_WORKER_USERNAME = 'RETIREMENT_SERVICE_USER'
 RETIRED_USERNAME_PREFIX = 'retired__user_'
 
 PROCTORING_SETTINGS = {}
+
+
+# Software Secure request retry settings
+# Time in seconds before a retry of the task should be 60 mints.
+SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60
+# Maximum of 6 retries before giving up.
+SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6