open-standards/openpay/crates/op-webhook/src/lib.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

96 lines
4.2 KiB
Rust

//! # `op-webhook` — Outbound webhook delivery
//!
//! The crate that turns ledger events (and any other internal
//! event-shaped data) into reliably-delivered HTTP POSTs to
//! merchant-configured endpoints. This is the **fanout** edge of
//! the OpenPay stack: everything upstream produces facts; this
//! crate signs them, retries them, and surfaces dead letters.
//!
//! ## Architectural place in the stack
//!
//! ```text
//! op-orchestrator ─► op-ledger ─► op-webhook ─► merchant ERP
//! (run intent) (record) (notify) (act)
//! ```
//!
//! The crate is intentionally **decoupled** from `op-ledger` and
//! `op-orchestrator`: it takes opaque `Vec<u8>` payloads. Operators
//! choose the wire format (JSON, CBOR, MessagePack — anything). The
//! signature scheme is Stripe-compatible
//! (`t={ts},v1={hmac_sha256_hex(secret, "{ts}.{body}")}`) so any
//! merchant who has already integrated with Stripe receives webhooks
//! they can verify with the same code.
//!
//! ## Core invariants
//!
//! 1. **At-least-once delivery.** A subscribed endpoint is
//! guaranteed to be **attempted** at least once. Network failures
//! queue retries; consumers must dedupe via `event_id`.
//! 2. **Exponential backoff with full jitter.** Failed deliveries
//! are rescheduled at `min(base * 2^n, max_delay)` modulated by
//! full jitter (`rand_uniform(0, computed_delay)`), capped at a
//! configurable max attempts and max age (Stripe's 72-hour
//! window by default).
//! 3. **Stripe-compatible signing.** Header
//! `OpenPay-Signature: t={unix_secs},v1={hex_hmac}` carries the
//! signature; the signed payload is `"{ts}.{body}"`; the HMAC
//! key is the endpoint's secret. Constant-time comparison
//! (`subtle::ConstantTimeEq`) is used for verification.
//! 4. **Auto-disable on chronic failure.** After
//! `disable_after_consecutive_failures` consecutive delivery
//! failures (Stripe: signal-via-email, Razorpay: 24h then
//! disable; we default to 10 failures), the endpoint's status
//! flips to [`EndpointStatus::Disabled`] and no further attempts
//! are made until an operator manually re-enables it.
//! 5. **Replay is explicit.** Operators trigger replay via
//! [`WebhookDispatcher::replay`]; the system never silently
//! resurrects events.
//! 6. **Pluggable HTTP transport.** The crate ships with no real
//! HTTP client; operators wire `reqwest`, `hyper`, `ureq`, or
//! anything else that implements [`HttpTransport`].
//! 7. **Pluggable storage.** [`WebhookStore`] trait;
//! [`InMemoryWebhookStore`] reference impl for tests and
//! single-process kiosks.
//!
//! ## What this crate does NOT do
//!
//! - **No HTTP client.** No `reqwest`, no `hyper`. The
//! [`HttpTransport`] trait is the seam.
//! - **No async runtime.** All operations are sync. Async wrappers
//! are operator-level (`tokio::task::spawn_blocking` or a
//! long-running thread).
//! - **No durable queue.** [`InMemoryWebhookStore`] loses state on
//! process exit. Production deployments plug in their own.
//! - **No CloudEvents formatting.** The payload is opaque; encode
//! however you like.
//! - **No key rotation flow.** A single secret per endpoint. Adding
//! a "previous secret" field for rotation is documented as a
//! future extension.
#![deny(unsafe_code)]
#![warn(missing_docs)]
pub mod dispatcher;
pub mod emitter;
pub mod endpoint;
pub mod error;
pub mod event;
pub mod hexutil;
#[cfg(feature = "reqwest-transport")]
pub mod reqwest_transport;
pub mod retry;
pub mod signing;
pub mod store;
pub mod transport;
pub use dispatcher::{DispatchOutcome, WebhookDispatcher};
pub use emitter::{EventEmitter, NoOpEmitter, WebhookEmitter};
pub use endpoint::{Endpoint, EndpointId, EndpointStatus};
pub use error::{Error, Result};
pub use event::{DeliveryAttempt, DeliveryAttemptId, DeliveryStatus, WebhookEvent, WebhookEventId};
#[cfg(feature = "reqwest-transport")]
pub use reqwest_transport::ReqwestTransport;
pub use retry::{ExponentialBackoffPolicy, RetryPolicy, jitter_full};
pub use signing::{SIGNATURE_HEADER, SignedPayload, compute_signature, verify_signature};
pub use store::{InMemoryWebhookStore, WebhookStore};
pub use transport::{HttpRequest, HttpResponse, HttpTransport, MockTransport};