diff --git a/reviewboard/notifications/email/message.py b/reviewboard/notifications/email/message.py
index 6234eb92526c4e10705b73fab28b1e650fa92a92..bea00069dde1f07ade405dcdd2d1173257b612c7 100644
--- a/reviewboard/notifications/email/message.py
+++ b/reviewboard/notifications/email/message.py
@@ -590,6 +590,7 @@ def prepare_webapi_token_mail(webapi_token, op):
     context = {
         'api_token': webapi_token,
         'api_token_url': AuthenticationPage.get_absolute_url(),
+        'client': webapi_token.extra_data.get('client'),
         'partial_token': '%s...' % webapi_token.token[:15],
         'user': user,
         'site_root_url': get_server_url(),
diff --git a/reviewboard/notifications/tests/test_email_sending.py b/reviewboard/notifications/tests/test_email_sending.py
index 63d8924dc19a7684b1799c7b82939e0be641b4da..5b2bbb1e498c6b907b56ce184326bc1cb447bb00 100644
--- a/reviewboard/notifications/tests/test_email_sending.py
+++ b/reviewboard/notifications/tests/test_email_sending.py
@@ -2034,16 +2034,93 @@ class WebAPITokenEmailTests(kgb.SpyAgency,
         correct_email_body = (
             '\n------------------------------------------\n'
             'This is an automatically generated e-mail.\n'
-            '------------------------------------------\n\n'
-            'Hi Sample User,\n\n'
+            '------------------------------------------\n'
+            '\n'
+            'Hi Sample User,\n'
+            '\n'
             'A new API token has been added to your Review Board account on\n'
-            'http://example.com/.\n\n'
+            'http://example.com/.\n'
+            '\n'
             'The API token ID starts with %s and was added\n'
-            'August 2nd, 2022, 5:45 a.m. UTC.\n\n'
+            'August 2nd, 2022, 5:45 a.m. UTC.\n'
+            '\n'
             'If you did not create this token, you should revoke it at\n'
             'http://example.com/account/preferences/#authentication'
-            ', change your password, and talk to your\n'
-            'administrator.\n\n'
+            ', change your password, and talk to your administrator.\n'
+            '\n'
+        ) % partial_token
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(email.subject, 'New Review Board API token created')
+        self.assertEqual(email.from_email, self.sender)
+        self.assertEqual(email.extra_headers['From'],
+                         settings.DEFAULT_FROM_EMAIL)
+        self.assertEqual(email.to[0], build_email_address_for_user(self.user))
+        self.assertHTMLEqual(html_body, correct_html)
+        self.assertEqual(email.body, correct_email_body)
+
+    def test_create_client_api_token(self):
+        """Testing sending e-mail when a new client API Token is created"""
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(
+            timezone.make_aware(datetime.datetime(2022, 8, 2, 5, 45))
+        ))
+
+        webapi_token = self.create_webapi_token(
+            self.user,
+            extra_data={
+                'client': 'TestClient',
+            })
+
+        email = mail.outbox[0]
+        html_body = email.alternatives[0][0]
+        partial_token = '%s...' % webapi_token.token[:15]
+
+        correct_html = (
+            '<html>'
+            '<body style="font-family: Verdana, Arial, Helvetica, '
+            'Sans-Serif;">'
+            '<table bgcolor="#f9f3c9" width="100%%" cellpadding="8"'
+            'style="border: 1px #c9c399 solid;">'
+            '<tr><td>This is an automatically generated e-mail.</td></tr>'
+            '</table>'
+            '<p>Hi Sample User,</p>'
+            '<p>'
+            'A new API token has been added to your Review Board account on'
+            '<a href="http://example.com/">http://example.com/</a>.'
+            '</p>'
+            '<p>'
+            'The API token ID starts with <code>%s</code>'
+            'and was added August 2nd, 2022, 5:45 a.m. UTC.'
+            '</p>'
+            '<p>'
+            'This token was automatically created for TestClient. '
+            'If you did not just authenticate to Review Board for '
+            'TestClient, you should revoke this token on your'
+            '<a href="http://example.com/account/preferences/#authentication">'
+            'API Tokens</a> page, change your password, and talk to your '
+            'administrator.'
+            '</p></body></html>'
+        ) % partial_token
+        correct_email_body = (
+            '\n------------------------------------------\n'
+            'This is an automatically generated e-mail.\n'
+            '------------------------------------------\n'
+            '\n'
+            'Hi Sample User,\n'
+            '\n'
+            'A new API token has been added to your Review Board account on\n'
+            'http://example.com/.\n'
+            '\n'
+            'The API token ID starts with %s and was added\n'
+            'August 2nd, 2022, 5:45 a.m. UTC.\n'
+            '\n'
+            'This token was automatically created for TestClient. '
+            'If you did not just\n'
+            'authenticate to Review Board for TestClient, you should revoke '
+            'this token at\n'
+            'http://example.com/account/preferences/#authentication, '
+            'change your password, and talk to your administrator.\n'
+            '\n'
         ) % partial_token
 
         self.assertEqual(len(mail.outbox), 1)
@@ -2105,16 +2182,21 @@ class WebAPITokenEmailTests(kgb.SpyAgency,
         correct_email_body = (
             '\n------------------------------------------\n'
             'This is an automatically generated e-mail.\n'
-            '------------------------------------------\n\n'
-            'Hi Sample User,\n\n'
+            '------------------------------------------\n'
+            '\n'
+            'Hi Sample User,\n'
+            '\n'
             'One of your API tokens has been updated on your Review Board '
-            'account on\nhttp://example.com/.\n\n'
+            'account on\nhttp://example.com/.\n'
+            '\n'
             'The API token ID starts with %s and was updated\n'
-            'August 2nd, 2022, 5:45 a.m. UTC.\n\n'
+            'August 2nd, 2022, 5:45 a.m. UTC.\n'
+            '\n'
             'If you did not update this token, you should revoke it at\n'
             'http://example.com/account/preferences/#authentication'
             ', change your password, and talk to your\n'
-            'administrator.\n\n'
+            'administrator.\n'
+            '\n'
         ) % partial_token
 
         self.assertEqual(len(mail.outbox), 1)
@@ -2162,14 +2244,19 @@ class WebAPITokenEmailTests(kgb.SpyAgency,
         correct_email_body = (
             '\n------------------------------------------\n'
             'This is an automatically generated e-mail.\n'
-            '------------------------------------------\n\n'
-            'Hi Sample User,\n\n'
+            '------------------------------------------\n'
+            '\n'
+            'Hi Sample User,\n'
+            '\n'
             'One of your API tokens has been deleted from your Review Board '
-            'account on\nhttp://example.com/.\n\n'
+            'account on\nhttp://example.com/.\n'
+            '\n'
             'The API token ID was %s. Any clients\nthat were using this '
-            'token will no longer be able to authenticate.\n\n'
+            'token will no longer be able to authenticate.\n'
+            '\n'
             'If you did not delete this token, you should change your '
-            'password and talk\nto your administrator.\n\n'
+            'password and talk\nto your administrator.\n'
+            '\n'
         ) % webapi_token.token
 
         self.assertEqual(len(mail.outbox), 1)
@@ -2238,15 +2325,20 @@ class WebAPITokenEmailTests(kgb.SpyAgency,
         correct_email_body = (
             '\n------------------------------------------\n'
             'This is an automatically generated e-mail.\n'
-            '------------------------------------------\n\n'
-            'Hi Sample User,\n\n'
+            '------------------------------------------\n'
+            '\n'
+            'Hi Sample User,\n'
+            '\n'
             'One of your API tokens has expired on your Review Board '
             'account\non http://example.com/. Any clients that were using '
-            'this token will no\nlonger be able to authenticate.\n\n'
+            'this token will no\nlonger be able to authenticate.\n'
+            '\n'
             'The API token ID starts with %s and expired on\n'
-            'August 1st, 2022, 5:45 a.m. UTC.\n\n'
+            'August 1st, 2022, 5:45 a.m. UTC.\n'
+            '\n'
             'New tokens can be created at '
-            'http://example.com/account/preferences/#authentication.\n\n'
+            'http://example.com/account/preferences/#authentication.\n'
+            '\n'
         ) % partial_token
 
         self.assertEqual(len(mail.outbox), 1)
diff --git a/reviewboard/templates/notifications/api_token_created.html b/reviewboard/templates/notifications/api_token_created.html
index 3ee69ae0ce533962894d45337a212d1f6c03c49d..3ed905fd628be951802c75448a6efe6d1cd18327 100644
--- a/reviewboard/templates/notifications/api_token_created.html
+++ b/reviewboard/templates/notifications/api_token_created.html
@@ -18,10 +18,19 @@
    The API token ID starts with <code>{{partial_token}}</code> and was added
    {{api_token.time_added|date:"F jS, Y, P T"}}.
   </p>
+{% if client %}
+  <p>
+   This token was automatically created for {{client}}. If you did not just
+   authenticate to Review Board for {{client}}, you should revoke this
+   token on your <a href="{{api_token_url}}">API Tokens</a> page,
+   change your password, and talk to your administrator.
+  </p>
+{% else %}
   <p>
    If you did not create this token, you should revoke it on your
    <a href="{{api_token_url}}">API Tokens</a>
    page, change your password, and talk to your administrator.
   </p>
+{% endif %}
  </body>
 </html>
diff --git a/reviewboard/templates/notifications/api_token_created.txt b/reviewboard/templates/notifications/api_token_created.txt
index 305b1ca2a2d52b6abd23342b8addd6d3acbf1a24..bd06c5966cc45ca3f872560249662226d8e02734 100644
--- a/reviewboard/templates/notifications/api_token_created.txt
+++ b/reviewboard/templates/notifications/api_token_created.txt
@@ -10,8 +10,11 @@ A new API token has been added to your {{PRODUCT_NAME}} account on
 
 The API token ID starts with {{partial_token}} and was added
 {{api_token.time_added|date:"F jS, Y, P T"}}.
-
+{% if client %}
+This token was automatically created for {{client}}. If you did not just
+authenticate to Review Board for {{client}}, you should revoke this token at
+{{api_token_url}}, change your password, and talk to your administrator.
+{% else %}
 If you did not create this token, you should revoke it at
-{{api_token_url}}, change your password, and talk to your
-administrator.
-{% endautoescape %}
+{{api_token_url}}, change your password, and talk to your administrator.
+{% endif %}{% endautoescape %}
diff --git a/reviewboard/webapi/managers.py b/reviewboard/webapi/managers.py
new file mode 100644
index 0000000000000000000000000000000000000000..1468ea0e9e09741c58bf67d90e15bd7c3f0e915c
--- /dev/null
+++ b/reviewboard/webapi/managers.py
@@ -0,0 +1,95 @@
+"""Managers for Web API related models.
+
+Version Added:
+    5.0.5
+"""
+
+from __future__ import annotations
+
+import datetime
+from typing import Optional, TYPE_CHECKING, Tuple, Union
+
+from django.db.models import F
+from djblets.secrets.token_generators import token_generator_registry
+from djblets.webapi.managers import (
+    WebAPITokenManager as DjbletsWebAPITokenManager)
+
+if TYPE_CHECKING:
+    from django.contrib.auth.base_user import AbstractBaseUser
+    from django.contrib.auth.models import AnonymousUser
+    from reviewboard.webapi.models import WebAPIToken
+
+
+class WebAPITokenManager(DjbletsWebAPITokenManager):
+    """Manager for the reviewboard.webapi.managers.WebAPIToken model.
+
+    Version Added:
+        5.0.5
+    """
+
+    def get_or_create_client_token(
+        self,
+        user: Union[AbstractBaseUser, AnonymousUser],
+        client_name: str,
+        expires: Optional[datetime.datetime] = None,
+    ) -> Tuple[WebAPIToken, bool]:
+        """Get a user's API token for authenticating a client to Review Board.
+
+        If the token does not already exist for the client, this will create
+        one. If multiple client tokens already exist, this will return the
+        one that has no expiration date or the furthest expiration date.
+        If the eligible client token is expired or invalid, a new one will
+        be created.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who owns the token.
+
+            client_name (str):
+                The name of the client that the token is for.
+
+            expires (datetime.datetime, optional):
+                The expiration date of the token. This defaults to no
+                expiration.
+
+        Returns:
+            tuple:
+            A 2-tuple of:
+
+            Tuple:
+                0 (reviewboard.webapi.models.WebAPIToken):
+                    The token for authenticating the client.
+
+                1 (bool):
+                    Whether a new token was created.
+
+        Raises:
+            djblets.webapi.errors.WebAPITokenGenerationError:
+                The token was not able to be generated after the max
+                number of collisions were hit.
+        """
+        tokens = self.filter(user=user, valid=True).order_by(
+            F('expires').desc(nulls_first=True))
+
+        for token in tokens:
+            if (token.extra_data.get('client_name') == client_name and
+                not token.is_expired()):
+                return token, False
+            elif token.is_expired():
+                # The rest of the tokens are also expired.
+                break
+
+        generator = token_generator_registry.get_default().token_generator_id
+        token = self.generate_token(
+            expires=expires,
+            extra_data={
+                'client_name': client_name,
+            },
+            note=f'API token automatically created for {client_name}.',
+            token_generator_id=generator,
+            token_info={
+                'token_type': 'rbp',
+            },
+            user=user)
+
+        return token, True
diff --git a/reviewboard/webapi/models.py b/reviewboard/webapi/models.py
index a3ee185d6f26157cf40e9f7837ff9ddfcac05a01..68c36f7666d8fca5425bb4f9beb7556167825ff0 100644
--- a/reviewboard/webapi/models.py
+++ b/reviewboard/webapi/models.py
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 from djblets.webapi.models import BaseWebAPIToken
 
 from reviewboard.site.models import LocalSite
+from reviewboard.webapi.managers import WebAPITokenManager
 
 
 class WebAPIToken(BaseWebAPIToken):
@@ -21,6 +22,8 @@ class WebAPIToken(BaseWebAPIToken):
                                    related_name='webapi_tokens',
                                    blank=True, null=True)
 
+    objects = WebAPITokenManager()
+
     @classmethod
     def get_root_resource(cls):
         from reviewboard.webapi.resources import resources
diff --git a/reviewboard/webapi/tests/test_managers.py b/reviewboard/webapi/tests/test_managers.py
new file mode 100644
index 0000000000000000000000000000000000000000..0b35bc100909e0098674ca2acd50e051ca51b2de
--- /dev/null
+++ b/reviewboard/webapi/tests/test_managers.py
@@ -0,0 +1,280 @@
+"""Unit tests for WebAPITokenManager.
+
+Version Added:
+    5.0.5
+"""
+
+import datetime
+from typing import Optional
+
+import kgb
+from django.contrib.auth.models import User
+from django.utils import timezone
+from djblets.secrets.token_generators.vendor_checksum import \
+    VendorChecksumTokenGenerator
+
+from reviewboard.testing.testcase import TestCase
+from reviewboard.webapi.models import WebAPIToken
+
+
+class WebAPITokenManagerTests(kgb.SpyAgency, TestCase):
+    """Unit tests for WebAPITokenManager.
+
+    Version Added:
+        5.0.5
+    """
+
+    #: The token info for created tokens.
+    token_info = {'token_type': 'rbp'}
+
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
+
+        self.token_generator_id = \
+            VendorChecksumTokenGenerator.token_generator_id
+        self.user = User.objects.create(username='test-user')
+
+        set_time = timezone.make_aware(datetime.datetime(2023, 1, 2, 3))
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(set_time))
+
+    def test_get_or_create_client_token_default(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token creates
+        a token with default arguments
+        """
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertTrue(created)
+        self._assert_client_token_equals(
+            token=client_token,
+            client_name='Test',
+            expires=None,
+            note='API token automatically created for Test.',
+            user=self.user)
+
+    def test_get_or_create_client_token_custom_expires(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token creates
+        a token with a custom expiration date
+        """
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            expires=timezone.now() + datetime.timedelta(days=10),
+            user=self.user)
+
+        self.assertTrue(created)
+        self._assert_client_token_equals(
+            token=client_token,
+            client_name='Test',
+            expires=timezone.now() + datetime.timedelta(days=10),
+            note='API token automatically created for Test.',
+            user=self.user)
+
+    def test_get_or_create_client_token_with_extra_data(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token returns
+        an existing client token when multiple fields exist in the extra data
+        """
+        token = WebAPIToken.objects.generate_token(
+            extra_data={
+                'field_1': 'Some field',
+                'client_name': 'Test',
+                'field_2': 25,
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        # An existing non-client token. This shouldn't be returned.
+        WebAPIToken.objects.generate_token(
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertFalse(created)
+        self.assertEqual(token, client_token)
+
+    def test_get_or_create_client_token_with_existing(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token returns
+        an existing client token
+        """
+        token = WebAPIToken.objects.generate_token(
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        # An existing non-client token. This shouldn't be returned.
+        WebAPIToken.objects.generate_token(
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertFalse(created)
+        self.assertEqual(token, client_token)
+
+    def test_get_or_create_client_token_with_expired(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token creates a new
+        client token when the existing client token is expired
+        """
+        expired_token = WebAPIToken.objects.generate_token(
+            expires=timezone.now() - datetime.timedelta(days=2),
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        # An existing non-client token. This shouldn't be returned.
+        WebAPIToken.objects.generate_token(
+            expires=timezone.now() + datetime.timedelta(days=50),
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertTrue(created)
+        self.assertNotEqual(expired_token, client_token)
+
+    def test_get_or_create_client_token_with_invalid(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token creates a new
+        client token when the existing client token is invalid
+        """
+        expired_token = WebAPIToken.objects.generate_token(
+            expires=None,
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user,
+            valid=False)
+
+        # An existing non-client token. This shouldn't be returned.
+        WebAPIToken.objects.generate_token(
+            expires=timezone.now() + datetime.timedelta(days=50),
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertTrue(created)
+        self.assertNotEqual(expired_token, client_token)
+
+    def test_get_or_create_client_token_sorted_by_expires(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token returns the
+        client token with the furthest expiration date when multiple ones exist
+        """
+        WebAPIToken.objects.generate_token(
+            expires=timezone.now() - datetime.timedelta(days=2),
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        # This non-client token with the furthest expiration shouldn't
+        # be returned.
+        WebAPIToken.objects.generate_token(
+            expires=timezone.now() + datetime.timedelta(days=50),
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        token_furthest = WebAPIToken.objects.generate_token(
+            expires=timezone.now() + datetime.timedelta(days=30),
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertFalse(created)
+        self.assertEqual(token_furthest, client_token)
+
+    def test_get_or_create_client_token_sorted_by_expires_none(self) -> None:
+        """Testing WebAPITokenManager.get_or_create_client_token returns the
+        client token with no expiration date when multiple ones exist
+        """
+        token_furthest = WebAPIToken.objects.generate_token(
+            expires=None,
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        WebAPIToken.objects.generate_token(
+            expires=timezone.now() + datetime.timedelta(days=365),
+            extra_data={
+                'client_name': 'Test',
+            },
+            token_generator_id=self.token_generator_id,
+            token_info=self.token_info,
+            user=self.user)
+
+        client_token, created = WebAPIToken.objects.get_or_create_client_token(
+            client_name='Test',
+            user=self.user)
+
+        self.assertFalse(created)
+        self.assertEqual(token_furthest, client_token)
+
+    def _assert_client_token_equals(
+        self,
+        token: WebAPIToken,
+        client_name: str,
+        expires: Optional[datetime.datetime],
+        note: str,
+        user: User,
+    ) -> None:
+        """Assert that the client token matches the given values.
+
+        Args:
+            token (reviewboard.webapi.models.WebAPIToken):
+                The token.
+
+            client_name (str):
+                The client for the token.
+
+            expires (datetime.datetime):
+                The expiration date for the token.
+
+            note (str):
+                The note for the token.
+
+            user (django.contrib.auth.models.User):
+                The user for the token.
+
+        Raises:
+            AssertionError:
+                The token is not invalid.
+        """
+        self.assertEqual(token.user, user)
+        self.assertEqual(token.extra_data['client_name'], client_name)
+        self.assertEqual(token.note, note)
+        self.assertEqual(token.expires, expires)
