# Phase 12 — Webhooks Platform

## Overview

Phase 12 builds a **production-grade, enterprise webhook platform** on top of the
generic middleware foundation laid in Phases 11. It covers both planes of webhook
traffic — inbound (external systems calling MercuryPay) and outbound (MercuryPay
notifying partner/customer endpoints) — with full observability via a structured
delivery log that captures complete request and response payloads.

```
Phase 12.1  Inbound Hardening       — per-source HMAC-SHA256 verification, async ACK,
                                       full request + response delivery log
Phase 12.2  Outbound Delivery       — customer-registered endpoints, signed delivery,
                                       exponential-backoff retry, full delivery log
Phase 12.3  Admin UI                — Sources, Endpoints, Deliveries LiveViews
                                       with payload inspector and replay
Phase 12.4  Flow Builder Nodes      — :webhook_trigger + :webhook_action DAG node types
```

---

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│  INBOUND (external → MercuryPay)                                    │
│                                                                     │
│  POST /api/v1/webhooks/:source_name                                 │
│    → WebhookAuthPlug  (HMAC-SHA256 verify per webhook_sources row)  │
│    → ACK 200 immediately                                            │
│    → Log to webhook_deliveries (full req headers + body)            │
│    → Pipeline.run  (async via Task)                                 │
│    → Update delivery log (response status + body + duration)        │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  OUTBOUND (MercuryPay → partner endpoint)                           │
│                                                                     │
│  WebhookDispatcher.deliver(event_type, payload, tenant_id)          │
│    → Lookup webhook_endpoints (matching event_type + tenant)        │
│    → Sign with HMAC-SHA256 → X-MercuryPay-Signature header         │
│    → HTTP POST to endpoint URL                                      │
│    → Log to webhook_deliveries (full req + response)                │
│    → On failure: retry with exponential backoff (up to max_attempts)│
│    → Final failure: mark :permanently_failed, alert                 │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  ADMIN UI  /admin/webhooks/*                                        │
│                                                                     │
│  /sources     — manage inbound sources (secret, HMAC config)        │
│  /endpoints   — manage outbound endpoints (URL, events, retry)      │
│  /deliveries  — unified log with payload inspector + replay         │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  FLOW BUILDER                                                       │
│                                                                     │
│  :webhook_trigger  — flow triggered by inbound webhook event        │
│  :webhook_action   — flow sends outbound webhook as a DAG step      │
└─────────────────────────────────────────────────────────────────────┘
```

---

## Database Schema

### `webhook_sources` — inbound source registry

| Column | Type | Notes |
|---|---|---|
| `id` | bigserial | PK |
| `source_name` | string(64) | unique, lowercase; matches URL segment |
| `display_name` | string(200) | human-readable label |
| `description` | text | optional notes |
| `secret` | text | HMAC signing secret (store encrypted in prod) |
| `signature_header` | string(100) | e.g. `X-Hub-Signature-256`, `X-Fraud-Guard-Sig` |
| `signature_algo` | string(20) | `sha256` (default) / `sha1` |
| `active` | boolean | gate — inactive sources are rejected |
| `tenant_id` | string(36) | optional tenant scoping |
| `inserted_at` | datetime | |
| `updated_at` | datetime | |

### `webhook_endpoints` — outbound endpoint registry

| Column | Type | Notes |
|---|---|---|
| `id` | bigserial | PK |
| `tenant_id` | string(36) | owning tenant |
| `name` | string(200) | human-readable label |
| `url` | string(2048) | target HTTPS URL |
| `event_types` | text | JSON array: `["payment.completed","payment.failed"]` |
| `secret` | text | HMAC secret for signing outbound payloads |
| `active` | boolean | |
| `max_attempts` | integer | default 5 |
| `initial_delay_ms` | integer | default 1000 (doubles each retry) |
| `timeout_ms` | integer | default 10000 |
| `inserted_at` | datetime | |
| `updated_at` | datetime | |

### `webhook_deliveries` — unified delivery log (inbound + outbound)

| Column | Type | Notes |
|---|---|---|
| `id` | bigserial | PK |
| `direction` | string(10) | `"inbound"` / `"outbound"` |
| `source_name` | string(64) | inbound: source name; outbound: nil |
| `endpoint_id` | bigint | outbound: FK to webhook_endpoints; inbound: nil |
| `event_type` | string(200) | message type string |
| `delivery_key` | string(64) | idempotency: external delivery ID from header |
| `attempt_number` | integer | 1-based retry count |
| `request_url` | string(2048) | outbound: target URL; inbound: full path |
| `request_method` | string(10) | always `POST` |
| `request_headers` | text | JSON map of sanitised headers (no Authorization) |
| `request_body` | text | raw request body string |
| `response_status` | integer | HTTP status code |
| `response_headers` | text | JSON map |
| `response_body` | text | raw response body |
| `status` | string(20) | `received` / `processing` / `delivered` / `failed` / `permanently_failed` |
| `duration_ms` | integer | round-trip time |
| `error_message` | text | error detail on failure |
| `inserted_at` | datetime | |
| `updated_at` | datetime | |

---

## Implementation Status

| Sub-phase | Status | Notes |
|---|---|---|
| **12.1** Inbound Hardening | ✅ **COMPLETE** | `WebhookAuthPlug`, `WebhookSource` schema, delivery log, full req/res capture |
| **12.2** Outbound Delivery Engine | ✅ **COMPLETE** | `WebhookEndpoint` schema, `WebhookDispatcher`, signed delivery, retry, full req/res log |
| **12.3** Admin UI | ✅ **COMPLETE** | `WebhookSourcesLive`, `WebhookEndpointsLive`, `WebhookDeliveriesLive`, sidebar nav |
| **12.4** Flow Builder Nodes | ✅ **COMPLETE** | `:webhook_trigger` + `:webhook_action` node types, palette, property panels |

---

## Sub-Phase Detail

---

### ✅ Phase 12.1 — Inbound Hardening

**Goal**: Secure inbound webhooks with per-source HMAC verification and log every
delivery with full request and response payloads.

#### Files changed

| File | Change |
|---|---|
| `apps/infra_repo/lib/infra_repo/schemas/webhook_source.ex` | NEW — Ecto schema |
| `apps/infra_repo/lib/infra_repo/schemas/webhook_delivery.ex` | NEW — Ecto schema |
| `apps/infra_repo/priv/repo/migrations/20260501000006_create_webhook_tables.exs` | NEW — creates all 3 tables |
| `apps/gateway_api/lib/gateway_api_web/plugs/webhook_auth_plug.ex` | NEW — HMAC verification |
| `apps/gateway_api/lib/gateway_api_web/controllers/webhook_controller.ex` | UPDATED — delivery logging |

#### How HMAC verification works

```
1. Look up webhook_sources row by source_name
2. If not found or inactive → 404
3. Read raw request body (before params parsing)
4. Compute HMAC-SHA256(secret, raw_body) → hex digest
5. Compare with value in configured signature_header
6. Mismatch → 401 Unauthorized
7. Match → proceed, ACK 200 immediately
8. Log delivery with full headers + body
9. Dispatch to pipeline asynchronously
10. Update delivery log with response status + body + duration
```

---

### ✅ Phase 12.2 — Outbound Delivery Engine

**Goal**: Deliver signed events to customer-registered endpoints with retry and
full delivery logging.

#### Files changed

| File | Change |
|---|---|
| `apps/infra_repo/lib/infra_repo/schemas/webhook_endpoint.ex` | NEW — Ecto schema |
| `apps/mw_router/lib/mw_router/webhook_dispatcher.ex` | NEW — outbound delivery |
| `apps/gateway_api/lib/gateway_api_web/router.ex` | UPDATED — webhook routes |

#### Outbound delivery flow

```
WebhookDispatcher.deliver("payment.completed", payload, tenant_id)
  → SELECT * FROM webhook_endpoints WHERE tenant_id = ? AND active = true
  → Filter: event_types JSON includes "payment.completed"
  → For each matching endpoint:
      1. Create webhook_deliveries row (status: :processing)
      2. Build payload JSON
      3. Sign: HMAC-SHA256(endpoint.secret, json_body) → hex
      4. POST to endpoint.url with headers:
           Content-Type: application/json
           X-MercuryPay-Signature: sha256=<hex>
           X-MercuryPay-Event: payment.completed
           X-MercuryPay-Delivery: <delivery_id>
      5. On success: update delivery (status: :delivered, response fields)
      6. On failure: retry after initial_delay_ms * 2^(attempt-1) up to max_attempts
      7. After max_attempts: status: :permanently_failed
```

---

### ✅ Phase 12.3 — Admin UI

**Goal**: Full visibility and management of webhook sources, endpoints, and deliveries.

#### New LiveView pages

| Route | Module | Purpose |
|---|---|---|
| `/admin/webhooks/sources` | `WebhookSourcesLive` | CRUD inbound sources, secret rotation |
| `/admin/webhooks/endpoints` | `WebhookEndpointsLive` | CRUD outbound endpoints, test delivery |
| `/admin/webhooks/deliveries` | `WebhookDeliveriesLive` | Unified log, payload inspector, replay |

#### Deliveries page features
- Filter by direction (inbound/outbound), status, source/endpoint, date range
- Per-row expansion: full request headers + body, response status + headers + body
- **Replay button**: re-delivers the same payload to the same target
- Duration badge (green < 500ms, yellow < 2000ms, red ≥ 2000ms)
- Auto-refresh every 10 seconds

---

### ✅ Phase 12.4 — Flow Builder Webhook Nodes

**Goal**: Allow flows to be triggered by webhooks and to send webhooks as actions.

#### New DagNode types

| Type | Role | Config keys |
|---|---|---|
| `:webhook_trigger` | Replaces `:request` for webhook-driven flows | `source_name`, `event_filter` |
| `:webhook_action` | Outbound webhook as a DAG step | `event_type`, `endpoint_id`, `payload_template` |

#### Example flow

```
[⚡ Webhook Trigger: fraud_guard]
         ↓
[Decision: outcome == pass]
   ↙ YES              ↘ NO
[Payment Adapter]   [📤 Webhook Action: payment.declined → merchant]
         ↓
[📤 Webhook Action: payment.completed → merchant]
         ↓
[Response]
```

---

## Security Considerations

- Secrets stored as plaintext in Phase 12 (Phase 13 target: `Cloak.Ecto` AES-256-GCM encryption)
- HMAC comparison uses `Plug.Crypto.secure_compare/2` to prevent timing attacks
- Request bodies in `webhook_deliveries` are stored raw — scrub sensitive fields
  in prod via a configurable blocklist (Phase 12 TODO)
- Outbound retry does not log Authorization headers from responses

---

## Retry Policy

```
Attempt 1: immediate
Attempt 2: after 1s
Attempt 3: after 2s
Attempt 4: after 4s
Attempt 5: after 8s (configurable per endpoint via initial_delay_ms)
After max_attempts: status = :permanently_failed, error_message logged
```
