diff --git a/tests/framework/http_api.py b/tests/framework/http_api.py
index a1ee37174b0..c4a576853ad 100644
--- a/tests/framework/http_api.py
+++ b/tests/framework/http_api.py
@@ -123,3 +123,4 @@ def __init__(self, api_usocket_full_name):
         self.snapshot_load = Resource(self, "/snapshot/load")
         self.cpu_config = Resource(self, "/cpu-config")
         self.entropy = Resource(self, "/entropy")
+        self.hotplug = Resource(self, "/hotplug")
diff --git a/tests/host_tools/1-cpu-hotplug.rules b/tests/host_tools/1-cpu-hotplug.rules
new file mode 100644
index 00000000000..d791cc4802a
--- /dev/null
+++ b/tests/host_tools/1-cpu-hotplug.rules
@@ -0,0 +1 @@
+SUBSYSTEM=="cpu", ACTION=="add", ATTR{online}!="1", ATTR{online}="1"
diff --git a/tests/integration_tests/functional/test_vcpu_hotplug.py b/tests/integration_tests/functional/test_vcpu_hotplug.py
new file mode 100644
index 00000000000..1dddc038a82
--- /dev/null
+++ b/tests/integration_tests/functional/test_vcpu_hotplug.py
@@ -0,0 +1,144 @@
+# Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Integration tests for hotplugging vCPUs"""
+
+import platform
+import re
+import time
+
+import pytest
+
+from framework.defs import MAX_SUPPORTED_VCPUS
+from framework.utils_cpuid import check_guest_cpuid_output
+
+
+@pytest.mark.skipif(
+    platform.machine() != "x86_64", reason="Hotplug only enabled on x86_64."
+)
+@pytest.mark.parametrize("vcpu_count", [1, MAX_SUPPORTED_VCPUS - 1])
+def test_hotplug_vcpus(microvm_factory, guest_kernel_linux_6_1, rootfs_rw, vcpu_count):
+    """
+    Test that hot-plugging API call functions as intended.
+
+    After the API call has been made, the new vCPUs should show up in the
+    guest as offline.
+    """
+    uvm_plain = microvm_factory.build(guest_kernel_linux_6_1, rootfs_rw)
+    uvm_plain.jailer.extra_args.update({"no-seccomp": None})
+    uvm_plain.spawn()
+    uvm_plain.basic_config(vcpu_count=1, mem_size_mib=128)
+    uvm_plain.add_net_iface()
+    uvm_plain.start()
+    uvm_plain.wait_for_up()
+
+    # Default udev rules are flaky, sometimes they automatically online CPUs,
+    # but other times they don't. Remove the respective rule in this test so
+    # they are added as offline every time.
+    uvm_plain.ssh.run(
+        "rm /usr/lib/udev/rules.d/40-vm-hotadd.rules && udevadm control --reload-rules"
+    )
+
+    uvm_plain.api.hotplug.put(Vcpu={"add": vcpu_count})
+
+    time.sleep(5)
+
+    check_guest_cpuid_output(
+        uvm_plain,
+        "lscpu",
+        None,
+        ":",
+        {
+            "CPU(s)": str(1 + vcpu_count),
+            "Off-line CPU(s) list": "1" if vcpu_count == 1 else f"1-{vcpu_count}",
+        },
+    )
+
+
+@pytest.mark.skipif(
+    platform.machine() != "x86_64", reason="Hotplug only enabled on x86_64."
+)
+@pytest.mark.parametrize(
+    "vcpu_count", [-1, 0, MAX_SUPPORTED_VCPUS, MAX_SUPPORTED_VCPUS + 1]
+)
+def test_negative_hotplug_vcpus(
+    microvm_factory, guest_kernel_linux_6_1, rootfs_rw, vcpu_count
+):
+    """
+    Test that the API rejects invalid calls.
+
+    Test cases where the API should reject the hot-plug request, where the
+    number of vCPUs is either too high or too low.
+    """
+    uvm_plain = microvm_factory.build(guest_kernel_linux_6_1, rootfs_rw)
+    uvm_plain.jailer.extra_args.update({"no-seccomp": None})
+    uvm_plain.spawn()
+    uvm_plain.basic_config(vcpu_count=1, mem_size_mib=128)
+    uvm_plain.add_net_iface()
+    uvm_plain.start()
+    uvm_plain.wait_for_up()
+
+    if vcpu_count == 0:
+        with pytest.raises(
+            RuntimeError,
+            match="Hotplug error: Vcpu hotplugging error: The number of vCPUs added must be greater than 0.",
+        ):
+            uvm_plain.api.hotplug.put(Vcpu={"add": vcpu_count})
+    elif vcpu_count < 0:
+        with pytest.raises(
+            RuntimeError,
+            match=re.compile(
+                "An error occurred when deserializing the json body of a request: invalid value: integer `-\\d+`, expected u8+"
+            ),
+        ):
+            uvm_plain.api.hotplug.put(Vcpu={"add": vcpu_count})
+    elif vcpu_count > 31:
+        with pytest.raises(
+            RuntimeError,
+            match="Hotplug error: Vcpu hotplugging error: The number of vCPUs added must be less than 32.",
+        ):
+            uvm_plain.api.hotplug.put(Vcpu={"add": vcpu_count})
+
+
+@pytest.mark.skipif(
+    platform.machine() != "x86_64", reason="Hotplug only enabled on x86_64."
+)
+@pytest.mark.parametrize("vcpu_count", [1, MAX_SUPPORTED_VCPUS - 1])
+def test_online_hotplugged_vcpus(
+    microvm_factory, guest_kernel_linux_6_1, rootfs_rw, vcpu_count
+):
+    """
+    Full end-to-end test of vCPU hot-plugging.
+
+    Makes API call and then tries to online vCPUs inside the guest.
+    """
+    uvm_plain = microvm_factory.build(guest_kernel_linux_6_1, rootfs_rw)
+    uvm_plain.jailer.extra_args.update({"no-seccomp": None})
+    uvm_plain.spawn()
+    uvm_plain.basic_config(vcpu_count=1, mem_size_mib=128)
+    uvm_plain.add_net_iface()
+    uvm_plain.start()
+    uvm_plain.wait_for_up()
+
+    # Default udev rules are flaky, sometimes they automatically online CPUs,
+    # but other times they don't. Remove default rule and add our own.
+    uvm_plain.ssh.run("rm /usr/lib/udev/rules.d/40-vm-hotadd.rules")
+    uvm_plain.ssh.scp_put(
+        "host_tools/1-cpu-hotplug.rules", "/usr/lib/udev/rules.d/1-cpu-hotplug.rules"
+    )
+    uvm_plain.ssh.run("udevadm control --reload-rules")
+
+    uvm_plain.api.hotplug.put(Vcpu={"add": vcpu_count})
+
+    time.sleep(5)
+
+    check_guest_cpuid_output(
+        uvm_plain,
+        "lscpu",
+        None,
+        ":",
+        {
+            "CPU(s)": str(1 + vcpu_count),
+            "On-line CPU(s) list": "0,1" if vcpu_count == 1 else f"0-{vcpu_count}",
+        },
+    )