use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum StripeWebhookError {
#[error("STRIPE_WEBHOOK_SECRET environment variable not set")]
MissingSecret,
#[error("Invalid webhook secret format: {0}")]
InvalidSecretFormat(String),
#[error("Missing stripe-signature header")]
MissingSignature,
#[error("Invalid signature format: {0}")]
InvalidSignatureFormat(String),
#[error("Signature verification failed")]
SignatureVerificationFailed,
#[error("Webhook timestamp too old: {age_seconds}s (max: {max_age_seconds}s)")]
TimestampTooOld {
age_seconds: i64,
max_age_seconds: i64,
},
#[error("Webhook timestamp in future by {drift_seconds}s")]
TimestampInFuture { drift_seconds: i64 },
#[error("Failed to parse request body: {0}")]
InvalidPayload(String),
#[error("Unknown event type: {0}")]
UnknownEventType(String),
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Event {event_id} already processed")]
AlreadyProcessed { event_id: String },
#[error("Event processing failed: {0}")]
ProcessingFailed(String),
#[error("Database error: {0}")]
DatabaseError(String),
#[error("External service error: {0}")]
ExternalServiceError(String),
#[error("Internal error: {0}")]
InternalError(String),
}
pub type StripeWebhookResult<T> = std::result::Result<T, StripeWebhookError>;
impl StripeWebhookError {
pub fn status_code(&self) -> StatusCode {
match self {
Self::InvalidPayload(_)
| Self::InvalidSignatureFormat(_)
| Self::UnknownEventType(_)
| Self::MissingField(_) => StatusCode::BAD_REQUEST,
Self::MissingSignature
| Self::SignatureVerificationFailed
| Self::TimestampTooOld { .. }
| Self::TimestampInFuture { .. } => StatusCode::UNAUTHORIZED,
Self::AlreadyProcessed { .. } => StatusCode::ACCEPTED,
Self::MissingSecret
| Self::InvalidSecretFormat(_)
| Self::ProcessingFailed(_)
| Self::DatabaseError(_)
| Self::ExternalServiceError(_)
| Self::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn should_retry(&self) -> bool {
matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE
)
}
pub fn error_code(&self) -> &'static str {
match self {
Self::MissingSecret => "MISSING_SECRET",
Self::InvalidSecretFormat(_) => "INVALID_SECRET_FORMAT",
Self::MissingSignature => "MISSING_SIGNATURE",
Self::InvalidSignatureFormat(_) => "INVALID_SIGNATURE_FORMAT",
Self::SignatureVerificationFailed => "SIGNATURE_VERIFICATION_FAILED",
Self::TimestampTooOld { .. } => "TIMESTAMP_TOO_OLD",
Self::TimestampInFuture { .. } => "TIMESTAMP_IN_FUTURE",
Self::InvalidPayload(_) => "INVALID_PAYLOAD",
Self::UnknownEventType(_) => "UNKNOWN_EVENT_TYPE",
Self::MissingField(_) => "MISSING_FIELD",
Self::AlreadyProcessed { .. } => "ALREADY_PROCESSED",
Self::ProcessingFailed(_) => "PROCESSING_FAILED",
Self::DatabaseError(_) => "DATABASE_ERROR",
Self::ExternalServiceError(_) => "EXTERNAL_SERVICE_ERROR",
Self::InternalError(_) => "INTERNAL_ERROR",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResponse {
pub error: ErrorDetails,
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorDetails {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after: Option<u64>,
}
impl IntoResponse for StripeWebhookError {
fn into_response(self) -> Response {
let status = self.status_code();
let error_code = self.error_code().to_string();
let message = match &self {
Self::MissingSignature => self.to_string(),
Self::InvalidSignatureFormat(_) => "Invalid signature format".to_string(),
Self::SignatureVerificationFailed => self.to_string(),
Self::TimestampTooOld { .. } | Self::TimestampInFuture { .. } => {
"Webhook timestamp validation failed".to_string()
}
Self::InvalidPayload(_) => "Invalid request payload".to_string(),
Self::UnknownEventType(t) => format!("Unknown event type: {}", t),
Self::MissingField(f) => format!("Missing required field: {}", f),
Self::AlreadyProcessed { event_id } => {
format!("Event {} already processed", event_id)
}
Self::MissingSecret
| Self::InvalidSecretFormat(_)
| Self::ProcessingFailed(_)
| Self::DatabaseError(_)
| Self::ExternalServiceError(_)
| Self::InternalError(_) => "Internal server error".to_string(),
};
let body = ErrorResponse {
error: ErrorDetails {
code: error_code,
message,
retry_after: if self.should_retry() {
Some(60) } else {
None
},
},
};
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_status_codes() {
assert_eq!(
StripeWebhookError::MissingSignature.status_code(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
StripeWebhookError::InvalidPayload("test".to_string()).status_code(),
StatusCode::BAD_REQUEST
);
assert_eq!(
StripeWebhookError::AlreadyProcessed {
event_id: "evt_123".to_string()
}
.status_code(),
StatusCode::ACCEPTED
);
assert_eq!(
StripeWebhookError::ProcessingFailed("test".to_string()).status_code(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn test_should_retry() {
assert!(!StripeWebhookError::MissingSignature.should_retry());
assert!(!StripeWebhookError::InvalidPayload("test".to_string()).should_retry());
assert!(StripeWebhookError::ProcessingFailed("test".to_string()).should_retry());
assert!(StripeWebhookError::DatabaseError("test".to_string()).should_retry());
}
#[test]
fn test_error_codes() {
assert_eq!(
StripeWebhookError::MissingSignature.error_code(),
"MISSING_SIGNATURE"
);
assert_eq!(
StripeWebhookError::SignatureVerificationFailed.error_code(),
"SIGNATURE_VERIFICATION_FAILED"
);
}
}