mirror of
				https://github.com/juspay/hyperswitch.git
				synced 2025-11-01 02:57:02 +08:00 
			
		
		
		
	feat(webhooks): allow manually retrying delivery of outgoing webhooks (#4176)
This commit is contained in:
		| @ -34,6 +34,7 @@ pub enum Permission { | |||||||
|     WebhookEventRead, |     WebhookEventRead, | ||||||
|     PayoutWrite, |     PayoutWrite, | ||||||
|     PayoutRead, |     PayoutRead, | ||||||
|  |     WebhookEventWrite, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, serde::Serialize)] | #[derive(Debug, serde::Serialize)] | ||||||
|  | |||||||
| @ -94,6 +94,14 @@ pub struct EventRetrieveResponse { | |||||||
|     pub delivery_attempt: Option<WebhookDeliveryAttempt>, |     pub delivery_attempt: Option<WebhookDeliveryAttempt>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | impl common_utils::events::ApiEventMetric for EventRetrieveResponse { | ||||||
|  |     fn get_api_event_type(&self) -> Option<common_utils::events::ApiEventsType> { | ||||||
|  |         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. | /// The request information (headers and body) sent in the webhook. | ||||||
| #[derive(Debug, Serialize, Deserialize, ToSchema)] | #[derive(Debug, Serialize, Deserialize, ToSchema)] | ||||||
| pub struct OutgoingWebhookRequestContent { | pub struct OutgoingWebhookRequestContent { | ||||||
| @ -114,20 +122,24 @@ pub struct OutgoingWebhookRequestContent { | |||||||
| #[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] | #[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] | ||||||
| pub struct OutgoingWebhookResponseContent { | pub struct OutgoingWebhookResponseContent { | ||||||
|     /// The response body received for the webhook sent. |     /// The response body received for the webhook sent. | ||||||
|     #[schema(value_type = String)] |     #[schema(value_type = Option<String>)] | ||||||
|     #[serde(alias = "payload")] |     #[serde(alias = "payload")] | ||||||
|     pub body: Secret<String>, |     pub body: Option<Secret<String>>, | ||||||
|  |  | ||||||
|     /// The response headers received for the webhook sent. |     /// The response headers received for the webhook sent. | ||||||
|     #[schema( |     #[schema( | ||||||
|         value_type = Vec<(String, String)>, |         value_type = Option<Vec<(String, String)>>, | ||||||
|         example = json!([["content-type", "application/json"], ["content-length", "1024"]])) |         example = json!([["content-type", "application/json"], ["content-length", "1024"]])) | ||||||
|     ] |     ] | ||||||
|     pub headers: Vec<(String, Secret<String>)>, |     pub headers: Option<Vec<(String, Secret<String>)>>, | ||||||
|  |  | ||||||
|     /// The HTTP status code for the webhook sent. |     /// The HTTP status code for the webhook sent. | ||||||
|     #[schema(example = 200)] |     #[schema(example = 200)] | ||||||
|     pub status_code: u16, |     pub status_code: Option<u16>, | ||||||
|  |  | ||||||
|  |     /// Error message in case any error occurred when trying to deliver the webhook. | ||||||
|  |     #[schema(example = 200)] | ||||||
|  |     pub error_message: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, serde::Serialize)] | #[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<common_utils::events::ApiEventsType> { | ||||||
|  |         Some(common_utils::events::ApiEventsType::Events { | ||||||
|  |             merchant_id_or_profile_id: self.merchant_id_or_profile_id.clone(), | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | |||||||
| @ -177,6 +177,7 @@ Never share your secret api keys. Keep them guarded and secure. | |||||||
|         // Routes for events |         // Routes for events | ||||||
|         routes::webhook_events::list_initial_webhook_delivery_attempts, |         routes::webhook_events::list_initial_webhook_delivery_attempts, | ||||||
|         routes::webhook_events::list_webhook_delivery_attempts, |         routes::webhook_events::list_webhook_delivery_attempts, | ||||||
|  |         routes::webhook_events::retry_webhook_delivery_attempt, | ||||||
|     ), |     ), | ||||||
|     components(schemas( |     components(schemas( | ||||||
|         api_models::refunds::RefundRequest, |         api_models::refunds::RefundRequest, | ||||||
|  | |||||||
| @ -68,3 +68,27 @@ pub fn list_initial_webhook_delivery_attempts() {} | |||||||
|     security(("admin_api_key" = [])) |     security(("admin_api_key" = [])) | ||||||
| )] | )] | ||||||
| pub fn list_webhook_delivery_attempts() {} | 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() {} | ||||||
|  | |||||||
| @ -887,9 +887,69 @@ async fn trigger_webhook_to_merchant( | |||||||
|     ); |     ); | ||||||
|     logger::debug!(outgoing_webhook_response=?response); |     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 = |     let api_client_error_handler = | ||||||
|         |client_error: error_stack::Report<errors::ApiClientError>, |         |state: AppState, | ||||||
|          delivery_attempt: enums::WebhookDeliveryAttempt| { |          merchant_key_store: domain::MerchantKeyStore, | ||||||
|  |          merchant_id: String, | ||||||
|  |          event_id: String, | ||||||
|  |          client_error: error_stack::Report<errors::ApiClientError>, | ||||||
|  |          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 = |             let error = | ||||||
|                 client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); |                 client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); | ||||||
|             logger::error!( |             logger::error!( | ||||||
| @ -897,6 +957,8 @@ async fn trigger_webhook_to_merchant( | |||||||
|                 ?delivery_attempt, |                 ?delivery_attempt, | ||||||
|                 "An error occurred when sending webhook to merchant" |                 "An error occurred when sending webhook to merchant" | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|  |             Ok::<_, error_stack::Report<errors::WebhooksFlowError>>(()) | ||||||
|         }; |         }; | ||||||
|     let update_event_in_storage = |state: AppState, |     let update_event_in_storage = |state: AppState, | ||||||
|                                    merchant_key_store: domain::MerchantKeyStore, |                                    merchant_key_store: domain::MerchantKeyStore, | ||||||
| @ -934,9 +996,10 @@ async fn trigger_webhook_to_merchant( | |||||||
|                 Secret::from(String::from("Non-UTF-8 response body")) |                 Secret::from(String::from("Non-UTF-8 response body")) | ||||||
|             }); |             }); | ||||||
|         let response_to_store = OutgoingWebhookResponseContent { |         let response_to_store = OutgoingWebhookResponseContent { | ||||||
|             body: response_body, |             body: Some(response_body), | ||||||
|             headers: response_headers, |             headers: Some(response_headers), | ||||||
|             status_code: status_code.as_u16(), |             status_code: Some(status_code.as_u16()), | ||||||
|  |             error_message: None, | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let event_update = domain::EventUpdate::UpdateResponse { |         let event_update = domain::EventUpdate::UpdateResponse { | ||||||
| @ -953,7 +1016,7 @@ async fn trigger_webhook_to_merchant( | |||||||
|                 ) |                 ) | ||||||
|                 .await |                 .await | ||||||
|                 .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) |                 .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) | ||||||
|                 .attach_printable("Failed to encrypt outgoing webhook request content")?, |                 .attach_printable("Failed to encrypt outgoing webhook response content")?, | ||||||
|             ), |             ), | ||||||
|         }; |         }; | ||||||
|         state |         state | ||||||
| @ -967,16 +1030,19 @@ async fn trigger_webhook_to_merchant( | |||||||
|             .await |             .await | ||||||
|             .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) |             .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 = |     let success_response_handler = | ||||||
|         |state: AppState, |         |state: AppState, | ||||||
|          merchant_id: String, |          merchant_id: String, | ||||||
|          process_tracker: Option<storage::ProcessTracker>, |          process_tracker: Option<storage::ProcessTracker>, | ||||||
|          business_status: &'static str| async move { |          business_status: &'static str| async move { | ||||||
|             metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( |             increment_webhook_outgoing_received_count(merchant_id); | ||||||
|                 &metrics::CONTEXT, |  | ||||||
|                 1, |  | ||||||
|                 &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], |  | ||||||
|             ); |  | ||||||
|  |  | ||||||
|             match process_tracker { |             match process_tracker { | ||||||
|                 Some(process_tracker) => state |                 Some(process_tracker) => state | ||||||
| @ -1006,7 +1072,17 @@ async fn trigger_webhook_to_merchant( | |||||||
|  |  | ||||||
|     match delivery_attempt { |     match delivery_attempt { | ||||||
|         enums::WebhookDeliveryAttempt::InitialAttempt => match response { |         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) => { |             Ok(response) => { | ||||||
|                 let status_code = response.status(); |                 let status_code = response.status(); | ||||||
|                 let _updated_event = update_event_in_storage( |                 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")?; |                 .attach_printable("`process_tracker` is unavailable in automatic retry flow")?; | ||||||
|             match response { |             match response { | ||||||
|                 Err(client_error) => { |                 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 |                     // Schedule a retry attempt for webhook delivery | ||||||
|                     outgoing_webhook_retry::retry_webhook_delivery_task( |                     outgoing_webhook_retry::retry_webhook_delivery_task( | ||||||
|                         &*state.store, |                         &*state.store, | ||||||
| @ -1095,10 +1179,41 @@ async fn trigger_webhook_to_merchant( | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         enums::WebhookDeliveryAttempt::ManualRetry => { |         enums::WebhookDeliveryAttempt::ManualRetry => match response { | ||||||
|             // Will be updated when manual retry is implemented |             Err(client_error) => { | ||||||
|             Err(errors::WebhooksFlowError::NotReceivedByMerchant)? |                 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(()) |     Ok(()) | ||||||
|  | |||||||
| @ -1,19 +1,21 @@ | |||||||
| use error_stack::ResultExt; | use error_stack::ResultExt; | ||||||
|  | use masking::PeekInterface; | ||||||
| use router_env::{instrument, tracing}; | use router_env::{instrument, tracing}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     core::errors::{self, RouterResponse, StorageErrorExt}, |     core::errors::{self, RouterResponse, StorageErrorExt}, | ||||||
|     routes::AppState, |     routes::AppState, | ||||||
|     services::ApplicationResponse, |     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; | const INITIAL_DELIVERY_ATTEMPTS_LIST_MAX_LIMIT: i64 = 100; | ||||||
|  |  | ||||||
| #[derive(Debug)] | #[derive(Debug)] | ||||||
| enum MerchantIdOrProfileId { | enum MerchantAccountOrBusinessProfile { | ||||||
|     MerchantId(String), |     MerchantAccount(domain::MerchantAccount), | ||||||
|     ProfileId(String), |     BusinessProfile(storage::BusinessProfile), | ||||||
| } | } | ||||||
|  |  | ||||||
| #[instrument(skip(state))] | #[instrument(skip(state))] | ||||||
| @ -27,22 +29,22 @@ pub async fn list_initial_delivery_attempts( | |||||||
|  |  | ||||||
|     let store = state.store.as_ref(); |     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?; |         determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; | ||||||
|  |  | ||||||
|     let events = match constraints { |     let events = match constraints { | ||||||
|         api_models::webhook_events::EventListConstraintsInternal::ObjectIdFilter { object_id } => { |         api_models::webhook_events::EventListConstraintsInternal::ObjectIdFilter { object_id } => { | ||||||
|             match identifier { |             match account { | ||||||
|                 MerchantIdOrProfileId::MerchantId(merchant_id) => store |                 MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store | ||||||
|                 .list_initial_events_by_merchant_id_primary_object_id( |                 .list_initial_events_by_merchant_id_primary_object_id( | ||||||
|                     &merchant_id, |                     &merchant_account.merchant_id, | ||||||
|                     &object_id, |                     &object_id, | ||||||
|                     &key_store, |                     &key_store, | ||||||
|                 ) |                 ) | ||||||
|                 .await, |                 .await, | ||||||
|                 MerchantIdOrProfileId::ProfileId(profile_id) => store |                 MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store | ||||||
|                 .list_initial_events_by_profile_id_primary_object_id( |                 .list_initial_events_by_profile_id_primary_object_id( | ||||||
|                     &profile_id, |                     &business_profile.profile_id, | ||||||
|                     &object_id, |                     &object_id, | ||||||
|                     &key_store, |                     &key_store, | ||||||
|                 ) |                 ) | ||||||
| @ -69,10 +71,10 @@ pub async fn list_initial_delivery_attempts( | |||||||
|                 _ => None, |                 _ => None, | ||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             match identifier { |             match account { | ||||||
|                 MerchantIdOrProfileId::MerchantId(merchant_id) => store |                 MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store | ||||||
|                 .list_initial_events_by_merchant_id_constraints( |                 .list_initial_events_by_merchant_id_constraints( | ||||||
|                     &merchant_id, |                     &merchant_account.merchant_id, | ||||||
|                     created_after, |                     created_after, | ||||||
|                     created_before, |                     created_before, | ||||||
|                     limit, |                     limit, | ||||||
| @ -80,9 +82,9 @@ pub async fn list_initial_delivery_attempts( | |||||||
|                     &key_store, |                     &key_store, | ||||||
|                 ) |                 ) | ||||||
|                 .await, |                 .await, | ||||||
|                 MerchantIdOrProfileId::ProfileId(profile_id) => store |                 MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store | ||||||
|                 .list_initial_events_by_profile_id_constraints( |                 .list_initial_events_by_profile_id_constraints( | ||||||
|                     &profile_id, |                     &business_profile.profile_id, | ||||||
|                     created_after, |                     created_after, | ||||||
|                     created_before, |                     created_before, | ||||||
|                     limit, |                     limit, | ||||||
| @ -112,23 +114,23 @@ pub async fn list_delivery_attempts( | |||||||
| ) -> RouterResponse<Vec<api::webhook_events::EventRetrieveResponse>> { | ) -> RouterResponse<Vec<api::webhook_events::EventRetrieveResponse>> { | ||||||
|     let store = state.store.as_ref(); |     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?; |         determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; | ||||||
|  |  | ||||||
|     let events = match identifier { |     let events = match account { | ||||||
|         MerchantIdOrProfileId::MerchantId(merchant_id) => { |         MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => { | ||||||
|             store |             store | ||||||
|                 .list_events_by_merchant_id_initial_attempt_id( |                 .list_events_by_merchant_id_initial_attempt_id( | ||||||
|                     &merchant_id, |                     &merchant_account.merchant_id, | ||||||
|                     &initial_attempt_id, |                     &initial_attempt_id, | ||||||
|                     &key_store, |                     &key_store, | ||||||
|                 ) |                 ) | ||||||
|                 .await |                 .await | ||||||
|         } |         } | ||||||
|         MerchantIdOrProfileId::ProfileId(profile_id) => { |         MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => { | ||||||
|             store |             store | ||||||
|                 .list_events_by_profile_id_initial_attempt_id( |                 .list_events_by_profile_id_initial_attempt_id( | ||||||
|                     &profile_id, |                     &business_profile.profile_id, | ||||||
|                     &initial_attempt_id, |                     &initial_attempt_id, | ||||||
|                     &key_store, |                     &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<api::webhook_events::EventRetrieveResponse> { | ||||||
|  |     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( | async fn determine_identifier_and_get_key_store( | ||||||
|     state: AppState, |     state: AppState, | ||||||
|     merchant_id_or_profile_id: String, |     merchant_id_or_profile_id: String, | ||||||
| ) -> errors::RouterResult<(MerchantIdOrProfileId, domain::MerchantKeyStore)> { | ) -> errors::RouterResult<(MerchantAccountOrBusinessProfile, domain::MerchantKeyStore)> { | ||||||
|     let store = state.store.as_ref(); |     let store = state.store.as_ref(); | ||||||
|     match store |     match store | ||||||
|         .get_merchant_key_store_by_merchant_id( |         .get_merchant_key_store_by_merchant_id( | ||||||
| @ -165,13 +265,25 @@ async fn determine_identifier_and_get_key_store( | |||||||
|         ) |         ) | ||||||
|         .await |         .await | ||||||
|     { |     { | ||||||
|         // Valid merchant ID |         // Since a merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`, | ||||||
|         Ok(key_store) => Ok(( |         // `merchant_id_or_profile_id` is a valid merchant ID. | ||||||
|             MerchantIdOrProfileId::MerchantId(merchant_id_or_profile_id), |         // Find a merchant account having `merchant_id` = `merchant_id_or_profile_id`. | ||||||
|             key_store, |         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() => { |         Err(error) if error.current_context().is_db_not_found() => { | ||||||
|             router_env::logger::debug!( |             router_env::logger::debug!( | ||||||
|                 ?error, |                 ?error, | ||||||
| @ -195,7 +307,7 @@ async fn determine_identifier_and_get_key_store( | |||||||
|                 .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; |                 .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; | ||||||
|  |  | ||||||
|             Ok(( |             Ok(( | ||||||
|                 MerchantIdOrProfileId::ProfileId(business_profile.profile_id), |                 MerchantAccountOrBusinessProfile::BusinessProfile(business_profile), | ||||||
|                 key_store, |                 key_store, | ||||||
|             )) |             )) | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -1247,8 +1247,15 @@ impl WebhookEvents { | |||||||
|             .app_data(web::Data::new(config)) |             .app_data(web::Data::new(config)) | ||||||
|             .service(web::resource("").route(web::get().to(list_initial_webhook_delivery_attempts))) |             .service(web::resource("").route(web::get().to(list_initial_webhook_delivery_attempts))) | ||||||
|             .service( |             .service( | ||||||
|                 web::resource("/{event_id}/attempts") |                 web::scope("/{event_id}") | ||||||
|                     .route(web::get().to(list_webhook_delivery_attempts)), |                     .service( | ||||||
|  |                         web::resource("attempts") | ||||||
|  |                             .route(web::get().to(list_webhook_delivery_attempts)), | ||||||
|  |                     ) | ||||||
|  |                     .service( | ||||||
|  |                         web::resource("retry") | ||||||
|  |                             .route(web::post().to(retry_webhook_delivery_attempt)), | ||||||
|  |                     ), | ||||||
|             ) |             ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -137,7 +137,8 @@ impl From<Flow> for ApiIdentifier { | |||||||
|             Flow::FrmFulfillment |             Flow::FrmFulfillment | ||||||
|             | Flow::IncomingWebhookReceive |             | Flow::IncomingWebhookReceive | ||||||
|             | Flow::WebhookEventInitialDeliveryAttemptList |             | Flow::WebhookEventInitialDeliveryAttemptList | ||||||
|             | Flow::WebhookEventDeliveryAttemptList => Self::Webhooks, |             | Flow::WebhookEventDeliveryAttemptList | ||||||
|  |             | Flow::WebhookEventDeliveryRetry => Self::Webhooks, | ||||||
|  |  | ||||||
|             Flow::ApiKeyCreate |             Flow::ApiKeyCreate | ||||||
|             | Flow::ApiKeyRetrieve |             | Flow::ApiKeyRetrieve | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ use crate::{ | |||||||
|     services::{api, authentication as auth, authorization::permissions::Permission}, |     services::{api, authentication as auth, authorization::permissions::Permission}, | ||||||
|     types::api::webhook_events::{ |     types::api::webhook_events::{ | ||||||
|         EventListConstraints, EventListRequestInternal, WebhookDeliveryAttemptListRequestInternal, |         EventListConstraints, EventListRequestInternal, WebhookDeliveryAttemptListRequestInternal, | ||||||
|  |         WebhookDeliveryRetryRequestInternal, | ||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @ -89,3 +90,42 @@ pub async fn list_webhook_delivery_attempts( | |||||||
|     ) |     ) | ||||||
|     .await |     .await | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[instrument(skip_all, fields(flow = ?Flow::WebhookEventDeliveryRetry))] | ||||||
|  | pub async fn retry_webhook_delivery_attempt( | ||||||
|  |     state: web::Data<AppState>, | ||||||
|  |     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 | ||||||
|  | } | ||||||
|  | |||||||
| @ -75,12 +75,13 @@ pub static USERS_MANAGE: [Permission; 2] = | |||||||
|  |  | ||||||
| pub static MERCHANT_DETAILS_VIEW: [Permission; 1] = [Permission::MerchantAccountRead]; | 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::MerchantAccountWrite, | ||||||
|     Permission::ApiKeyRead, |     Permission::ApiKeyRead, | ||||||
|     Permission::ApiKeyWrite, |     Permission::ApiKeyWrite, | ||||||
|     Permission::MerchantAccountRead, |     Permission::MerchantAccountRead, | ||||||
|     Permission::WebhookEventRead, |     Permission::WebhookEventRead, | ||||||
|  |     Permission::WebhookEventWrite, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| pub static ORGANIZATION_MANAGE: [Permission; 2] = [ | pub static ORGANIZATION_MANAGE: [Permission; 2] = [ | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ pub enum Permission { | |||||||
|     UsersWrite, |     UsersWrite, | ||||||
|     MerchantAccountCreate, |     MerchantAccountCreate, | ||||||
|     WebhookEventRead, |     WebhookEventRead, | ||||||
|  |     WebhookEventWrite, | ||||||
|     PayoutRead, |     PayoutRead, | ||||||
|     PayoutWrite, |     PayoutWrite, | ||||||
| } | } | ||||||
| @ -71,6 +72,7 @@ impl Permission { | |||||||
|             Self::UsersWrite => "Invite users, assign and update roles", |             Self::UsersWrite => "Invite users, assign and update roles", | ||||||
|             Self::MerchantAccountCreate => "Create merchant account", |             Self::MerchantAccountCreate => "Create merchant account", | ||||||
|             Self::WebhookEventRead => "View webhook events", |             Self::WebhookEventRead => "View webhook events", | ||||||
|  |             Self::WebhookEventWrite => "Trigger retries for webhook events", | ||||||
|             Self::PayoutRead => "View all payouts", |             Self::PayoutRead => "View all payouts", | ||||||
|             Self::PayoutWrite => "Create payout, download payout data", |             Self::PayoutWrite => "Create payout, download payout data", | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -2,4 +2,5 @@ pub use api_models::webhook_events::{ | |||||||
|     EventListConstraints, EventListConstraintsInternal, EventListItemResponse, |     EventListConstraints, EventListConstraintsInternal, EventListItemResponse, | ||||||
|     EventListRequestInternal, EventRetrieveResponse, OutgoingWebhookRequestContent, |     EventListRequestInternal, EventRetrieveResponse, OutgoingWebhookRequestContent, | ||||||
|     OutgoingWebhookResponseContent, WebhookDeliveryAttemptListRequestInternal, |     OutgoingWebhookResponseContent, WebhookDeliveryAttemptListRequestInternal, | ||||||
|  |     WebhookDeliveryRetryRequestInternal, | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -44,6 +44,7 @@ impl From<Permission> for user_role_api::Permission { | |||||||
|             Permission::UsersWrite => Self::UsersWrite, |             Permission::UsersWrite => Self::UsersWrite, | ||||||
|             Permission::MerchantAccountCreate => Self::MerchantAccountCreate, |             Permission::MerchantAccountCreate => Self::MerchantAccountCreate, | ||||||
|             Permission::WebhookEventRead => Self::WebhookEventRead, |             Permission::WebhookEventRead => Self::WebhookEventRead, | ||||||
|  |             Permission::WebhookEventWrite => Self::WebhookEventWrite, | ||||||
|             Permission::PayoutRead => Self::PayoutRead, |             Permission::PayoutRead => Self::PayoutRead, | ||||||
|             Permission::PayoutWrite => Self::PayoutWrite, |             Permission::PayoutWrite => Self::PayoutWrite, | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -120,7 +120,7 @@ impl ProcessTrackerWorkflow<AppState> for OutgoingWebhookRetryWorkflow { | |||||||
|                     &key_store, |                     &key_store, | ||||||
|                     event, |                     event, | ||||||
|                     request_content, |                     request_content, | ||||||
|                     storage::enums::WebhookDeliveryAttempt::AutomaticRetry, |                     delivery_attempt, | ||||||
|                     None, |                     None, | ||||||
|                     Some(process), |                     Some(process), | ||||||
|                 ) |                 ) | ||||||
| @ -172,7 +172,7 @@ impl ProcessTrackerWorkflow<AppState> for OutgoingWebhookRetryWorkflow { | |||||||
|                             &key_store, |                             &key_store, | ||||||
|                             event, |                             event, | ||||||
|                             request_content, |                             request_content, | ||||||
|                             storage::enums::WebhookDeliveryAttempt::AutomaticRetry, |                             delivery_attempt, | ||||||
|                             Some(content), |                             Some(content), | ||||||
|                             Some(process), |                             Some(process), | ||||||
|                         ) |                         ) | ||||||
|  | |||||||
| @ -398,6 +398,8 @@ pub enum Flow { | |||||||
|     WebhookEventInitialDeliveryAttemptList, |     WebhookEventInitialDeliveryAttemptList, | ||||||
|     /// List delivery attempts for a webhook event |     /// List delivery attempts for a webhook event | ||||||
|     WebhookEventDeliveryAttemptList, |     WebhookEventDeliveryAttemptList, | ||||||
|  |     /// Manually retry the delivery for a webhook event | ||||||
|  |     WebhookEventDeliveryRetry, | ||||||
| } | } | ||||||
|  |  | ||||||
| /// | /// | ||||||
|  | |||||||
| @ -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": { |   "components": { | ||||||
| @ -11661,15 +11708,11 @@ | |||||||
|       "OutgoingWebhookResponseContent": { |       "OutgoingWebhookResponseContent": { | ||||||
|         "type": "object", |         "type": "object", | ||||||
|         "description": "The response information (headers, body and status code) received for the webhook sent.", |         "description": "The response information (headers, body and status code) received for the webhook sent.", | ||||||
|         "required": [ |  | ||||||
|           "body", |  | ||||||
|           "headers", |  | ||||||
|           "status_code" |  | ||||||
|         ], |  | ||||||
|         "properties": { |         "properties": { | ||||||
|           "body": { |           "body": { | ||||||
|             "type": "string", |             "type": "string", | ||||||
|             "description": "The response body received for the webhook sent." |             "description": "The response body received for the webhook sent.", | ||||||
|  |             "nullable": true | ||||||
|           }, |           }, | ||||||
|           "headers": { |           "headers": { | ||||||
|             "type": "array", |             "type": "array", | ||||||
| @ -11696,14 +11739,22 @@ | |||||||
|                 "content-length", |                 "content-length", | ||||||
|                 "1024" |                 "1024" | ||||||
|               ] |               ] | ||||||
|             ] |             ], | ||||||
|  |             "nullable": true | ||||||
|           }, |           }, | ||||||
|           "status_code": { |           "status_code": { | ||||||
|             "type": "integer", |             "type": "integer", | ||||||
|             "format": "int32", |             "format": "int32", | ||||||
|             "description": "The HTTP status code for the webhook sent.", |             "description": "The HTTP status code for the webhook sent.", | ||||||
|             "example": 200, |             "example": 200, | ||||||
|  |             "nullable": true, | ||||||
|             "minimum": 0 |             "minimum": 0 | ||||||
|  |           }, | ||||||
|  |           "error_message": { | ||||||
|  |             "type": "string", | ||||||
|  |             "description": "Error message in case any error occurred when trying to deliver the webhook.", | ||||||
|  |             "example": 200, | ||||||
|  |             "nullable": true | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Sanchith Hegde
					Sanchith Hegde