diff --git a/config/settings/base.py b/config/settings/base.py
index 74e02545c56d8b048c009cc7b12fd19362b305ae..4f7fc80a169c467ad9e603cabc3e8d9b1aa92c00 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -54,8 +54,18 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 TWIML_BASE_URL = 'https://handler.twilio.com/twiml/'
 # TwiML bin SIDs (for Twilio)
 TWIML_BIN = {
-    'new': 'EH761b6a35102737e3d21830a484a98a08',
-    'label_added': 'EHb596a53b9c92a41950ce1a47335fd834',
+    'event': {
+        'new': 'EH761b6a35102737e3d21830a484a98a08',
+        'update': 'EH95d69491c166fbe8888a3b83b8aff4af',
+        'label_added': 'EHb596a53b9c92a41950ce1a47335fd834',
+        'label_removed': 'EH071c034f27f714bb7a832e85e6f82119',
+    },
+    'superevent': {
+        'new': 'EH5d4d61f5aee9f8687c5bc7d9d42acab9',
+        'update': 'EH35356707718e1b9a887c50359c3ab064',
+        'label_added': 'EH244c07ceeb152c6a374e4ffbd853e7a4',
+        'label_removed': 'EH9d796ce6a80e282a5c96e757e5c39406',
+    },
     'test': 'EH6c0a168b0c6b011047afa1caeb49b241',
     'verify': 'EHfaea274d4d87f6ff152ac39fea3a87d4',
 }
diff --git a/gracedb/alerts/email.py b/gracedb/alerts/email.py
index 026827375f010696d0e653a4504ddc4682158234..3abeefe51080552bd0cdbaf6d8ac686db3bd3310 100644
--- a/gracedb/alerts/email.py
+++ b/gracedb/alerts/email.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import
 import logging
+import textwrap
 
 from django.conf import settings
 from django.core.mail import EmailMessage
@@ -7,92 +8,145 @@ from django.urls import reverse
 
 from core.time_utils import gpsToUtc
 from core.urls import build_absolute_uri
+from events.shortcuts import is_event
 
 # Set up logger
 logger = logging.getLogger(__name__)
 
