mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-31 01:57:45 +08:00
feat(compatibility): add support for stripe compatible webhooks (#1728)
This commit is contained in:
committed by
GitHub
parent
14c2d72509
commit
87ae99f7f2
@ -98,9 +98,3 @@ pub enum OutgoingWebhookContent {
|
|||||||
RefundDetails(refunds::RefundResponse),
|
RefundDetails(refunds::RefundResponse),
|
||||||
DisputeDetails(Box<disputes::DisputeResponse>),
|
DisputeDetails(Box<disputes::DisputeResponse>),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait OutgoingWebhookType:
|
|
||||||
Serialize + From<OutgoingWebhook> + Sync + Send + std::fmt::Debug
|
|
||||||
{
|
|
||||||
}
|
|
||||||
impl OutgoingWebhookType for OutgoingWebhook {}
|
|
||||||
|
|||||||
@ -2,21 +2,70 @@ use api_models::{
|
|||||||
enums::DisputeStatus,
|
enums::DisputeStatus,
|
||||||
webhooks::{self as api},
|
webhooks::{self as api},
|
||||||
};
|
};
|
||||||
|
use common_utils::{crypto::SignMessage, date_time, ext_traits};
|
||||||
|
use error_stack::{IntoReport, ResultExt};
|
||||||
|
use router_env::logger;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
payment_intents::types::StripePaymentIntentResponse, refunds::types::StripeRefundResponse,
|
payment_intents::types::StripePaymentIntentResponse, refunds::types::StripeRefundResponse,
|
||||||
};
|
};
|
||||||
|
use crate::{
|
||||||
|
core::{errors, webhooks::types::OutgoingWebhookType},
|
||||||
|
headers,
|
||||||
|
services::request::Maskable,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug)]
|
||||||
pub struct StripeOutgoingWebhook {
|
pub struct StripeOutgoingWebhook {
|
||||||
id: Option<String>,
|
id: String,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
stype: &'static str,
|
stype: &'static str,
|
||||||
|
object: &'static str,
|
||||||
data: StripeWebhookObject,
|
data: StripeWebhookObject,
|
||||||
|
created: u64,
|
||||||
|
// api_version: "2019-11-05", // not used
|
||||||
}
|
}
|
||||||
|
|
||||||
impl api::OutgoingWebhookType for StripeOutgoingWebhook {}
|
impl OutgoingWebhookType for StripeOutgoingWebhook {
|
||||||
|
fn get_outgoing_webhooks_signature(
|
||||||
|
&self,
|
||||||
|
payment_response_hash_key: Option<String>,
|
||||||
|
) -> errors::CustomResult<Option<String>, errors::WebhooksFlowError> {
|
||||||
|
let timestamp = self.created;
|
||||||
|
|
||||||
|
let payment_response_hash_key = payment_response_hash_key
|
||||||
|
.ok_or(errors::WebhooksFlowError::MerchantConfigNotFound)
|
||||||
|
.into_report()
|
||||||
|
.attach_printable("For stripe compatibility payment_response_hash_key is mandatory")?;
|
||||||
|
|
||||||
|
let webhook_signature_payload =
|
||||||
|
ext_traits::Encode::<serde_json::Value>::encode_to_string_of_json(self)
|
||||||
|
.change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed)
|
||||||
|
.attach_printable("failed encoding outgoing webhook payload")?;
|
||||||
|
|
||||||
|
let new_signature_payload = format!("{timestamp}.{webhook_signature_payload}");
|
||||||
|
let v1 = hex::encode(
|
||||||
|
common_utils::crypto::HmacSha256::sign_message(
|
||||||
|
&common_utils::crypto::HmacSha256,
|
||||||
|
payment_response_hash_key.as_bytes(),
|
||||||
|
new_signature_payload.as_bytes(),
|
||||||
|
)
|
||||||
|
.change_context(errors::WebhooksFlowError::OutgoingWebhookSigningFailed)
|
||||||
|
.attach_printable("Failed to sign the message")?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let t = timestamp;
|
||||||
|
Ok(Some(format!("t={t},v1={v1}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_webhook_header(header: &mut Vec<(String, Maskable<String>)>, signature: String) {
|
||||||
|
header.push((
|
||||||
|
headers::STRIPE_COMPATIBLE_WEBHOOK_SIGNATURE.to_string(),
|
||||||
|
signature.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug)]
|
||||||
#[serde(tag = "type", content = "object", rename_all = "snake_case")]
|
#[serde(tag = "type", content = "object", rename_all = "snake_case")]
|
||||||
@ -76,13 +125,46 @@ impl From<DisputeStatus> for StripeDisputeStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_stripe_event_type(event_type: api_models::enums::EventType) -> &'static str {
|
||||||
|
match event_type {
|
||||||
|
api_models::enums::EventType::PaymentSucceeded => "payment_intent.succeeded",
|
||||||
|
api_models::enums::EventType::PaymentFailed => "payment_intent.payment_failed",
|
||||||
|
api_models::enums::EventType::PaymentProcessing => "payment_intent.processing",
|
||||||
|
|
||||||
|
// the below are not really stripe compatible because stripe doesn't provide this
|
||||||
|
api_models::enums::EventType::ActionRequired => "action.required",
|
||||||
|
api_models::enums::EventType::RefundSucceeded => "refund.succeeded",
|
||||||
|
api_models::enums::EventType::RefundFailed => "refund.failed",
|
||||||
|
api_models::enums::EventType::DisputeOpened => "dispute.failed",
|
||||||
|
api_models::enums::EventType::DisputeExpired => "dispute.expired",
|
||||||
|
api_models::enums::EventType::DisputeAccepted => "dispute.accepted",
|
||||||
|
api_models::enums::EventType::DisputeCancelled => "dispute.cancelled",
|
||||||
|
api_models::enums::EventType::DisputeChallenged => "dispute.challenged",
|
||||||
|
api_models::enums::EventType::DisputeWon => "dispute.won",
|
||||||
|
api_models::enums::EventType::DisputeLost => "dispute.lost",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<api::OutgoingWebhook> for StripeOutgoingWebhook {
|
impl From<api::OutgoingWebhook> for StripeOutgoingWebhook {
|
||||||
fn from(value: api::OutgoingWebhook) -> Self {
|
fn from(value: api::OutgoingWebhook) -> Self {
|
||||||
let data: StripeWebhookObject = value.content.into();
|
|
||||||
Self {
|
Self {
|
||||||
id: data.get_id(),
|
id: value.event_id,
|
||||||
stype: "webhook_endpoint",
|
stype: get_stripe_event_type(value.event_type),
|
||||||
data,
|
data: StripeWebhookObject::from(value.content),
|
||||||
|
object: "event",
|
||||||
|
// put this conversion it into a function
|
||||||
|
created: u64::try_from(value.timestamp.assume_utc().unix_timestamp()).unwrap_or_else(
|
||||||
|
|error| {
|
||||||
|
logger::error!(
|
||||||
|
%error,
|
||||||
|
"incorrect value for `webhook.timestamp` provided {}", value.timestamp
|
||||||
|
);
|
||||||
|
// Current timestamp converted to Unix timestamp should have a positive value
|
||||||
|
// for many years to come
|
||||||
|
u64::try_from(date_time::now().assume_utc().unix_timestamp())
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,13 +182,3 @@ impl From<api::OutgoingWebhookContent> for StripeWebhookObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StripeWebhookObject {
|
|
||||||
fn get_id(&self) -> Option<String> {
|
|
||||||
match self {
|
|
||||||
Self::PaymentIntent(p) => p.id.to_owned(),
|
|
||||||
Self::Refund(r) => Some(r.id.to_owned()),
|
|
||||||
Self::Dispute(d) => Some(d.id.to_owned()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
pub mod transformers;
|
pub mod types;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use common_utils::{crypto::SignMessage, ext_traits};
|
|
||||||
use error_stack::{report, IntoReport, ResultExt};
|
use error_stack::{report, IntoReport, ResultExt};
|
||||||
use masking::ExposeInterface;
|
use masking::ExposeInterface;
|
||||||
use router_env::{instrument, tracing};
|
use router_env::{instrument, tracing};
|
||||||
@ -14,11 +13,11 @@ use crate::{
|
|||||||
errors::{self, CustomResult, RouterResponse},
|
errors::{self, CustomResult, RouterResponse},
|
||||||
payments, refunds,
|
payments, refunds,
|
||||||
},
|
},
|
||||||
headers, logger,
|
logger,
|
||||||
routes::AppState,
|
routes::AppState,
|
||||||
services,
|
services,
|
||||||
types::{
|
types::{
|
||||||
self, api, domain,
|
self as router_types, api, domain,
|
||||||
storage::{self, enums},
|
storage::{self, enums},
|
||||||
transformers::{ForeignInto, ForeignTryInto},
|
transformers::{ForeignInto, ForeignTryInto},
|
||||||
},
|
},
|
||||||
@ -29,7 +28,7 @@ const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5;
|
|||||||
const MERCHANT_ID: &str = "merchant_id";
|
const MERCHANT_ID: &str = "merchant_id";
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn payments_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
pub async fn payments_incoming_webhook_flow<W: types::OutgoingWebhookType>(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
key_store: domain::MerchantKeyStore,
|
key_store: domain::MerchantKeyStore,
|
||||||
@ -108,7 +107,7 @@ pub async fn payments_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn refunds_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
pub async fn refunds_incoming_webhook_flow<W: types::OutgoingWebhookType>(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
key_store: domain::MerchantKeyStore,
|
key_store: domain::MerchantKeyStore,
|
||||||
@ -325,7 +324,7 @@ pub async fn get_or_update_dispute_object(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn disputes_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
pub async fn disputes_incoming_webhook_flow<W: types::OutgoingWebhookType>(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
webhook_details: api::IncomingWebhookDetails,
|
webhook_details: api::IncomingWebhookDetails,
|
||||||
@ -388,7 +387,7 @@ pub async fn disputes_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn bank_transfer_webhook_flow<W: api::OutgoingWebhookType>(
|
async fn bank_transfer_webhook_flow<W: types::OutgoingWebhookType>(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
key_store: domain::MerchantKeyStore,
|
key_store: domain::MerchantKeyStore,
|
||||||
@ -465,7 +464,7 @@ async fn bank_transfer_webhook_flow<W: api::OutgoingWebhookType>(
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookType>(
|
pub async fn create_event_and_trigger_outgoing_webhook<W: types::OutgoingWebhookType>(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
event_type: enums::EventType,
|
event_type: enums::EventType,
|
||||||
@ -506,34 +505,9 @@ pub async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookTy
|
|||||||
timestamp: event.created_at,
|
timestamp: event.created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
let webhook_signature_payload =
|
|
||||||
ext_traits::Encode::<serde_json::Value>::encode_to_string_of_json(&outgoing_webhook)
|
|
||||||
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
|
|
||||||
.attach_printable("failed encoding outgoing webhook payload")?;
|
|
||||||
|
|
||||||
let outgoing_webhooks_signature = merchant_account
|
|
||||||
.payment_response_hash_key
|
|
||||||
.clone()
|
|
||||||
.map(|key| {
|
|
||||||
common_utils::crypto::HmacSha512::sign_message(
|
|
||||||
&common_utils::crypto::HmacSha512,
|
|
||||||
key.as_bytes(),
|
|
||||||
webhook_signature_payload.as_bytes(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
|
|
||||||
.attach_printable("Failed to sign the message")?
|
|
||||||
.map(hex::encode);
|
|
||||||
|
|
||||||
arbiter.spawn(async move {
|
arbiter.spawn(async move {
|
||||||
let result = trigger_webhook_to_merchant::<W>(
|
let result =
|
||||||
merchant_account,
|
trigger_webhook_to_merchant::<W>(merchant_account, outgoing_webhook, &state).await;
|
||||||
outgoing_webhook,
|
|
||||||
outgoing_webhooks_signature,
|
|
||||||
&state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
logger::error!(?e);
|
logger::error!(?e);
|
||||||
@ -544,10 +518,9 @@ pub async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookTy
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
pub async fn trigger_webhook_to_merchant<W: types::OutgoingWebhookType>(
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
webhook: api::OutgoingWebhook,
|
webhook: api::OutgoingWebhook,
|
||||||
outgoing_webhooks_signature: Option<String>,
|
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> CustomResult<(), errors::WebhooksFlowError> {
|
) -> CustomResult<(), errors::WebhooksFlowError> {
|
||||||
let webhook_details_json = merchant_account
|
let webhook_details_json = merchant_account
|
||||||
@ -570,7 +543,10 @@ pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
|||||||
|
|
||||||
let transformed_outgoing_webhook = W::from(webhook);
|
let transformed_outgoing_webhook = W::from(webhook);
|
||||||
|
|
||||||
let transformed_outgoing_webhook_string = types::RequestBody::log_and_get_request_body(
|
let outgoing_webhooks_signature = transformed_outgoing_webhook
|
||||||
|
.get_outgoing_webhooks_signature(merchant_account.payment_response_hash_key.clone())?;
|
||||||
|
|
||||||
|
let transformed_outgoing_webhook_string = router_types::RequestBody::log_and_get_request_body(
|
||||||
&transformed_outgoing_webhook,
|
&transformed_outgoing_webhook,
|
||||||
Encode::<serde_json::Value>::encode_to_string_of_json,
|
Encode::<serde_json::Value>::encode_to_string_of_json,
|
||||||
)
|
)
|
||||||
@ -583,7 +559,7 @@ pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
|||||||
)];
|
)];
|
||||||
|
|
||||||
if let Some(signature) = outgoing_webhooks_signature {
|
if let Some(signature) = outgoing_webhooks_signature {
|
||||||
header.push((headers::X_WEBHOOK_SIGNATURE.to_string(), signature.into()))
|
W::add_webhook_header(&mut header, signature)
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = services::RequestBuilder::new()
|
let request = services::RequestBuilder::new()
|
||||||
@ -649,7 +625,7 @@ pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub async fn webhooks_core<W: api::OutgoingWebhookType>(
|
pub async fn webhooks_core<W: types::OutgoingWebhookType>(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
req: &actix_web::HttpRequest,
|
req: &actix_web::HttpRequest,
|
||||||
merchant_account: domain::MerchantAccount,
|
merchant_account: domain::MerchantAccount,
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
45
crates/router/src/core/webhooks/types.rs
Normal file
45
crates/router/src/core/webhooks/types.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
use api_models::webhooks;
|
||||||
|
use common_utils::{crypto::SignMessage, ext_traits};
|
||||||
|
use error_stack::ResultExt;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::{core::errors, headers, services::request::Maskable};
|
||||||
|
|
||||||
|
pub trait OutgoingWebhookType:
|
||||||
|
Serialize + From<webhooks::OutgoingWebhook> + Sync + Send + std::fmt::Debug
|
||||||
|
{
|
||||||
|
fn get_outgoing_webhooks_signature(
|
||||||
|
&self,
|
||||||
|
payment_response_hash_key: Option<String>,
|
||||||
|
) -> errors::CustomResult<Option<String>, errors::WebhooksFlowError>;
|
||||||
|
|
||||||
|
fn add_webhook_header(header: &mut Vec<(String, Maskable<String>)>, signature: String);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutgoingWebhookType for webhooks::OutgoingWebhook {
|
||||||
|
fn get_outgoing_webhooks_signature(
|
||||||
|
&self,
|
||||||
|
payment_response_hash_key: Option<String>,
|
||||||
|
) -> errors::CustomResult<Option<String>, errors::WebhooksFlowError> {
|
||||||
|
let webhook_signature_payload =
|
||||||
|
ext_traits::Encode::<serde_json::Value>::encode_to_string_of_json(self)
|
||||||
|
.change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed)
|
||||||
|
.attach_printable("failed encoding outgoing webhook payload")?;
|
||||||
|
|
||||||
|
Ok(payment_response_hash_key
|
||||||
|
.map(|key| {
|
||||||
|
common_utils::crypto::HmacSha512::sign_message(
|
||||||
|
&common_utils::crypto::HmacSha512,
|
||||||
|
key.as_bytes(),
|
||||||
|
webhook_signature_payload.as_bytes(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.change_context(errors::WebhooksFlowError::OutgoingWebhookSigningFailed)
|
||||||
|
.attach_printable("Failed to sign the message")?
|
||||||
|
.map(hex::encode))
|
||||||
|
}
|
||||||
|
fn add_webhook_header(header: &mut Vec<(String, Maskable<String>)>, signature: String) {
|
||||||
|
header.push((headers::X_WEBHOOK_SIGNATURE.to_string(), signature.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -65,6 +65,8 @@ pub mod headers {
|
|||||||
pub const X_ACCEPT_VERSION: &str = "X-Accept-Version";
|
pub const X_ACCEPT_VERSION: &str = "X-Accept-Version";
|
||||||
pub const X_DATE: &str = "X-Date";
|
pub const X_DATE: &str = "X-Date";
|
||||||
pub const X_WEBHOOK_SIGNATURE: &str = "X-Webhook-Signature-512";
|
pub const X_WEBHOOK_SIGNATURE: &str = "X-Webhook-Signature-512";
|
||||||
|
|
||||||
|
pub const STRIPE_COMPATIBLE_WEBHOOK_SIGNATURE: &str = "Stripe-Signature";
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod pii {
|
pub mod pii {
|
||||||
|
|||||||
@ -3,13 +3,12 @@ use router_env::{instrument, tracing, Flow};
|
|||||||
|
|
||||||
use super::app::AppState;
|
use super::app::AppState;
|
||||||
use crate::{
|
use crate::{
|
||||||
core::webhooks,
|
core::webhooks::{self, types},
|
||||||
services::{api, authentication as auth},
|
services::{api, authentication as auth},
|
||||||
types::api as api_types,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[instrument(skip_all, fields(flow = ?Flow::IncomingWebhookReceive))]
|
#[instrument(skip_all, fields(flow = ?Flow::IncomingWebhookReceive))]
|
||||||
pub async fn receive_incoming_webhook<W: api_types::OutgoingWebhookType>(
|
pub async fn receive_incoming_webhook<W: types::OutgoingWebhookType>(
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
body: web::Bytes,
|
body: web::Bytes,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use api_models::admin::MerchantConnectorWebhookDetails;
|
use api_models::admin::MerchantConnectorWebhookDetails;
|
||||||
pub use api_models::webhooks::{
|
pub use api_models::webhooks::{
|
||||||
IncomingWebhookDetails, IncomingWebhookEvent, MerchantWebhookConfig, ObjectReferenceId,
|
IncomingWebhookDetails, IncomingWebhookEvent, MerchantWebhookConfig, ObjectReferenceId,
|
||||||
OutgoingWebhook, OutgoingWebhookContent, OutgoingWebhookType, WebhookFlow,
|
OutgoingWebhook, OutgoingWebhookContent, WebhookFlow,
|
||||||
};
|
};
|
||||||
use common_utils::ext_traits::ValueExt;
|
use common_utils::ext_traits::ValueExt;
|
||||||
use error_stack::{IntoReport, ResultExt};
|
use error_stack::{IntoReport, ResultExt};
|
||||||
|
|||||||
Reference in New Issue
Block a user