diff --git a/reviewboard/scmtools/admin.py b/reviewboard/scmtools/admin.py
index d649689d93ec8cb53b670cfe35086e2e955d42a9..3d0732481155366bbf6a093a9458c9007bebf304 100644
--- a/reviewboard/scmtools/admin.py
+++ b/reviewboard/scmtools/admin.py
@@ -73,7 +73,7 @@ class RepositoryAdmin(admin.ModelAdmin):
         (_('Internal State'), {
             'description': _('<p>This is advanced state that should not be '
                              'modified unless something is wrong.</p>'),
-            'fields': ('local_site', 'extra_data'),
+            'fields': ('local_site', 'hooks_uuid', 'extra_data'),
             'classes': ['collapse'],
         }),
     )
diff --git a/reviewboard/scmtools/evolutions/__init__.py b/reviewboard/scmtools/evolutions/__init__.py
index 46ca1c7502cb539b898ea889100e820f8c6455d4..d963c99468a16276f7e44318c5a43fdb2dd67dbc 100644
--- a/reviewboard/scmtools/evolutions/__init__.py
+++ b/reviewboard/scmtools/evolutions/__init__.py
@@ -13,4 +13,5 @@ SEQUENCE = [
     'repository_extra_data_null',
     'unique_together_baseline',
     'repository_archive',
+    'repository_hooks_uuid',
 ]
diff --git a/reviewboard/scmtools/evolutions/repository_hooks_uuid.py b/reviewboard/scmtools/evolutions/repository_hooks_uuid.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0cdad95b791c4b059a2d0f420f85e114655b516
--- /dev/null
+++ b/reviewboard/scmtools/evolutions/repository_hooks_uuid.py
@@ -0,0 +1,14 @@
+from __future__ import unicode_literals
+
+from django_evolution.mutations import AddField, ChangeMeta
+from django.db import models
+
+
+MUTATIONS = [
+    AddField('Repository', 'hooks_uuid', models.CharField, max_length=32,
+             null=True),
+    ChangeMeta('Repository', 'unique_together',
+               (('name', 'local_site'),
+                ('archived_timestamp', 'path', 'local_site'),
+                ('hooks_uuid', 'local_site'))),
+]
diff --git a/reviewboard/scmtools/models.py b/reviewboard/scmtools/models.py
index 3eb278170e03016d7c8b32f1035d1f04e28662fb..20d3a4955ab686ee16bcb3a8936ebd54be5be5e7 100644
--- a/reviewboard/scmtools/models.py
+++ b/reviewboard/scmtools/models.py
@@ -1,19 +1,22 @@
 from __future__ import unicode_literals
 
+import logging
+import uuid
 from time import time
 
 from django.contrib.auth.models import User
 from django.core.cache import cache
 from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.db import models
-from django.utils import timezone
+from django.db import IntegrityError
+from django.utils import six, timezone
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.http import urlquote
+from django.utils.six.moves import range
 from django.utils.translation import ugettext_lazy as _
 from djblets.cache.backend import cache_memoize, make_cache_key
 from djblets.db.fields import JSONField
 from djblets.log import log_timed
-from django.utils import six
 
 from reviewboard.hostingsvcs.models import HostingServiceAccount
 from reviewboard.scmtools.managers import RepositoryManager, ToolManager
@@ -157,6 +160,14 @@ class Repository(models.Model):
         help_text=_('A list of invite-only review groups whose members have '
                     'explicit access to the repository.'))
 
+    hooks_uuid = models.CharField(
+        _('Hooks UUID'),
+        max_length=32,
+        null=True,
+        blank=True,
+        help_text=_('Unique identifier used for validating incoming '
+                    'webhooks.'))
+
     objects = RepositoryManager()
 
     BRANCHES_CACHE_PERIOD = 60 * 5  # 5 minutes
@@ -210,6 +221,33 @@ class Repository(models.Model):
             'password': password,
         }
 
+    def get_or_create_hooks_uuid(self, max_attempts=20):
+        """Returns a hooks UUID, creating one if necessary.
+
+        If a hooks UUID isn't already saved, then this will try to generate one
+        that doesn't conflict with any other registered hooks UUID. It will try
+        up to `max_attempts` times, and if it fails, None will be returned.
+        """
+        if not self.hooks_uuid:
+            for attempt in range(max_attempts):
+                self.hooks_uuid = uuid.uuid4().hex
+
+                try:
+                    self.save(update_fields=['hooks_uuid'])
+                    break
+                except IntegrityError:
+                    # We hit a collision with the token value. Try again.
+                    self.hooks_uuid = None
+
+            if not self.hooks_uuid:
+                s = ('Unable to generate a unique hooks UUID for '
+                     'repository %s after %d attempts'
+                     % (self.pk, max_attempts))
+                logging.error(s)
+                raise Exception(s)
+
+        return self.hooks_uuid
+
     def archive(self, save=True):
         """Archives a repository.
 
@@ -520,4 +558,5 @@ class Repository(models.Model):
         # archiving repositories. We should really remove this constraint from
         # the tables and enforce it in code whenever visible=True
         unique_together = (('name', 'local_site'),
-                           ('archived_timestamp', 'path', 'local_site'))
+                           ('archived_timestamp', 'path', 'local_site'),
+                           ('hooks_uuid', 'local_site'))