-EMAIL_SUBJECT_NEW = "[gracedb] {pipeline} event. ID: {graceid}"
-EMAIL_MESSAGE_NEW = """
-New Event
-{group} / {pipeline}
-GRACEID:   {graceid}
-Info:      {url}
-Data:      {file_url}
-Submitter: {submitter}
-Event Summary:
-{summary}
-"""
-
-EMAIL_SUBJECT_LABEL = "[gracedb] {label} / {pipeline} / {search} / {graceid}"
-EMAIL_SUBJECT_LABEL_NOSEARCH = "[gracedb] {label} / {pipeline} / {graceid}"
-EMAIL_MESSAGE_LABEL = """
-A {pipeline} event with graceid {graceid} was labeled with {label}: {url}
-"""
-
-
-def indent(nindent, text):
-    return "\n".join([(nindent*' ')+line for line in text.split('\n')])
-
-
-def prepareSummary(event):
-    gpstime = event.gpstime
-    utctime = gpsToUtc(gpstime).strftime("%Y-%m-%d %H:%M:%S")
-    instruments = getattr(event, 'instruments', "")
-    far = getattr(event, 'far', 1.0)
-    summary_template = """
-    Event Time (GPS): %s 
-    Event Time (UTC): %s
-    Instruments: %s 
-    FAR: %.3E """
-    summary = summary_template % (gpstime, utctime, instruments, far)
-    si_set = event.singleinspiral_set.all()
-    if si_set.count():
-        si = si_set[0]
-        summary += """
-    Component masses: %.2f, %.2f """ % (si.mass1, si.mass2)
-    return summary
-
-
-def issue_email_alerts(event, recips, label=None):
-
-    # Prepare URLs for email message body
-    event_url = build_absolute_uri(reverse("view", args=[event.graceid]))
-    file_url = build_absolute_uri(reverse("file_list", args=[event.graceid]))
-
-    # Compile subject and message content
-    if label is None:
-        # Alert for new event
-        subject = EMAIL_SUBJECT_NEW.format(pipeline=event.pipeline.name,
-            graceid=event.graceid)
-        message = EMAIL_MESSAGE_NEW.format(group=event.group.name,
-            pipeline=event.pipeline.name, graceid=event.graceid,
-            url=event_url, file_url=file_url,
-            submitter=event.submitter.get_full_name(),
-            summary=indent(3, prepareSummary(event)))
+
+EMAIL_SUBJECT = {
+    'event': {
+        'new': '[gracedb] {pipeline} event created: {graceid}',
+        'update': '[gracedb] {pipeline} event updated: {graceid}',
+        'label_added': \
+            '[gracedb] {pipeline} event labeled with {label}: {graceid}',
+        'label_removed': ('[gracedb] Label {label} removed from {pipeline} '
+            'event: {graceid}'),
+    },
+    'superevent': {
+        'new': '[gracedb] Superevent created: {sid}',
+        'update': '[gracedb] Superevent updated: {sid}',
+        'label_added': \
+                '[gracedb] Superevent labeled with {label}: {sid}',
+        'label_removed': \
+                '[gracedb] Label {label} removed from superevent: {sid}',
+    },
+}
+
+# es = 'Event' or 'Superevent'
+# group_pipeline_ev_or_s = '{group} / {pipeline} event' or 'superevent'
+EMAIL_BODY_TEMPLATE = textwrap.dedent("""\
+    {alert_type_verb} {group_pipeline_ev_or_s}: {graceid}
+
+        {es} time (GPS): {gpstime}
+        {es} time (UTC): {utctime}
+        {es} page: {detail_url}
+        {es} data: {file_url}
+        FAR: {far}
+        Labels: {labels}
+""").rstrip()
+
+ALERT_TYPE_VERBS = {
+    'new': 'New',
+    'update': 'Updated',
+    'label_added': 'Label {label} added to',
+    'label_removed': 'Label {label} removed from',
+}
+
+
+def prepare_email_body(event_or_superevent, alert_type, label=None):
+
+    # Sort out different cases for events or superevents
+    if is_event(event_or_superevent):
+        group_pipeline_ev_or_s = '{group} / {pipeline} event'.format(
+            group=event_or_superevent.group.name,
+            pipeline=event_or_superevent.pipeline.name)
+        es = 'Event'
+        detail_url = build_absolute_uri(reverse('view',
+            args=[event_or_superevent.graceid]))
+        file_url = build_absolute_uri(reverse('file_list',
+            args=[event_or_superevent.graceid]))
+    else:
+        group_pipeline_ev_or_s = 'superevent'
+        es = 'Superevent'
+        detail_url = build_absolute_uri(reverse('superevents:view',
+            args=[event_or_superevent.graceid]))
+        file_url = build_absolute_uri(reverse('superevents:file-list',
+            args=[event_or_superevent.graceid]))
+
+    # Compile email body
+    alert_type_verb = ALERT_TYPE_VERBS[alert_type]
+    if label is not None and 'label' in alert_type:
+        alert_type_verb = alert_type_verb.format(label=label.name)
+    gpstime = event_or_superevent.gpstime
+    labels = None
+    if event_or_superevent.labels.exists():
+        labels = ", ".join(event_or_superevent.labels.values_list(
+            'name', flat=True))
+    email_body = EMAIL_BODY_TEMPLATE.format(
+        graceid=event_or_superevent.graceid,
+        alert_type_verb=alert_type_verb,
+        group_pipeline_ev_or_s=group_pipeline_ev_or_s,
+        es=es,
+        detail_url=detail_url,
+        file_url=file_url,
+        gpstime=gpstime,
+        utctime=gpsToUtc(gpstime).isoformat(),
+        far=event_or_superevent.far,
+        labels=labels)
+
+    # Add some extra information
+    has_si = False
+    if is_event(event_or_superevent):
+        instruments = getattr(event_or_superevent, 'instruments', None)
+        if instruments:
+            email_body += '\n    Instruments: {inst}'.format(inst=instruments)
+        if event_or_superevent.singleinspiral_set.exists():
+            si = event_or_superevent.singleinspiral_set.first()
+            has_si = True
+    else:
+        if event_or_superevent.preferred_event.singleinspiral_set.exists():
+            si = event_or_superevent.preferred_event.singleinspiral_set.first()
+            has_si = True
+    if has_si:
+        email_body += '\n    Component masses: {m1}, {m2}'.format(m1=si.mass1,
+            m2=si.mass2)
+
+    return email_body
+
+
+def issue_email_alerts(event_or_superevent, alert_type, recipients,
+    label=None):
+
+    # Get subject template
+    if is_event(event_or_superevent):
+        event_type = 'event'
+    else:
+        event_type = 'superevent'
+    subject_template = EMAIL_SUBJECT[event_type][alert_type]
+
+    # Construct subject
+    subj_kwargs = {}
+    if label is not None and 'label' in alert_type:
+        subj_kwargs['label'] = label.name
+    if is_event(event_or_superevent):
+        subj_kwargs['graceid'] = event_or_superevent.graceid
+        subj_kwargs['pipeline'] = event_or_superevent.pipeline.name
     else:
