diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py
index e00a324b5bde4f2b1951bc4352d8f4af03ece8c8..818a3b2094660075193ae2a82651e63c11b8a6b4 100644
--- a/lms/djangoapps/certificates/models.py
+++ b/lms/djangoapps/certificates/models.py
@@ -47,8 +47,8 @@ Eligibility:
 """
 import json
 import logging
-import uuid
 import os
+import uuid
 
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
@@ -62,12 +62,14 @@ from django_extensions.db.fields import CreationDateTimeField
 from django_extensions.db.fields.json import JSONField
 from model_utils import Choices
 from model_utils.models import TimeStampedModel
-from xmodule.modulestore.django import modulestore
+from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
+
 from config_models.models import ConfigurationModel
-from xmodule_django.models import CourseKeyField, NoneToEmptyManager
-from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
 from course_modes.models import CourseMode
 from instructor_task.models import InstructorTask
+from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
+from xmodule.modulestore.django import modulestore
+from xmodule_django.models import CourseKeyField, NoneToEmptyManager
 
 LOGGER = logging.getLogger(__name__)
 
@@ -292,6 +294,24 @@ class GeneratedCertificate(models.Model):
         """
         return self.status == CertificateStatuses.downloadable
 
+    def save(self, *args, **kwargs):
+        """
+        After the base save() method finishes, fire the COURSE_CERT_AWARDED
+        signal iff we have stored a record of a learner passing the course.
+
+        The learner is assumed to have passed the course if certificate status
+        is either 'generating' or 'downloadable'.
+        """
+        super(GeneratedCertificate, self).save(*args, **kwargs)
+        if self.status in [CertificateStatuses.generating, CertificateStatuses.downloadable]:
+            COURSE_CERT_AWARDED.send_robust(
+                sender=self.__class__,
+                user=self.user,
+                course_key=self.course_id,
+                mode=self.mode,
+                status=self.status,
+            )
+
 
 class CertificateGenerationHistory(TimeStampedModel):
     """
@@ -401,16 +421,13 @@ class CertificateInvalidation(TimeStampedModel):
         return data
 
 
-@receiver(post_save, sender=GeneratedCertificate)
-def handle_post_cert_generated(sender, instance, **kwargs):  # pylint: disable=unused-argument
+@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
+def handle_course_cert_awarded(sender, user, course_key, **kwargs):  # pylint: disable=unused-argument
     """
-    Handles post_save signal of GeneratedCertificate, and mark user collected
-    course milestone entry if user has passed the course.
-    User is assumed to have passed the course if certificate status is either 'generating' or 'downloadable'.
+    Mark a milestone entry if user has passed the course.
     """
-    allowed_cert_states = [CertificateStatuses.generating, CertificateStatuses.downloadable]
-    if is_prerequisite_courses_enabled() and instance.status in allowed_cert_states:
-        fulfill_course_milestone(instance.course_id, instance.user)
+    if is_prerequisite_courses_enabled():
+        fulfill_course_milestone(course_key, user)
 
 
 def certificate_status_for_student(student, course_id):
@@ -1065,25 +1082,25 @@ class CertificateTemplateAsset(TimeStampedModel):
         app_label = "certificates"
 
 
-@receiver(post_save, sender=GeneratedCertificate)
+@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
 #pylint: disable=unused-argument
-def create_badge(sender, instance, **kwargs):
+def create_badge(sender, user, course_key, status, **kwargs):
     """
     Standard signal hook to create badges when a certificate has been generated.
     """
     if not settings.FEATURES.get('ENABLE_OPENBADGES', False):
         return
-    if not modulestore().get_course(instance.course_id).issue_badges:
+    if not modulestore().get_course(course_key).issue_badges:
         LOGGER.info("Course is not configured to issue badges.")
         return
-    if BadgeAssertion.objects.filter(user=instance.user, course_id=instance.course_id):
+    if BadgeAssertion.objects.filter(user=user, course_id=course_key):
         LOGGER.info("Badge already exists for this user on this course.")
         # Badge already exists. Skip.
         return
     # Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this
     # by making sure it only gets run on the callback during normal workflow.
-    if not instance.status == CertificateStatuses.downloadable:
+    if not status == CertificateStatuses.downloadable:
         return
     from .badge_handler import BadgeHandler
-    handler = BadgeHandler(instance.course_id)
-    handler.award(instance.user)
+    handler = BadgeHandler(course_key)
+    handler.award(user)
diff --git a/openedx/core/djangoapps/programs/migrations/0004_programsapiconfig_enable_certification.py b/openedx/core/djangoapps/programs/migrations/0004_programsapiconfig_enable_certification.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd27de259cfb09bd184182c993160901966801e9
--- /dev/null
+++ b/openedx/core/djangoapps/programs/migrations/0004_programsapiconfig_enable_certification.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('programs', '0003_auto_20151120_1613'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='programsapiconfig',
+            name='enable_certification',
+            field=models.BooleanField(default=False, verbose_name='Enable Program Certificate Generation'),
+        ),
+    ]
diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py
index 6229e140e1fe2c1fc27634287dcf0b5e1f5ae7b6..069051ef51137137d77c8a894be6cb736ff0ef40 100644
--- a/openedx/core/djangoapps/programs/models.py
+++ b/openedx/core/djangoapps/programs/models.py
@@ -54,11 +54,17 @@ class ProgramsApiConfig(ConfigurationModel):
         verbose_name=_("Enable Student Dashboard Displays"),
         default=False
     )
+
     enable_studio_tab = models.BooleanField(
         verbose_name=_("Enable Studio Authoring Interface"),
         default=False
     )
 
+    enable_certification = models.BooleanField(
+        verbose_name=_("Enable Program Certificate Generation"),
+        default=False
+    )
+
     @property
     def internal_api_url(self):
         """
