diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 70bfc27e17dce4bdd2df10ee86b2490ec7789d98..45164592a74d3f5e59204397932331edde5141a3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,7 +9,9 @@ repos:
           - --fix
           - --preview
           - --ignore=PLR0917,PLR0914
+          - --extend-exclude=test/*
       # Run the formatter.
       - id: ruff-format
         args:
           - --preview
+          - --exclude=test/*
diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst
index 959d89761d27f3652cbae2d840a5e2ef2f7a3703..0bbcce276f79bee067aef114ea5b0f0c3101ab39 100644
--- a/docs/source/glossary.rst
+++ b/docs/source/glossary.rst
@@ -58,6 +58,9 @@ Glossary of terms
     Simple Network Management Protocol: a protocol that's used for gathering data, widely used for network management
     and monitoring.
 
+  TWAMP
+    Two-way Active Measurement Protocol.
+
   UUID
     Universally Unique Identifier
 
diff --git a/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt b/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt
index 081f7913cba3d95518c76bfb75278bdf9300dbf8..9172c42e39a10632a75a6e73c17dd9b0e9b5eb41 100644
--- a/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt
+++ b/docs/vale/styles/config/vocabularies/geant-jargon/accept.txt
@@ -14,4 +14,6 @@ Dark_fiber
 PHASE 1
 [Mm]odify
 AAI
-[M|m]iddleware
\ No newline at end of file
+[M|m]iddleware
+TWAMP
+Pydantic
diff --git a/gso/auth/oidc_policy_helper.py b/gso/auth/oidc_policy_helper.py
index 04e2fc8e5ec419fb5f80a6a5646bb3429512cad9..241641dcfe3a93ffd41bb2962255c6557b95d235 100644
--- a/gso/auth/oidc_policy_helper.py
+++ b/gso/auth/oidc_policy_helper.py
@@ -82,13 +82,8 @@ class OIDCUserModel(dict):
         if the attribute is one of the registered claims or raises an AttributeError
         if the key is not found.
 
-        Args:
-        ----
-        key: The attribute name to retrieve.
-
-        Returns:
-        -------
-        The value of the attribute if it exists, otherwise raises AttributeError.
+        :param str key: The attribute name to retrieve.
+        :return: The value of the attribute if it exists, otherwise raises AttributeError.
         """
         try:
             return object.__getattribute__(self, key)
@@ -167,6 +162,7 @@ class OPAResult(BaseModel):
     ----------
     - result (bool): Indicates whether the access request is allowed or denied.
     - decision_id (str): A unique identifier for the decision made by OPA.
+
     """
 
     result: bool = False
@@ -208,15 +204,10 @@ class OIDCUser(HTTPBearer):
 
         This is used as a security module in Fastapi projects
 
-        Args:
-        ----
-            request: Starlette request method.
-            token: Optional value to directly pass a token.
-
-        Returns:
-        -------
-            OIDCUserModel object.
 
+        :param Request request: Starlette request method.
+        :param str token: Optional value to directly pass a token.
+        :return: OIDCUserModel object.
         """
         if not oauth2lib_settings.OAUTH2_ACTIVE:
             return None
@@ -380,16 +371,12 @@ def opa_decision(
     to authorize requests based on OPA policies. It utilizes OIDC for user information and makes a
     call to the OPA service to determine authorization.
 
-    Args:
-    ----
-    opa_url: URL of the Open Policy Agent service.
-    oidc_security: An instance of OIDCUser for user authentication.
-    auto_error: If True, automatically raises an HTTPException on authorization failure.
-    opa_kwargs: Additional keyword arguments to be passed to the OPA input.
+    :param str opa_url: URL of the Open Policy Agent service.
+    :param OIDCUser oidc_security: An instance of OIDCUser for user authentication.
+    :param bool auto_error: If True, automatically raises an HTTPException on authorization failure.
+    :param Mapping[str, str] | None opa_kwargs: Additional keyword arguments to be passed to the OPA input.
 
-    Returns:
-    -------
-    An asynchronous decision function that can be used as a dependency in FastAPI endpoints.
+    :return: An asynchronous decision function that can be used as a dependency in FastAPI endpoints.
     """
 
     async def _opa_decision(
@@ -407,6 +394,7 @@ def opa_decision(
             request: Request object that will be used to retrieve request metadata.
             user_info: The OIDCUserModel object that will be checked
             async_request: The :term:`httpx` client.
+
         """
         if not (oauth2lib_settings.OAUTH2_ACTIVE and oauth2lib_settings.OAUTH2_AUTHORIZATION_ACTIVE):
             return None
diff --git a/gso/auth/security.py b/gso/auth/security.py
index 16065e467e02176d92df20563c4c3e0f56845667..e1d5376479e9e95f50847af06fa7120272135a4a 100644
--- a/gso/auth/security.py
+++ b/gso/auth/security.py
@@ -34,8 +34,6 @@ def get_oidc_user() -> OIDCUser:
     This function returns the instance of OIDCUser initialized in the module.
     It is typically used for accessing the OIDCUser across different parts of the application.
 
-    Returns
-    -------
-        OIDCUser: The instance of OIDCUser configured with OAuth2 settings.
+    :return OIDCUser: The instance of OIDCUser configured with OAuth2 settings.
     """
     return oidc_user
diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json
index 931367ea48746610b29646651e99a68f89d1288e..007b899bf956cca6cdf148d79001637d181b5939 100644
--- a/gso/oss-params-example.json
+++ b/gso/oss-params-example.json
@@ -77,5 +77,13 @@
   "THIRD_PARTY_API_KEYS": {
     "AnsibleDynamicInventoryGenerator": "REALLY_random_AND_secure_T0keN",
     "Application_2": "another_REALY_random_AND_3cure_T0keN"
+  },
+  "EMAIL": {
+    "from_address": "noreply@nren.local",
+    "smtp_host": "smtp.nren.local",
+    "smtp_port": 487,
+    "starttls_enabled": true,
+    "smtp_username": "username",
+    "smtp_password": "password"
   }
 }
diff --git a/gso/products/product_blocks/router.py b/gso/products/product_blocks/router.py
index d8e9996ba0821d48765ae36f50df1bb2aa541752..5d1a307d85825759b734a0c06b665d1e7eb06866 100644
--- a/gso/products/product_blocks/router.py
+++ b/gso/products/product_blocks/router.py
@@ -68,12 +68,12 @@ class RouterBlockProvisioning(RouterBlockInactive, lifecycle=[SubscriptionLifecy
 
     router_fqdn: str
     router_ts_port: PortNumber
-    router_access_via_ts: bool | None = None
-    router_lo_ipv4_address: ipaddress.IPv4Address | None = None
-    router_lo_ipv6_address: ipaddress.IPv6Address | None = None
-    router_lo_iso_address: str | None = None
-    router_role: RouterRole | None = None
-    router_site: SiteBlockProvisioning | None
+    router_access_via_ts: bool
+    router_lo_ipv4_address: ipaddress.IPv4Address
+    router_lo_ipv6_address: ipaddress.IPv6Address
+    router_lo_iso_address: str
+    router_role: RouterRole
+    router_site: SiteBlockProvisioning
     vendor: RouterVendor
 
 
diff --git a/gso/products/product_blocks/site.py b/gso/products/product_blocks/site.py
index efe21c4b270a8974c1615b89f025012ff8793582..1852b24615076b2d76dde41db71a9e5d5fcc535f 100644
--- a/gso/products/product_blocks/site.py
+++ b/gso/products/product_blocks/site.py
@@ -82,16 +82,16 @@ class SiteBlockInactive(
 class SiteBlockProvisioning(SiteBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
     """A site that's currently being provisioned, see :class:`SiteBlock`."""
 
-    site_name: str | None = None
-    site_city: str | None = None
-    site_country: str | None = None
-    site_country_code: str | None = None
-    site_latitude: LatitudeCoordinate | None = None
-    site_longitude: LongitudeCoordinate | None = None
-    site_internal_id: int | None = None
-    site_bgp_community_id: int | None = None
-    site_tier: SiteTier | None = None
-    site_ts_address: str | None = None
+    site_name: str
+    site_city: str
+    site_country: str
+    site_country_code: str
+    site_latitude: LatitudeCoordinate
+    site_longitude: LongitudeCoordinate
+    site_internal_id: int
+    site_bgp_community_id: int
+    site_tier: SiteTier
+    site_ts_address: str
 
 
 class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
@@ -120,4 +120,4 @@ class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE])
     #:  The address of the terminal server that this router is connected to. The terminal server provides out of band
     #:  access. This is required in case a link goes down, or when a router is initially added to the network and it
     #:  does not have any IP trunks connected to it.
-    site_ts_address: str | None = None
+    site_ts_address: str
diff --git a/gso/services/mailer.py b/gso/services/mailer.py
new file mode 100644
index 0000000000000000000000000000000000000000..3cd85b7369fd176d55d02674f9b55e2c02a47a72
--- /dev/null
+++ b/gso/services/mailer.py
@@ -0,0 +1,32 @@
+"""The mailer service sends notification emails, as part of workflows that require interaction with external parties."""
+
+import smtplib
+from email.message import EmailMessage
+from ssl import create_default_context
+
+from gso.settings import load_oss_params
+
+
+def send_mail(recipient: str, subject: str, body: str) -> None:
+    """Send an email message to the given address.
+
+    Only supports STARTTLS, not SSL.
+
+    :param str recipient: The destination address.
+    :param str subject: The email subject.
+    :param str body: The contents of the email message.
+    """
+    email_params = load_oss_params().EMAIL
+    msg = EmailMessage()
+    msg["From"] = email_params.from_address
+    msg["To"] = recipient
+    msg["Subject"] = subject
+    msg.set_content(body)
+
+    with smtplib.SMTP(email_params.smtp_host, email_params.smtp_port) as s:
+        if email_params.starttls_enabled:
+            tls_context = create_default_context()
+            s.starttls(context=tls_context)
+        if email_params.smtp_username and email_params.smtp_password:
+            s.login(email_params.smtp_username, email_params.smtp_password)
+        s.send_message(msg)
diff --git a/gso/settings.py b/gso/settings.py
index ca0da591c00e829585db55c5a68731b3c44aac90..0189986d9cdf15c347393f2a55c1f23944922b36 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -152,6 +152,18 @@ class NetBoxParams(BaseSettings):
     api: str
 
 
+class EmailParams(BaseSettings):
+    """Parameters for the email service."""
+
+    # TODO: Use more strict types after we've migrated to Pydantic 2.x
+    from_address: str
+    smtp_host: str
+    smtp_port: int
+    starttls_enabled: bool
+    smtp_username: str | None
+    smtp_password: str | None
+
+
 class OSSParams(BaseSettings):
     """The set of parameters required for running :term:`GSO`."""
 
@@ -162,6 +174,7 @@ class OSSParams(BaseSettings):
     PROVISIONING_PROXY: ProvisioningProxyParams
     CELERY: CeleryParams
     THIRD_PARTY_API_KEYS: dict[str, str]
+    EMAIL: EmailParams
 
 
 def load_oss_params() -> OSSParams:
diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py
index 463d5d5d84a369fad779366008deff1fd4d34601..c43a80737f56e37e3288e78d89a0ab38dced94e4 100644
--- a/gso/utils/helpers.py
+++ b/gso/utils/helpers.py
@@ -77,8 +77,7 @@ def available_interfaces_choices_including_current_members(
         ],
     )
     options = {
-        interface["name"]: f"{interface['name']} - {interface['module']['display']} - {interface['description']}"
-        for interface in available_interfaces
+        interface["name"]: f"{interface['name']}  {interface['description']}" for interface in available_interfaces
     }
     return Choice("ae member", zip(options.keys(), options.items(), strict=True))  # type: ignore[arg-type]
 
diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py
index 80a9431a172b75d461c69d850bff82f5d767f236..db6f91ce99d706d1c97d59bbda15785532f36d50 100644
--- a/gso/workflows/iptrunk/create_iptrunk.py
+++ b/gso/workflows/iptrunk/create_iptrunk.py
@@ -17,12 +17,12 @@ from pynetbox.models.dcim import Interfaces
 
 from gso.products.product_blocks.iptrunk import (
     IptrunkInterfaceBlockInactive,
-    IptrunkSideBlockProvisioning,
+    IptrunkSideBlockInactive,
     IptrunkType,
     PhyPortCapacity,
 )
 from gso.products.product_blocks.router import RouterVendor
-from gso.products.product_types.iptrunk import IptrunkInactive, IptrunkProvisioning
+from gso.products.product_types.iptrunk import IptrunkInactive
 from gso.products.product_types.router import Router
 from gso.services import infoblox, subscriptions
 from gso.services.crm import get_customer_by_name
@@ -185,7 +185,7 @@ def create_subscription(product: UUIDstr, customer: str) -> State:
 
 
 @step("Get information from IPAM")
-def get_info_from_ipam(subscription: IptrunkProvisioning) -> State:
+def get_info_from_ipam(subscription: IptrunkInactive) -> State:
     """Allocate IP resources in :term:`IPAM`."""
     subscription.iptrunk.iptrunk_ipv4_network = infoblox.allocate_v4_network(
         "TRUNK",
@@ -243,14 +243,13 @@ def initialize_subscription(
         )
     side_names = sorted([side_a.router_site.site_name, side_b.router_site.site_name])
     subscription.description = f"IP trunk {side_names[0]} {side_names[1]}, geant_s_sid:{geant_s_sid}"
-    subscription = IptrunkProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING)
 
     return {"subscription": subscription}
 
 
 @step("Provision IP trunk interface [DRY RUN]")
 def provision_ip_trunk_iface_dry(
-    subscription: IptrunkProvisioning,
+    subscription: IptrunkInactive,
     callback_route: str,
     process_id: UUIDstr,
     tt_number: str,
@@ -278,7 +277,7 @@ def provision_ip_trunk_iface_dry(
 
 @step("Provision IP trunk interface [FOR REAL]")
 def provision_ip_trunk_iface_real(
-    subscription: IptrunkProvisioning,
+    subscription: IptrunkInactive,
     callback_route: str,
     process_id: UUIDstr,
     tt_number: str,
@@ -306,7 +305,7 @@ def provision_ip_trunk_iface_real(
 
 @step("Check IP connectivity of the trunk")
 def check_ip_trunk_connectivity(
-    subscription: IptrunkProvisioning,
+    subscription: IptrunkInactive,
     callback_route: str,
 ) -> State:
     """Check successful connectivity across the new trunk."""
@@ -324,7 +323,7 @@ def check_ip_trunk_connectivity(
 
 @step("Provision IP trunk ISIS interface [DRY RUN]")
 def provision_ip_trunk_isis_iface_dry(
-    subscription: IptrunkProvisioning,
+    subscription: IptrunkInactive,
     callback_route: str,
     process_id: UUIDstr,
     tt_number: str,
@@ -352,7 +351,7 @@ def provision_ip_trunk_isis_iface_dry(
 
 @step("Provision IP trunk ISIS interface [FOR REAL]")
 def provision_ip_trunk_isis_iface_real(
-    subscription: IptrunkProvisioning,
+    subscription: IptrunkInactive,
     callback_route: str,
     process_id: UUIDstr,
     tt_number: str,
@@ -380,7 +379,7 @@ def provision_ip_trunk_isis_iface_real(
 
 @step("Check ISIS adjacency")
 def check_ip_trunk_isis(
-    subscription: IptrunkProvisioning,
+    subscription: IptrunkInactive,
     callback_route: str,
 ) -> State:
     """Run an Ansible playbook to confirm :term:`ISIS` adjacency."""
@@ -397,7 +396,7 @@ def check_ip_trunk_isis(
 
 
 @step("NextBox integration")
-def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State:
+def reserve_interfaces_in_netbox(subscription: IptrunkInactive) -> State:
     """Create the :term:`LAG` interfaces in NetBox and attach the lag interfaces to the physical interfaces."""
     nbclient = NetboxClient()
     for trunk_side in subscription.iptrunk.iptrunk_sides:
@@ -429,22 +428,25 @@ def reserve_interfaces_in_netbox(subscription: IptrunkProvisioning) -> State:
     }
 
 
-def _allocate_interfaces_in_netbox(iptrunk_side: IptrunkSideBlockProvisioning) -> None:
+def _allocate_interfaces_in_netbox(iptrunk_side: IptrunkSideBlockInactive) -> None:
     for interface in iptrunk_side.iptrunk_side_ae_members:
-        NetboxClient().allocate_interface(
-            device_name=iptrunk_side.iptrunk_side_node.router_fqdn,
-            iface_name=interface.interface_name,
-        )
+        fqdn = iptrunk_side.iptrunk_side_node.router_fqdn
+        iface_name = interface.interface_name
+        if not fqdn or not iface_name:
+            msg = f"FQDN and/or interface name missing in subscription {interface.owner_subscription_id}"
+            raise ValueError(msg)
+
+        NetboxClient().allocate_interface(device_name=fqdn, iface_name=iface_name)
 
 
 @step("Allocate interfaces in Netbox for side A")
-def netbox_allocate_side_a_interfaces(subscription: IptrunkProvisioning) -> None:
+def netbox_allocate_side_a_interfaces(subscription: IptrunkInactive) -> None:
     """Allocate the :term:`LAG` interfaces for the Nokia router on side A."""
     _allocate_interfaces_in_netbox(subscription.iptrunk.iptrunk_sides[0])
 
 
 @step("Allocate interfaces in Netbox for side B")
-def netbox_allocate_side_b_interfaces(subscription: IptrunkProvisioning) -> None:
+def netbox_allocate_side_b_interfaces(subscription: IptrunkInactive) -> None:
     """Allocate the :term:`LAG` interfaces for the Nokia router on side B."""
     _allocate_interfaces_in_netbox(subscription.iptrunk.iptrunk_sides[1])
 
@@ -485,7 +487,7 @@ def create_iptrunk() -> StepList:
         >> pp_interaction(check_ip_trunk_isis)
         >> side_a_is_nokia(netbox_allocate_side_a_interfaces)
         >> side_b_is_nokia(netbox_allocate_side_b_interfaces)
-        >> set_status(SubscriptionLifecycle.ACTIVE)
+        >> set_status(SubscriptionLifecycle.PROVISIONING)
         >> resync
         >> done
     )
diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py
index 6bec84a6a1eca393da95d68e28258e3c70d1946c..29a60dbb87d73f68f695710d3b746a8e8fa4c198 100644
--- a/gso/workflows/router/create_router.py
+++ b/gso/workflows/router/create_router.py
@@ -2,11 +2,12 @@
 
 from typing import Any
 
+from orchestrator.config.assignee import Assignee
 from orchestrator.forms import FormPage
-from orchestrator.forms.validators import Choice
+from orchestrator.forms.validators import Choice, Label
 from orchestrator.targets import Target
 from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr
-from orchestrator.workflow import StepList, conditional, done, init, step, workflow
+from orchestrator.workflow import StepList, conditional, done, init, inputstep, step, workflow
 from orchestrator.workflows.steps import resync, set_status, store_process_subscription
 from orchestrator.workflows.utils import wrap_create_initial_input_form
 from pydantic import validator
@@ -18,7 +19,7 @@ from gso.products.product_blocks.router import (
     RouterVendor,
     generate_fqdn,
 )
-from gso.products.product_types.router import RouterInactive, RouterProvisioning
+from gso.products.product_types.router import RouterInactive
 from gso.products.product_types.site import Site
 from gso.services import infoblox, subscriptions
 from gso.services.crm import get_customer_by_name
@@ -106,15 +107,16 @@ def initialize_subscription(
     subscription.router.vendor = vendor
     subscription.description = f"Router {fqdn}"
 
-    subscription = RouterProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING)
-
     return {"subscription": subscription}
 
 
 @step("Allocate loopback interfaces in IPAM")
-def ipam_allocate_loopback(subscription: RouterProvisioning) -> State:
+def ipam_allocate_loopback(subscription: RouterInactive) -> State:
     """Allocate :term:`IPAM` resources for the loopback interface."""
     fqdn = subscription.router.router_fqdn
+    if not fqdn:
+        msg = f"Router fqdn for subscription id {subscription.subscription_id} is missing!"
+        raise ValueError(msg)
     loopback_v4, loopback_v6 = infoblox.allocate_host(f"lo0.{fqdn}", "LO", [fqdn], str(subscription.subscription_id))
 
     subscription.router.router_lo_ipv4_address = loopback_v4
@@ -125,17 +127,21 @@ def ipam_allocate_loopback(subscription: RouterProvisioning) -> State:
 
 
 @step("Create NetBox Device")
-def create_netbox_device(subscription: RouterProvisioning) -> State:
+def create_netbox_device(subscription: RouterInactive) -> State:
     """Create a new NOKIA device in Netbox."""
-    NetboxClient().create_device(
-        subscription.router.router_fqdn,
-        str(subscription.router.router_site.site_tier),  # type: ignore[union-attr]
-    )
+    fqdn = subscription.router.router_fqdn
+    site_tier = subscription.router.router_site.site_tier if subscription.router.router_site else None
+    if not fqdn or not site_tier:
+        msg = f"FQDN and/or Site tier missing in router subscription {subscription.subscription_id}!"
+        raise ValueError(msg)
+
+    NetboxClient().create_device(fqdn, site_tier)
+
     return {"subscription": subscription}
 
 
 @step("Verify IPAM resources for loopback interface")
-def verify_ipam_loopback(subscription: RouterProvisioning) -> State:
+def verify_ipam_loopback(subscription: RouterInactive) -> State:
     """Validate the :term:`IPAM` resources for the loopback interface."""
     host_record = infoblox.find_host_by_fqdn(f"lo0.{subscription.router.router_fqdn}")
     if not host_record or str(subscription.subscription_id) not in host_record.comment:
@@ -144,6 +150,63 @@ def verify_ipam_loopback(subscription: RouterProvisioning) -> State:
     return {"subscription": subscription}
 
 
+@inputstep("Prompt to reboot", assignee=Assignee.SYSTEM)
+def prompt_reboot_router(subscription: RouterInactive) -> FormGenerator:
+    """Wait for confirmation from an operator that the router has been rebooted."""
+
+    class RebootPrompt(FormPage):
+        class Config:
+            title = "Please reboot before continuing"
+
+        if subscription.router.router_site and subscription.router.router_site.site_ts_address:
+            info_label_1: Label = (
+                f"Base config has been deployed. Please log in via the console using https://"  # type: ignore[assignment]
+                f"{subscription.router.router_site.site_ts_address}."
+            )
+        else:
+            info_label_1 = "Base config has been deployed. Please log in via the console."  # type: ignore[assignment]
+
+        info_label_2: Label = "Reboot the router, and once it is up again, press submit to continue the workflow."  # type: ignore[assignment]
+
+    yield RebootPrompt
+
+    return {}
+
+
+@inputstep("Prompt to test the console", assignee=Assignee.SYSTEM)
+def prompt_console_login() -> FormGenerator:
+    """Wait for confirmation from an operator that the router can be logged into."""
+
+    class ConsolePrompt(FormPage):
+        class Config:
+            title = "Verify local authentication"
+
+        info_label_1: Label = (
+            "Verify that you are able to log in to the router via the console using the admin account."  # type: ignore[assignment]
+        )
+        info_label_2: Label = "Once this is done, press submit to continue the workflow."  # type: ignore[assignment]
+
+    yield ConsolePrompt
+
+    return {}
+
+
+@inputstep("Prompt IMS insertion", assignee=Assignee.SYSTEM)
+def prompt_insert_in_ims() -> FormGenerator:
+    """Wait for confirmation from an operator that the router has been inserted in IMS."""
+
+    class IMSPrompt(FormPage):
+        class Config:
+            title = "Update IMS mediation server"
+
+        info_label_1: Label = "Insert the router into IMS."  # type: ignore[assignment]
+        info_label_2: Label = "Once this is done, press submit to continue the workflow."  # type: ignore[assignment]
+
+    yield IMSPrompt
+
+    return {}
+
+
 @workflow(
     "Create router",
     initial_input_form=wrap_create_initial_input_form(initial_input_form_generator),
@@ -169,9 +232,12 @@ def create_router() -> StepList:
         >> pp_interaction(deploy_base_config_dry)
         >> pp_interaction(deploy_base_config_real)
         >> verify_ipam_loopback
+        >> prompt_reboot_router
+        >> prompt_console_login
+        >> prompt_insert_in_ims
         >> router_is_nokia(create_netbox_device)
         >> pp_interaction(run_checks_after_base_config)
-        >> set_status(SubscriptionLifecycle.ACTIVE)
+        >> set_status(SubscriptionLifecycle.PROVISIONING)
         >> resync
         >> done
     )
diff --git a/gso/workflows/router/update_ibgp_mesh.py b/gso/workflows/router/update_ibgp_mesh.py
index d2f6d8d66b4dcd95250c6bcbc87f15815f3d15a6..d8ba52a09b36735bd46404a091f7f408ad4f2f66 100644
--- a/gso/workflows/router/update_ibgp_mesh.py
+++ b/gso/workflows/router/update_ibgp_mesh.py
@@ -2,10 +2,12 @@
 
 from typing import Any
 
+from orchestrator.config.assignee import Assignee
 from orchestrator.forms import FormPage
+from orchestrator.forms.validators import Label
 from orchestrator.targets import Target
 from orchestrator.types import FormGenerator, State, UUIDstr
-from orchestrator.workflow import StepList, done, init, step, workflow
+from orchestrator.workflow import StepList, done, init, inputstep, step, workflow
 from orchestrator.workflows.steps import resync, store_process_subscription, unsync
 from orchestrator.workflows.utils import wrap_modify_initial_input_form
 from pydantic import root_validator
@@ -188,6 +190,36 @@ def add_device_to_librenms(subscription: Router) -> State:
     return {"librenms_device": librenms_result}
 
 
+@inputstep("Prompt RADIUS insertion", assignee=Assignee.SYSTEM)
+def prompt_insert_in_radius() -> FormGenerator:
+    """Wait for confirmation from an operator that the router has been inserted in RADIUS."""
+
+    class RADIUSPrompt(FormPage):
+        class Config:
+            title = "Please update RADIUS before continuing"
+
+        info_label: Label = "Insert the router into RADIUS, and continue the workflow once this has been completed."  # type: ignore[assignment]
+
+    yield RADIUSPrompt
+
+    return {}
+
+
+@inputstep("Prompt RADIUS login", assignee=Assignee.SYSTEM)
+def prompt_radius_login() -> FormGenerator:
+    """Wait for confirmation from an operator that the router can be logged into using RADIUS."""
+
+    class RADIUSPrompt(FormPage):
+        class Config:
+            title = "Please check RADIUS before continuing"
+
+        info_label: Label = "Log in to the router using RADIUS, and continue the workflow when this was successful."  # type: ignore[assignment]
+
+    yield RADIUSPrompt
+
+    return {}
+
+
 @step("Update subscription model")
 def update_subscription_model(subscription: Router) -> State:
     """Update the database model, such that it should not be reached via :term:`OOB` access anymore."""
@@ -221,6 +253,8 @@ def update_ibgp_mesh() -> StepList:
         >> pp_interaction(add_all_pe_to_p_real)
         >> pp_interaction(check_ibgp_session)
         >> add_device_to_librenms
+        >> prompt_insert_in_radius
+        >> prompt_radius_login
         >> update_subscription_model
         >> resync
         >> done
diff --git a/test/conftest.py b/test/conftest.py
index dd6e9d6d3fc3429f0ce0df33213de8dc1f195523..895b4798208a936c137ecd32358cd38cbeb42617 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -208,6 +208,14 @@ def configuration_data() -> dict:
                 "AnsibleDynamicInventoryGenerator": "REALY_random_AND_3cure_T0keN",
                 "Application_2": "another_REALY_random_AND_3cure_T0keN",
             },
+            "EMAIL": {
+                "from_address": "noreply@nren.local",
+                "smtp_host": "smtp.nren.local",
+                "smtp_port": 487,
+                "starttls_enabled": True,
+                "smtp_username": "username",
+                "smtp_password": "password",
+            },
         }
 
 
diff --git a/test/workflows/__init__.py b/test/workflows/__init__.py
index 669fd75cf6b91057d0c327b0dbc111abb63e6f8f..5470858c105666ac8396b42649c65a02eeef8d0c 100644
--- a/test/workflows/__init__.py
+++ b/test/workflows/__init__.py
@@ -140,7 +140,7 @@ class WorkflowInstanceForTests(LazyWorkflowInstance):
         This can be as simple as merely importing a workflow function. However, if it concerns a workflow generating
         function, that function will be called with or without arguments as specified.
 
-        Returns: A workflow function.
+        :return Workflow: A workflow function.
         """
         self.workflow.name = self.name
         return self.workflow
diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py
index 59ecc85e9e5fc3901ebf519246c9ca6fdb0a95ec..c069618476f87b4beafcf4fbcb89f259fc043071 100644
--- a/test/workflows/iptrunk/test_create_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_iptrunk.py
@@ -129,7 +129,7 @@ def test_successful_iptrunk_creation_with_standard_lso_result(
             subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name,
         ]
     )
-    assert subscription.status == "active"
+    assert subscription.status == "provisioning"
     assert subscription.description == (
         f"IP trunk {sorted_sides[0]} {sorted_sides[1]}, geant_s_sid:{input_form_wizard_data[0]['geant_s_sid']}"
     )
diff --git a/test/workflows/router/test_create_router.py b/test/workflows/router/test_create_router.py
index 957c3bda41026db88a7d7fc70ce2efa556770774..ca012757153478de3ca4e50fa37b44daa9696595 100644
--- a/test/workflows/router/test_create_router.py
+++ b/test/workflows/router/test_create_router.py
@@ -7,11 +7,14 @@ from gso.products import ProductType, Site
 from gso.products.product_blocks.router import RouterRole, RouterVendor
 from gso.products.product_types.router import Router
 from gso.services.subscriptions import get_product_id_by_name
+from test import USER_CONFIRM_EMPTY_FORM
 from test.workflows import (
     assert_complete,
     assert_pp_interaction_failure,
     assert_pp_interaction_success,
+    assert_suspended,
     extract_state,
+    resume_workflow,
     run_workflow,
 )
 
@@ -80,15 +83,22 @@ def test_create_nokia_router_success(
         name=mock_fqdn,
     )
 
-    for _ in range(3):
+    for _ in range(2):
         result, step_log = assert_pp_interaction_success(result, process_stat, step_log)
 
+    # Handle three consecutive user input steps
+    for _ in range(3):
+        assert_suspended(result)
+        result, step_log = resume_workflow(process_stat, step_log, input_data=USER_CONFIRM_EMPTY_FORM)
+
+    result, step_log = assert_pp_interaction_success(result, process_stat, step_log)
+
     assert_complete(result)
 
     state = extract_state(result)
     subscription = Router.from_subscription(subscription_id)
 
-    assert subscription.status == "active"
+    assert subscription.status == "provisioning"
     assert subscription.description == f"Router {mock_fqdn}"
 
     assert mock_provision_router.call_count == 3
@@ -167,7 +177,7 @@ def test_create_nokia_router_lso_failure(
 
     assert_pp_interaction_failure(result, process_stat, step_log)
 
-    assert subscription.status == "provisioning"
+    assert subscription.status == "initial"
     assert subscription.description == f"Router {mock_fqdn}"
 
     assert mock_provision_router.call_count == 2
diff --git a/test/workflows/router/test_update_ibgp_mesh.py b/test/workflows/router/test_update_ibgp_mesh.py
index 14543066130d06d5d6f5d148b2d126a44d267adc..8c7da5d5d0abb2a4275f52e2c89cc2c52bc7f51e 100644
--- a/test/workflows/router/test_update_ibgp_mesh.py
+++ b/test/workflows/router/test_update_ibgp_mesh.py
@@ -6,7 +6,8 @@ from pydantic_forms.exceptions import FormValidationError
 
 from gso.products import Iptrunk
 from gso.products.product_blocks.router import RouterRole
-from test.workflows import assert_pp_interaction_success, extract_state, run_workflow
+from test import USER_CONFIRM_EMPTY_FORM
+from test.workflows import assert_pp_interaction_success, assert_suspended, extract_state, resume_workflow, run_workflow
 
 
 @pytest.fixture()
@@ -33,6 +34,11 @@ def test_update_ibgp_mesh_success(
     for _ in range(5):
         result, step_log = assert_pp_interaction_success(result, process_stat, step_log)
 
+    # Handle two consecutive user input steps
+    for _ in range(2):
+        assert_suspended(result)
+        result, step_log = resume_workflow(process_stat, step_log, input_data=USER_CONFIRM_EMPTY_FORM)
+
     state = extract_state(result)
 
     assert mock_execute_playbook.call_count == 5