-        # Alert for label
-        if event.search:
-            subject = EMAIL_SUBJECT_LABEL.format(label=label.name,
-                pipeline=event.pipeline.name, search=event.search.name,
-                graceid=event.graceid)
-        else:
-            subject = EMAIL_SUBJECT_LABEL_NOSEARCH.format(label=label.name,
-                pipeline=event.pipeline.name, graceid=event.graceid)
-        message = EMAIL_MESSAGE_LABEL.format(pipeline=event.pipeline.name,
-            graceid=event.graceid, label=label.name, url=event_url)
-
-    # Actual recipients should be BCC'd
-    bcc_addresses = [recip.email for recip in recips]
-
-    # Compile from/to addresses
-    from_address = settings.ALERT_EMAIL_FROM
-    to_addresses = settings.ALERT_EMAIL_TO
+        subj_kwargs['sid'] = event_or_superevent.superevent_id
+    subject = subject_template.format(**subj_kwargs)
+
+    # Get email body
+    email_body = prepare_email_body(event_or_superevent, alert_type, label)
 
     # Log email recipients
     logger.debug("Sending email to {recips}".format(
-        recips=", ".join(bcc_addresses)))
-
-    # Send email
-    email = EmailMessage(subject, message, from_address, to_addresses,
-        bcc_addresses)
-    email.send()
+        recips=", ".join([r.email for r in recipients])))
+
+    # Send mail individually so all emails are not rejected due to a single
+    # address being blacklisted
+    for recip in recipients:
+        # Send email
+        email = EmailMessage(subject=subject, body=email_body,
+            from_email=settings.ALERT_EMAIL_FROM, to=[recip.email])
+        email.send()
diff --git a/gracedb/alerts/main.py b/gracedb/alerts/main.py
index d8b76a5d7439227a5d0beda60f933b817992640b..6aef17ee27e4771a23d909e1a1603b96c56af0e7 100644
--- a/gracedb/alerts/main.py
+++ b/gracedb/alerts/main.py
@@ -1,103 +1,21 @@
 from __future__ import absolute_import
-import json
 import logging
-import os
-import socket
-from subprocess import Popen, PIPE, STDOUT
-import sys
 
 from django.conf import settings
-from django.contrib.auth.models import Group
-from django.core.mail import EmailMessage
-from django.db.models import QuerySet, Q
-from django.urls import reverse
-
-from core.time_utils import gpsToUtc
-from events.models import Event
-from events.permission_utils import is_external
+
 from events.shortcuts import is_event
-from search.query.labels import filter_for_labels
-from superevents.shortcuts import is_superevent
-from .models import Contact
 from .email import issue_email_alerts
 from .phone import issue_phone_alerts
+from .recipients import ALERT_TYPE_RECIPIENT_GETTERS
 from .xmpp import issue_xmpp_alerts
 
+
 # Set up logger
 logger = logging.getLogger(__name__)
 
 
