open-standards/openpay/docs/28-webhooks-progress.md
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

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:

  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:

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 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.