diff --git a/lib/plausible/auth/api_key.ex b/lib/plausible/auth/api_key.ex
index 61cbf66f292dd35caa4752799c9a530a216e44ce..594d70cb5b31d601018ce08770be828e3bf6ddf9 100644
--- a/lib/plausible/auth/api_key.ex
+++ b/lib/plausible/auth/api_key.ex
@@ -3,10 +3,11 @@ defmodule Plausible.Auth.ApiKey do
   import Ecto.Changeset
 
   @required [:user_id, :key, :name]
-  @optional [:scopes]
+  @optional [:scopes, :hourly_request_limit]
   schema "api_keys" do
     field :name, :string
     field :scopes, {:array, :string}, default: ["stats:read:*"]
+    field :hourly_request_limit, :integer
 
     field :key, :string, virtual: true
     field :key_hash, :string
diff --git a/lib/plausible_web/controllers/api/helpers.ex b/lib/plausible_web/controllers/api/helpers.ex
index 068c24cf245a0fb183f872d5ba48fab27fca6b97..6229ed8707428bad7a1e6964ba01dcd680f4bbb3 100644
--- a/lib/plausible_web/controllers/api/helpers.ex
+++ b/lib/plausible_web/controllers/api/helpers.ex
@@ -21,4 +21,11 @@ defmodule PlausibleWeb.Api.Helpers do
     |> Phoenix.Controller.json(%{error: msg})
     |> halt()
   end
+
+  def too_many_requests(conn, msg) do
+    conn
+    |> put_status(429)
+    |> Phoenix.Controller.json(%{error: msg})
+    |> halt()
+  end
 end
diff --git a/lib/plausible_web/plugs/authorize_stats_api.ex b/lib/plausible_web/plugs/authorize_stats_api.ex
index b41daadff68944b2b21fa5a2a73350bfb9d4f173..6b76ec2e2dd428f1db7096403f173d7097622231 100644
--- a/lib/plausible_web/plugs/authorize_stats_api.ex
+++ b/lib/plausible_web/plugs/authorize_stats_api.ex
@@ -9,7 +9,9 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
   end
 
   def call(conn, _opts) do
-    with {:ok, api_key} <- get_bearer_token(conn),
+    with {:ok, token} <- get_bearer_token(conn),
+         {:ok, api_key} <- find_api_key(token),
+         :ok <- check_api_key_rate_limit(api_key),
          {:ok, site} <- verify_access(api_key, conn.params["site_id"]) do
       assign(conn, :site, site)
     else
@@ -25,6 +27,12 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
           "Missing site ID. Please provide the required site_id parameter with your request."
         )
 
+      {:error, :rate_limit, limit} ->
+        H.too_many_requests(
+          conn,
+          "Too many API requests. Your API key is limited to #{limit} requests per hour."
+        )
+
       {:error, :invalid_api_key} ->
         H.unauthorized(
           conn,
@@ -36,13 +44,11 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
   defp verify_access(_api_key, nil), do: {:error, :missing_site_id}
 
   defp verify_access(api_key, site_id) do
-    hashed_key = ApiKey.do_hash(api_key)
-    found_key = Repo.get_by(ApiKey, key_hash: hashed_key)
     site = Repo.get_by(Plausible.Site, domain: site_id)
-    is_owner = site && found_key && Plausible.Sites.is_owner?(found_key.user_id, site)
+    is_owner = site && Plausible.Sites.is_owner?(api_key.user_id, site)
 
     cond do
-      found_key && site && is_owner -> {:ok, site}
+      site && is_owner -> {:ok, site}
       true -> {:error, :invalid_api_key}
     end
   end
@@ -57,4 +63,18 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
       _ -> {:error, :missing_api_key}
     end
   end
+
+  defp find_api_key(token) do
+    hashed_key = ApiKey.do_hash(token)
+    found_key = Repo.get_by(ApiKey, key_hash: hashed_key)
+    if found_key, do: {:ok, found_key}, else: {:error, :invalid_api_key}
+  end
+
+  @one_hour 60 * 60 * 1000
+  defp check_api_key_rate_limit(api_key) do
+    case Hammer.check_rate("api_request:#{api_key.id}", @one_hour, api_key.hourly_request_limit) do
+      {:allow, _} -> :ok
+      {:deny, _} -> {:error, :rate_limit, api_key.hourly_request_limit}
+    end
+  end
 end
diff --git a/priv/repo/migrations/20210525085655_add_rate_limit_to_api_keys.exs b/priv/repo/migrations/20210525085655_add_rate_limit_to_api_keys.exs
new file mode 100644
index 0000000000000000000000000000000000000000..a0234faf99863ca3ea80d0a209104c28769f9981
--- /dev/null
+++ b/priv/repo/migrations/20210525085655_add_rate_limit_to_api_keys.exs
@@ -0,0 +1,9 @@
+defmodule Plausible.Repo.Migrations.AddRateLimitToApiKeys do
+  use Ecto.Migration
+
+  def change do
+    alter table(:api_keys) do
+      add :hourly_request_limit, :integer, null: false, default: 1000
+    end
+  end
+end
diff --git a/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs b/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs
index 8eea5be27f08daa27e73260aa6022109f3bf5670..ce65e91b70a3562ef734110f6f9f5262b6c50827 100644
--- a/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs
+++ b/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs
@@ -51,4 +51,29 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do
                "Missing site ID. Please provide the required site_id parameter with your request."
            }
   end
+
+  test "limits the rate of API requests", %{user: user} do
+    api_key = insert(:api_key, user_id: user.id, hourly_request_limit: 3)
+
+    build_conn()
+    |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
+    |> get("/api/v1/stats/aggregate")
+
+    build_conn()
+    |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
+    |> get("/api/v1/stats/aggregate")
+
+    build_conn()
+    |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
+    |> get("/api/v1/stats/aggregate")
+
+    conn =
+      build_conn()
+      |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
+      |> get("/api/v1/stats/aggregate")
+
+    assert json_response(conn, 429) == %{
+             "error" => "Too many API requests. Your API key is limited to 3 requests per hour."
+           }
+  end
 end