-def get_alert_recips(event_or_superevent):
-
-    if is_superevent(event_or_superevent):
-        # TODO: update for superevents
-        pass
-    elif is_event(event_or_superevent):
-        event = event_or_superevent
-        # Queryset of all notifications for this pipeline
-        notifications = event.pipeline.notification_set.filter(labels=None)
-        # Filter on FAR threshold requirements
-        query = Q(far_threshold__isnull=True)
-        if event.far:
-            query |= Q(far_threshold__gt=event.far)
-        notifications = notifications.filter(query)
-        # Contacts for all notifications, make sure user is in LVC group (safeguard)
-        contacts = Contact.objects.filter(notification__in=notifications,
-            user__groups__name=settings.LVC_GROUP).select_related('user')
-        email_recips = contacts.exclude(email="")
-        phone_recips = contacts.exclude(phone="")
-
-    return email_recips, phone_recips
-
-
-def get_alert_recips_for_label(event_or_superevent, label):
-    # Blank QuerySets for recipients
-    email_recips = Contact.objects.none()
-    phone_recips = Contact.objects.none()
-
-    # Construct a queryset containing only this object; needed for
-    # call to filter_for_labels
-    qs = event_or_superevent._meta.model.objects.filter(
-        pk=event_or_superevent.pk)
-
-    # Notifications on given label matching pipeline OR with no pipeline;
-    # no pipeline indicates that pipeline is irrelevant
-    if is_superevent(event_or_superevent):
-        # TODO: fix this
-        query = Q()
-    elif is_event(event_or_superevent):
-        event = event_or_superevent
-        query = Q(pipelines=event.pipeline) | Q(pipelines=None)
-
-    # Iterate over notifications found from the label query
-    # TODO: this doesn't work quite correctly since negated labels aren't
-    # properly handled in the view function which creates notifications.
-    # Example: a notification with '~INJ' as the label_query has INJ in its labels
-    # Idea: have filter_for_labels return a Q object generated from the
-    #       label query
-    notifications = label.notification_set.filter(query).prefetch_related('contacts')
-    for notification in notifications:
-
-        if len(notification.label_query) > 0:
-            qs_out = filter_for_labels(qs, notification.label_query)
-
-            # If the label query cleans out our query set, we'll continue
-            # without adding the recipient.
-            if not qs_out.exists():
-                continue
-
-        # Compile a list of recipients from the notification's contacts.
-        # Require that the user is in the LVC group as a safeguard
-        contacts = notification.contacts.filter(user__groups__name=
-            settings.LVC_GROUP)
-        email_recips |= contacts.exclude(email="").select_related('user')
-        phone_recips |= contacts.exclude(phone="").select_related('user')
-
-    return email_recips, phone_recips
-
-
 def issue_alerts(event_or_superevent, alert_type, serialized_object,
-    serialized_parent=None, label=None):
+    serialized_parent=None, **kwargs):
 
     # Send XMPP alert
     if settings.SEND_XMPP_ALERTS:
@@ -105,37 +23,58 @@ def issue_alerts(event_or_superevent, alert_type, serialized_object,
             serialized_parent=serialized_parent)
 
     # Below here, we only do processing for email and phone alerts ------------
-    if not (settings.SEND_EMAIL_ALERTS or settings.SEND_PHONE_ALERTS):
-        return
 
-    # TODO: make phone and e-mail alerts work for superevents
-    if is_superevent(event_or_superevent):
+    # A few checks on whether we should issue a phone and/or email alert ------
+    if not (settings.SEND_EMAIL_ALERTS or settings.SEND_PHONE_ALERTS):
         return
 
-    # Phone and email alerts are currently only for "new" and "label_added"
-    # alert_types, for events only.
-    if (alert_type not in ['new', 'label_added']):
+    # Phone and email alerts are only issued for certain alert types
+    # We check this by looking at the keys of ALERT_TYPE_RECIPIENT_GETTERS
+    if (alert_type not in ALERT_TYPE_RECIPIENT_GETTERS):
         return
 
-    # Don't send phone or email alerts for MDC events or Test events
+    # Don't send phone or email alerts for MDC or Test cases, or offline
     if is_event(event_or_superevent):
+        # Test/MDC events
         event = event_or_superevent
         if ((event.search and event.search.name == 'MDC') \
             or event.group.name == 'Test'):
             return
 
-        # Don't send them for offline events, either
+        # Offline events
         if event.offline:
             return
+    else:
+        # Test/MDC superevents
+        s = event_or_superevent
+        if (s.category in [s.__class__.SUPEREVENT_CATEGORY_TEST,
+            s.__class__.SUPEREVENT_CATEGORY_MDC]):
+            return
+
+        # Superevents with offline preferred events
+        if s.preferred_event.offline:
+            return
+
+    # Looks like we're going to issue phone and/or email alerts ---------------
+
+    # Get recipient getter class
+    rg_class = ALERT_TYPE_RECIPIENT_GETTERS[alert_type]
+
+    # Instantiate recipient getter
+    rg = rg_class(event_or_superevent, **kwargs)
+
+    # Get recipients
+    email_recipients, phone_recipients = rg.get_recipients()
 
