diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs index 31da8386ab..25c1c5ecd4 100644 --- a/crates/api_models/src/user_role.rs +++ b/crates/api_models/src/user_role.rs @@ -34,6 +34,7 @@ pub enum Permission { WebhookEventRead, PayoutWrite, PayoutRead, + WebhookEventWrite, } #[derive(Debug, serde::Serialize)] diff --git a/crates/api_models/src/webhook_events.rs b/crates/api_models/src/webhook_events.rs index ef7db3231a..40a694a665 100644 --- a/crates/api_models/src/webhook_events.rs +++ b/crates/api_models/src/webhook_events.rs @@ -94,6 +94,14 @@ pub struct EventRetrieveResponse { pub delivery_attempt: Option, } +impl common_utils::events::ApiEventMetric for EventRetrieveResponse { + fn get_api_event_type(&self) -> Option { + Some(common_utils::events::ApiEventsType::Events { + merchant_id_or_profile_id: self.event_information.merchant_id.clone(), + }) + } +} + /// The request information (headers and body) sent in the webhook. #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct OutgoingWebhookRequestContent { @@ -114,20 +122,24 @@ pub struct OutgoingWebhookRequestContent { #[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] pub struct OutgoingWebhookResponseContent { /// The response body received for the webhook sent. - #[schema(value_type = String)] + #[schema(value_type = Option)] #[serde(alias = "payload")] - pub body: Secret, + pub body: Option>, /// The response headers received for the webhook sent. #[schema( - value_type = Vec<(String, String)>, + value_type = Option>, example = json!([["content-type", "application/json"], ["content-length", "1024"]])) ] - pub headers: Vec<(String, Secret)>, + pub headers: Option)>>, /// The HTTP status code for the webhook sent. #[schema(example = 200)] - pub status_code: u16, + pub status_code: Option, + + /// Error message in case any error occurred when trying to deliver the webhook. + #[schema(example = 200)] + pub error_message: Option, } #[derive(Debug, serde::Serialize)] @@ -157,3 +169,17 @@ impl common_utils::events::ApiEventMetric for WebhookDeliveryAttemptListRequestI }) } } + +#[derive(Debug, serde::Serialize)] +pub struct WebhookDeliveryRetryRequestInternal { + pub merchant_id_or_profile_id: String, + pub event_id: String, +} + +impl common_utils::events::ApiEventMetric for WebhookDeliveryRetryRequestInternal { + fn get_api_event_type(&self) -> Option { + Some(common_utils::events::ApiEventsType::Events { + merchant_id_or_profile_id: self.merchant_id_or_profile_id.clone(), + }) + } +} diff --git a/crates/openapi/src/openapi.rs b/crates/openapi/src/openapi.rs index 4a61a20d08..c9114cb134 100644 --- a/crates/openapi/src/openapi.rs +++ b/crates/openapi/src/openapi.rs @@ -177,6 +177,7 @@ Never share your secret api keys. Keep them guarded and secure. // Routes for events routes::webhook_events::list_initial_webhook_delivery_attempts, routes::webhook_events::list_webhook_delivery_attempts, + routes::webhook_events::retry_webhook_delivery_attempt, ), components(schemas( api_models::refunds::RefundRequest, diff --git a/crates/openapi/src/routes/webhook_events.rs b/crates/openapi/src/routes/webhook_events.rs index 4238b6e99c..fc06ffee58 100644 --- a/crates/openapi/src/routes/webhook_events.rs +++ b/crates/openapi/src/routes/webhook_events.rs @@ -68,3 +68,27 @@ pub fn list_initial_webhook_delivery_attempts() {} security(("admin_api_key" = [])) )] pub fn list_webhook_delivery_attempts() {} + +/// Events - Manual Retry +/// +/// Manually retry the delivery of the specified Event. +#[utoipa::path( + post, + path = "/events/{merchant_id_or_profile_id}/{event_id}/retry", + params( + ("merchant_id_or_profile_id" = String, Path, description = "The unique identifier for the Merchant Account or Business Profile"), + ("event_id" = String, Path, description = "The unique identifier for the Event"), + ), + responses( + ( + status = 200, + description = "The delivery of the Event was attempted. \ + Check the `response` field in the response payload to identify the status of the delivery attempt.", + body = EventRetrieveResponse + ), + ), + tag = "Event", + operation_id = "Manually retry the delivery of an Event", + security(("admin_api_key" = [])) +)] +pub fn retry_webhook_delivery_attempt() {} diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 143d9d8e26..031cf28c71 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -887,9 +887,69 @@ async fn trigger_webhook_to_merchant( ); logger::debug!(outgoing_webhook_response=?response); + let update_event_if_client_error = + |state: AppState, + merchant_key_store: domain::MerchantKeyStore, + merchant_id: String, + event_id: String, + error_message: String| async move { + let is_webhook_notified = false; + + let response_to_store = OutgoingWebhookResponseContent { + body: None, + headers: None, + status_code: None, + error_message: Some(error_message), + }; + + let event_update = domain::EventUpdate::UpdateResponse { + is_webhook_notified, + response: Some( + domain_types::encrypt( + response_to_store + .encode_to_string_of_json() + .change_context( + errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed, + ) + .map(Secret::new)?, + merchant_key_store.key.get_inner().peek(), + ) + .await + .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) + .attach_printable("Failed to encrypt outgoing webhook response content")?, + ), + }; + + state + .store + .update_event_by_merchant_id_event_id( + &merchant_id, + &event_id, + event_update, + &merchant_key_store, + ) + .await + .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) + }; + let api_client_error_handler = - |client_error: error_stack::Report, - delivery_attempt: enums::WebhookDeliveryAttempt| { + |state: AppState, + merchant_key_store: domain::MerchantKeyStore, + merchant_id: String, + event_id: String, + client_error: error_stack::Report, + delivery_attempt: enums::WebhookDeliveryAttempt| async move { + // Not including detailed error message in response information since it contains too + // much of diagnostic information to be exposed to the merchant. + update_event_if_client_error( + state, + merchant_key_store, + merchant_id, + event_id, + "Unable to send request to merchant server".to_string(), + ) + .await?; + let error = client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); logger::error!( @@ -897,6 +957,8 @@ async fn trigger_webhook_to_merchant( ?delivery_attempt, "An error occurred when sending webhook to merchant" ); + + Ok::<_, error_stack::Report>(()) }; let update_event_in_storage = |state: AppState, merchant_key_store: domain::MerchantKeyStore, @@ -934,9 +996,10 @@ async fn trigger_webhook_to_merchant( Secret::from(String::from("Non-UTF-8 response body")) }); let response_to_store = OutgoingWebhookResponseContent { - body: response_body, - headers: response_headers, - status_code: status_code.as_u16(), + body: Some(response_body), + headers: Some(response_headers), + status_code: Some(status_code.as_u16()), + error_message: None, }; let event_update = domain::EventUpdate::UpdateResponse { @@ -953,7 +1016,7 @@ async fn trigger_webhook_to_merchant( ) .await .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) - .attach_printable("Failed to encrypt outgoing webhook request content")?, + .attach_printable("Failed to encrypt outgoing webhook response content")?, ), }; state @@ -967,16 +1030,19 @@ async fn trigger_webhook_to_merchant( .await .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) }; + let increment_webhook_outgoing_received_count = |merchant_id: String| { + metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], + ) + }; let success_response_handler = |state: AppState, merchant_id: String, process_tracker: Option, business_status: &'static str| async move { - metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], - ); + increment_webhook_outgoing_received_count(merchant_id); match process_tracker { Some(process_tracker) => state @@ -1006,7 +1072,17 @@ async fn trigger_webhook_to_merchant( match delivery_attempt { enums::WebhookDeliveryAttempt::InitialAttempt => match response { - Err(client_error) => api_client_error_handler(client_error, delivery_attempt), + Err(client_error) => { + api_client_error_handler( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + client_error, + delivery_attempt, + ) + .await? + } Ok(response) => { let status_code = response.status(); let _updated_event = update_event_in_storage( @@ -1043,7 +1119,15 @@ async fn trigger_webhook_to_merchant( .attach_printable("`process_tracker` is unavailable in automatic retry flow")?; match response { Err(client_error) => { - api_client_error_handler(client_error, delivery_attempt); + api_client_error_handler( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + client_error, + delivery_attempt, + ) + .await?; // Schedule a retry attempt for webhook delivery outgoing_webhook_retry::retry_webhook_delivery_task( &*state.store, @@ -1095,10 +1179,41 @@ async fn trigger_webhook_to_merchant( } } } - enums::WebhookDeliveryAttempt::ManualRetry => { - // Will be updated when manual retry is implemented - Err(errors::WebhooksFlowError::NotReceivedByMerchant)? - } + enums::WebhookDeliveryAttempt::ManualRetry => match response { + Err(client_error) => { + api_client_error_handler( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + client_error, + delivery_attempt, + ) + .await? + } + Ok(response) => { + let status_code = response.status(); + let _updated_event = update_event_in_storage( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + response, + ) + .await?; + + if status_code.is_success() { + increment_webhook_outgoing_received_count(business_profile.merchant_id.clone()); + } else { + error_response_handler( + business_profile.merchant_id, + delivery_attempt, + status_code.as_u16(), + "Ignoring error when sending webhook to merchant", + ); + } + } + }, } Ok(()) diff --git a/crates/router/src/core/webhooks/webhook_events.rs b/crates/router/src/core/webhooks/webhook_events.rs index c719200384..6db214c2b0 100644 --- a/crates/router/src/core/webhooks/webhook_events.rs +++ b/crates/router/src/core/webhooks/webhook_events.rs @@ -1,19 +1,21 @@ use error_stack::ResultExt; +use masking::PeekInterface; use router_env::{instrument, tracing}; use crate::{ core::errors::{self, RouterResponse, StorageErrorExt}, routes::AppState, services::ApplicationResponse, - types::{api, domain, transformers::ForeignTryFrom}, + types::{api, domain, storage, transformers::ForeignTryFrom}, + utils::{OptionExt, StringExt}, }; const INITIAL_DELIVERY_ATTEMPTS_LIST_MAX_LIMIT: i64 = 100; #[derive(Debug)] -enum MerchantIdOrProfileId { - MerchantId(String), - ProfileId(String), +enum MerchantAccountOrBusinessProfile { + MerchantAccount(domain::MerchantAccount), + BusinessProfile(storage::BusinessProfile), } #[instrument(skip(state))] @@ -27,22 +29,22 @@ pub async fn list_initial_delivery_attempts( let store = state.store.as_ref(); - let (identifier, key_store) = + let (account, key_store) = determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; let events = match constraints { api_models::webhook_events::EventListConstraintsInternal::ObjectIdFilter { object_id } => { - match identifier { - MerchantIdOrProfileId::MerchantId(merchant_id) => store + match account { + MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store .list_initial_events_by_merchant_id_primary_object_id( - &merchant_id, + &merchant_account.merchant_id, &object_id, &key_store, ) .await, - MerchantIdOrProfileId::ProfileId(profile_id) => store + MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store .list_initial_events_by_profile_id_primary_object_id( - &profile_id, + &business_profile.profile_id, &object_id, &key_store, ) @@ -69,10 +71,10 @@ pub async fn list_initial_delivery_attempts( _ => None, }; - match identifier { - MerchantIdOrProfileId::MerchantId(merchant_id) => store + match account { + MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store .list_initial_events_by_merchant_id_constraints( - &merchant_id, + &merchant_account.merchant_id, created_after, created_before, limit, @@ -80,9 +82,9 @@ pub async fn list_initial_delivery_attempts( &key_store, ) .await, - MerchantIdOrProfileId::ProfileId(profile_id) => store + MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store .list_initial_events_by_profile_id_constraints( - &profile_id, + &business_profile.profile_id, created_after, created_before, limit, @@ -112,23 +114,23 @@ pub async fn list_delivery_attempts( ) -> RouterResponse> { let store = state.store.as_ref(); - let (identifier, key_store) = + let (account, key_store) = determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; - let events = match identifier { - MerchantIdOrProfileId::MerchantId(merchant_id) => { + let events = match account { + MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => { store .list_events_by_merchant_id_initial_attempt_id( - &merchant_id, + &merchant_account.merchant_id, &initial_attempt_id, &key_store, ) .await } - MerchantIdOrProfileId::ProfileId(profile_id) => { + MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => { store .list_events_by_profile_id_initial_attempt_id( - &profile_id, + &business_profile.profile_id, &initial_attempt_id, &key_store, ) @@ -153,10 +155,108 @@ pub async fn list_delivery_attempts( } } +#[instrument(skip(state))] +pub async fn retry_delivery_attempt( + state: AppState, + merchant_id_or_profile_id: String, + event_id: String, +) -> RouterResponse { + let store = state.store.as_ref(); + + let (account, key_store) = + determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; + + let event_to_retry = store + .find_event_by_merchant_id_event_id(&key_store.merchant_id, &event_id, &key_store) + .await + .to_not_found_response(errors::ApiErrorResponse::EventNotFound)?; + + let business_profile = match account { + MerchantAccountOrBusinessProfile::MerchantAccount(_) => { + let business_profile_id = event_to_retry + .business_profile_id + .get_required_value("business_profile_id") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to read business profile ID from event to retry")?; + store + .find_business_profile_by_profile_id(&business_profile_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to find business profile") + } + MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => Ok(business_profile), + }?; + + let delivery_attempt = storage::enums::WebhookDeliveryAttempt::ManualRetry; + let new_event_id = super::utils::generate_event_id(); + let idempotent_event_id = super::utils::get_idempotent_event_id( + &event_to_retry.primary_object_id, + event_to_retry.event_type, + delivery_attempt, + ); + + let now = common_utils::date_time::now(); + let new_event = domain::Event { + event_id: new_event_id.clone(), + event_type: event_to_retry.event_type, + event_class: event_to_retry.event_class, + is_webhook_notified: false, + primary_object_id: event_to_retry.primary_object_id, + primary_object_type: event_to_retry.primary_object_type, + created_at: now, + merchant_id: Some(business_profile.merchant_id.clone()), + business_profile_id: Some(business_profile.profile_id.clone()), + primary_object_created_at: event_to_retry.primary_object_created_at, + idempotent_event_id: Some(idempotent_event_id), + initial_attempt_id: event_to_retry.initial_attempt_id, + request: event_to_retry.request, + response: None, + delivery_attempt: Some(delivery_attempt), + }; + + let event = store + .insert_event(new_event, &key_store) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to insert event")?; + + // We only allow retrying deliveries for events with `request` populated. + let request_content = event + .request + .as_ref() + .get_required_value("request") + .change_context(errors::ApiErrorResponse::InternalServerError)? + .peek() + .parse_struct("OutgoingWebhookRequestContent") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse webhook event request information")?; + + super::trigger_webhook_and_raise_event( + state.clone(), + business_profile, + &key_store, + event, + request_content, + delivery_attempt, + None, + None, + ) + .await; + + let updated_event = store + .find_event_by_merchant_id_event_id(&key_store.merchant_id, &new_event_id, &key_store) + .await + .to_not_found_response(errors::ApiErrorResponse::EventNotFound)?; + + Ok(ApplicationResponse::Json( + api::webhook_events::EventRetrieveResponse::try_from(updated_event)?, + )) +} + async fn determine_identifier_and_get_key_store( state: AppState, merchant_id_or_profile_id: String, -) -> errors::RouterResult<(MerchantIdOrProfileId, domain::MerchantKeyStore)> { +) -> errors::RouterResult<(MerchantAccountOrBusinessProfile, domain::MerchantKeyStore)> { let store = state.store.as_ref(); match store .get_merchant_key_store_by_merchant_id( @@ -165,13 +265,25 @@ async fn determine_identifier_and_get_key_store( ) .await { - // Valid merchant ID - Ok(key_store) => Ok(( - MerchantIdOrProfileId::MerchantId(merchant_id_or_profile_id), - key_store, - )), + // Since a merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`, + // `merchant_id_or_profile_id` is a valid merchant ID. + // Find a merchant account having `merchant_id` = `merchant_id_or_profile_id`. + Ok(key_store) => { + let merchant_account = store + .find_merchant_account_by_merchant_id(&merchant_id_or_profile_id, &key_store) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; - // Invalid merchant ID, check if we can find a business profile with the identifier + Ok(( + MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account), + key_store, + )) + } + + // Since no merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`, + // `merchant_id_or_profile_id` is not a valid merchant ID. + // Assuming that `merchant_id_or_profile_id` is a business profile ID, try to find a + // business profile having `profile_id` = `merchant_id_or_profile_id`. Err(error) if error.current_context().is_db_not_found() => { router_env::logger::debug!( ?error, @@ -195,7 +307,7 @@ async fn determine_identifier_and_get_key_store( .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; Ok(( - MerchantIdOrProfileId::ProfileId(business_profile.profile_id), + MerchantAccountOrBusinessProfile::BusinessProfile(business_profile), key_store, )) } diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 7d9ee1670d..41893b0544 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1247,8 +1247,15 @@ impl WebhookEvents { .app_data(web::Data::new(config)) .service(web::resource("").route(web::get().to(list_initial_webhook_delivery_attempts))) .service( - web::resource("/{event_id}/attempts") - .route(web::get().to(list_webhook_delivery_attempts)), + web::scope("/{event_id}") + .service( + web::resource("attempts") + .route(web::get().to(list_webhook_delivery_attempts)), + ) + .service( + web::resource("retry") + .route(web::post().to(retry_webhook_delivery_attempt)), + ), ) } } diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 02a3a6841f..56df8b171c 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -137,7 +137,8 @@ impl From for ApiIdentifier { Flow::FrmFulfillment | Flow::IncomingWebhookReceive | Flow::WebhookEventInitialDeliveryAttemptList - | Flow::WebhookEventDeliveryAttemptList => Self::Webhooks, + | Flow::WebhookEventDeliveryAttemptList + | Flow::WebhookEventDeliveryRetry => Self::Webhooks, Flow::ApiKeyCreate | Flow::ApiKeyRetrieve diff --git a/crates/router/src/routes/webhook_events.rs b/crates/router/src/routes/webhook_events.rs index 2761ebbd1d..2ee18cbe76 100644 --- a/crates/router/src/routes/webhook_events.rs +++ b/crates/router/src/routes/webhook_events.rs @@ -7,6 +7,7 @@ use crate::{ services::{api, authentication as auth, authorization::permissions::Permission}, types::api::webhook_events::{ EventListConstraints, EventListRequestInternal, WebhookDeliveryAttemptListRequestInternal, + WebhookDeliveryRetryRequestInternal, }, }; @@ -89,3 +90,42 @@ pub async fn list_webhook_delivery_attempts( ) .await } + +#[instrument(skip_all, fields(flow = ?Flow::WebhookEventDeliveryRetry))] +pub async fn retry_webhook_delivery_attempt( + state: web::Data, + req: HttpRequest, + path: web::Path<(String, String)>, +) -> impl Responder { + let flow = Flow::WebhookEventDeliveryRetry; + let (merchant_id_or_profile_id, event_id) = path.into_inner(); + + let request_internal = WebhookDeliveryRetryRequestInternal { + merchant_id_or_profile_id: merchant_id_or_profile_id.clone(), + event_id, + }; + + api::server_wrap( + flow, + state, + &req, + request_internal, + |state, _, request_internal| { + webhook_events::retry_delivery_attempt( + state, + request_internal.merchant_id_or_profile_id, + request_internal.event_id, + ) + }, + auth::auth_type( + &auth::AdminApiAuth, + &auth::JWTAuthMerchantOrProfileFromRoute { + merchant_id_or_profile_id, + required_permission: Permission::WebhookEventWrite, + }, + req.headers(), + ), + api_locking::LockAction::NotApplicable, + ) + .await +} diff --git a/crates/router/src/services/authorization/permission_groups.rs b/crates/router/src/services/authorization/permission_groups.rs index b68bef213c..f3275a78fd 100644 --- a/crates/router/src/services/authorization/permission_groups.rs +++ b/crates/router/src/services/authorization/permission_groups.rs @@ -75,12 +75,13 @@ pub static USERS_MANAGE: [Permission; 2] = pub static MERCHANT_DETAILS_VIEW: [Permission; 1] = [Permission::MerchantAccountRead]; -pub static MERCHANT_DETAILS_MANAGE: [Permission; 5] = [ +pub static MERCHANT_DETAILS_MANAGE: [Permission; 6] = [ Permission::MerchantAccountWrite, Permission::ApiKeyRead, Permission::ApiKeyWrite, Permission::MerchantAccountRead, Permission::WebhookEventRead, + Permission::WebhookEventWrite, ]; pub static ORGANIZATION_MANAGE: [Permission; 2] = [ diff --git a/crates/router/src/services/authorization/permissions.rs b/crates/router/src/services/authorization/permissions.rs index 997eff8274..583b22fe86 100644 --- a/crates/router/src/services/authorization/permissions.rs +++ b/crates/router/src/services/authorization/permissions.rs @@ -31,6 +31,7 @@ pub enum Permission { UsersWrite, MerchantAccountCreate, WebhookEventRead, + WebhookEventWrite, PayoutRead, PayoutWrite, } @@ -71,6 +72,7 @@ impl Permission { Self::UsersWrite => "Invite users, assign and update roles", Self::MerchantAccountCreate => "Create merchant account", Self::WebhookEventRead => "View webhook events", + Self::WebhookEventWrite => "Trigger retries for webhook events", Self::PayoutRead => "View all payouts", Self::PayoutWrite => "Create payout, download payout data", } diff --git a/crates/router/src/types/api/webhook_events.rs b/crates/router/src/types/api/webhook_events.rs index 4c702b7038..950f6dc6a0 100644 --- a/crates/router/src/types/api/webhook_events.rs +++ b/crates/router/src/types/api/webhook_events.rs @@ -2,4 +2,5 @@ pub use api_models::webhook_events::{ EventListConstraints, EventListConstraintsInternal, EventListItemResponse, EventListRequestInternal, EventRetrieveResponse, OutgoingWebhookRequestContent, OutgoingWebhookResponseContent, WebhookDeliveryAttemptListRequestInternal, + WebhookDeliveryRetryRequestInternal, }; diff --git a/crates/router/src/utils/user_role.rs b/crates/router/src/utils/user_role.rs index 1fb58f983a..1a52153b43 100644 --- a/crates/router/src/utils/user_role.rs +++ b/crates/router/src/utils/user_role.rs @@ -44,6 +44,7 @@ impl From for user_role_api::Permission { Permission::UsersWrite => Self::UsersWrite, Permission::MerchantAccountCreate => Self::MerchantAccountCreate, Permission::WebhookEventRead => Self::WebhookEventRead, + Permission::WebhookEventWrite => Self::WebhookEventWrite, Permission::PayoutRead => Self::PayoutRead, Permission::PayoutWrite => Self::PayoutWrite, } diff --git a/crates/router/src/workflows/outgoing_webhook_retry.rs b/crates/router/src/workflows/outgoing_webhook_retry.rs index 0a9a103a05..0d40705bec 100644 --- a/crates/router/src/workflows/outgoing_webhook_retry.rs +++ b/crates/router/src/workflows/outgoing_webhook_retry.rs @@ -120,7 +120,7 @@ impl ProcessTrackerWorkflow for OutgoingWebhookRetryWorkflow { &key_store, event, request_content, - storage::enums::WebhookDeliveryAttempt::AutomaticRetry, + delivery_attempt, None, Some(process), ) @@ -172,7 +172,7 @@ impl ProcessTrackerWorkflow for OutgoingWebhookRetryWorkflow { &key_store, event, request_content, - storage::enums::WebhookDeliveryAttempt::AutomaticRetry, + delivery_attempt, Some(content), Some(process), ) diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index ca8d75b332..06667f96bb 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -398,6 +398,8 @@ pub enum Flow { WebhookEventInitialDeliveryAttemptList, /// List delivery attempts for a webhook event WebhookEventDeliveryAttemptList, + /// Manually retry the delivery for a webhook event + WebhookEventDeliveryRetry, } /// diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index 0ae5b25fc9..46687fcc9c 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -4469,6 +4469,53 @@ } ] } + }, + "/events/{merchant_id_or_profile_id}/{event_id}/retry": { + "post": { + "tags": [ + "Event" + ], + "summary": "Events - Manual Retry", + "description": "Events - Manual Retry\n\nManually retry the delivery of the specified Event.", + "operationId": "Manually retry the delivery of an Event", + "parameters": [ + { + "name": "merchant_id_or_profile_id", + "in": "path", + "description": "The unique identifier for the Merchant Account or Business Profile", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "event_id", + "in": "path", + "description": "The unique identifier for the Event", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The delivery of the Event was attempted. Check the `response` field in the response payload to identify the status of the delivery attempt.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventRetrieveResponse" + } + } + } + } + }, + "security": [ + { + "admin_api_key": [] + } + ] + } } }, "components": { @@ -11661,15 +11708,11 @@ "OutgoingWebhookResponseContent": { "type": "object", "description": "The response information (headers, body and status code) received for the webhook sent.", - "required": [ - "body", - "headers", - "status_code" - ], "properties": { "body": { "type": "string", - "description": "The response body received for the webhook sent." + "description": "The response body received for the webhook sent.", + "nullable": true }, "headers": { "type": "array", @@ -11696,14 +11739,22 @@ "content-length", "1024" ] - ] + ], + "nullable": true }, "status_code": { "type": "integer", "format": "int32", "description": "The HTTP status code for the webhook sent.", "example": 200, + "nullable": true, "minimum": 0 + }, + "error_message": { + "type": "string", + "description": "Error message in case any error occurred when trying to deliver the webhook.", + "example": 200, + "nullable": true } } },