diff --git a/rbintegrations/extension.py b/rbintegrations/extension.py
index b1fd6a8e53cefdbb41454badf77b56664a3fac82..cd63438d51b5118f5470a476ff190642a066a436 100644
--- a/rbintegrations/extension.py
+++ b/rbintegrations/extension.py
@@ -1,10 +1,12 @@
 from __future__ import unicode_literals
 
+from django.conf.urls import include, url
 from django.utils.translation import ugettext_lazy as _
 from reviewboard.extensions.base import Extension
-from reviewboard.extensions.hooks import IntegrationHook
+from reviewboard.extensions.hooks import IntegrationHook, URLHook
 
 from rbintegrations.slack.integration import SlackIntegration
+from rbintegrations.travisci.integration import TravisCIIntegration
 
 
 class RBIntegrationsExtension(Extension):
@@ -18,9 +20,27 @@ class RBIntegrationsExtension(Extension):
 
     integrations = [
         SlackIntegration,
+        TravisCIIntegration,
     ]
 
+    css_bundles = {
+        'travis-ci-integration-config': {
+            'source_filenames': ['css/travisci/integration-config.less'],
+        },
+    }
+
+    js_bundles = {
+        'travis-ci-integration-config': {
+            'source_filenames': ['js/travisci/integrationConfig.es6.js'],
+        },
+    }
+
     def initialize(self):
         """Initialize the extension."""
         for integration_cls in self.integrations:
             IntegrationHook(self, integration_cls)
