feat(webhooks): add n8n external workflow integration with signup-triggered welcome email
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:
- Code calls
webhook_dispatcher.emit("user.signup", payload)at event points - Dispatch queries the
webhook_configtable for active rows whoseeventslist matches - For each match, POSTs the enriched payload (with
_event+config_id) to the configured URL - The external workflow calls back to the incoming endpoint with the
config_idso 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.pyapp/schemas/webhook_config.pyalembic/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_configrows - 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 inAuthorization: Bearerheader (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_idfrom request body (payload.data.config_id) - Looks up the
WebhookConfigrow to get itssecret - Validates the JWT in
Authorization: Bearerheader against that secret - Returns 401 if
config_idis missing, config not found, or JWT is invalid
EmailLog update:
- Reads
email_log_idfrom the body - Updates the corresponding
EmailLogstatus tosentorfailed - 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):
- Creates an
EmailLogrecord (validated viaEmailLogCreateschema) - Calls
webhook_dispatcher.emit("user.signup", payload)viaBackgroundTasks(non-blocking) - 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— Createsemail_logtable -
f0a1b2c3d4e5— Createswebhook_configtable
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.exampleupdated -
No env vars needed for webhook URLs or secrets (except test mode) -
Migrations included for both tables