@@ -109,3 +115,11 @@ class ProgramsApiConfig(ConfigurationModel):
             bool(self.authoring_app_js_path) and
             bool(self.authoring_app_css_path)
         )
+
+    @property
+    def is_certification_enabled(self):
+        """
+        Indicates whether background tasks should be initiated to grant
+        certificates for Program completion.
+        """
+        return self.enabled and self.enable_certification
diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..405f154d409104fe7dc048de66f2bbe1b5a35697
--- /dev/null
+++ b/openedx/core/djangoapps/programs/signals.py
@@ -0,0 +1,51 @@
+"""
+This module contains signals / handlers related to programs.
+"""
+import logging
+
+from django.dispatch import receiver
+
+from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+@receiver(COURSE_CERT_AWARDED)
+def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs):  # pylint: disable=unused-argument
+    """
+    If programs is enabled and a learner is awarded a course certificate,
+    schedule a celery task to process any programs certificates for which
+    the learner may now be eligible.
+
+    Args:
+        sender:
+            class of the object instance that sent this signal
+        user:
+            django.contrib.auth.User - the user to whom a cert was awarded
+        course_key:
+            refers to the course run for which the cert was awarded
+        mode:
+            mode / certificate type, e.g. "verified"
+        status:
+            either "downloadable" or "generating"
+
+    Returns:
+        None
+
+    """
+    if not ProgramsApiConfig.current().is_certification_enabled:
+        return
+
+    # schedule background task to process
+    LOGGER.debug(
+        'handling COURSE_CERT_AWARDED: username=%s, course_key=%s, mode=%s, status=%s',
+        user,
+        course_key,
+        mode,
+        status,
+    )
+    # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
+    from openedx.core.djangoapps.programs import tasks
+    tasks.award_program_certificates.delay(user.username)
diff --git a/openedx/core/djangoapps/programs/tasks.py b/openedx/core/djangoapps/programs/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..07337082ded86fe8a5b02242b4cf110a43e5d78c
--- /dev/null
+++ b/openedx/core/djangoapps/programs/tasks.py
@@ -0,0 +1,55 @@
+"""
+This file contains celery tasks for programs-related functionality.
+"""
+
+from celery import task
+from celery.utils.log import get_task_logger  # pylint: disable=no-name-in-module, import-error
+
+from lms.djangoapps.certificates.api import get_certificates_for_user
+
+LOGGER = get_task_logger(__name__)
+
+
+@task
+def award_program_certificates(username):
+    """
+    This task is designed to be called whenever a user's completion status
+    changes with respect to one or more courses (primarily, when a course
+    certificate is awarded).
+
+    It will consult with a variety of APIs to determine whether or not the
+    specified user should be awarded a certificate in one or more programs, and
+    use the credentials service to create said certificates if so.
+
+    This task may also be invoked independently of any course completion status
+    change - for example, to backpopulate missing program credentials for a
+    user.
+
+    TODO: this is shelled out and incomplete for now.
+    """
+
+    # fetch the set of all course runs for which the user has earned a certificate
+    LOGGER.debug('fetching all completed courses for user %s', username)
+    user_certs = get_certificates_for_user(username)
+    course_certs = [
+        {'course_id': uc['course_id'], 'mode': uc['mode']}
+        for uc in user_certs
+        if uc['status'] in ('downloadable', 'generating')
+    ]
+
+    # invoke the Programs API completion check endpoint to identify any programs
+    # that are satisfied by these course completions
+    LOGGER.debug('determining completed programs for courses: %r', course_certs)
+    program_ids = []  # TODO
+
+    # determine which program certificates the user has already been awarded, if
+    # any, and remove those, since they already exist.
+    LOGGER.debug('fetching existing program certificates for %s', username)
+    existing_program_ids = []  # TODO
+    new_program_ids = list(set(program_ids) - set(existing_program_ids))
+
+    # generate a new certificate for each of the remaining programs.
+    LOGGER.debug('generating new program certificates for %s in programs: %r', username, new_program_ids)
+    for program_id in new_program_ids:
+        LOGGER.debug('calling credentials service to issue certificate for user %s in program %s', username, program_id)
+        # TODO
diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py
index 5d9f450d6a07e25bf70c43b3c65c398e45b56fe0..b78862202e0b0e91793de4ad55a30bd121482076 100644
--- a/openedx/core/djangoapps/programs/tests/mixins.py
+++ b/openedx/core/djangoapps/programs/tests/mixins.py
@@ -19,6 +19,7 @@ class ProgramsApiConfigMixin(object):
         'cache_ttl': 0,
         'enable_student_dashboard': True,
         'enable_studio_tab': True,
