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.
194 lines
8.4 KiB
Markdown
194 lines
8.4 KiB
Markdown
# Phase 28 — Webhooks wired end-to-end
|
|
|
|
**Status**: Draft v0.28
|
|
**Date**: 2026-05-22
|
|
|
|
## Why
|
|
|
|
`op-webhook` has shipped since Phase 13: dispatcher,
|
|
Stripe-compatible signing, exponential backoff with jitter,
|
|
endpoint auto-disable. But no internal event in the workspace
|
|
actually called it. Refunds settled, disputes escalated,
|
|
subscriptions canceled — and nothing fired outbound. That made the
|
|
crate dead weight from an operator's perspective.
|
|
|
|
Phase 28 closes that loop: add an `EventEmitter` seam, wire it
|
|
into every domain-mutation handler in op-server, expose endpoint
|
|
registration over HTTP, and prove with an integration test that a
|
|
refund creation lands on a subscribed endpoint.
|
|
|
|
First of three sequenced phases (28 → 29 → 30). Next: multi-
|
|
currency / FX, then 3DS/SCA resume.
|
|
|
|
## What shipped
|
|
|
|
| # | Item | Where |
|
|
|--:|---|---|
|
|
| 1 | `EventEmitter` trait — single-method `emit(event_type, payload, now_unix_secs)` | `op-webhook/src/emitter.rs` |
|
|
| 2 | `WebhookEmitter` adapter — wraps a `WebhookDispatcher` and fans out events to subscribed endpoints | `op-webhook/src/emitter.rs` |
|
|
| 3 | `NoOpEmitter` — drops every event on the floor; default in `AppState` so operators who haven't wired transports stay green | `op-webhook/src/emitter.rs` |
|
|
| 4 | `AppState.events: Arc<dyn EventEmitter>` + `with_webhook_transport(transport)` builder | `op-server/src/state.rs` |
|
|
| 5 | `AppState.webhooks: Arc<GraphWebhookStore>` — webhooks now persist to the same `.graph` file as everything else | `op-server/src/state.rs` |
|
|
| 6 | `crate::events::emit(state, event_type, body)` helper — wall-clock timestamp + serde JSON encoding + tracing-logged failure | `op-server/src/events.rs` |
|
|
| 7 | Emissions wired into 11 handler success points across refund / dispute / settlement / subscription / intent | `op-server/src/handlers/*.rs` |
|
|
| 8 | HTTP endpoint CRUD: `POST /v1/webhooks/endpoints`, `GET /v1/webhooks/endpoints/{id}`, `POST .../disable`, `POST .../enable` | `op-server/src/handlers/webhook.rs`, `routes.rs` |
|
|
| 9 | Integration tests — register endpoint + create refund → verify outbound delivery; disable + create refund → verify zero deliveries | `op-server/tests/api.rs` |
|
|
|
|
Workspace at the end of Phase 28:
|
|
|
|
| Check | Result |
|
|
|---|---|
|
|
| `cargo build --workspace --all-targets` | **0 errors, 0 warnings** |
|
|
| `cargo test --workspace` | **1027 passing, 0 failing** (+4 vs Phase 27) |
|
|
| `cargo clippy --workspace --all-targets` | **0 warnings** |
|
|
|
|
## Emitted events
|
|
|
|
| Event type | Emitted by |
|
|
|---|---|
|
|
| `refund.created` | `POST /v1/refunds` |
|
|
| `refund.submitted` | `POST /v1/refunds/{id}/submit` |
|
|
| `refund.approved` | `POST /v1/refunds/{id}/approve` |
|
|
| `refund.settled` | `POST /v1/refunds/{id}/settle` |
|
|
| `dispute.created` | `POST /v1/disputes` |
|
|
| `dispute.evidence_attached` | `POST /v1/disputes/{id}/evidence` |
|
|
| `settlement.batch_opened` | `POST /v1/settlement/batches` |
|
|
| `settlement.batch_closed` | `POST /v1/settlement/batches/{id}/close` |
|
|
| `subscription.created` | `POST /v1/subscriptions` |
|
|
| `subscription.canceled` | `POST /v1/subscriptions/{id}/cancel` |
|
|
| `subscription.paused` | `POST /v1/subscriptions/{id}/pause` |
|
|
| `subscription.resumed` | `POST /v1/subscriptions/{id}/resume` |
|
|
| `intent.approved` / `intent.declined` / `intent.requires_action` | `POST /v1/intents` |
|
|
|
|
Payloads are the same JSON the HTTP handler returned to the
|
|
caller — operators implementing webhook consumers can deserialize
|
|
into the same structs they'd see from a direct API call.
|
|
|
|
## Design choice: emit at the handler, not the store
|
|
|
|
Two ways to wire emission:
|
|
|
|
1. **Store-trait callbacks** — each `RefundStore` impl emits after
|
|
a successful mutation. Pro: every caller, including direct
|
|
library users, gets events. Con: invasive — adds an
|
|
`Arc<dyn EventEmitter>` parameter to every store constructor
|
|
and every trait method, breaking the existing surfaces.
|
|
2. **Handler-boundary emit** — the HTTP layer emits right after
|
|
the store's mutation persists. Pro: zero changes to the
|
|
existing store traits. Con: callers using the library directly
|
|
(CLI, embedded SDK) don't automatically fire events.
|
|
|
|
We chose (2). The reference deployment is the HTTP server, so
|
|
the emit-site is the HTTP boundary. Library-direct callers either
|
|
wire their own emitter at their own call sites or upgrade to a
|
|
v2 store trait if/when that becomes worth doing.
|
|
|
|
## Why emission is fire-and-forget
|
|
|
|
A webhook delivery failure must **never** roll back the user's
|
|
state change. The customer was refunded; we recorded it; the fact
|
|
that our HTTP POST to the merchant's endpoint failed is a
|
|
delivery problem, not a domain problem. The dispatcher's retry
|
|
loop handles delivery — the caller's transaction is already
|
|
committed.
|
|
|
|
Concretely:
|
|
|
|
```rust
|
|
let id = state.refunds.create_refund(refund.clone())?; // persists
|
|
let stored = state.refunds.get_refund(id)?;
|
|
let response = RefundResponse::from(&stored);
|
|
emit(&state, "refund.created", &response); // best-effort
|
|
Ok(Json(response)) // user sees 200
|
|
```
|
|
|
|
`emit` swallows serde / dispatch errors with a `tracing::warn!`.
|
|
The HTTP 200 went out before the operator's webhook consumer
|
|
ever received its POST. That asymmetry is correct.
|
|
|
|
## HTTP surface
|
|
|
|
```
|
|
POST /v1/webhooks/endpoints # register
|
|
GET /v1/webhooks/endpoints/{id}
|
|
POST /v1/webhooks/endpoints/{id}/disable # operator-driven kill switch
|
|
POST /v1/webhooks/endpoints/{id}/enable # reset consecutive_failures
|
|
```
|
|
|
|
Body for create:
|
|
|
|
```json
|
|
{
|
|
"url": "https://merchant.example/webhooks/openpay",
|
|
"secret": "merchant-shared-secret",
|
|
"event_filters": ["refund.created", "subscription.canceled"]
|
|
}
|
|
```
|
|
|
|
Filters are exact-match strings; `"*"` matches everything. Same
|
|
contract as `op-webhook::Endpoint::matches`.
|
|
|
|
## Persistence
|
|
|
|
Webhook endpoints, events, and delivery attempts now live in the
|
|
same `.graph` file as refunds, disputes, settlements, etc. (Phase
|
|
26's `GraphWebhookStore` was already in the workspace; this phase
|
|
wires it into `AppState` as the default). One file, one substrate,
|
|
everything queryable from the audit report.
|
|
|
|
## Operator wiring
|
|
|
|
The reference binary defaults to `NoOpEmitter`. Operators turn on
|
|
real delivery by handing in a transport:
|
|
|
|
```rust
|
|
let transport: Arc<dyn HttpTransport> = Arc::new(MyReqwestTransport::new());
|
|
let state = AppState::with_graph_path("/var/lib/openpay/data.graph")?
|
|
.with_webhook_transport(transport);
|
|
```
|
|
|
|
We don't ship a real `reqwest` / `ureq` / `hyper` adapter in the
|
|
workspace by design — Phase 13's HttpTransport trait is the seam,
|
|
and operators bring their own. A reference `MockTransport`
|
|
already exists for tests.
|
|
|
|
## Honest concerns (carry-forward)
|
|
|
|
- **No HTTP transport in the workspace.** Real delivery still
|
|
requires the operator to plug in `reqwest` or similar. This is
|
|
intentional — the workspace stays free of HTTP client
|
|
dependencies — but it's a step operators have to take. A
|
|
future phase could ship a thin `op-webhook-reqwest` crate.
|
|
- **`process_due_retries` not called automatically.** The
|
|
dispatcher knows how to retry failed deliveries; the
|
|
background loop that calls it on a schedule isn't wired into
|
|
`op-server` (it would need a `tokio::spawn` plus a tick
|
|
interval). Operators run a sidecar that calls
|
|
`dispatcher.process_due_retries()` periodically.
|
|
- **No emission on store-direct (non-HTTP) callers.** A binary
|
|
that calls `state.refunds.create_refund(...)` directly bypasses
|
|
the handler-layer emit. Library users who care must call
|
|
`state.events.emit(...)` themselves or wait for a v2 store
|
|
trait that bakes emission in.
|
|
- **No event versioning.** Payloads are the current shape of
|
|
`RefundResponse` / etc.; if those change, existing webhook
|
|
consumers break. Operators should treat them as evolving (add
|
|
fields safely; never remove or rename) until a versioning
|
|
scheme is added.
|
|
- **No replay UI.** `WebhookDispatcher::replay` exists but no
|
|
HTTP endpoint surfaces it yet. Operators using the in-process
|
|
API can call it directly.
|
|
|
|
## Test totals
|
|
|
|
```
|
|
op-webhook +2 tests (emitter dispatches / noop is silent)
|
|
op-server +2 integration tests (refund webhook delivery,
|
|
disabled endpoint skip)
|
|
----
|
|
+4 net
|
|
```
|
|
|
|
`cargo test --workspace`: **1027 passing, 0 failing.**
|
|
`cargo build --workspace --all-targets`: **0 warnings.**
|
|
`cargo clippy --workspace --all-targets`: **0 warnings.**
|