-    if alert_type == "new":
-        email_recips, phone_recips = get_alert_recips(event_or_superevent)
-    elif alert_type == "label_added":
-        email_recips, phone_recips = \
-            get_alert_recips_for_label(event_or_superevent, label)
+    # Try to get label explicitly from kwargs
+    label = kwargs.get('label', None)
 
-    if settings.SEND_EMAIL_ALERTS and email_recips.exists():
-        issue_email_alerts(event_or_superevent, email_recips, label=label)
+    # Issue email alerts
+    if settings.SEND_EMAIL_ALERTS and email_recipients.exists():
+        issue_email_alerts(event_or_superevent, alert_type, email_recipients,
+            label=label)
 
-    if settings.SEND_PHONE_ALERTS and phone_recips.exists():
-        issue_phone_alerts(event_or_superevent, phone_recips, label=label)
+    # Issue phone alerts
+    if settings.SEND_PHONE_ALERTS and phone_recipients.exists():
+        issue_phone_alerts(event_or_superevent, alert_type, phone_recipients,
+            label=label)
diff --git a/gracedb/alerts/phone.py b/gracedb/alerts/phone.py
index 11897539320b26e696201e2d4f4864418174a246..dd98d1724b93870a94e709b070efb5cefe03ac56 100644
--- a/gracedb/alerts/phone.py
+++ b/gracedb/alerts/phone.py
@@ -1,34 +1,42 @@
 from __future__ import absolute_import
 import logging
-import socket
 
 from django.conf import settings
 from django.urls import reverse
+from django.utils.http import urlencode
 
 from django_twilio.client import twilio_client
 
 from core.urls import build_absolute_uri
 from events.permission_utils import is_external
+from events.shortcuts import is_event
+from .utils import convert_superevent_id_to_speech
+
 
 # Set up logger
 logger = logging.getLogger(__name__)
 
 
-# TODO: generalize to superevents
-# Dict for managing TwiML bin arguments.
-# Should match structure of TWIML_BINS dict in
-# config/settings/secret.py.
-TWIML_ARG_STR = {
-    'new': 'pipeline={pipeline}&graceid={graceid}&server={server}',
-    'label_added': ('pipeline={pipeline}&graceid={graceid}&label_lower={label}'
-              '&server={server}'),
-}
-
 # Dict for managing Twilio message contents.
 TWILIO_MSG_CONTENT = {
-    'new': 'A {pipeline} event with GraceDB ID {graceid} was created. {url}',
-    'label_added': ('A {pipeline} event with GraceDB ID {graceid} was labeled '
-              'with {label}. {url}'),
+    'event': {
+        'new': ('A {pipeline} event with GraceDB ID {graceid} was created. '
+            '{url}'),
+        'update': ('A {pipeline} event with GraceDB ID {graceid} was updated. '
+            '{url}'),
+        'label_added': ('A {pipeline} event with GraceDB ID {graceid} was '
+              'labeled with {label}. {url}'),
+        'label_removed': ('The label {label} was removed from a {pipeline} '
+            'event with GraceDB ID {graceid}. {url}'),
+    },
+    'superevent': {
+        'new': 'A superevent with GraceDB ID {sid} was created. {url}',
+        'update': 'A superevent with GraceDB ID {sid} was updated. {url}',
+        'label_added': ('A superevent with GraceDB ID {sid} was labeled with '
+            '{label}. {url}'),
+        'label_removed': ('The label {label} was removed from a superevent '
+            'with GraceDB ID {sid}. {url}'),
+    },
 }
 
 
@@ -39,40 +47,84 @@ def get_twilio_from():
     raise RuntimeError('Could not determine "from" Twilio phone number')
 
 