+        'enable_certification': True,
     }
 
     def create_programs_config(self, **kwargs):
diff --git a/openedx/core/djangoapps/programs/tests/test_models.py b/openedx/core/djangoapps/programs/tests/test_models.py
index 5f03842d4138b80dc7a9124564201795d9d81232..5f0c3f7cfe99cc6f8ac17299036f6cb5f3fd81d4 100644
--- a/openedx/core/djangoapps/programs/tests/test_models.py
+++ b/openedx/core/djangoapps/programs/tests/test_models.py
@@ -76,3 +76,17 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
 
         programs_config = self.create_programs_config()
         self.assertTrue(programs_config.is_studio_tab_enabled)
+
+    def test_is_certification_enabled(self, _mock_cache):
+        """
+        Verify that the property controlling certification-related functionality
+        for Programs behaves as expected.
+        """
+        programs_config = self.create_programs_config(enabled=False)
+        self.assertFalse(programs_config.is_certification_enabled)
+
+        programs_config = self.create_programs_config(enable_certification=False)
+        self.assertFalse(programs_config.is_certification_enabled)
+
+        programs_config = self.create_programs_config()
+        self.assertTrue(programs_config.is_certification_enabled)
diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..80545f25e324ae4ee3e999426427233ddea29dcd
--- /dev/null
+++ b/openedx/core/djangoapps/programs/tests/test_signals.py
@@ -0,0 +1,71 @@
+"""
+This module contains tests for programs-related signals and signal handlers.
+"""
+
+from django.test import TestCase
+import mock
+
+from student.tests.factories import UserFactory
+
+from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
+from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded
+
+
+TEST_USERNAME = 'test-user'
+
+
+@mock.patch('openedx.core.djangoapps.programs.tasks.award_program_certificates.delay')
+@mock.patch(
+    'openedx.core.djangoapps.programs.models.ProgramsApiConfig.is_certification_enabled',
+    new_callable=mock.PropertyMock,
+    return_value=False,
+)
+class CertAwardedReceiverTest(TestCase):
+    """
+    Tests for the `handle_course_cert_awarded` signal handler function.
+    """
+
+    @property
+    def signal_kwargs(self):
+        """
+        DRY helper.
+        """
+        return dict(
+            sender=self.__class__,
+            user=UserFactory.create(username=TEST_USERNAME),
+            course_key='test-course',
+            mode='test-mode',
+            status='test-status',
+        )
+
+    def test_signal_received(self, mock_is_certification_enabled, mock_task):  # pylint: disable=unused-argument
+        """
+        Ensures the receiver function is invoked when COURSE_CERT_AWARDED is
+        sent.
+
+        Suboptimal: because we cannot mock the receiver function itself (due
+        to the way django signals work), we mock a configuration call that is
+        known to take place inside the function.
+        """
+        COURSE_CERT_AWARDED.send(**self.signal_kwargs)
+        self.assertEqual(mock_is_certification_enabled.call_count, 1)
+
+    def test_programs_disabled(self, mock_is_certification_enabled, mock_task):
+        """
+        Ensures that the receiver function does nothing when the programs API
+        configuration is not enabled.
+        """
+        handle_course_cert_awarded(**self.signal_kwargs)
+        self.assertEqual(mock_is_certification_enabled.call_count, 1)
+        self.assertEqual(mock_task.call_count, 0)
+
+    def test_programs_enabled(self, mock_is_certification_enabled, mock_task):
+        """
+        Ensures that the receiver function invokes the expected celery task
+        when the programs API configuration is enabled.
+        """
+        mock_is_certification_enabled.return_value = True
+        handle_course_cert_awarded(**self.signal_kwargs)
+        self.assertEqual(mock_is_certification_enabled.call_count, 1)
+        self.assertEqual(mock_task.call_count, 1)
+        self.assertEqual(mock_task.call_args[0], (TEST_USERNAME,))
diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py
index 57200789f836dccdfe6258f44d937338a746d430..13e7ff6a8467d619852470b396165a85a49ab5d3 100644
--- a/openedx/core/djangoapps/signals/signals.py
+++ b/openedx/core/djangoapps/signals/signals.py
@@ -7,3 +7,8 @@ from django.dispatch import Signal
 
 # Signal that fires when a user is graded (in lms/courseware/grades.py)
 GRADES_UPDATED = Signal(providing_args=["username", "grade_summary", "course_key", "deadline"])
+
+# Signal that fires when a user is awarded a certificate in a course (in the certificates django app)
+# TODO: runtime coupling between apps will be reduced if this event is changed to carry a username
+# rather than a User object; however, this will require changes to the milestones and badges APIs
+COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "status"])