Skip to main content

fraiseql_webhooks/
lib.rs

1//! # fraiseql-webhooks
2//!
3//! **Inbound** webhook receiver for FraiseQL — receives, verifies, and routes HTTP
4//! callbacks from third-party services into your database.
5//!
6//! ## Inbound vs. Outbound
7//!
8//! FraiseQL has two webhook-related crates with complementary roles:
9//!
10//! | Crate | Direction | Purpose |
11//! |-------|-----------|---------|
12//! | `fraiseql-webhooks` | **Inbound** | Receive callbacks from Stripe, GitHub, Shopify, … |
13//! | `fraiseql-observers` | **Outbound** | Emit notifications when your data changes |
14//!
15//! Use `fraiseql-webhooks` when you want to react to events from external providers.
16//! Use `fraiseql-observers` when you want to push events to subscribers.
17//!
18//! ## Supported Providers
19//!
20//! Built-in signature verification for:
21//! - **Stripe** — HMAC-SHA256 on `Stripe-Signature` header with replay protection
22//! - **GitHub** — HMAC-SHA256 on `X-Hub-Signature-256` header
23//! - **Shopify** — HMAC-SHA256 on `X-Shopify-Hmac-Sha256` header
24//! - **SendGrid** — ECDSA on `X-Twilio-Email-Event-Webhook-Signature`
25//! - **Paddle** — RSA-SHA256 on `Paddle-Signature` header
26//! - Custom providers via the [`traits::SignatureVerifier`] trait
27//!
28//! ## Security Properties
29//!
30//! - **Constant-time comparison** — prevents timing attacks on HMAC tokens
31//! - **Replay protection** — Stripe/Paddle timestamps validated within a 5-minute window
32//! - **Idempotency** — duplicate delivery is detected and silently discarded
33//! - **Transaction boundaries** — each webhook is processed inside a database transaction
34//!
35//! ## Quick Start
36//!
37//! ```text
38//! See docs/architecture/webhooks.md for the full integration guide.
39//! ```
40//!
41//! ## See Also
42//!
43//! - [`docs/architecture/webhooks.md`](../../../../docs/architecture/webhooks.md) — full design doc
44//! - `fraiseql-observers` — outbound change notifications
45//!
46//! ## Features
47
48#![forbid(unsafe_code)]
49// module_name_repetitions, must_use_candidate, uninlined_format_args:
50// allowed at workspace level (Cargo.toml [workspace.lints.clippy]).
51#![allow(clippy::doc_markdown)] // Reason: technical terms don't need backtick wrapping
52#![allow(clippy::struct_field_names)] // Reason: field prefixes match domain terminology
53#![allow(clippy::wildcard_imports)] // Reason: test modules use wildcard imports
54#![allow(clippy::items_after_statements)] // Reason: helper structs near point of use in tests
55#![allow(clippy::missing_const_for_fn)] // Reason: const fn not stable for all patterns used
56#![allow(clippy::cast_possible_wrap)] // Reason: values are within i64 range by design
57#![allow(clippy::redundant_clone)] // Reason: explicit clone at API boundaries for clarity
58//! - **15+ provider support**: Stripe, GitHub, Shopify, and more
59//! - **Signature verification**: Constant-time comparison for security
60//! - **Idempotency**: Prevent duplicate event processing
61//! - **Event routing**: Map webhook events to database functions
62//! - **Transaction boundaries**: Correct isolation levels for data consistency
63
64pub mod config;
65pub mod signature;
66pub mod testing;
67pub mod traits;
68pub mod transaction;
69
70// Re-exports
71pub use config::{WebhookConfig, WebhookEventConfig};
72pub use signature::SignatureError;
73// Re-export testing mocks for unit tests and integration tests with `testing` feature
74#[cfg(any(test, feature = "testing"))]
75pub use testing::mocks;
76pub use traits::{Clock, EventHandler, IdempotencyStore, SecretProvider, SignatureVerifier};
77pub use transaction::{WebhookIsolation, execute_in_transaction};
78
79/// Errors that can occur during webhook request processing.
80#[derive(Debug, thiserror::Error)]
81#[non_exhaustive]
82pub enum WebhookError {
83    /// The incoming request did not include the expected signature header for the provider.
84    #[error("Missing signature header")]
85    MissingSignature,
86
87    /// The signature header was present but could not be parsed according to the provider's format.
88    /// The inner string contains a description of the parse failure.
89    #[error("Invalid signature format: {0}")]
90    InvalidSignature(String),
91
92    /// The computed HMAC or asymmetric signature did not match the value in the request header.
93    #[error("Signature verification failed")]
94    SignatureVerificationFailed,
95
96    /// The webhook request timestamp is outside the configured replay-protection tolerance window.
97    #[error("Timestamp expired (received: {received}, now: {now}, tolerance: {tolerance}s)")]
98    TimestampExpired {
99        /// Unix timestamp (seconds) extracted from the request.
100        received:  i64,
101        /// Unix timestamp (seconds) at the time of verification.
102        now:       i64,
103        /// Maximum allowed age of a request in seconds before it is rejected.
104        tolerance: u64,
105    },
106
107    /// The provider requires a timestamp header for replay protection, but none was present.
108    #[error("Missing timestamp header")]
109    MissingTimestamp,
110
111    /// The secret named in the configuration could not be retrieved from the secret provider.
112    /// The inner string is the secret name that was not found.
113    #[error("Missing webhook secret: {0}")]
114    MissingSecret(String),
115
116    /// The request arrived for a provider name that is not registered in the `ProviderRegistry`.
117    /// The inner string is the unrecognised provider name.
118    #[error("Unknown webhook provider: {0}")]
119    UnknownProvider(String),
120
121    /// The event type extracted from the payload has no corresponding handler in the configuration.
122    /// The inner string is the unrecognised event type.
123    #[error("Unknown event type: {0}")]
124    UnknownEvent(String),
125
126    /// The request body could not be deserialised as a valid JSON payload.
127    /// The inner string is the serde_json error message.
128    #[error("Invalid payload: {0}")]
129    InvalidPayload(String),
130
131    /// The database function called by the event handler returned an error or panicked.
132    /// The inner string contains the handler's error message.
133    #[error("Handler execution failed: {0}")]
134    HandlerFailed(String),
135
136    /// A sqlx database operation failed during transaction management or idempotency checking.
137    /// The inner string is the sqlx error message.
138    #[error("Database error: {0}")]
139    Database(String),
140
141    /// Evaluation of a configured conditional expression failed.
142    /// The inner string describes the evaluation error.
143    #[error("Condition evaluation error: {0}")]
144    Condition(String),
145
146    /// A field mapping from the webhook payload to a function parameter failed.
147    /// The inner string describes which mapping could not be applied.
148    #[error("Mapping error: {0}")]
149    Mapping(String),
150
151    /// A webhook was received for a provider that has no entry in the active configuration.
152    /// The inner string is the provider name.
153    #[error("Provider not configured: {0}")]
154    ProviderNotConfigured(String),
155
156    /// A configuration field contains an invalid value.
157    /// The inner string describes the validation failure.
158    #[error("Configuration error: {0}")]
159    Configuration(String),
160}
161
162impl From<sqlx::Error> for WebhookError {
163    fn from(err: sqlx::Error) -> Self {
164        Self::Database(err.to_string())
165    }
166}
167
168impl From<serde_json::Error> for WebhookError {
169    fn from(err: serde_json::Error) -> Self {
170        Self::InvalidPayload(err.to_string())
171    }
172}
173
174/// Result type for webhook operations
175pub type Result<T> = std::result::Result<T, WebhookError>;