-def issue_phone_alerts(event, contacts, label=None):
-    """
-    USAGE:
-    ------
-        New event created:
-            issue_phone_alerts(event, contacts)
-        New label applied to event (Label is a GraceDB model):
-            issue_phone_alerts(event, contacts, label=Label)
+def get_message_content(event_or_superevent, alert_type, **kwargs):
+    """Get content for text messages"""
+    # kwargs should include 'label' for label_added and label_removed alerts
 
-    Note: contacts is a QuerySet of Contact objects.
-    """
-
-    # Determine alert_type
-    if label is not None:
-        alert_type = "label_added"
+    # Get template
+    if is_event(event_or_superevent):
+        event_type = 'event'
+    else:
+        event_type = 'superevent'
+    msg_template = TWILIO_MSG_CONTENT[event_type][alert_type]
+
+    # Compile message content
+    if is_event(event_or_superevent):
+        event = event_or_superevent
+        # Get url
+        url = build_absolute_uri(reverse('view', args=[event.graceid]))
+
+        # Compile message
+        msg = msg_template.format(graceid=event.graceid,
+            pipeline=event.pipeline.name, url=url, **kwargs)
     else:
-        alert_type = "new"
+        superevent = event_or_superevent
+        # get url
+        url = build_absolute_uri(reverse('superevents:view',
+            args=[superevent.superevent_id]))
+
+        # Compile message
+        msg = msg_template.format(sid=superevent.superevent_id, url=url,
+            **kwargs)
+
+    return msg
+
+
+def compile_twiml_url(event_or_superevent, alert_type, **kwargs):
+    # Try to get label from kwargs - should be a string corresponding
+    # to the label name here
+    label = kwargs.get('label', None)
+
+    # Compile urlparams
+    if is_event(event_or_superevent):
+        urlparams = {
+            'graceid': event_or_superevent.graceid,
+            'pipeline': event_or_superevent.pipeline.name,
+        }
+        twiml_bin_dict = settings.TWIML_BIN['event']
+    else:
+        urlparams = {'sid': convert_superevent_id_to_speech(
+            event_or_superevent.superevent_id)}
+        twiml_bin_dict = settings.TWIML_BIN['superevent']
+    if label is not None:
+        urlparams['label_lower'] = label.lower()
 
+    # Construct TwiML URL
+    twiml_url = '{base}{twiml_bin}?{params}'.format(
+        base=settings.TWIML_BASE_URL,
+        twiml_bin=twiml_bin_dict[alert_type],
+        params=urlencode(urlparams))
+
+    return twiml_url
+
+
+def issue_phone_alerts(event_or_superevent, alert_type, contacts, label=None):
+    """
+    Note: contacts is a QuerySet of Contact objects.
+    """
     # Get "from" phone number.
     from_ = get_twilio_from()
 
-    # Compile Twilio voice URL and message body
-    msg_params = {
-        'pipeline': event.pipeline.name,
-        'graceid': event.graceid,
-        'server': settings.SERVER_HOSTNAME,
-        'url': build_absolute_uri(reverse('view', args=[event.graceid])),
-    }
-    if alert_type == "label_added":
-        msg_params['label'] = label.name
-    twiml_url = settings.TWIML_BASE_URL + settings.TWIML_BIN[alert_type] + \
-        "?" + TWIML_ARG_STR[alert_type]
-    twiml_url = twiml_url.format(**msg_params)
-    msg_body = TWILIO_MSG_CONTENT[alert_type].format(**msg_params)
+    # Get message content
+    msg_kwargs = {}
+    if alert_type in ['label_added', 'label_removed'] and label:
+        msg_kwargs['label'] = label.name
+    msg_body = get_message_content(event_or_superevent, alert_type,
+        **msg_kwargs)
+
+    # Compile Twilio voice URL
+    twiml_url = compile_twiml_url(event_or_superevent, alert_type,
+        **msg_kwargs)
 
     # Loop over recipients and make calls and/or texts.
     for contact in contacts:
@@ -81,23 +133,29 @@ def issue_phone_alerts(event, contacts, label=None):
             # shouldn't even be able to sign up for phone alerts,
             # but this is another safety measure.
             logger.warning("External user {0} is somehow signed up for"
-                        " phone alerts".format(contact.user.username))
+                " phone alerts".format(contact.user.username))
             continue
 
         try:
-            # POST to TwiML bin to make voice call.
-            if contact.call_phone:
+            if (contact.phone_method in [contact.__class__.CONTACT_PHONE_CALL,
+                contact.__class__.CONTACT_PHONE_BOTH]):
+                # POST to TwiML bin to make voice call.
                 logger.debug("Calling {0} at {1}".format(contact.user.username,
                     contact.phone))
                 twilio_client.calls.create(to=contact.phone, from_=from_,
                     url=twiml_url, method='GET')
+        except Exception as e:
+            logger.exception("Failed to call {0} at {1}.".format(
+                contact.user.username, contact.phone))
 
