Bug: [FEATURE] Add security restrictions on the payout links



### Feature Description

Payout links are public to the internet, anybody can make the GET request to fetch the link's HTML response. This renders the link on the client's browser without any sort of validations. Due to the sensitive nature of payout operations, a few restrictions are to be added for security purposes.

- Make sure the link is only accessible within iframes (no document loading)
- Make sure iframe can only be embedded by hosts in `allowed_domains` (set by consumers, prior to creating payout links)

### Possible Implementation

For implementing this, a combination of request and response headers can be consumed.

- Incoming request validations
  - `Sec-Fetch-Dest` header specifies where this link was requested from (for iframes, this should be `iframe` [[ref](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest)])
  - `Origin/Referer` header specifies the host where the request originated from (this host should be a part of `allowed_domains`)
 
- Client side restrictions
  - CSP directives can be used for restricting iframes to be accessible only by allowed domains
    - `frame-ancestors` defines a list of allowed domains which can embed the content inside an iframe
    - `X-Frame-Options` header is used for the same thing, but for older browsers


### Have you spent some time checking if this feature request has been raised before?

- [X] I checked and didn't find a similar issue

### Have you read the Contributing Guidelines?

- [X] I have read the [Contributing Guidelines](https://github.com/juspay/hyperswitch/blob/main/docs/CONTRIBUTING.md)

### Are you willing to submit a PR?

Yes, I am willing to submit a PR!

_Note_ - relates to this #4940 
diff --git a/Cargo.lock b/Cargo.lock
index b29894b49c3..0188b3d6f1c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6129,6 +6129,7 @@ dependencies = [
  "events",
  "external_services",
  "futures 0.3.30",
+ "globset",
  "hex",
  "http 0.2.12",
  "hyper 0.14.28",
diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs
index 27bd098953c..b22e0286395 100644
--- a/crates/api_models/src/admin.rs
+++ b/crates/api_models/src/admin.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 #[cfg(feature = "v2")]
 use common_utils::new_type;
@@ -1390,11 +1390,39 @@ pub struct BusinessGenericLinkConfig {
     /// Custom domain name to be used for hosting the link
     pub domain_name: Option<String>,
 
+    /// A list of allowed domains (glob patterns) where this link can be embedded / opened from
+    pub allowed_domains: HashSet<String>,
+
     #[serde(flatten)]
     #[schema(value_type = GenericLinkUiConfig)]
     pub ui_config: link_utils::GenericLinkUiConfig,
 }
 
+impl BusinessGenericLinkConfig {
+    pub fn validate(&self) -> Result<(), &str> {
+        // Validate host domain name
+        let host_domain_valid = self
+            .domain_name
+            .clone()
+            .map(|host_domain| link_utils::validate_strict_domain(&host_domain))
+            .unwrap_or(true);
+        if !host_domain_valid {
+            return Err("Invalid host domain name received");
+        }
+
+        let are_allowed_domains_valid = self
+            .allowed_domains
+            .clone()
+            .iter()
+            .all(|allowed_domain| link_utils::validate_wildcard_domain(allowed_domain));
+        if !are_allowed_domains_valid {
+            return Err("Invalid allowed domain names received");
+        }
+
+        Ok(())
+    }
+}
+
 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, ToSchema)]
 pub struct BusinessPaymentLinkConfig {
     /// Custom domain name to be used for hosting the link in your own domain
diff --git a/crates/common_utils/src/consts.rs b/crates/common_utils/src/consts.rs
index a9229c74024..848189cd899 100644
--- a/crates/common_utils/src/consts.rs
+++ b/crates/common_utils/src/consts.rs
@@ -99,5 +99,21 @@ pub const MAX_ALLOWED_MERCHANT_REFERENCE_ID_LENGTH: u8 = 64;
 /// Minimum allowed length for MerchantReferenceId
 pub const MIN_REQUIRED_MERCHANT_REFERENCE_ID_LENGTH: u8 = 1;
 
+/// Regex for matching a domain
+/// Eg -
+/// http://www.example.com
+/// https://www.example.com
+/// www.example.com
+/// example.io
+pub const STRICT_DOMAIN_REGEX: &str = r"^(https?://)?(([A-Za-z0-9][-A-Za-z0-9]\.)*[A-Za-z0-9][-A-Za-z0-9]*|(\d{1,3}\.){3}\d{1,3})+(:[0-9]{2,4})?$";
+
+/// Regex for matching a wildcard domain
+/// Eg -
+/// *.example.com
+/// *.subdomain.domain.com
+/// *://example.com
+/// *example.com
+pub const WILDCARD_DOMAIN_REGEX: &str = r"^((\*|https?)?://)?((\*\.|[A-Za-z0-9][-A-Za-z0-9]*\.)*[A-Za-z0-9][-A-Za-z0-9]*|((\d{1,3}|\*)\.){3}(\d{1,3}|\*)|\*)(:\*|:[0-9]{2,4})?(/\*)?$";
+
 /// Maximum allowed length for MerchantName
 pub const MAX_ALLOWED_MERCHANT_NAME_LENGTH: usize = 64;
diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs
index 2960209dfc9..e95832eeba9 100644
--- a/crates/common_utils/src/link_utils.rs
+++ b/crates/common_utils/src/link_utils.rs
@@ -1,4 +1,4 @@
-//! Common
+//! This module has common utilities for links in HyperSwitch
 
 use std::{collections::HashSet, primitive::i64};
 
@@ -13,10 +13,13 @@ use diesel::{
 };
 use error_stack::{report, ResultExt};
 use masking::Secret;
+use regex::Regex;
+#[cfg(feature = "logs")]
+use router_env::logger;
 use serde::Serialize;
 use utoipa::ToSchema;
 
-use crate::{errors::ParsingError, id_type, types::MinorUnit};
+use crate::{consts, errors::ParsingError, id_type, types::MinorUnit};
 
 #[derive(
     Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, FromSqlRow, AsExpression, ToSchema,
@@ -162,6 +165,8 @@ pub struct PayoutLinkData {
     pub amount: MinorUnit,
     /// Payout currency
     pub currency: enums::Currency,
+    /// A list of allowed domains (glob patterns) where this link can be embedded / opened from
+    pub allowed_domains: HashSet<String>,
 }
 
 crate::impl_to_sql_from_sql_json!(PayoutLinkData);
@@ -209,3 +214,140 @@ pub struct EnabledPaymentMethod {
     #[schema(value_type = HashSet<PaymentMethodType>)]
     pub payment_method_types: HashSet<enums::PaymentMethodType>,
 }
+
+/// Util function for validating a domain without any wildcard characters.
+pub fn validate_strict_domain(domain: &str) -> bool {
+    Regex::new(consts::STRICT_DOMAIN_REGEX)
+        .map(|regex| regex.is_match(domain))
+        .map_err(|err| {
+            let err_msg = format!("Invalid strict domain regex: {err:?}");
+            #[cfg(feature = "logs")]
+            logger::error!(err_msg);
+            err_msg
+        })
+        .unwrap_or(false)
+}
+
+/// Util function for validating a domain with "*" wildcard characters.
+pub fn validate_wildcard_domain(domain: &str) -> bool {
+    Regex::new(consts::WILDCARD_DOMAIN_REGEX)
+        .map(|regex| regex.is_match(domain))
+        .map_err(|err| {
+            let err_msg = format!("Invalid strict domain regex: {err:?}");
+            #[cfg(feature = "logs")]
+            logger::error!(err_msg);
+            err_msg
+        })
+        .unwrap_or(false)
+}
+
+#[cfg(test)]
+mod domain_tests {
+    use regex::Regex;
+
+    use super::*;
+
+    #[test]
+    fn test_validate_strict_domain_regex() {
+        assert!(
+            Regex::new(consts::STRICT_DOMAIN_REGEX).is_ok(),
+            "Strict domain regex is invalid"
+        );
+    }
+
+    #[test]
+    fn test_validate_wildcard_domain_regex() {
+        assert!(
+            Regex::new(consts::WILDCARD_DOMAIN_REGEX).is_ok(),
+            "Wildcard domain regex is invalid"
+        );
+    }
+
+    #[test]
+    fn test_validate_strict_domain() {
+        let valid_domains = vec![
+            "example.com",
+            "example.subdomain.com",
+            "https://example.com:8080",
+            "http://example.com",
+            "example.com:8080",
+            "example.com:443",
+            "localhost:443",
+            "127.0.0.1:443",
+        ];
+
+        for domain in valid_domains {
+            assert!(
+                validate_strict_domain(domain),
+                "Could not validate strict domain: {}",
+                domain
+            );
+        }
+
+        let invalid_domains = vec![
+            "",
+            "invalid.domain.",
+            "not_a_domain",
+            "http://example.com/path?query=1#fragment",
+            "127.0.0.1.2:443",
+        ];
+
+        for domain in invalid_domains {
+            assert!(
+                !validate_strict_domain(domain),
+                "Could not validate invalid strict domain: {}",
+                domain
+            );
+        }
+    }
+
+    #[test]
+    fn test_validate_wildcard_domain() {
+        let valid_domains = vec![
+            "example.com",
+            "example.subdomain.com",
+            "https://example.com:8080",
+            "http://example.com",
+            "example.com:8080",
+            "example.com:443",
+            "localhost:443",
+            "127.0.0.1:443",
+            "*.com",
+            "example.*.com",
+            "example.com:*",
+            "*:443",
+            "localhost:*",
+            "127.0.0.*:*",
+            "*:*",
+        ];
+
+        for domain in valid_domains {
+            assert!(
+                validate_wildcard_domain(domain),
+                "Could not validate wildcard domain: {}",
+                domain
+            );
+        }
+
+        let invalid_domains = vec![
+            "",
+            "invalid.domain.",
+            "not_a_domain",
+            "http://example.com/path?query=1#fragment",
+            "*.",
+            ".*",
+            "example.com:*:",
+            "*:443:",
+            ":localhost:*",
+            "127.00.*:*",
+        ];
+
+        for domain in invalid_domains {
+            assert!(
+                !validate_wildcard_domain(domain),
+                "Could not validate invalid wildcard domain: {}",
+                domain
+            );
+        }
+    }
+}
diff --git a/crates/hyperswitch_domain_models/src/api.rs b/crates/hyperswitch_domain_models/src/api.rs
index bb768d21dd0..07d9337e450 100644
--- a/crates/hyperswitch_domain_models/src/api.rs
+++ b/crates/hyperswitch_domain_models/src/api.rs
@@ -1,4 +1,4 @@
-use std::fmt::Display;
+use std::{collections::HashSet, fmt::Display};
 
 use common_utils::{
     events::{ApiEventMetric, ApiEventsType},
@@ -59,7 +59,13 @@ pub struct PaymentLinkStatusData {
 }
 
 #[derive(Debug, Eq, PartialEq)]
-pub enum GenericLinks {
+pub struct GenericLinks {
+    pub allowed_domains: HashSet<String>,
+    pub data: GenericLinksData,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub enum GenericLinksData {
     ExpiredLink(GenericExpiredLinkData),
     PaymentMethodCollect(GenericLinkFormData),
     PayoutLink(GenericLinkFormData),
@@ -67,12 +73,12 @@ pub enum GenericLinks {
     PaymentMethodCollectStatus(GenericLinkStatusData),
 }
 
-impl Display for GenericLinks {
+impl Display for GenericLinksData {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         write!(
             f,
             "{}",
-            match self {
+            match *self {
                 Self::ExpiredLink(_) => "ExpiredLink",
                 Self::PaymentMethodCollect(_) => "PaymentMethodCollect",
                 Self::PayoutLink(_) => "PayoutLink",
diff --git a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs
index 782376519c2..e660850a983 100644
--- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs
+++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs
@@ -271,6 +271,8 @@ pub enum ApiErrorResponse {
     InvalidCookie,
     #[error(error_type = ErrorType::InvalidRequestError, code = "IR_27", message = "Extended card info does not exist")]
     ExtendedCardInfoNotFound,
+    #[error(error_type = ErrorType::InvalidRequestError, code = "IR_28", message = "{message}")]
+    LinkConfigurationError { message: String },
     #[error(error_type = ErrorType::ServerNotAvailable, code = "IE", message = "{reason} as data mismatched for {field_names}", ignore = "status_code")]
     IntegrityCheckFailed {
         reason: String,
@@ -615,6 +617,9 @@ impl ErrorSwitch<api_models::errors::types::ApiErrorResponse> for ApiErrorRespon
             Self::ExtendedCardInfoNotFound => {
                 AER::NotFound(ApiError::new("IR", 27, "Extended card info does not exist", None))
             }
+            Self::LinkConfigurationError { message } => {
+                AER::BadRequest(ApiError::new("IR", 28, message, None))
+            },
             Self::IntegrityCheckFailed {
                 reason,
                 field_names,
diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml
index 108b035ec0f..1212869600b 100644
--- a/crates/router/Cargo.toml
+++ b/crates/router/Cargo.toml
@@ -58,6 +58,7 @@ dyn-clone = "1.0.17"
 encoding_rs = "0.8.33"
 error-stack = "0.4.1"
 futures = "0.3.30"
+globset = "0.4.14"
 hex = "0.4.3"
 http = "0.2.12"
 hyper = "0.14.28"
diff --git a/crates/router/src/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs
index f72b71570a0..e0a9b6c8f87 100644
--- a/crates/router/src/compatibility/stripe/errors.rs
+++ b/crates/router/src/compatibility/stripe/errors.rs
@@ -264,6 +264,8 @@ pub enum StripeErrorCode {
     PaymentMethodDeleteFailed,
     #[error(error_type = StripeErrorType::InvalidRequestError, code = "", message = "Extended card info does not exist")]
     ExtendedCardInfoNotFound,
+    #[error(error_type = StripeErrorType::InvalidRequestError, code = "not_configured", message = "{message}")]
+    LinkConfigurationError { message: String },
     #[error(error_type = StripeErrorType::ConnectorError, code = "CE", message = "{reason} as data mismatched for {field_names}")]
     IntegrityCheckFailed {
         reason: String,
@@ -656,6 +658,9 @@ impl From<errors::ApiErrorResponse> for StripeErrorCode {
                 Self::InvalidWalletToken { wallet_name }
             }
             errors::ApiErrorResponse::ExtendedCardInfoNotFound => Self::ExtendedCardInfoNotFound,
+            errors::ApiErrorResponse::LinkConfigurationError { message } => {
+                Self::LinkConfigurationError { message }
+            }
             errors::ApiErrorResponse::IntegrityCheckFailed {
                 reason,
                 field_names,
@@ -742,7 +747,8 @@ impl actix_web::ResponseError for StripeErrorCode {
             | Self::InvalidConnectorConfiguration { .. }
             | Self::CurrencyConversionFailed
             | Self::PaymentMethodDeleteFailed
-            | Self::ExtendedCardInfoNotFound => StatusCode::BAD_REQUEST,
+            | Self::ExtendedCardInfoNotFound
+            | Self::LinkConfigurationError { .. } => StatusCode::BAD_REQUEST,
             Self::RefundFailed
             | Self::PayoutFailed
             | Self::PaymentLinkNotFound
diff --git a/crates/router/src/compatibility/wrap.rs b/crates/router/src/compatibility/wrap.rs
index 7c1bd7e6712..9ed03c6d2ce 100644
--- a/crates/router/src/compatibility/wrap.rs
+++ b/crates/router/src/compatibility/wrap.rs
@@ -141,10 +141,11 @@ where
         }
 
         Ok(api::ApplicationResponse::GenericLinkForm(boxed_generic_link_data)) => {
-            let link_type = (boxed_generic_link_data).to_string();
-            match services::generic_link_response::build_generic_link_html(*boxed_generic_link_data)
-            {
-                Ok(rendered_html) => api::http_response_html_data(rendered_html),
+            let link_type = (boxed_generic_link_data).data.to_string();
+            match services::generic_link_response::build_generic_link_html(
+                boxed_generic_link_data.data,
+            ) {
+                Ok(rendered_html) => api::http_response_html_data(rendered_html, None),
                 Err(_) => {
                     api::http_response_err(format!("Error while rendering {} HTML page", link_type))
                 }
@@ -155,7 +156,7 @@ where
             match *boxed_payment_link_data {
                 api::PaymentLinkAction::PaymentLinkFormData(payment_link_data) => {
                     match api::build_payment_link_html(payment_link_data) {
-                        Ok(rendered_html) => api::http_response_html_data(rendered_html),
+                        Ok(rendered_html) => api::http_response_html_data(rendered_html, None),
                         Err(_) => api::http_response_err(
                             r#"{
                                 "error": {
@@ -167,7 +168,7 @@ where
                 }
                 api::PaymentLinkAction::PaymentLinkStatus(payment_link_data) => {
                     match api::get_payment_link_status(payment_link_data) {
-                        Ok(rendered_html) => api::http_response_html_data(rendered_html),
+                        Ok(rendered_html) => api::http_response_html_data(rendered_html, None),
                         Err(_) => api::http_response_err(
                             r#"{
                                 "error": {
diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs
index d8eb1400660..af6d469fd3a 100644
--- a/crates/router/src/core/admin.rs
+++ b/crates/router/src/core/admin.rs
@@ -2061,6 +2061,21 @@ pub async fn update_business_profile(
         .change_context(errors::ApiErrorResponse::InternalServerError)
         .attach_printable("Unable to encrypt outgoing webhook custom HTTP headers")?;
 
+    let payout_link_config = request
+        .payout_link_config
+        .as_ref()
+        .map(|payout_conf| match payout_conf.config.validate() {
+            Ok(_) => payout_conf.encode_to_value().change_context(
+                errors::ApiErrorResponse::InvalidDataValue {
+                    field_name: "payout_link_config",
+                },
+            ),
+            Err(e) => Err(report!(errors::ApiErrorResponse::InvalidRequestData {
+                message: e.to_string()
+            })),
+        })
+        .transpose()?;
+
     let business_profile_update = storage::business_profile::BusinessProfileUpdate::Update {
         profile_name: request.profile_name,
         modified_at: Some(date_time::now()),
@@ -2089,14 +2104,7 @@ pub async fn update_business_profile(
             .change_context(errors::ApiErrorResponse::InvalidDataValue {
                 field_name: "authentication_connector_details",
             })?,
-        payout_link_config: request
-            .payout_link_config
-            .as_ref()
-            .map(Encode::encode_to_value)
-            .transpose()
-            .change_context(errors::ApiErrorResponse::InvalidDataValue {
-                field_name: "payout_link_config",
-            })?,
+        payout_link_config,
         extended_card_info_config,
         use_billing_as_payment_method_billing: request.use_billing_as_payment_method_billing,
         collect_shipping_details_from_wallet_connector: request
diff --git a/crates/router/src/core/generic_link/payout_link/initiate/script.js b/crates/router/src/core/generic_link/payout_link/initiate/script.js
index a8050e90d6e..731f3f76d0a 100644
--- a/crates/router/src/core/generic_link/payout_link/initiate/script.js
+++ b/crates/router/src/core/generic_link/payout_link/initiate/script.js
@@ -1,153 +1,171 @@
 // @ts-check
 
-var widgets = null;
-var payoutWidget = null;
-// @ts-ignore
-var publishableKey = window.__PAYOUT_DETAILS.publishable_key;
-var hyper = null;
+// Top level checks
+var isFramed = false;
+try {
+  isFramed = window.parent.location !== window.location;
 
-/**
- * Use - format date in "hh:mm AM/PM timezone MM DD, YYYY"
- * @param {Date} date
- **/
-function formatDate(date) {
-  var months = [
-    "January",
-    "February",
-    "March",
-    "April",
-    "May",
-    "June",
-    "July",
-    "August",
-    "September",
-    "October",
-    "November",
-    "December",
-  ];
+  // If parent's window object is restricted, DOMException is
+  // thrown which concludes that the webpage is iframed
+} catch (err) {
+  isFramed = true;
+}
 
-  var hours = date.getHours();
-  var minutes = date.getMinutes();
-  // @ts-ignore
-  minutes = minutes < 10 ? "0" + minutes : minutes;
-  var suffix = hours > 11 ? "PM" : "AM";
-  hours = hours % 12;
-  hours = hours ? hours : 12;
-  var day = date.getDate();
-  var month = months[date.getMonth()];
-  var year = date.getUTCFullYear();
+// Remove the script from DOM incase it's not iframed
+if (!isFramed) {
+  function initializePayoutSDK() {
+    var errMsg = "You are not allowed to view this content.";
+    var contentElement = document.getElementById("payout-link");
+    if (contentElement instanceof HTMLDivElement) {
+      contentElement.innerHTML = errMsg;
+    } else {
+      document.body.innerHTML = errMsg;
+    }
+  }
 
-  // @ts-ignore
-  var locale = navigator.language || navigator.userLanguage;
-  var timezoneShorthand = date
-    .toLocaleDateString(locale, {
-      day: "2-digit",
-      timeZoneName: "long",
-    })
-    .substring(4)
-    .split(" ")
-    .reduce(function (tz, c) {
-      return tz + c.charAt(0).toUpperCase();
-    }, "");
+  // webpage is iframed, good to load
+} else {
+  var hyper = null;
+  var payoutWidget = null;
+  var widgets = null;
+  /**
+   * Use - format date in "hh:mm AM/PM timezone MM DD, YYYY"
+   * @param {Date} date
+   **/
+  function formatDate(date) {
+    var months = [
+      "January",
+      "February",
+      "March",
+      "April",
+      "May",
+      "June",
+      "July",
+      "August",
+      "September",
+      "October",
+      "November",
+      "December",
+    ];
 
-  var formatted =
-    hours +
-    ":" +
-    minutes +
-    " " +
-    suffix +
-    " " +
-    timezoneShorthand +
-    "  " +
-    month +
-    " " +
-    day +
-    ", " +
-    year;
-  return formatted;
-}
+    var hours = date.getHours();
+    var minutes = date.getMinutes();
+    // @ts-ignore
+    minutes = minutes < 10 ? "0" + minutes : minutes;
+    var suffix = hours > 11 ? "PM" : "AM";
+    hours = hours % 12;
+    hours = hours ? hours : 12;
+    var day = date.getDate();
+    var month = months[date.getMonth()];
+    var year = date.getUTCFullYear();
 
-/**
- * Trigger - init
- * Uses
- *  - Initialize SDK
- *  - Update document's icon
- */
-function boot() {
-  // Initialize SDK
-  // @ts-ignore
-  if (window.Hyper) {
-    initializePayoutSDK();
+    // @ts-ignore
+    var locale = navigator.language || navigator.userLanguage;
+    var timezoneShorthand = date
+      .toLocaleDateString(locale, {
+        day: "2-digit",
+        timeZoneName: "long",
+      })
+      .substring(4)
+      .split(" ")
+      .reduce(function (tz, c) {
+        return tz + c.charAt(0).toUpperCase();
+      }, "");
+
+    var formatted =
+      hours +
+      ":" +
+      minutes +
+      " " +
+      suffix +
+      " " +
+      timezoneShorthand +
+      "  " +
+      month +
+      " " +
+      day +
+      ", " +
+      year;
+    return formatted;
   }
 
-  // @ts-ignore
-  var payoutDetails = window.__PAYOUT_DETAILS;
+  /**
+   * Trigger - init
+   * Uses
+   *  - Initialize SDK
+   *  - Update document's icon
+   */
+  function boot() {
+    // Initialize SDK
+    // @ts-ignore
+    if (window.Hyper) {
+      initializePayoutSDK();
+    }
+
+    // @ts-ignore
+    var payoutDetails = window.__PAYOUT_DETAILS;
 
-  // Attach document icon
-  if (payoutDetails.logo) {
-    var link = document.createElement("link");
-    link.rel = "icon";
-    link.href = payoutDetails.logo;
-    link.type = "image/x-icon";
-    document.head.appendChild(link);
+    // Attach document icon
+    if (payoutDetails.logo) {
+      var link = document.createElement("link");
+      link.rel = "icon";
+      link.href = payoutDetails.logo;
+      link.type = "image/x-icon";
+      document.head.appendChild(link);
+    }
   }
-}
-boot();
+  boot();
 
-/**
- * Trigger - post downloading SDK
- * Uses
- *  - Initialize SDK
- *  - Create a payout widget
- *  - Mount it in DOM
- **/
-function initializePayoutSDK() {
-  // @ts-ignore
-  var payoutDetails = window.__PAYOUT_DETAILS;
-  var clientSecret = payoutDetails.client_secret;
-  var appearance = {
-    variables: {
-      colorPrimary: payoutDetails?.theme?.primary_color || "rgb(0, 109, 249)",
-      fontFamily: "Work Sans, sans-serif",
-      fontSizeBase: "16px",
-      colorText: "rgb(51, 65, 85)",
-      colorTextSecondary: "#334155B3",
-      colorPrimaryText: "rgb(51, 65, 85)",
-      colorTextPlaceholder: "#33415550",
-      borderColor: "#33415550",
-      colorBackground: "rgb(255, 255, 255)",
-    },
-  };
-  // Instantiate
-  // @ts-ignore
-  hyper = window.Hyper(publishableKey, {
-    isPreloadEnabled: false,
-  });
-  widgets = hyper.widgets({
-    appearance: appearance,
-    clientSecret: clientSecret,
-  });
+  /**
+   * Trigger - post downloading SDK
+   * Uses
+   *  - Initialize SDK
+   *  - Create a payout widget
+   *  - Mount it in DOM
+   **/
+  function initializePayoutSDK() {
+    // @ts-ignore
+    var payoutDetails = window.__PAYOUT_DETAILS;
+    var clientSecret = payoutDetails.client_secret;
+    var publishableKey = payoutDetails.publishable_key;
+    var appearance = {
+      variables: {
+        colorPrimary: payoutDetails?.theme?.primary_color || "rgb(0, 109, 249)",
+        fontFamily: "Work Sans, sans-serif",
+        fontSizeBase: "16px",
+        colorText: "rgb(51, 65, 85)",
+      },
+    };
+    // @ts-ignore
+    hyper = window.Hyper(publishableKey, {
+      isPreloadEnabled: false,
+    });
+    widgets = hyper.widgets({
+      appearance: appearance,
+      clientSecret: clientSecret,
+    });
 
-  // Create payment method collect widget
-  let sessionExpiry = formatDate(new Date(payoutDetails.session_expiry));
-  var payoutOptions = {
-    linkId: payoutDetails.payout_link_id,
-    payoutId: payoutDetails.payout_id,
-    customerId: payoutDetails.customer_id,
-    theme: payoutDetails.theme,
-    collectorName: payoutDetails.merchant_name,
-    logo: payoutDetails.logo,
-    enabledPaymentMethods: payoutDetails.enabled_payment_methods,
-    returnUrl: payoutDetails.return_url,
-    sessionExpiry,
-    amount: payoutDetails.amount,
-    currency: payoutDetails.currency,
-    flow: "PayoutLinkInitiate",
-  };
-  payoutWidget = widgets.create("paymentMethodCollect", payoutOptions);
+    // Create payment method collect widget
+    let sessionExpiry = formatDate(new Date(payoutDetails.session_expiry));
+    var payoutOptions = {
+      linkId: payoutDetails.payout_link_id,
+      payoutId: payoutDetails.payout_id,
+      customerId: payoutDetails.customer_id,
+      theme: payoutDetails.theme,
+      collectorName: payoutDetails.merchant_name,
+      logo: payoutDetails.logo,
+      enabledPaymentMethods: payoutDetails.enabled_payment_methods,
+      returnUrl: payoutDetails.return_url,
+      sessionExpiry,
+      amount: payoutDetails.amount,
+      currency: payoutDetails.currency,
+      flow: "PayoutLinkInitiate",
+    };
+    payoutWidget = widgets.create("paymentMethodCollect", payoutOptions);
 
-  // Mount
-  if (payoutWidget !== null) {
-    payoutWidget.mount("#payout-link");
+    // Mount
+    if (payoutWidget !== null) {
+      payoutWidget.mount("#payout-link");
+    }
   }
 }
diff --git a/crates/router/src/core/generic_link/payout_link/status/styles.css b/crates/router/src/core/generic_link/payout_link/status/styles.css
index cf2d89b6a30..d5668553129 100644
--- a/crates/router/src/core/generic_link/payout_link/status/styles.css
+++ b/crates/router/src/core/generic_link/payout_link/status/styles.css
@@ -78,9 +78,9 @@ body {
 }
 
 #resource-info-container {
-  width: calc(100% - 80px);
+  width: 100%;
   border-top: 1px solid rgb(231, 234, 241);
-  padding: 20px 40px;
+  padding: 20px 0;
 }
 #resource-info {
   display: flex;
@@ -88,7 +88,7 @@ body {
 }
 #info-key {
   text-align: right;
-  font-size: 15px;
+  font-size: 14px;
   min-width: 10ch;
 }
 #info-val {
@@ -101,13 +101,33 @@ body {
   margin-top: 40px;
 }
 
-@media only screen and (max-width: 1199px) {
+@media only screen and (max-width: 420px) {
   body {
     overflow-y: scroll;
   }
 
+  body {
+    justify-content: start;
+  }
+
   .main {
-    width: auto;
+    width: 100%;
     min-width: 300px;
   }
+
+  #status-card {
+    box-shadow: none;
+  }
+
+  #info-key {
+    min-width: 12ch;
+  }
+
+  #info-val {
+    font-size: 11px;
+  }
+
+  #resource-info {
+    margin: 0 10px;
+  }
 }
diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs
index a7a87b6d96e..a9a85b8e04e 100644
--- a/crates/router/src/core/payment_methods.rs
+++ b/crates/router/src/core/payment_methods.rs
@@ -1,3 +1,4 @@
+use std::collections::HashSet;
 pub mod cards;
 pub mod migration;
 pub mod surcharge_decision_configs;
@@ -13,7 +14,10 @@ use diesel_models::{
     enums, GenericLinkNew, PaymentMethodCollectLink, PaymentMethodCollectLinkData,
 };
 use error_stack::{report, ResultExt};
-use hyperswitch_domain_models::payments::{payment_attempt::PaymentAttempt, PaymentIntent};
+use hyperswitch_domain_models::{
+    api::{GenericLinks, GenericLinksData},
+    payments::{payment_attempt::PaymentAttempt, PaymentIntent},
+};
 use masking::PeekInterface;
 use router_env::{instrument, tracing};
 use time::Duration;
@@ -27,7 +31,7 @@ use crate::{
         pm_auth as core_pm_auth,
     },
     routes::{app::StorageInterface, SessionState},
-    services::{self, GenericLinks},
+    services,
     types::{
         api::{self, payments},
         domain, storage,
@@ -245,7 +249,10 @@ pub async fn render_pm_collect_link(
                     theme: link_data.ui_config.theme.unwrap_or(default_ui_config.theme),
                 };
                 Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
-                    GenericLinks::ExpiredLink(expired_link_data),
+                    GenericLinks {
+                        allowed_domains: HashSet::from([]),
+                        data: GenericLinksData::ExpiredLink(expired_link_data),
+                    },
                 )))
 
             // else, send back form link
@@ -303,7 +310,11 @@ pub async fn render_pm_collect_link(
                     html_meta_tags: String::new(),
                 };
                 Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
-                    GenericLinks::PaymentMethodCollect(generic_form_data),
+                    GenericLinks {
+                        allowed_domains: HashSet::from([]),
+
+                        data: GenericLinksData::PaymentMethodCollect(generic_form_data),
+                    },
                 )))
             }
         }
@@ -344,7 +355,11 @@ pub async fn render_pm_collect_link(
                 css_data: serialized_css_content,
             };
             Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
-                GenericLinks::PaymentMethodCollectStatus(generic_status_data),
+                GenericLinks {
+                    allowed_domains: HashSet::from([]),
+
+                    data: GenericLinksData::PaymentMethodCollectStatus(generic_status_data),
+                },
             )))
         }
     }
diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs
index 0d4c525f104..7fca84dbccf 100644
--- a/crates/router/src/core/payout_link.rs
+++ b/crates/router/src/core/payout_link.rs
@@ -1,5 +1,9 @@
-use std::collections::{HashMap, HashSet};
+use std::{
+    cmp::Ordering,
+    collections::{HashMap, HashSet},
+};
 
+use actix_web::http::header;
 use api_models::payouts;
 use common_utils::{
     ext_traits::{Encode, OptionExt},
@@ -8,14 +12,15 @@ use common_utils::{
 };
 use diesel_models::PayoutLinkUpdate;
 use error_stack::ResultExt;
+use hyperswitch_domain_models::api::{GenericLinks, GenericLinksData};
 
 use super::errors::{RouterResponse, StorageErrorExt};
 use crate::{
     configs::settings::{PaymentMethodFilterKey, PaymentMethodFilters},
-    core::payments::helpers,
+    core::{payments::helpers, payouts::validator},
     errors,
     routes::{app::StorageInterface, SessionState},
-    services::{self, GenericLinks},
+    services,
     types::domain,
 };
 
@@ -24,6 +29,7 @@ pub async fn initiate_payout_link(
     merchant_account: domain::MerchantAccount,
     key_store: domain::MerchantKeyStore,
     req: payouts::PayoutLinkInitiateRequest,
+    request_headers: &header::HeaderMap,
 ) -> RouterResponse<services::GenericLinkFormData> {
     let db: &dyn StorageInterface = &*state.store;
     let merchant_id = &merchant_account.merchant_id;
@@ -59,6 +65,8 @@ pub async fn initiate_payout_link(
             message: "payout link not found".to_string(),
         })?;
 
+    validator::validate_payout_link_render_request(request_headers, &payout_link)?;
+
     // Check status and return form data accordingly
     let has_expired = common_utils::date_time::now() > payout_link.expiry;
     let status = payout_link.link_status.clone();
@@ -97,7 +105,10 @@ pub async fn initiate_payout_link(
             }
 
             Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
-                GenericLinks::ExpiredLink(expired_link_data),
+                GenericLinks {
+                    allowed_domains: (link_data.allowed_domains),
+                    data: GenericLinksData::ExpiredLink(expired_link_data),
+                },
             )))
         }
 
@@ -146,9 +157,17 @@ pub async fn initiate_payout_link(
             };
             // Fetch enabled payout methods from the request. If not found, fetch the enabled payout methods from MCA,
             // If none are configured for merchant connector accounts, fetch them from the default enabled payout methods.
-            let enabled_payment_methods = link_data
+            let mut enabled_payment_methods = link_data
                 .enabled_payment_methods
                 .unwrap_or(fallback_enabled_payout_methods.to_vec());
+
+            // Sort payment methods (cards first)
+            enabled_payment_methods.sort_by(|a, b| match (a.payment_method, b.payment_method) {
+                (_, common_enums::PaymentMethod::Card) => Ordering::Greater,
+                (common_enums::PaymentMethod::Card, _) => Ordering::Less,
+                _ => Ordering::Equal,
+            });
+
             let js_data = payouts::PayoutLinkDetails {
                 publishable_key: masking::Secret::new(merchant_account.publishable_key),
                 client_secret: link_data.client_secret.clone(),
@@ -186,7 +205,10 @@ pub async fn initiate_payout_link(
                 html_meta_tags: String::new(),
             };
             Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
-                GenericLinks::PayoutLink(generic_form_data),
+                GenericLinks {
+                    allowed_domains: (link_data.allowed_domains),
+                    data: GenericLinksData::PayoutLink(generic_form_data),
+                },
             )))
         }
 
@@ -225,7 +247,10 @@ pub async fn initiate_payout_link(
                 css_data: serialized_css_content,
             };
             Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
-                GenericLinks::PayoutLinkStatus(generic_status_data),
+                GenericLinks {
+                    allowed_domains: (link_data.allowed_domains),
+                    data: GenericLinksData::PayoutLinkStatus(generic_status_data),
+                },
             )))
         }
     }
diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs
index 848d57bce87..e36036eb798 100644
--- a/crates/router/src/core/payouts.rs
+++ b/crates/router/src/core/payouts.rs
@@ -5,38 +5,41 @@ pub mod retry;
 pub mod validator;
 use std::vec::IntoIter;
 
-use api_models::{self, enums as api_enums, payouts::PayoutLinkResponse};
+use api_models::{self, admin, enums as api_enums, payouts::PayoutLinkResponse};
 use common_utils::{
     consts,
     crypto::Encryptable,
     ext_traits::{AsyncExt, ValueExt},
-    link_utils::PayoutLinkStatus,
+    id_type::CustomerId,
+    link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus},
     pii,
     types::MinorUnit,
 };
-use diesel_models::{enums as storage_enums, generic_link::PayoutLink};
+use diesel_models::{
+    enums as storage_enums,
+    generic_link::{GenericLinkNew, PayoutLink},
+};
 use error_stack::{report, ResultExt};
 #[cfg(feature = "olap")]
 use futures::future::join_all;
 #[cfg(feature = "olap")]
 use hyperswitch_domain_models::errors::StorageError;
-use masking::PeekInterface;
+use masking::{PeekInterface, Secret};
 #[cfg(feature = "payout_retry")]
 use retry::GsmValidation;
 use router_env::{instrument, logger, tracing};
 use scheduler::utils as pt_utils;
 use serde_json;
+use time::Duration;
 
-use super::{
-    errors::{ConnectorErrorExt, StorageErrorExt},
-    payments::customers,
-};
 #[cfg(feature = "olap")]
 use crate::types::domain::behaviour::Conversion;
 use crate::{
     core::{
-        errors::{self, CustomResult, RouterResponse, RouterResult},
-        payments::{self, helpers as payment_helpers},
+        errors::{
+            self, ConnectorErrorExt, CustomResult, RouterResponse, RouterResult, StorageErrorExt,
+        },
+        payments::{self, customers, helpers as payment_helpers},
         utils as core_utils,
     },
     db::StorageInterface,
@@ -1106,7 +1109,7 @@ pub async fn create_recipient(
                     add_external_account_addition_task(
                         &*state.store,
                         payout_data,
-                        common_utils::date_time::now().saturating_add(time::Duration::seconds(consts::STRIPE_ACCOUNT_ONBOARDING_DELAY_IN_SECONDS)),
+                        common_utils::date_time::now().saturating_add(Duration::seconds(consts::STRIPE_ACCOUNT_ONBOARDING_DELAY_IN_SECONDS)),
                     )
                     .await
                     .change_context(errors::ApiErrorResponse::InternalServerError)
@@ -2172,7 +2175,7 @@ pub async fn payout_create_db_entries(
 
     let payout_link = match req.payout_link {
         Some(true) => Some(
-            validator::create_payout_link(
+            create_payout_link(
                 state,
                 &business_profile,
                 &customer_id,
@@ -2479,3 +2482,139 @@ async fn validate_and_get_business_profile(
             })
     }
 }
+
+#[allow(clippy::too_many_arguments)]
+pub async fn create_payout_link(
+    state: &SessionState,
+    business_profile: &storage::BusinessProfile,
+    customer_id: &CustomerId,
+    merchant_id: &String,
+    req: &payouts::PayoutCreateRequest,
+    payout_id: &String,
+) -> RouterResult<PayoutLink> {
+    let payout_link_config_req = req.payout_link_config.to_owned();
+
+    // Fetch all configs
+    let default_config = &state.conf.generic_link.payout_link;
+    let profile_config = business_profile
+        .payout_link_config
+        .as_ref()
+        .map(|config| {
+            config
+                .clone()
+                .parse_value::<admin::BusinessPayoutLinkConfig>("BusinessPayoutLinkConfig")
+        })
+        .transpose()
+        .change_context(errors::ApiErrorResponse::InvalidDataValue {
+            field_name: "payout_link_config in business_profile",
+        })?;
+    let profile_ui_config = profile_config.as_ref().map(|c| c.config.ui_config.clone());
+    let ui_config = payout_link_config_req
+        .as_ref()
+        .and_then(|config| config.ui_config.clone())
+        .or(profile_ui_config);
+
+    // Validate allowed_domains presence
+    let allowed_domains = profile_config
+        .as_ref()
+        .map(|config| config.config.allowed_domains.to_owned())
+        .get_required_value("allowed_domains")
+        .change_context(errors::ApiErrorResponse::LinkConfigurationError {
+            message: "Payout links cannot be used without setting allowed_domains in profile"
+                .to_string(),
+        })?;
+
+    // Form data to be injected in the link
+    let (logo, merchant_name, theme) = match ui_config {
+        Some(config) => (config.logo, config.merchant_name, config.theme),
+        _ => (None, None, None),
+    };
+    let payout_link_config = GenericLinkUiConfig {
+        logo,
+        merchant_name,
+        theme,
+    };
+    let client_secret = utils::generate_id(consts::ID_LENGTH, "payout_link_secret");
+    let base_url = profile_config
+        .as_ref()
+        .and_then(|c| c.config.domain_name.as_ref())
+        .map(|domain| format!("https://{}", domain))
+        .unwrap_or(state.base_url.clone());
+    let session_expiry = req
+        .session_expiry
+        .as_ref()
+        .map_or(default_config.expiry, |expiry| *expiry);
+    let url = format!("{base_url}/payout_link/{merchant_id}/{payout_id}");
+    let link = url::Url::parse(&url)
+        .change_context(errors::ApiErrorResponse::InternalServerError)
+        .attach_printable_lazy(|| format!("Failed to form payout link URL - {}", url))?;
+    let req_enabled_payment_methods = payout_link_config_req
+        .as_ref()
+        .and_then(|req| req.enabled_payment_methods.to_owned());
+    let amount = req
+        .amount
+        .as_ref()
+        .get_required_value("amount")
+        .attach_printable("amount is a required value when creating payout links")?;
+    let currency = req
+        .currency
+        .as_ref()
+        .get_required_value("currency")
+        .attach_printable("currency is a required value when creating payout links")?;
+    let payout_link_id = core_utils::get_or_generate_id(
+        "payout_link_id",
+        &payout_link_config_req
+            .as_ref()
+            .and_then(|config| config.payout_link_id.clone()),
+        "payout_link",
+    )?;
+
+    let data = PayoutLinkData {
+        payout_link_id: payout_link_id.clone(),
+        customer_id: customer_id.clone(),
+        payout_id: payout_id.to_string(),
+        link,
+        client_secret: Secret::new(client_secret),
+        session_expiry,
+        ui_config: payout_link_config,
+        enabled_payment_methods: req_enabled_payment_methods,
+        amount: MinorUnit::from(*amount),
+        currency: *currency,
+        allowed_domains,
+    };
+
+    create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await
+}
+
+pub async fn create_payout_link_db_entry(
+    state: &SessionState,
+    merchant_id: &String,
+    payout_link_data: &PayoutLinkData,
+    return_url: Option<String>,
+) -> RouterResult<PayoutLink> {
+    let db: &dyn StorageInterface = &*state.store;
+
+    let link_data = serde_json::to_value(payout_link_data)
+        .map_err(|_| report!(errors::ApiErrorResponse::InternalServerError))
+        .attach_printable("Failed to convert PayoutLinkData to Value")?;
+
+    let payout_link = GenericLinkNew {
+        link_id: payout_link_data.payout_link_id.to_string(),
+        primary_reference: payout_link_data.payout_id.to_string(),
+        merchant_id: merchant_id.to_string(),
+        link_type: common_enums::GenericLinkType::PayoutLink,
+        link_status: GenericLinkStatus::PayoutLink(PayoutLinkStatus::Initiated),
+        link_data,
+        url: payout_link_data.link.to_string().into(),
+        return_url,
+        expiry: common_utils::date_time::now()
+            + Duration::seconds(payout_link_data.session_expiry.into()),
+        ..Default::default()
+    };
+
+    db.insert_payout_link(payout_link)
+        .await
+        .to_duplicate_response(errors::ApiErrorResponse::GenericDuplicateError {
+            message: "payout link already exists".to_string(),
+        })
+}
diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs
index 3e69216167d..a1332181f6d 100644
--- a/crates/router/src/core/payouts/validator.rs
+++ b/crates/router/src/core/payouts/validator.rs
@@ -1,33 +1,25 @@
-use api_models::admin;
+use std::collections::HashSet;
+
+use actix_web::http::header;
 #[cfg(feature = "olap")]
 use common_utils::errors::CustomResult;
-use common_utils::{
-    ext_traits::ValueExt,
-    id_type::CustomerId,
-    link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus},
-    types::MinorUnit,
-};
-use diesel_models::{
-    business_profile::BusinessProfile,
-    generic_link::{GenericLinkNew, PayoutLink},
-};
+use diesel_models::generic_link::PayoutLink;
 use error_stack::{report, ResultExt};
+use globset::Glob;
 pub use hyperswitch_domain_models::errors::StorageError;
-use masking::Secret;
-use router_env::{instrument, tracing};
-use time::Duration;
+use router_env::{instrument, logger, tracing};
+use url::Url;
 
 use super::helpers;
 use crate::{
-    consts,
     core::{
-        errors::{self, RouterResult, StorageErrorExt},
+        errors::{self, RouterResult},
         utils as core_utils,
     },
     db::StorageInterface,
     routes::SessionState,
     types::{api::payouts, domain, storage},
-    utils::{self, OptionExt},
+    utils,
 };
 
 #[instrument(skip(db))]
@@ -192,128 +184,97 @@ pub(super) fn validate_payout_list_request_for_joins(
     Ok(())
 }
 
-#[allow(clippy::too_many_arguments)]
-pub async fn create_payout_link(
-    state: &SessionState,
-    business_profile: &BusinessProfile,
-    customer_id: &CustomerId,
-    merchant_id: &String,
-    req: &payouts::PayoutCreateRequest,
-    payout_id: &String,
-) -> RouterResult<PayoutLink> {
-    let payout_link_config_req = req.payout_link_config.to_owned();
-    // Create payment method collect link ID
-    let payout_link_id = core_utils::get_or_generate_id(
-        "payout_link_id",
-        &payout_link_config_req
-            .as_ref()
-            .and_then(|config| config.payout_link_id.clone()),
-        "payout_link",
-    )?;
-
-    // Fetch all configs
-    let default_config = &state.conf.generic_link.payout_link;
-    let profile_config = business_profile
-        .payout_link_config
-        .as_ref()
-        .map(|config| {
-            config
-                .clone()
-                .parse_value::<admin::BusinessPayoutLinkConfig>("BusinessPayoutLinkConfig")
-        })
-        .transpose()
-        .change_context(errors::ApiErrorResponse::InvalidDataValue {
-            field_name: "payout_link_config in business_profile",
-        })?;
-    let profile_ui_config = profile_config.as_ref().map(|c| c.config.ui_config.clone());
-    let ui_config = payout_link_config_req
-        .as_ref()
-        .and_then(|config| config.ui_config.clone())
-        .or(profile_ui_config);
-
-    // Form data to be injected in the link
-    let (logo, merchant_name, theme) = match ui_config {
-        Some(config) => (config.logo, config.merchant_name, config.theme),
-        _ => (None, None, None),
-    };
-    let payout_link_config = GenericLinkUiConfig {
-        logo,
-        merchant_name,
-        theme,
-    };
-    let client_secret = utils::generate_id(consts::ID_LENGTH, "payout_link_secret");
-    let base_url = profile_config
-        .as_ref()
-        .and_then(|c| c.config.domain_name.as_ref())
-        .map(|domain| format!("https://{}", domain))
-        .unwrap_or(state.base_url.clone());
-    let session_expiry = req
-        .session_expiry
-        .as_ref()
-        .map_or(default_config.expiry, |expiry| *expiry);
-    let url = format!("{base_url}/payout_link/{merchant_id}/{payout_id}");
-    let link = url::Url::parse(&url)
-        .change_context(errors::ApiErrorResponse::InternalServerError)
-        .attach_printable_lazy(|| format!("Failed to form payout link URL - {}", url))?;
-    let req_enabled_payment_methods = payout_link_config_req
-        .as_ref()
-        .and_then(|req| req.enabled_payment_methods.to_owned());
-    let amount = req
-        .amount
-        .as_ref()
-        .get_required_value("amount")
-        .attach_printable("amount is a required value when creating payout links")?;
-    let currency = req
-        .currency
-        .as_ref()
-        .get_required_value("currency")
-        .attach_printable("currency is a required value when creating payout links")?;
-
-    let data = PayoutLinkData {
-        payout_link_id: payout_link_id.clone(),
-        customer_id: customer_id.clone(),
-        payout_id: payout_id.to_string(),
-        link,
-        client_secret: Secret::new(client_secret),
-        session_expiry,
-        ui_config: payout_link_config,
-        enabled_payment_methods: req_enabled_payment_methods,
-        amount: MinorUnit::from(*amount),
-        currency: *currency,
-    };
+pub fn validate_payout_link_render_request(
+    request_headers: &header::HeaderMap,
+    payout_link: &PayoutLink,
+) -> RouterResult<()> {
+    let link_id = payout_link.link_id.to_owned();
+    let link_data = payout_link.link_data.to_owned();
 
-    create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await
-}
+    // Fetch destination is "iframe"
+    match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) {
+        Some("iframe") => Ok(()),
+        Some(requestor) => Err(report!(errors::ApiErrorResponse::AccessForbidden {
+            resource: "payout_link".to_string(),
+        }))
+        .attach_printable_lazy(|| {
+            format!(
+                "Access to payout_link [{}] is forbidden when requested through {}",
+                link_id, requestor
+            )
+        }),
+        None => Err(report!(errors::ApiErrorResponse::AccessForbidden {
+            resource: "payout_link".to_string(),
+        }))
+        .attach_printable_lazy(|| {
+            format!(
+                "Access to payout_link [{}] is forbidden when sec-fetch-dest is not present in request headers",
+                link_id
+            )
+        }),
+    }?;
 
-pub async fn create_payout_link_db_entry(
-    state: &SessionState,
-    merchant_id: &String,
-    payout_link_data: &PayoutLinkData,
-    return_url: Option<String>,
-) -> RouterResult<PayoutLink> {
-    let db: &dyn StorageInterface = &*state.store;
+    // Validate origin / referer
+    let domain_in_req = {
+        let origin_or_referer = request_headers
+            .get("origin")
+            .or_else(|| request_headers.get("referer"))
+            .and_then(|v| v.to_str().ok())
+            .ok_or_else(|| {
+                report!(errors::ApiErrorResponse::AccessForbidden {
+                    resource: "payout_link".to_string(),
+                })
+            })
+            .attach_printable_lazy(|| {
+                format!(
+                    "Access to payout_link [{}] is forbidden when origin or referer is not present in request headers",
+                    link_id
+                )
+            })?;
 
-    let link_data = serde_json::to_value(payout_link_data)
-        .map_err(|_| report!(errors::ApiErrorResponse::InternalServerError))
-        .attach_printable("Failed to convert PayoutLinkData to Value")?;
+        let url = Url::parse(origin_or_referer)
+            .map_err(|_| {
+                report!(errors::ApiErrorResponse::AccessForbidden {
+                    resource: "payout_link".to_string(),
+                })
+            })
+            .attach_printable_lazy(|| {
+                format!("Invalid URL found in request headers {}", origin_or_referer)
+            })?;
 
-    let payout_link = GenericLinkNew {
-        link_id: payout_link_data.payout_link_id.to_string(),
-        primary_reference: payout_link_data.payout_id.to_string(),
-        merchant_id: merchant_id.to_string(),
-        link_type: common_enums::GenericLinkType::PayoutLink,
-        link_status: GenericLinkStatus::PayoutLink(PayoutLinkStatus::Initiated),
-        link_data,
-        url: payout_link_data.link.to_string().into(),
-        return_url,
-        expiry: common_utils::date_time::now()
-            + Duration::seconds(payout_link_data.session_expiry.into()),
-        ..Default::default()
+        url.host_str()
+            .and_then(|host| url.port().map(|port| format!("{}:{}", host, port)))
+            .or_else(|| url.host_str().map(String::from))
+            .ok_or_else(|| {
+                report!(errors::ApiErrorResponse::AccessForbidden {
+                    resource: "payout_link".to_string(),
+                })
+            })
+            .attach_printable_lazy(|| {
+                format!("host or port not found in request headers {:?}", url)
+            })?
     };
 
-    db.insert_payout_link(payout_link)
-        .await
-        .to_duplicate_response(errors::ApiErrorResponse::GenericDuplicateError {
-            message: "payout link already exists".to_string(),
+    if is_domain_allowed(&domain_in_req, link_data.allowed_domains) {
+        Ok(())
+    } else {
+        Err(report!(errors::ApiErrorResponse::AccessForbidden {
+            resource: "payout_link".to_string(),
+        }))
+        .attach_printable_lazy(|| {
+            format!(
+                "Access to payout_link [{}] is forbidden from requestor - {}",
+                link_id, domain_in_req
+            )
         })
+    }
+}
+
+fn is_domain_allowed(domain: &str, allowed_domains: HashSet<String>) -> bool {
+    allowed_domains.iter().any(|allowed_domain| {
+        Glob::new(allowed_domain)
+            .map(|glob| glob.compile_matcher().is_match(domain))
+            .map_err(|err| logger::error!("Invalid glob pattern! - {:?}", err))
+            .unwrap_or(false)
+    })
 }
diff --git a/crates/router/src/routes/payout_link.rs b/crates/router/src/routes/payout_link.rs
index 34850bdea6e..e3a0327c329 100644
--- a/crates/router/src/routes/payout_link.rs
+++ b/crates/router/src/routes/payout_link.rs
@@ -23,13 +23,14 @@ pub async fn render_payout_link(
         merchant_id: merchant_id.clone(),
         payout_id,
     };
+    let headers = req.headers();
     Box::pin(api::server_wrap(
         flow,
         state,
         &req,
         payload.clone(),
         |state, auth, req, _| {
-            initiate_payout_link(state, auth.merchant_account, auth.key_store, req)
+            initiate_payout_link(state, auth.merchant_account, auth.key_store, req, headers)
         },
         &auth::MerchantIdAuth(merchant_id),
         api_locking::LockAction::NotApplicable,
diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs
index 5456d4c2502..5dc46d35652 100644
--- a/crates/router/src/services/api.rs
+++ b/crates/router/src/services/api.rs
@@ -13,8 +13,9 @@ use std::{
 
 use actix_http::header::HeaderMap;
 use actix_web::{
-    body, http::header::HeaderValue, web, FromRequest, HttpRequest, HttpResponse, Responder,
-    ResponseError,
+    body,
+    http::header::{HeaderName, HeaderValue},
+    web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError,
 };
 use api_models::enums::{CaptureMethod, PaymentMethodType};
 pub use client::{proxy_bypass_urls, ApiClient, MockApiClient, ProxyClient};
@@ -1049,9 +1050,18 @@ where
         }
 
         Ok(ApplicationResponse::GenericLinkForm(boxed_generic_link_data)) => {
-            let link_type = (boxed_generic_link_data).to_string();
-            match build_generic_link_html(*boxed_generic_link_data) {
-                Ok(rendered_html) => http_response_html_data(rendered_html),
+            let link_type = boxed_generic_link_data.data.to_string();
+            match build_generic_link_html(boxed_generic_link_data.data) {
+                Ok(rendered_html) => {
+                    let domains_str = boxed_generic_link_data
+                        .allowed_domains
+                        .into_iter()
+                        .collect::<Vec<String>>()
+                        .join(" ");
+                    let csp_header = format!("frame-ancestors 'self' {};", domains_str);
+                    let headers = HashSet::from([("content-security-policy", csp_header)]);
+                    http_response_html_data(rendered_html, Some(headers))
+                }
                 Err(_) => {
                     http_response_err(format!("Error while rendering {} HTML page", link_type))
                 }
@@ -1062,7 +1072,7 @@ where
             match *boxed_payment_link_data {
                 PaymentLinkAction::PaymentLinkFormData(payment_link_data) => {
                     match build_payment_link_html(payment_link_data) {
-                        Ok(rendered_html) => http_response_html_data(rendered_html),
+                        Ok(rendered_html) => http_response_html_data(rendered_html, None),
                         Err(_) => http_response_err(
                             r#"{
                                 "error": {
@@ -1074,7 +1084,7 @@ where
                 }
                 PaymentLinkAction::PaymentLinkStatus(payment_link_data) => {
                     match get_payment_link_status(payment_link_data) {
-                        Ok(rendered_html) => http_response_html_data(rendered_html),
+                        Ok(rendered_html) => http_response_html_data(rendered_html, None),
                         Err(_) => http_response_err(
                             r#"{
                                 "error": {
@@ -1231,8 +1241,22 @@ pub fn http_response_file_data<T: body::MessageBody + 'static>(
     HttpResponse::Ok().content_type(content_type).body(res)
 }
 
-pub fn http_response_html_data<T: body::MessageBody + 'static>(res: T) -> HttpResponse {
-    HttpResponse::Ok().content_type(mime::TEXT_HTML).body(res)
+pub fn http_response_html_data<T: body::MessageBody + 'static>(
+    res: T,
+    optional_headers: Option<HashSet<(&'static str, String)>>,
+) -> HttpResponse {
+    let mut res_builder = HttpResponse::Ok();
+    res_builder.content_type(mime::TEXT_HTML);
+
+    if let Some(headers) = optional_headers {
+        for (key, value) in headers {
+            if let Ok(header_val) = HeaderValue::try_from(value) {
+                res_builder.insert_header((HeaderName::from_static(key), header_val));
+            }
+        }
+    }
+
+    res_builder.body(res)
 }
 
 pub fn http_response_ok() -> HttpResponse {
diff --git a/crates/router/src/services/api/generic_link_response.rs b/crates/router/src/services/api/generic_link_response.rs
index fb8b3b3ec74..497926481ab 100644
--- a/crates/router/src/services/api/generic_link_response.rs
+++ b/crates/router/src/services/api/generic_link_response.rs
@@ -1,25 +1,27 @@
 use common_utils::errors::CustomResult;
 use error_stack::ResultExt;
+use hyperswitch_domain_models::api::{
+    GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinksData,
+};
 use tera::{Context, Tera};
 
-use super::{GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinks};
 use crate::core::errors;
 
 pub fn build_generic_link_html(
-    boxed_generic_link_data: GenericLinks,
+    boxed_generic_link_data: GenericLinksData,
 ) -> CustomResult<String, errors::ApiErrorResponse> {
     match boxed_generic_link_data {
-        GenericLinks::ExpiredLink(link_data) => build_generic_expired_link_html(&link_data),
+        GenericLinksData::ExpiredLink(link_data) => build_generic_expired_link_html(&link_data),
 
-        GenericLinks::PaymentMethodCollect(pm_collect_data) => {
+        GenericLinksData::PaymentMethodCollect(pm_collect_data) => {
             build_pm_collect_link_html(&pm_collect_data)
         }
-        GenericLinks::PaymentMethodCollectStatus(pm_collect_data) => {
+        GenericLinksData::PaymentMethodCollectStatus(pm_collect_data) => {
             build_pm_collect_link_status_html(&pm_collect_data)
         }
-        GenericLinks::PayoutLink(payout_link_data) => build_payout_link_html(&payout_link_data),
+        GenericLinksData::PayoutLink(payout_link_data) => build_payout_link_html(&payout_link_data),
 
-        GenericLinks::PayoutLinkStatus(pm_collect_data) => {
+        GenericLinksData::PayoutLinkStatus(pm_collect_data) => {
             build_payout_link_status_html(&pm_collect_data)
         }
     }
diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs
index 55288e02f86..884e47c6fc4 100644
--- a/crates/router/src/types/api/admin.rs
+++ b/crates/router/src/types/api/admin.rs
@@ -9,7 +9,7 @@ pub use api_models::admin::{
     ToggleKVResponse, WebhookDetails,
 };
 use common_utils::ext_traits::{AsyncExt, Encode, ValueExt};
-use error_stack::ResultExt;
+use error_stack::{report, ResultExt};
 use hyperswitch_domain_models::{merchant_key_store::MerchantKeyStore, type_encryption::decrypt};
 use masking::{ExposeInterface, PeekInterface, Secret};
 
@@ -194,6 +194,21 @@ pub async fn create_business_profile(
         .change_context(errors::ApiErrorResponse::InternalServerError)
         .attach_printable("Unable to encrypt outgoing webhook custom HTTP headers")?;
 
+    let payout_link_config = request
+        .payout_link_config
+        .as_ref()
+        .map(|payout_conf| match payout_conf.config.validate() {
+            Ok(_) => payout_conf.encode_to_value().change_context(
+                errors::ApiErrorResponse::InvalidDataValue {
+                    field_name: "payout_link_config",
+                },
+            ),
+            Err(e) => Err(report!(errors::ApiErrorResponse::InvalidRequestData {
+                message: e.to_string()
+            })),
+        })
+        .transpose()?;
+
     Ok(storage::business_profile::BusinessProfileNew {
         profile_id,
         merchant_id: merchant_account.merchant_id,
@@ -246,14 +261,7 @@ pub async fn create_business_profile(
             .change_context(errors::ApiErrorResponse::InvalidDataValue {
                 field_name: "authentication_connector_details",
             })?,
-        payout_link_config: request
-            .payout_link_config
-            .as_ref()
-            .map(Encode::encode_to_value)
-            .transpose()
-            .change_context(errors::ApiErrorResponse::InvalidDataValue {
-                field_name: "payout_link_config",
-            })?,
+        payout_link_config,
         is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled,
         is_extended_card_info_enabled: None,
         extended_card_info_config: None,
diff --git a/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql
new file mode 100644
index 00000000000..623bec2a2f0
--- /dev/null
+++ b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/down.sql
@@ -0,0 +1,3 @@
+UPDATE generic_link
+SET link_data = link_data - 'allowed_domains'
+WHERE link_data -> 'allowed_domains' = '["*"]'::jsonb AND link_type = 'payout_link';
\ No newline at end of file
diff --git a/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql
new file mode 100644
index 00000000000..affa2755d7e
--- /dev/null
+++ b/migrations/2024-07-17-064610_add_allowed_domains_to_link_data/up.sql
@@ -0,0 +1,5 @@
+UPDATE generic_link
+SET link_data = jsonb_set(link_data, '{allowed_domains}', '["*"]'::jsonb)
+WHERE
+    NOT link_data ? 'allowed_domains'
+    AND link_type = 'payout_link';
\ No newline at end of file

## Type of Change

- [ ] Bugfix
- [ ] New feature
- [x] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
This PR includes below changes -
- adding `allowed_domains` field in payout link config
  - this is added at both business profile level and individual payout txns
  - business profile config is used as a fallback in case `allowed_domains` are not passed in payout link's create request
  - this is a required field in production env
- server side validations for incoming render request
  - check `Sec-Fetch-Dest` [[ref](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest)] for request source (must be `iframe`)
  - check `Origin / Referer` for the host of the incoming request (must match one in `allowed_domains`)
  - these are skipped if `allowed_domains` is not setup (only in non-production env)
- client side headers
  - attach CSP header - `frame-ancestors` for directing which hosts can embed the links in an iframe [[ref](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors)]
  - ~~attach X-Frame header - similar to CSP, but for older browsers [[ref](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options)]~~ _Removing this as it's outdated_
- update status UI for payout links

feat(generic_link): add allowed_domains in GenericLink response
feat(payout_link): add allowed_domains in payout_link_config (fallback profile config + payout request config)
feat(payout_link): consume meta headers for validating render request for payout links
feat(payout_link): consume CSP and X-Frame headers in the link's response

### Additional Changes

- [ ] This PR modifies the API contract
- [x] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->


## How did you test it?
**Tested locally using postman**

1. Update business profile's `payout_link_config` to include `allowed_domains`
```
curl --location 'http://localhost:8080/account/merchant_1721206540/business_profile/pro_iMeDAsjCBueHxdjCiZ9I' \
--header 'Content-Type: application/json' \
--header 'api-key: test_admin' \
--data '{
    "payout_link_config": {
        "allowed_domains": ["127.0.0.1:5500"]
    }
}'
```

2. Create a payout link
```
curl --location 'http://localhost:8080/payouts/create' \
--header 'Content-Type: application/json' \
--header 'api-key: dev_Ew5ejag6iXMc80CDUUflU4daqTvm0wsMc1AgUo174U7iaV1GFvf8kkwt8LLCxDkT' \
--data '{
    "amount": 3000,
    "currency": "EUR",
    "billing": {
        "address": {
            "city": "Hoogeveen",
            "country": "NL",
            "line1": "Raadhuisplein",
            "line2": "92",
            "zip": "7901 BW",
            "state": "FL"
        },
        "phone": {
            "number": "0650242319",
            "country_code": "+31"
        }
    },
    "payout_link": true,
    "payout_link_config": {
        "theme": "#143F1E",
        "logo": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg",
        "merchant_name": "Spotify"
    }
}'
```
3. Try opening this link in a new tab (should not open)
4. Open this inside an iframe (should open provided the `allowed_domains` and the iframe's host is same)

**Test cases**
```
cargo test --package common_utils --lib -- link_utils::domain_tests --show-output
```
![image](https://github.com/user-attachments/assets/cdc03c39-f914-4a5a-9166-ceb83f8b92ec)


## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [x] I added unit tests for my changes where possible
