open-standards/openpay/crates/op-webhook/src/event.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

250 lines
7.3 KiB
Rust

//! Webhook events and per-(event, endpoint) delivery attempts.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Opaque event id. UUID v4.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct WebhookEventId(pub Uuid);
impl WebhookEventId {
/// Generate a fresh id.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Wrap an existing UUID.
#[must_use]
pub fn from_uuid(u: Uuid) -> Self {
Self(u)
}
/// The underlying UUID.
#[must_use]
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl Default for WebhookEventId {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Display for WebhookEventId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
self.0.fmt(f)
}
}
/// A webhook event. The payload is **opaque** — the crate doesn't
/// know JSON from CBOR from raw bytes. Operators encode upstream.
///
/// The `created_at_unix_secs` field is the authoritative event time
/// (used as the retry window anchor). It's caller-supplied for
/// deterministic replay and to handle backfill scenarios.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebhookEvent {
/// Stable id used for downstream deduplication.
pub id: WebhookEventId,
/// Event type (`"payment.authorized"`, `"ledger.transaction.posted"`,
/// etc.). Free-form; operators define their taxonomy.
pub event_type: String,
/// Opaque body that will be POSTed.
pub payload: Vec<u8>,
/// Caller-supplied creation time (unix epoch seconds).
pub created_at_unix_secs: u64,
}
impl WebhookEvent {
/// Construct.
#[must_use]
pub fn new(event_type: impl Into<String>, payload: Vec<u8>, created_at_unix_secs: u64) -> Self {
Self {
id: WebhookEventId::new(),
event_type: event_type.into(),
payload,
created_at_unix_secs,
}
}
/// Builder: set a specific id (for replay or migration).
#[must_use]
pub fn with_id(mut self, id: WebhookEventId) -> Self {
self.id = id;
self
}
}
/// Opaque attempt id.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct DeliveryAttemptId(pub Uuid);
impl DeliveryAttemptId {
/// Generate a fresh id.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// Wrap an existing UUID.
#[must_use]
pub fn from_uuid(u: Uuid) -> Self {
Self(u)
}
}
impl Default for DeliveryAttemptId {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Display for DeliveryAttemptId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
self.0.fmt(f)
}
}
/// Lifecycle status of a single delivery attempt.
///
/// Note: a [`WebhookEvent`] may produce multiple
/// [`DeliveryAttempt`]s across retries — the attempts themselves
/// are immutable records; status updates create a new attempt with
/// updated state.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeliveryStatus {
/// Queued, not yet attempted.
Pending,
/// In flight (between dispatch and response).
InFlight,
/// HTTP 2xx received.
Succeeded,
/// Retryable failure; another attempt is scheduled.
RetryScheduled,
/// Retry budget exhausted; this is a dead letter. Operator
/// must `replay` to dispatch again.
Failed,
}
impl DeliveryStatus {
/// True if no further state transitions are expected.
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed)
}
}
/// A single delivery attempt for an (event, endpoint) pair.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeliveryAttempt {
/// Stable id.
pub id: DeliveryAttemptId,
/// Which event this attempt is for.
pub event_id: WebhookEventId,
/// Which endpoint this attempt is for.
pub endpoint_id: crate::endpoint::EndpointId,
/// 0 for the first try, N for the Nth retry.
pub attempt_number: u32,
/// Current status.
pub status: DeliveryStatus,
/// HTTP response status code, if a response was received.
pub http_status: Option<u16>,
/// HTTP response body excerpt (truncated for storage).
pub response_body_excerpt: Option<String>,
/// Wall-clock time the attempt was started (unix epoch seconds).
pub started_at_unix_secs: u64,
/// Wall-clock time the attempt completed (unix epoch seconds).
pub completed_at_unix_secs: Option<u64>,
/// Earliest wall-clock time this attempt may be re-dispatched
/// (for `RetryScheduled`).
pub next_attempt_at_unix_secs: Option<u64>,
/// Free-form error context.
pub error: Option<String>,
}
/// Maximum bytes of response body kept for diagnostics.
pub const RESPONSE_BODY_EXCERPT_BYTES: usize = 512;
impl DeliveryAttempt {
/// Construct a fresh pending attempt.
#[must_use]
pub fn new_pending(
event_id: WebhookEventId,
endpoint_id: crate::endpoint::EndpointId,
attempt_number: u32,
started_at_unix_secs: u64,
) -> Self {
Self {
id: DeliveryAttemptId::new(),
event_id,
endpoint_id,
attempt_number,
status: DeliveryStatus::Pending,
http_status: None,
response_body_excerpt: None,
started_at_unix_secs,
completed_at_unix_secs: None,
next_attempt_at_unix_secs: None,
error: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::endpoint::EndpointId;
#[test]
fn event_id_round_trip() {
let u = Uuid::new_v4();
assert_eq!(WebhookEventId::from_uuid(u).as_uuid(), u);
}
#[test]
fn event_id_display_is_uuid() {
let u = Uuid::new_v4();
assert_eq!(format!("{}", WebhookEventId::from_uuid(u)), u.to_string());
}
#[test]
fn fresh_event_has_new_id() {
let a = WebhookEvent::new("test.event", b"{}".to_vec(), 0);
let b = WebhookEvent::new("test.event", b"{}".to_vec(), 0);
assert_ne!(a.id, b.id);
}
#[test]
fn with_id_overrides() {
let custom = WebhookEventId::new();
let e = WebhookEvent::new("t", vec![], 0).with_id(custom);
assert_eq!(e.id, custom);
}
#[test]
fn delivery_status_terminal_correctness() {
assert!(!DeliveryStatus::Pending.is_terminal());
assert!(!DeliveryStatus::InFlight.is_terminal());
assert!(!DeliveryStatus::RetryScheduled.is_terminal());
assert!(DeliveryStatus::Succeeded.is_terminal());
assert!(DeliveryStatus::Failed.is_terminal());
}
#[test]
fn new_pending_attempt_initial_state() {
let eid = WebhookEventId::new();
let epid = EndpointId::new();
let a = DeliveryAttempt::new_pending(eid, epid, 0, 100);
assert_eq!(a.status, DeliveryStatus::Pending);
assert_eq!(a.attempt_number, 0);
assert_eq!(a.event_id, eid);
assert_eq!(a.endpoint_id, epid);
assert_eq!(a.started_at_unix_secs, 100);
assert!(a.completed_at_unix_secs.is_none());
assert!(a.http_status.is_none());
assert!(a.error.is_none());
}
}