+        try:
             # Create Twilio message.
-            if contact.text_phone:
+            if (contact.phone_method in [contact.__class__.CONTACT_PHONE_TEXT,
+                contact.__class__.CONTACT_PHONE_BOTH]):
                 logger.debug("Texting {0} at {1}".format(contact.user.username,
                     contact.phone))
                 twilio_client.messages.create(to=contact.phone, from_=from_,
                     body=msg_body)
         except Exception as e:
-            logger.exception("Failed to contact {0} at {1}.".format(
+            logger.exception("Failed to text {0} at {1}.".format(
                 contact.user.username, contact.phone))
diff --git a/gracedb/alerts/recipients.py b/gracedb/alerts/recipients.py
new file mode 100644
index 0000000000000000000000000000000000000000..bed4db3659bb46136de1df83ea60e42624a51eb1
--- /dev/null
+++ b/gracedb/alerts/recipients.py
@@ -0,0 +1,179 @@
+from django.conf import settings
+from django.db.models import Q
+
+from events.shortcuts import is_event
+from search.query.labels import filter_for_labels
+from superevents.shortcuts import is_superevent
+from .models import Contact, Notification
+
+
+class CreationRecipientGetter(object):
+    queryset = Notification.objects.all()
+
+    def __init__(self, event_or_superevent, **kwargs):
+        self.es = event_or_superevent
+        self.is_event_alert = is_event(event_or_superevent)
+        self.event = event_or_superevent if self.is_event_alert \
+            else event_or_superevent.preferred_event
+        self.process_kwargs(**kwargs)
+        # event_or_superevent queryset - used for filtering by labels
+        self.es_qs = self.es._meta.model.objects.filter(pk=self.es.pk)
+
+    def process_kwargs(self, **kwargs):
+        pass
+
+    def get_category_filter(self):
+        if self.is_event_alert:
+            return Q(category=Notification.NOTIFICATION_CATEGORY_EVENT)
+        return Q(category=Notification.NOTIFICATION_CATEGORY_SUPEREVENT)
+
+    def get_far_filter(self):
+        query = Q(far_threshold__isnull=True)
+        if self.event.far:
+            query |= Q(far_threshold__gt=self.event.far)
+        return query
+
+    def get_nscand_filter(self):
+        if self.event.is_ns_candidate():
+            return Q()
+        return Q(ns_candidate=False)
+
+    def get_group_filter(self):
+        if self.is_event_alert:
+            return Q(groups__isnull=True) | Q(groups=self.event.group)
+        return Q()
+        
+    def get_pipeline_filter(self):
+        if self.is_event_alert:
+            return Q(pipelines__isnull=True) | Q(pipelines=self.event.pipeline)
+        return Q()
+        
+    def get_search_filter(self):
+        if self.is_event_alert:
+            return Q(searches__isnull=True) | Q(searches=self.event.search)
+        return Q()
+
+    def get_filter_query(self):
+        filter_list =  [getattr(self, method)() for method in dir(self) 
+            if method.startswith('get_') and method.endswith('_filter')]
+        if filter_list:
+            return reduce(Q.__and__, filter_list)
+        return Q()
+
+    def get_trigger_query(self):
+        return Q()
+
+    def filter_for_labels(self, notifications):
+        notification_pks = []
+        for n in notifications:
+            if n.labels.exists() and not n.label_query:
+                if not set(n.labels.all()).issubset(self.es.labels.all()):
+                    continue
+            elif n.label_query:
+                qs_out = filter_for_labels(self.es_qs, n.label_query)
+                if not qs_out.exists():
+                    continue
+            notification_pks.append(n.pk)
+        return Notification.objects.filter(pk__in=notification_pks)
+
+    def get_contacts_for_notifications(self, notifications):
+        # Get contacts; make sure contacts are verified and user is in the
+        # LVC group (safeguards)
+        contacts = Contact.objects.filter(notification__in=notifications,
+            verified=True, user__groups__name=settings.LVC_GROUP) \
+            .select_related('user')
+
+        # Separate into email and phone contacts
+        email_recipients = contacts.filter(email__isnull=False)
+        phone_recipients = contacts.filter(phone__isnull=False)
+
+        return email_recipients, phone_recipients
+
+    def get_recipients(self):
+        # Get trigger query and apply to get baseline set of notifications
+        trigger_query = self.get_trigger_query()
+        base_notifications = self.queryset.filter(trigger_query)
+
+        # Get and apply filter query to trim it down
+        filter_query = self.get_filter_query()
+        notifications = base_notifications.filter(filter_query)
+
+        # Do label filtering
+        final_notifications = self.filter_for_labels(notifications)
+
+        # Get email and phone recipients and return
+        return self.get_contacts_for_notifications(final_notifications)
+
+
+class UpdateRecipientGetter(CreationRecipientGetter):
+
+    def process_kwargs(self, **kwargs):
+        self.old_far = kwargs.get('old_far', None)
+        self.old_nscand = kwargs.get('old_nscand', None)
+
+    def get_trigger_query(self):
+        # Initial query should match no objects
+        query = Q(pk__in=[])
+
+        # Then we add other options that could possibly match
+        if self.event.far is not None:
+            if self.old_far is None:
+                query |= Q(far_threshold__gt=self.event.far)
+            else:
+                query |= (Q(far_threshold__lte=self.old_far) &
+                    Q(far_threshold__gt=self.event.far))
+        if not self.old_nscand and self.event.is_ns_candidate():
+            query |= Q(ns_candidate=True)
+        return query
+
+
+class LabelAddedRecipientGetter(CreationRecipientGetter):
+
+    def process_kwargs(self, **kwargs):
+        self.label = kwargs.get('label', None)
+        if self.label is None:
+            raise ValueError('label must be provided')
+
+    def get_recipients(self):
+
+        # Any notification that might be triggered by a label_added action
+        # should have that label in the 'labels' field.  This includes
+        # notifications with a label_query. Part of the Notification creation
+        # process picks out all labels in the label_query (even negated ones)
+        # and adds the to the 'labels' field.
+        base_notifications = self.label.notification_set.all()
+
+        # Get and apply filter query to trim it down
+        filter_query = self.get_filter_query()
+        notifications = base_notifications.filter(filter_query)
+
+        # Do label filtering
+        final_notifications = self.filter_for_labels(notifications)
+
+        # Get email and phone recipients and return
+        return self.get_contacts_for_notifications(final_notifications)
+
+
+class LabelRemovedRecipientGetter(LabelAddedRecipientGetter):
+
+    def filter_for_labels(self, notifications):
+        # Only notifications with a label query should be triggered
+        # by a label_removed alert, since notifications with a
+        # label set can only have non-negated labels.
+        notification_pks = []
+        for n in notifications:
+            if n.label_query:
+                qs_out = filter_for_labels(self.es_qs, n.label_query)
+                if not qs_out.exists():
+                    continue
+                notification_pks.append(n.pk)
+        return Notification.objects.filter(pk__in=notification_pks)
+
+
+# Dict which maps alert types to recipient getter classes
+ALERT_TYPE_RECIPIENT_GETTERS = {
+    'new': CreationRecipientGetter,
+    'update': UpdateRecipientGetter,
+    'label_added': LabelAddedRecipientGetter,
+    'label_removed': LabelRemovedRecipientGetter,
+}
diff --git a/gracedb/alerts/utils.py b/gracedb/alerts/utils.py
index 0db9afb91eea639368ea4246b105423b99a0af1e..343ec19f2b63bbcf0734665b228ec72bbac4a839 100644
--- a/gracedb/alerts/utils.py
+++ b/gracedb/alerts/utils.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import
 from pyparsing import oneOf, Literal, Optional, ZeroOrMore, StringEnd, Suppress
+import re
 
 from django.db.models import Q
 
@@ -29,3 +30,17 @@ def parse_label_query(s):
         ZeroOrMore(im + label) + StringEnd()
     
     return labelQ.parseString(s).asList()
+
+
+def convert_superevent_id_to_speech(sid):
+    """Used for Twilio voice calls"""
+    grps = list(re.match('^(\w+)(\d{2})(\d{2})(\d{2})(\w+)$', sid).groups())
+
+    # Add spaces between all letters in prefix and suffix
+    grps[0] = " ".join(grps[0])
+    grps[-1] = " ".join(grps[-1])
+
+    # Join with spaces, replace leading zeroes with Os
+    # and make uppercase
+    twilio_str = " ".join(grps).replace(' 0', ' O').upper()
+    return twilio_str