+
+        URLHook(self, [
+            url(r'^rbintegrations/travis-ci/',
+                include('rbintegrations.travisci.urls'))
+        ])
diff --git a/rbintegrations/static/css/travisci/integration-config.less b/rbintegrations/static/css/travisci/integration-config.less
new file mode 100644
index 0000000000000000000000000000000000000000..4813990b3cd56228056f2a03e597efb30e143ec6
--- /dev/null
+++ b/rbintegrations/static/css/travisci/integration-config.less
@@ -0,0 +1,4 @@
+// This gets shown by JavaScript if it's required.
+#row-travis_custom_endpoint {
+  display: none;
+}
diff --git a/rbintegrations/static/images/travisci/icon.png b/rbintegrations/static/images/travisci/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..2737dbf8961cee45f27375f02ed74704b599e56d
Binary files /dev/null and b/rbintegrations/static/images/travisci/icon.png differ
diff --git a/rbintegrations/static/images/travisci/icon@2x.png b/rbintegrations/static/images/travisci/icon@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..e20a03f583457964464ff585693b5c3297ddeb6c
Binary files /dev/null and b/rbintegrations/static/images/travisci/icon@2x.png differ
diff --git a/rbintegrations/static/js/travisci/integrationConfig.es6.js b/rbintegrations/static/js/travisci/integrationConfig.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..138107e80994104a4ddda9d1023712d3695a03ed
--- /dev/null
+++ b/rbintegrations/static/js/travisci/integrationConfig.es6.js
@@ -0,0 +1,11 @@
+$(function() {
+    const $endpoint = $('#id_travis_endpoint');
+    const $server = $('#row-travis_custom_endpoint');
+
+    function changeServerVisibility() {
+        $server.setVisible($endpoint.val() === 'E');
+    }
+
+    $endpoint.change(changeServerVisibility);
+    changeServerVisibility();
+});
diff --git a/rbintegrations/travisci/__init__.py b/rbintegrations/travisci/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rbintegrations/travisci/api.py b/rbintegrations/travisci/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..2d808318faec907330bdc57e195b5f4f0a373d76
--- /dev/null
+++ b/rbintegrations/travisci/api.py
@@ -0,0 +1,247 @@
+"""Utilities for interacting with the Travis CI API."""
+
+from __future__ import unicode_literals
+
+import logging
+import json
+
+from django.utils.http import urlquote_plus
+from django.utils.six.moves.urllib.request import (Request as BaseURLRequest,
+                                                   urlopen)
+from django.utils.translation import ugettext_lazy as _
+
+
+logger = logging.getLogger(__name__)
+
+
+class URLRequest(BaseURLRequest):
+    """A request that can use any HTTP method.
+
+    By default, the :py:class:`urllib2.Request` class only supports HTTP GET
+    and HTTP POST methods. This subclass allows for any HTTP method to be
+    specified for the request.
+    """
+
+    def __init__(self, url, body='', headers=None, method='GET'):
+        """Initialize the URLRequest.
+
+        Args:
+            url (unicode):
+                The URL to make the request against.
+
+            body (unicode or bytes):
+                The content of the request.
+
+            headers (dict, optional):
+                Additional headers to attach to the request.
+
+            method (unicode, optional):
+                The request method. If not provided, it defaults to a ``GET``
+                request.
+        """
+        BaseURLRequest.__init__(self, url, body, headers or {})
+        self.method = method
+
+    def get_method(self):
+        """Return the HTTP method of the request.
+
+        Returns:
+            unicode:
+            The HTTP method of the request.
+        """
+        return self.method
+
+
+class TravisAPI(object):
+    """Object for interacting with the Travis CI API."""
+
+    OPEN_SOURCE_ENDPOINT = 'O'
+    PRIVATE_PROJECT_ENDPOINT = 'P'
+    ENTERPRISE_ENDPOINT = 'E'
+
+    ENDPOINT_CHOICES = (
+        (OPEN_SOURCE_ENDPOINT, _('Open Source (travis-ci.org)')),
+        (PRIVATE_PROJECT_ENDPOINT, _('Private Projects (travis-ci.com)')),
+        (ENTERPRISE_ENDPOINT, _('Enterprise (custom domain)')),
+    )
+
+    OPEN_SOURCE_ENDPOINT_URL = 'https://api.travis-ci.org'
+    PRIVATE_PROJECT_ENDPOINT_URL = 'https://api.travis-ci.com'
+
+    def __init__(self, config):
+        """Initialize the object.
+
+        Args:
+            config (dict):
+                The integration config to use.
+
+        Raises:
+            ValueError:
+                The provided endpoint type was not valid.
+        """
+        endpoint = config.get('travis_endpoint')
+
+        if endpoint == self.OPEN_SOURCE_ENDPOINT:
+            self.endpoint = self.OPEN_SOURCE_ENDPOINT_URL
+        elif endpoint == self.PRIVATE_PROJECT_ENDPOINT:
+            self.endpoint = self.PRIVATE_PROJECT_ENDPOINT_URL
+        elif endpoint == self.ENTERPRISE_ENDPOINT:
+            custom_endpoint = config.get('travis_custom_endpoint')
+
+            if custom_endpoint.endswith('/'):
+                custom_endpoint = custom_endpoint[:-1]
+
+            self.endpoint = '%s/api' % custom_endpoint
+        else:
+            raise ValueError('Unexpected value for Travis CI endpoint: %s'
+                             % endpoint)
+
+        self.token = config.get('travis_ci_token')
+
+    def lint(self, travis_yml):
+        """Lint a prospective travis.yml file.
+
+        Args:
+            travis_yml (unicode):
+                The contents of the travis.yml file to validate.
+
+        Returns:
+            dict:
+            The parsed contents of the JSON response.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+
+            Exception:
+                Some other exception occurred when trying to parse the results.
+        """
+        data = self._make_request('%s/lint' % self.endpoint,
+                                  body=travis_yml,
+                                  method='POST',
+                                  content_type='text/yaml')
+        return json.loads(data)
+
+    def get_config(self):
+        """Return the Travis CI server's config.
+
+        Returns:
+            dict:
+            The parsed contents of the JSON response.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        # This request can't go through _make_request because this endpoint
+        # isn't available with API version 3 and doesn't require
+        # authentication.
+        u = urlopen(URLRequest('%s/config' % self.endpoint))
+        return json.loads(u.read())
+
+    def get_user(self):
+        """Return the Travis CI user.
+
+        Returns:
+            dict:
+            The parsed contents of the JSON response.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        data = self._make_request('%s/user' % self.endpoint)
+        return json.loads(data)
+
+    def start_build(self, repo_slug, travis_config, commit_message,
+                    branch=None):
+        """Start a build.
+
+        Args:
+            repo_slug (unicode):
+                The "slug" for the repository based on it's location on GitHub.
+
+            travis_config (unicode):
+                The contents of the travis config to use when doing the build.
+
+            commit_message (unicode):
+                The text to use as the commit message displayed in the Travis
+                UI.
+
+            branch (unicode, optional):
+                The branch name to use.
+
+        Returns:
+            dict:
+            The parsed contents of the JSON response.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        travis_config['merge_mode'] = 'replace'
+
+        request_data = {
+            'request': {
+                'message': commit_message,
+                'config': travis_config,
+            },
+        }
+
+        if branch:
+            request_data['request']['branch'] = branch
+
+        data = self._make_request(
+            '%s/repo/%s/requests' % (self.endpoint,
+                                     urlquote_plus(repo_slug)),
+            body=json.dumps(request_data),
+            method='POST',
+            content_type='application/json')
+
+        return json.loads(data)
+
+    def _make_request(self, url, body=None, method='GET',
+                      content_type='application/json'):
+        """Make an HTTP request.
+
+        Args:
+            url (unicode):
+                The URL to make the request against.
+
+            body (unicode or bytes, optional):
+                The content of the request.
+
+            method (unicode, optional):
+                The request method. If not provided, it defaults to a ``GET``
+                request.
+
+            content_type (unicode, optional):
+                The type of the content being POSTed.
+
+        Returns:
+            bytes:
+            The contents of the HTTP response body.
+
+        Raises:
+            urllib2.URLError:
+                The HTTP request failed.
+        """
+        logger.debug('Making request to Travis CI %s', url)
+
+        headers = {
+            'Accept': 'application/json',
+            'Authorization': 'token %s' % self.token,
+            'Travis-API-Version': '3',
+        }
+
+        if content_type:
+            headers['Content-Type'] = content_type
+
+        request = URLRequest(
+            url,
+            body=body,
+            method=method,
+            headers=headers)
+
+        u = urlopen(request)
+        return u.read()
diff --git a/rbintegrations/travisci/forms.py b/rbintegrations/travisci/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd9e3b590b0dd925070079f28ec19d1f52d878d0
--- /dev/null
+++ b/rbintegrations/travisci/forms.py
@@ -0,0 +1,258 @@
+"""The form for configuring the Travis CI integration."""
+
+from __future__ import unicode_literals
+
+import logging
+
+from django import forms
+from django.utils import six
+from django.utils.six.moves.urllib.error import HTTPError, URLError
+from django.utils.translation import ugettext_lazy as _
+from djblets.forms.fields import ConditionsField
+from djblets.conditions.choices import ConditionChoices
+from reviewboard.integrations.forms import IntegrationConfigForm
+from reviewboard.reviews.conditions import (ReviewRequestConditionChoiceMixin,
+                                            ReviewRequestConditionChoices,
+                                            ReviewRequestRepositoriesChoice,
+                                            ReviewRequestRepositoryTypeChoice)
+from reviewboard.scmtools.conditions import RepositoriesChoice
+from reviewboard.scmtools.models import Repository
+
+from rbintegrations.travisci.api import TravisAPI
+
+
+logger = logging.getLogger(__name__)
+
+
+class GitHubRepositoriesChoice(ReviewRequestConditionChoiceMixin,
+                               RepositoriesChoice):
+    """A condition choice for matching a review request's repositories.
+
+    This works the same as the built-in ``ReviewRequestRepositoriesChoice``,
+    but limits the queryset to only be GitHub repositories.
+    """
+
+    queryset = Repository.objects.filter(
+        hosting_account__service_name='github')
+
+    def get_match_value(self, review_request, **kwargs):
+        """Return the repository used for matching.
+
+        Args:
+            review_request (reviewboard.scmtools.models.review_request.
+                            ReviewRequest):
+                The provided review request.
+
+            **kwargs (dict):
+                Unused keyword arguments.
+
+        Returns:
+            reviewboard.scmtools.models.Repository:
+            The review request's repository.
+        """
+        return review_request.repository
+
+
+class GitHubOnlyConditionChoices(ConditionChoices):
+    """A set of condition choices which limits to GitHub repositories.
+
+    The basic ReviewRequestConditionChoices allows a little too much freedom to
+    select repositories which can never work with Travis CI. This gets rid of
+    the repository type condition, and limits the repository condition to only
+    show GitHub repositories.
+    """
+
+    choice_classes = list(
+        (set(ReviewRequestConditionChoices.choice_classes) - {
+            ReviewRequestRepositoriesChoice,
+            ReviewRequestRepositoryTypeChoice,
+        }) | {
+            GitHubRepositoriesChoice,
+        }
+    )
+
+
+class TravisCIIntegrationConfigForm(IntegrationConfigForm):
+    """Form for configuring Travis CI."""
+
+    conditions = ConditionsField(
+        GitHubOnlyConditionChoices,
+        label=_('Conditions'),
+        help_text=_('You can choose which review requests will be built using '
+                    'this Travis CI configuration.'))
+
+    travis_endpoint = forms.ChoiceField(
+        label=_('Travis CI'),
+        choices=TravisAPI.ENDPOINT_CHOICES,
+        help_text=_('The Travis CI endpoint for your project.'))
+
+    travis_custom_endpoint = forms.URLField(
+        label=_('CI Server'),
+        required=False,
+        help_text=_('The URL to your enterprise Travis CI server. For '
+                    'example, <code>https://travis.example.com/</code>.'))
+
+    travis_ci_token = forms.CharField(
+        label=_('API Token'),
+        help_text=(
+            _('The Travis CI API token. To get an API token, follow the '
+              'instructions at <a href="%(url)s">%(url)s</a>.')
+            % {'url': 'https://developer.travis-ci.com/authentication'}))
+
+    travis_yml = forms.CharField(
+        label=_('Build Config'),
+        help_text=_('The configuration needed to do a test build, without '
+                    'any notification or deploy stages.'),
+        widget=forms.Textarea(attrs={'cols': '80'}))
+
+    branch_name = forms.CharField(
+        label=_('Build Branch'),
+        required=False,
+        help_text=_('An optional branch name to use for review request '
+                    'builds within the Travis CI user interface.'))
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the form.
+
+        Args:
+            *args (tuple):
+                Arguments for the form.
+
+            **kwargs (dict):
+                Keyword arguments for the form.
+        """
+        super(TravisCIIntegrationConfigForm, self).__init__(*args, **kwargs)
+
+        from rbintegrations.extension import RBIntegrationsExtension
+        extension = RBIntegrationsExtension.instance
+
+        travis_integration_config_bundle = \
+            extension.get_bundle_id('travis-ci-integration-config')
+        self.css_bundle_names = [travis_integration_config_bundle]
+        self.js_bundle_names = [travis_integration_config_bundle]
+
+    def clean(self):
+        """Clean the form.
+
+        This validates that the configured settings are correct. It checks that
+        the API token works, and uses Travis' lint API to validate the
+        ``travis_yml`` field.
+
+        Returns:
+            dict:
+            The cleaned data.
+        """
+        cleaned_data = super(TravisCIIntegrationConfigForm, self).clean()
+
+        if self._errors:
+            # If individual form field validation already failed, don't try to
+            # do any of the below.
+            return cleaned_data
+
+        endpoint = cleaned_data['travis_endpoint']
+
+        if (endpoint == TravisAPI.ENTERPRISE_ENDPOINT and
+            not cleaned_data['travis_custom_endpoint']):
+            self._errors['travis_custom_endpoint'] = self.error_class([
+                _('The server URL is required when using an enterprise '
+                  'Travis CI server.')
+            ])
+            return cleaned_data
+
+        try:
+            api = TravisAPI(cleaned_data)
+        except ValueError as e:
+            self._errors['travis_endpoint'] = self.error_class(
+                [six.text_type(e)])
+
+        # First try fetching the "user" endpoint. We don't actually do anything
+        # with the data returned by this, but it's a good check to see if the
+        # API token is correct because it requires authentication.
+        try:
+            api.get_user()
+        except HTTPError as e:
+            if e.code == 403:
+                message = _('Unable to authenticate with this API token.')
+            else:
+                message = six.text_type(e)
+
+            self._errors['travis_ci_token'] = self.error_class([message])
+
+            return cleaned_data
+        except URLError as e:
+            self._errors['travis_endpoint'] = self.error_class([e])
+            return cleaned_data
+
+        # Use the Travis API's "lint" endpoint to verify that the provided
+        # config is valid.
+        try:
+            lint_results = api.lint(cleaned_data['travis_yml'])
+
+            for warning in lint_results['warnings']:
+                if warning['key']:
+                    if isinstance(warning['key'], list):
+                        key = '.'.join(warning['key'])
+                    else:
+                        key = warning['key']
+
+                    message = (_('In %s section: %s')
+                               % (key, warning['message']))
+                else:
+                    message = warning['message']
+
+                self._errors['travis_yml'] = self.error_class([message])
+        except URLError as e:
+            logger.exception('Unexpected error when trying to lint Travis CI '
+                             'config: %s',
+                             e,
+                             request=self.request)
+            self._errors['travis_endpoint'] = self.error_class([
+                _('Unable to communicate with Travis CI server.')
+            ])
+        except Exception as e:
+            logger.exception('Unexpected error when trying to lint Travis CI '
+                             'config: %s',
+                             e,
+                             request=self.request)
+            self._errors['travis_endpoint'] = self.error_class([e])
+
+        return cleaned_data
+
+    class Meta:
+        fieldsets = (
+            (_('What To Build'), {
+                'description': _(
+                    'You can choose which review requests to build using this '
+                    'configuration by setting conditions here. At a minimum, '
+                    'this should include the specific repository to use '
+                    'this configuration for.'
+                ),
+                'fields': ('conditions',),
+            }),
+            (_('Where To Build'), {
+                'description': _(
+                    'Travis CI offers several different servers depending on '
+                    'your project. Select that here and set up your API key '
+                    'for the correct server.'
+                ),
+                'fields': ('travis_endpoint', 'travis_custom_endpoint',
+                           'travis_ci_token'),
+            }),
+            (_('How To Build'), {
+                'description': _(
+                    "Builds performed on the code in review requests will use "
+                    "a completely separate configuration from commits which "
+                    "are pushed to the GitHub repository. The configuration "
+                    "listed here will be used instead of the contents of the "
+                    "repository's <code>.travis.yml</code> file.\n"
+                    "It's also recommended to create a special branch head "
+                    "in the GitHub repository to use for these builds, so "
+                    "they don't appear to be happening on "
+                    "<code>master</code>. This branch can contain anything "
+                    "(or even be empty), since the code will come from the "
+                    "review request."
+                ),
+                'fields': ('travis_yml', 'branch_name'),
+                'classes': ('wide',)
+            }),
+        )
diff --git a/rbintegrations/travisci/integration.py b/rbintegrations/travisci/integration.py
new file mode 100644
index 0000000000000000000000000000000000000000..da1b880b69c2dac2dff970cf4a01006629d942c8
--- /dev/null
+++ b/rbintegrations/travisci/integration.py
@@ -0,0 +1,257 @@
+"""Integration for building changes on Travis CI."""
+
+from __future__ import unicode_literals
+
+import base64
+import logging
+
+import yaml
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.db import IntegrityError, transaction
+from django.utils.functional import cached_property
+from djblets.avatars.services import URLAvatarService
+from djblets.siteconfig.models import SiteConfiguration
+from reviewboard.admin.server import build_server_url
+from reviewboard.avatars import avatar_services
+from reviewboard.extensions.hooks import SignalHook
+from reviewboard.integrations import Integration
+from reviewboard.reviews.models.status_update import StatusUpdate
+from reviewboard.reviews.signals import review_request_published
+
+from rbintegrations.travisci.api import TravisAPI
+from rbintegrations.travisci.forms import TravisCIIntegrationConfigForm
+
+
+logger = logging.getLogger(__name__)
+
+
+class TravisCIIntegration(Integration):
+    """Integrates Review Board with Travis CI."""
+
+    name = 'Travis CI'
+    description = 'Builds diffs posted to Review Board using Travis CI.'
+    config_form_cls = TravisCIIntegrationConfigForm
+
+    def initialize(self):
+        """Initialize the integration hooks."""
+        SignalHook(self, review_request_published,
+                   self._on_review_request_published)
+
+    @cached_property
+    def icon_static_urls(self):
+        """Return the icons used for the integration.
+
+        Returns:
+            dict:
+            The icons for Travis CI.
+        """
+        from rbintegrations.extension import RBIntegrationsExtension
+
+        extension = RBIntegrationsExtension.instance
+
+        return {
+            '1x': extension.get_static_url('images/travisci/icon.png'),
+            '2x': extension.get_static_url('images/travisci/icon@2x.png'),
+        }
+
+    def _on_review_request_published(self, sender, review_request,
+                                     changedesc=None, **kwargs):
+        """Handle when a review request is published.
+
+        Args:
+            sender (object):
+                The sender of the signal.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request which was published.
+
+            changedesc (reviewboard.changedescs.models.ChangeDescription,
+                        optional):
+                The change description associated with the publish.
+
+            **kwargs (dict):
+                Additional keyword arguments.
+        """
+        repository = review_request.repository
+
+        # Only build changes against GitHub repositories.
+        if not (repository and
+                repository.hosting_account and
+                repository.hosting_account.service_name == 'github'):
+            return
+
+        diffset = review_request.get_latest_diffset()
+
+        # Don't build any review requests that don't include diffs.
+        if not diffset:
+            return
+
+        # If this was an update to a review request, make sure that there was a
+        # diff update in it.
+        if changedesc is not None:
+            fields_changed = changedesc.fields_changed
+
+            if ('diff' not in fields_changed or
+                'added' not in fields_changed['diff']):
+                return
+
+        matching_configs = [
+            config
+            for config in self.get_configs(review_request.local_site)
+            if config.match_conditions(form_cls=self.config_form_cls,
+                                       review_request=review_request)
+        ]
+
+        if not matching_configs:
+            return
+
+        user = self._get_or_create_user()
+
+        scmtool = repository.get_scmtool()
+        diff_data = base64.b64encode(scmtool.get_parser('').raw_diff(diffset))
+
+        commit_message = '%s\n\n%s' % (review_request.summary,
+                                       review_request.description)
+        webhook_url = build_server_url(reverse('travis-ci-webhook'))
+
+        for config in matching_configs:
+            status_update = StatusUpdate.objects.create(
+                service_id='travis-ci',
+                user=user,
+                summary='Travis CI',
+                description='starting build...',
+                state=StatusUpdate.PENDING,
+                review_request=review_request,
+                change_description=changedesc)
+
+            travis_config = yaml.load(config.get('travis_yml'))
+
+            # Add set-up and patching to the start of the "script" section of
+            # the config.
+            script = travis_config.get('script', [])
+
+            if not isinstance(script, list):
+                script = [script]
+
+            travis_config['script'] = [
+                'git fetch --unshallow origin',
+                'git checkout %s' % diffset.base_commit_id.encode('utf-8'),
+                'echo %s | base64 --decode | patch -p1' % diff_data,
+            ] + script
+
+            # Set up webhook notifications.
+            notifications = travis_config.get('notifications') or {}
+            webhooks = notifications.get('webhooks') or {}
+
+            urls = webhooks.get('urls', [])
+
+            if not isinstance(urls, list):
+                urls = [urls]
+
+            urls.append(webhook_url)
+
+            webhooks['urls'] = urls
+            webhooks['on_start'] = 'always'
+
+            notifications['webhooks'] = webhooks
+            notifications['email'] = False
+            travis_config['notifications'] = notifications
+
+            # Add some special data in the environment so that when the
+            # webhooks come in, we can find the right status update to update.
+            env = travis_config.setdefault('env', {})
+
+            if not isinstance(env, dict):
+                env = {
+                    'matrix': env,
+                }
+
+            global_ = env.setdefault('global', [])
+
+            if not isinstance(global_, list):
+                global_ = [global_]
+
+            global_ += [
+                'REVIEWBOARD_STATUS_UPDATE_ID=%d' % status_update.pk,
+                'REVIEWBOARD_TRAVIS_INTEGRATION_CONFIG_ID=%d' % config.pk,
+            ]
+
+            env['global'] = global_
+
+            travis_config['env'] = env
+
+            # Time to kick off the build!
+            logger.info('Triggering Travis CI build for review request %s '
+                        '(diffset revision %d)',
+                        review_request.get_absolute_url(), diffset.revision)
+            api = TravisAPI(config)
+            repo_slug = self._get_github_repository_slug(repository)
+            api.start_build(repo_slug, travis_config, commit_message,
+                            config.get('branch_name') or 'master')
+
+    def _get_github_repository_slug(self, repository):
+        """Return the "slug" for a GitHub repository.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository.
+
+        Returns:
+            unicode:
+            The slug for use with the Travis CI API.
+        """
+        extra_data = repository.extra_data
+        plan = extra_data['repository_plan']
+
+        if plan == 'public':
+            return '%s/%s' % (extra_data['hosting_account_username'],
+                              extra_data['github_public_repo_name'])
+        elif plan == 'public-org':
+            return '%s/%s' % (extra_data['github_public_org_name'],
+                              extra_data['github_public_org_repo_name'])
+        elif plan == 'private':
+            return '%s/%s' % (extra_data['hosting_account_username'],
+                              extra_data['github_private_repo_name'])
+        elif plan == 'private-org':
+            return '%s/%s' % (extra_data['github_private_org_name'],
+                              extra_data['github_private_org_repo_name'])
+        else:
+            raise ValueError('Unexpected plan for GitHub repository %d: %s'
+                             % (repository.pk, plan))
+
+    def _get_or_create_user(self):
+        """Return a user to use for Travis CI.
+
+        Returns:
+            django.contrib.auth.models.User:
+            A user instance.
+        """
+        try:
+            return User.objects.get(username='travis-ci')
+        except User.DoesNotExist:
+            logger.info('Creating new user for Travis CI')
+            siteconfig = SiteConfiguration.objects.get_current()
+            noreply_email = siteconfig.get('mail_default_from')
+
+            with transaction.atomic():
+                try:
+                    user = User.objects.create(username='travis-ci',
+                                               email=noreply_email,
+                                               first_name='Travis',
+                                               last_name='CI')
+                except IntegrityError:
+                    # Another process/thread beat us to it.
+                    return User.objects.get(username='travis-ci')
+
+                profile = user.get_profile()
+                profile.should_send_email = False
+                profile.save()
+
+                avatar_service = avatar_services.get_avatar_service(
+                    URLAvatarService.avatar_service_id)
+                # TODO: make somewhat higher-res versions for the main avatar.
+                avatar_service.setup(user, self.icon_static_urls)
+
+                return user
diff --git a/rbintegrations/travisci/urls.py b/rbintegrations/travisci/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..32b70e614f9373259d0e99c49c9eba9d59fa5a90
--- /dev/null
+++ b/rbintegrations/travisci/urls.py
@@ -0,0 +1,11 @@
+from __future__ import unicode_literals
+
+from django.conf.urls import url
+
+from rbintegrations.travisci.views import TravisCIWebHookView
+
+
+urlpatterns = [
+    url(r'^webhook/$', TravisCIWebHookView.as_view(),
+        name='travis-ci-webhook'),
+]
diff --git a/rbintegrations/travisci/views.py b/rbintegrations/travisci/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..d4d2dc97fcea81d4c07840ddb4580b5e13be13e8
--- /dev/null
+++ b/rbintegrations/travisci/views.py
@@ -0,0 +1,161 @@
+"""Views for the Travis CI integration (webhook listener)."""
+
+from __future__ import unicode_literals
+
+import base64
+import json
+import logging
+
+import cryptography
+from django.views.generic import View
+from reviewboard.integrations.models import IntegrationConfig
+from reviewboard.reviews.models.status_update import StatusUpdate
+
+from rbintegrations.travisci.api import TravisAPI
+
+
+logger = logging.getLogger(__name__)
+
+
+class TravisCIWebHookView(View):
+    """The view to handle webhook notifications from a Travis CI build."""
+
+    def post(self, request, *args, **kwargs):
+        """Handle the POST.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request.
+
+            *args (tuple):
+                Additional positional arguments, parsed from the URL.
+
+            **kwargs (dict):
+                Additional keyword arguments, parsed from the URL.
+
+        Returns:
+            django.http.HttpResponse:
+            A response.
+        """
+        payload = json.loads(request.POST['payload'])
+
+        try:
+            global_env = payload['config']['global_env']
+        except KeyError:
+            logger.error('Travis CI webhook: Got event without a global_env '
+                         'in config! Skipping.')
+            return
+
+        integration_config_id = None
+        status_update_id = None
+
+        for line in global_env:
+            key, value = line.split('=', 1)
+
+            if key == 'REVIEWBOARD_STATUS_UPDATE_ID':
+                status_update_id = int(value)
+            elif key == 'REVIEWBOARD_TRAVIS_INTEGRATION_CONFIG_ID':
+                integration_config_id = int(value)
+
+        if status_update_id is None:
+            logger.error('Travis CI webhook: Unable to find '
+                         'REVIEWBOARD_STATUS_UPDATE_ID in payload.')
+            return
+
+        if integration_config_id is None:
+            logger.error('Travis CI webhook: Unable to find '
+                         'REVIEWBOARD_TRAVIS_INTEGRATION_CONFIG_ID in '
+                         'payload.')
+            return
+
+        logger.debug('Got Travis CI webhook event for Integration Config %d '
+                     '(status update %d)',
+                     integration_config_id, status_update_id)
+
+        try:
+            integration_config = IntegrationConfig.objects.get(
+                pk=integration_config_id)
+        except IntegrationConfig.DoesNotExist:
+            logger.error('Unable to find matching integration config ID %d '
+                         'for Travis CI webhook.',
+                         integration_config_id)
+            return
+
+        if self._validate_signature(request, integration_config):
+            try:
+                status_update = StatusUpdate.objects.get(pk=status_update_id)
+            except StatusUpdate.DoesNotExist:
+                logger.error('Unable to find matching status update ID %d '
+                             'for Travis CI webhook.',
+                             status_update_id)
+                return
+
+            status_update.url = payload['build_url']
+            status_update.url_text = 'View Build'
+
+            build_state = payload['state']
+
+            if build_state == 'passed':
+                status_update.state = StatusUpdate.DONE_SUCCESS
+                status_update.description = 'build succeeded.'
+            elif build_state == 'started':
+                status_update.state = StatusUpdate.PENDING
+                status_update.description = 'building...'
+            elif build_state == 'failed':
+                status_update.state = StatusUpdate.DONE_FAILURE
+                status_update.description = 'build failed.'
+
+            status_update.save()
+
+    def _validate_signature(self, request, integration_config):
+        """Validate the webhook signature.
+
+        This will fetch the public key from the appropriate Travis CI server
+        and use it to verify the signature of the payload.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request for the webhook.
+
+            integration_config (reviewboard.integrations.models.
+                                IntegrationConfig):
+                The integration configuration that requested the Travis CI job.
+
+        Returns:
+            bool:
+            True if the signature validated correctly.
+        """
+        api = TravisAPI(integration_config)
+
+        try:
+            data = api.get_config()
+        except Exception as e:
+            logger.error('Failed to fetch config information from Travis CI '
+                         'server at %s: %s',
+                         api.endpoint, e)
+            return False
+
+        try:
+            crypto_primitives = cryptography.hazmat.primitives
+
+            signature = base64.b64decode(request.META['HTTP_SIGNATURE'])
+            backend = cryptography.hazmat.backends.default_backend()
+            public_key = \
+                data['config']['notifications']['webhook']['public_key']
+            key = crypto_primitives.serialization.load_pem_public_key(
+                public_key.encode('ascii'), backend)
+
+            key.verify(
+                signature,
+                request.POST['payload'].encode('ascii'),
+                crypto_primitives.asymmetric.padding.PKCS1v15(),
+                crypto_primitives.hashes.SHA1())
+            return True
+        except cryptography.exceptions.InvalidSignature:
+            logger.error('Unable to verify signature for Travis CI webhook.')
+            return False
+        except Exception as e:
+            logger.exception('Unexpected error while verifying Travis CI '
+                             'signature: %s',
+                             e, request=request)
+            return False
diff --git a/setup.py b/setup.py
index 158fa3456776268c4830454b456a5eccbbc9547a..1d83d1a85529ea1221b71320b09bbb259d3893d6 100755
--- a/setup.py
+++ b/setup.py
@@ -19,6 +19,9 @@ setup(
     maintainer='Beanbag, Inc.',
     maintainer_email='support@beanbaginc.com',
     packages=find_packages(),
+    install_requires=[
+        'PyYAML>=3.12',
+    ],
     entry_points={
         'reviewboard.extensions':
             '%s = rbintegrations.extension:RBIntegrationsExtension' % PACKAGE,
