|
| 1 | +# Webhook Events on Template CRUD |
| 2 | + |
| 3 | +## Status: Feasibility Investigation (complete) |
| 4 | + |
| 5 | +## Summary |
| 6 | + |
| 7 | +Adding webhook events for template CRUD operations is **highly feasible** and follows an established pattern already used by 5 other entity types (contacts, lists, segments, emails, custom_events). |
| 8 | + |
| 9 | +## Current Webhook Architecture |
| 10 | + |
| 11 | +All outgoing webhook events are generated via **PostgreSQL AFTER triggers** on workspace tables. When a row is inserted/updated/deleted, the trigger function: |
| 12 | + |
| 13 | +1. Determines the event type (e.g. `contact.created`) |
| 14 | +2. Builds a JSONB payload |
| 15 | +3. Queries `webhook_subscriptions` for matching subscriptions |
| 16 | +4. Inserts rows into `webhook_deliveries` with `status='pending'` |
| 17 | + |
| 18 | +The `WebhookDeliveryWorker` polls every 10s, signs the payload with HMAC-SHA256 (Standard Webhooks spec), and delivers via HTTP POST with exponential backoff retries (up to 10 attempts over ~48h). |
| 19 | + |
| 20 | +### Key Files |
| 21 | + |
| 22 | +| Layer | File | |
| 23 | +|---|---| |
| 24 | +| Event types | `internal/domain/webhook_subscription.go` (line 79: `WebhookEventTypes`) | |
| 25 | +| Delivery worker | `internal/service/webhook_delivery_worker.go` | |
| 26 | +| Trigger examples | `internal/migrations/v19.go` (contacts, lists, segments, emails, custom_events) | |
| 27 | +| Subscription service | `internal/service/webhook_subscription_service.go` | |
| 28 | +| Template service | `internal/service/template_service.go` | |
| 29 | +| Template repo | `internal/repository/template_postgres.go` | |
| 30 | +| Template domain | `internal/domain/template.go` | |
| 31 | + |
| 32 | +## New Event Types |
| 33 | + |
| 34 | +``` |
| 35 | +template.created |
| 36 | +template.updated |
| 37 | +template.deleted |
| 38 | +``` |
| 39 | + |
| 40 | +## Design Consideration: Template Versioning |
| 41 | + |
| 42 | +Templates use an **INSERT-based versioning model** — updates don't `UPDATE` the row, they `INSERT` a new row with an incremented `version`. Deletes are **soft deletes** (set `deleted_at`). This affects trigger logic: |
| 43 | + |
| 44 | +| Operation | DB Operation | Trigger Detection | |
| 45 | +|---|---|---| |
| 46 | +| Create | `INSERT` | `AFTER INSERT` where `version = 1` and `deleted_at IS NULL` | |
| 47 | +| Update | `INSERT` (new version) | `AFTER INSERT` where `version > 1` and `deleted_at IS NULL` | |
| 48 | +| Delete | `UPDATE` (set deleted_at) | `AFTER UPDATE` where `deleted_at` transitions from `NULL` to non-`NULL` | |
| 49 | + |
| 50 | +This is different from contacts (which use standard INSERT/UPDATE/DELETE) but straightforward in PL/pgSQL: |
| 51 | + |
| 52 | +```sql |
| 53 | +IF TG_OP = 'INSERT' THEN |
| 54 | + IF NEW.version = 1 THEN |
| 55 | + event_kind := 'template.created'; |
| 56 | + ELSE |
| 57 | + event_kind := 'template.updated'; |
| 58 | + END IF; |
| 59 | +ELSIF TG_OP = 'UPDATE' THEN |
| 60 | + IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN |
| 61 | + event_kind := 'template.deleted'; |
| 62 | + ELSE |
| 63 | + RETURN NEW; -- skip non-delete updates |
| 64 | + END IF; |
| 65 | +END IF; |
| 66 | +``` |
| 67 | + |
| 68 | +## Design Decision: Payload Content |
| 69 | + |
| 70 | +Templates can carry large MJML trees and compiled HTML in their `email` JSONB column. Two options: |
| 71 | + |
| 72 | +### Option A: Lightweight payload (recommended) |
| 73 | + |
| 74 | +Send only metadata — consumer calls back for full details if needed: |
| 75 | + |
| 76 | +```json |
| 77 | +{ |
| 78 | + "template": { |
| 79 | + "id": "welcome-email", |
| 80 | + "name": "Welcome Email", |
| 81 | + "version": 3, |
| 82 | + "channel": "email", |
| 83 | + "category": "transactional", |
| 84 | + "created_at": "...", |
| 85 | + "updated_at": "..." |
| 86 | + } |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +**Pros**: Small payload, fast delivery, no risk of exceeding webhook size limits. |
| 91 | +**Cons**: Consumer needs an extra API call for full template content. |
| 92 | + |
| 93 | +### Option B: Full payload |
| 94 | + |
| 95 | +Send the entire template object including MJML tree and compiled HTML. |
| 96 | + |
| 97 | +**Pros**: Consumer has everything in one delivery. |
| 98 | +**Cons**: Payloads could be very large (MJML trees + compiled HTML), may cause delivery timeouts or exceed consumer limits. |
| 99 | + |
| 100 | +## What Needs to Change |
| 101 | + |
| 102 | +### 1. Domain layer (~3 lines) |
| 103 | + |
| 104 | +Add 3 event types to `WebhookEventTypes` in `internal/domain/webhook_subscription.go`. |
| 105 | + |
| 106 | +### 2. New database migration (~80 lines SQL) |
| 107 | + |
| 108 | +New migration file (e.g. `internal/migrations/v28.go`) with: |
| 109 | +- `webhook_templates_trigger()` function |
| 110 | +- `AFTER INSERT OR UPDATE` trigger on `templates` table |
| 111 | +- Following the exact same pattern as `webhook_contacts_trigger()` in v19 |
| 112 | + |
| 113 | +### 3. Tests (~50 lines) |
| 114 | + |
| 115 | +- Update `WebhookEventTypes` test expectations in `internal/domain/webhook_subscription_test.go` |
| 116 | +- Add migration tests in `internal/migrations/v28_test.go` |
| 117 | + |
| 118 | +### 4. Frontend: zero changes |
| 119 | + |
| 120 | +The subscription UI dynamically reads available event types from the `GET /api/webhookSubscriptions.eventTypes` endpoint, which returns `WebhookEventTypes`. New types will appear automatically. |
| 121 | + |
| 122 | +### 5. No changes needed to |
| 123 | + |
| 124 | +- Delivery worker (generic, processes any event type) |
| 125 | +- Signing/retry logic |
| 126 | +- Subscription service or HTTP handlers |
| 127 | +- Template service or repository (triggers are at DB level) |
| 128 | + |
| 129 | +## Out of Scope: Template Blocks |
| 130 | + |
| 131 | +Template blocks are stored inside `workspace.Settings` (not a separate table), so adding webhook triggers for block CRUD would require an **application-level approach** — inserting into `webhook_deliveries` from Go code in the service layer. This is a different pattern from the rest and should be a separate initiative if desired. |
| 132 | + |
| 133 | +## Risk Assessment |
| 134 | + |
| 135 | +- **Low risk**: The pattern is battle-tested with 5 other entity types |
| 136 | +- **Only nuance**: The version-based INSERT pattern requires careful trigger logic (see above) |
| 137 | +- **No breaking changes**: Existing subscriptions are unaffected; new events only fire for subscriptions that opt in |
0 commit comments