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.
8.4 KiB
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:
- Store-trait callbacks — each
RefundStoreimpl emits after a successful mutation. Pro: every caller, including direct library users, gets events. Con: invasive — adds anArc<dyn EventEmitter>parameter to every store constructor and every trait method, breaking the existing surfaces. - 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:
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:
{
"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:
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
reqwestor 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 thinop-webhook-reqwestcrate. process_due_retriesnot called automatically. The dispatcher knows how to retry failed deliveries; the background loop that calls it on a schedule isn't wired intoop-server(it would need atokio::spawnplus a tick interval). Operators run a sidecar that callsdispatcher.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 callstate.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::replayexists 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.