Skip to content

feat(webhooks): add n8n external workflow integration with signup-triggered welcome email

Surya Manoj Pentakota requested to merge feat/email-webhook into develop

Webhook Integration for External Workflows

This MR adds a complete event-driven webhook integration layer, wired into the signup flow to dispatch external workflow calls (e.g. n8n) asynchronously.


Architecture

The system follows an Events → Conditions → Actions model:

  1. Code calls webhook_dispatcher.emit("user.signup", payload) at event points
  2. Dispatch queries the webhook_config table for active rows whose events list matches
  3. For each match, POSTs the enriched payload (with _event + config_id) to the configured URL
  4. The external workflow calls back to the incoming endpoint with the config_id so we can resolve the right JWT secret

All routing, URLs, and secrets are stored in the DB — no env vars needed for configuration.


1. Webhook Configuration (DB-Driven)

New Table: webhook_config

Column Type Description
id UUID PK
name varchar(200) Human-readable label
url varchar(500) Target webhook URL
events JSONB Event names that trigger this webhook (e.g. ["user.signup"])
secret varchar(255) Per-config JWT signing secret
is_active bool Toggle on/off
created_by UUID (FK) Admin who created it
created_at / updated_at timestamptz

Each webhook config owns its own URL and secret. The secret is never stored in env vars.

New Files

  • app/models/webhook_config.py
  • app/schemas/webhook_config.py
  • alembic/versions/f0a1b2c3d4e5_create_webhook_config_table.py

2. Dispatch Service

New File: app/services/webhook_dispatch.py

await webhook_dispatcher.emit("user.signup", payload)
  • Queries all active webhook_config rows
  • Filters by event name match
  • Builds enriched payload with _event + config_id
  • Calls webhook_service.trigger(url, payload, secret) for each match
  • Returns list of result dicts

When WEBHOOK_TEST_MODE=true, the target URL is overridden with WEBHOOK_TEST_URL (from env) while the secret still comes from the DB config.


3. Webhook Service (HTTP Caller)

File: app/services/webhook_service.py

Low-level HTTP service using httpx.AsyncClient:

  • trigger(payload, url, secret) — POSTs to the given URL with a JWT in Authorization: Bearer header (signed with the config's secret). Only sends auth header if a secret is provided.
  • validate_jwt(token, secret) — Decodes and validates a JWT against the given secret.

4. Incoming Callback Endpoint

POST /api/v1/webhooks/incoming

Accepts callbacks from external workflows after execution.

Security:

  • Reads config_id from request body (payload.data.config_id)
  • Looks up the WebhookConfig row to get its secret
  • Validates the JWT in Authorization: Bearer header against that secret
  • Returns 401 if config_id is missing, config not found, or JWT is invalid

EmailLog update:

  • Reads email_log_id from the body
  • Updates the corresponding EmailLog status to sent or failed
  • Stores the response data in workflow_response

5. Signup Flow Integration

Modified: app/api/v1/endpoints/auth.py

On successful signup (POST /api/v1/auth/signup/verify-otp):

  1. Creates an EmailLog record (validated via EmailLogCreate schema)
  2. Calls webhook_dispatcher.emit("user.signup", payload) via BackgroundTasks (non-blocking)
  3. Returns the signup response immediately

Email Templates (Configurable)

Env Var Default Placeholders
SIGNUP_WELCOME_SUBJECT "Welcome to Indic Corpus!"
SIGNUP_WELCOME_BODY Multi-line template {name}, {username}, {email}

6. Database Migration

Migrations:

  • e7f8a9b0c1d2 — Creates email_log table
  • f0a1b2c3d4e5 — Creates webhook_config table

email_log Table

Column Type Notes
id UUID PK
user_id UUID (FK) Nullable
action varchar(50) e.g. signup
recipient_email varchar(255)
subject varchar(255)
body text
status varchar(20) Default pending
workflow_response JSONB Callback payload from external workflow
error_message varchar(1000)
created_at / updated_at timestamptz

Indexes on user_id and status.


7. Removed / Replaced

Old (previous commits) Current
POST /api/v1/webhooks/trigger Removed — dispatch calls service directly
X-Webhook-Signature HMAC Replaced with JWT (HS256)
N8N_WEBHOOK_* env vars Renamed to generic WEBHOOK_*
WEBHOOK_BASE_URL env var Removed — URLs come from DB configs
WEBHOOK_SECRET env var Removed — secrets are per-config in DB
n8n_workflow_id / n8n_response Renamed to workflow_response
conditions field on config Removed — events are sufficient for routing

8. Tests

File Tests Scope
tests/unit/services/test_webhook_service.py 11 JWT validation, sync/async trigger, HTTP errors, timeout, no URL, no-auth mode
tests/unit/api/test_webhooks.py 7 Incoming endpoint: EmailLog update, failure marking, invalid JWT, missing auth, missing config_id
tests/unit/api/test_auth_webhook.py 3 Dispatch event emission payload, graceful failure, full signup flow with EmailLog + background task

Result: 21/21 tests passing.


9. Usage

Insert a webhook config:

INSERT INTO webhook_config (id, name, url, events, secret, is_active, created_at, updated_at)
VALUES (
  gen_random_uuid(),
  'n8n welcome email',
  'https://n8n.example.com/webhook/corpus',
  '["user.signup"]',
  'your-secret-here',
  true, NOW(), NOW()
);

Test mode (overrides URL, keeps DB secret):

WEBHOOK_TEST_URL=https://n8n.example.com/webhook-test/corpus
WEBHOOK_TEST_MODE=true

Checklist

  • Feature fully implemented
  • 21/21 tests passing
  • .env.example updated
  • No env vars needed for webhook URLs or secrets (except test mode)
  • Migrations included for both tables
Edited by Surya Manoj Pentakota

Merge request reports

Loading