Events Module
Overview
Foundry's event system is designed so you can swap analytics providers without changing a single line of event code.
User Action → useEvents().trackEvent() → nuxtApp.callHook('events:track') → Providers
├── Console (logs)
├── Umami (analytics)
└── Webhook (server delivery)
- A component calls
trackEvent()with an event payload - The payload is enriched with user ID and timestamp
- The
events:trackhook fires - Each registered provider listens on that hook and handles the event independently
This means adding or removing an analytics provider is a config change — no event code needs to change.
Configuration
export default defineNuxtConfig({
events: {
providers: ['console', 'umami', 'webhook'],
webhook: {
enabled: true,
platforms: ['discord'],
},
debug: false,
},
})
| Option | Type | Default | Description |
|---|---|---|---|
providers | string[] | ['console'] | Active providers: 'console', 'umami', 'webhook' |
webhook.enabled | boolean | false | Register the /api/v1/webhook server handler |
webhook.platforms | string[] | [] | Supported platforms: 'discord', 'slack', 'telegram' |
debug | boolean | false | Enable debug logging |
Tracking Events
useEvents()
The primary API for firing events:
const { trackEvent } = useEvents()
await trackEvent({
type: 'offer_click',
location: 'hero',
target: 'payment_external',
})
trackEvent accepts a partial EventPayload and enriches it with:
id— Auto-generated UUID if not providedtimestamp— Current timedata.userId— Anonymous user ID fromuseUserIdentity()
Event Types
| Type | Category | Fired By |
|---|---|---|
form_submitted | Form | ConvertForm on successful submission |
form_error | Form | ConvertForm on submission failure |
offer_click | Conversion | ConvertExternal, ConvertInternal, ConvertSocial, ConvertRss |
section_view | Engagement | SectionWrapper when a section enters the viewport |
Payload Shapes
Base Fields (all events)
interface BaseEventPayload {
id: string // UUID
timestamp: number // Date.now()
action?: string // Optional action descriptor
location?: string // Where on the page (e.g., 'hero', 'footer')
target?: string // What was interacted with (e.g., 'payment_external')
}
form_submitted
interface FormSubmittedPayload extends BaseEventPayload {
type: 'form_submitted'
target: string
data: {
formData: Record<string, unknown> // Validated form fields
antiSpam?: {
honeypot: string
timeOnForm: number
jsToken: string
}
}
}
form_error
interface FormErrorPayload extends BaseEventPayload {
type: 'form_error'
target: string
error: unknown
data?: Record<string, unknown>
}
offer_click / section_view
interface GenericEventPayload extends BaseEventPayload {
type: TrackedEvents // 'offer_click' | 'section_view' | etc.
data?: Record<string, unknown>
}
Target Naming Convention
Targets follow the {type}_{direction} pattern:
| Target | Meaning |
|---|---|
payment_external | Click to external payment link |
booking_external | Click to external booking link |
mentorship_internal | Click to internal mentorship offer page |
social_external | Click to social media profile |
Anonymous User Identity
useUserIdentity() manages a persistent anonymous user ID:
const { getUserId } = useUserIdentity()
const userId = getUserId() // 'user_a1b2c3d4-...'
- On first visit, generates
user_{uuid}and stores in localStorage - Returns the same ID on subsequent visits
- Returns empty string on the server (SSR-safe)
- No personal data is collected — this is purely for event correlation
Auto-Tracked Events
SectionWrapper automatically fires section_view when a section enters the viewport (threshold: 20% visible). It fires once per section per page load, then stops observing.
All other events are fired by convert components on user interaction.
Providers
Each provider is a Nuxt client-side plugin that listens on the events:track hook. Providers run independently — if one fails, others still process the event.
Console Provider
Logs events to the browser console. Useful for development.
Config: Add 'console' to providers array.
Behavior: Calls console.log() with the full event payload. No external dependencies.
Umami Provider
Sends events to Umami, a privacy-first analytics platform.
Config: Add 'umami' to providers array + configure Umami script.
Environment variables:
| Variable | Description |
|---|---|
NUXT_PUBLIC_SCRIPTS_UMAMI_ANALYTICS_WEBSITE_ID | Your Umami website ID |
NUXT_PUBLIC_SCRIPTS_UMAMI_ANALYTICS_SCRIPT_INPUT_SRC | Umami script URL |
Behavior:
- Uses Nuxt Scripts to load the Umami tracker
- Forwards event ID, location, action, target, and timestamp
- Mocked automatically in development (via
scripts.registry.umamiAnalytics: 'mock') - Active in production (via
scripts.registry.umamiAnalytics: true)
Setup: See the Umami documentation to create an account and get your website ID.
Webhook Provider
Sends form_submitted events to the server webhook handler for delivery to Slack, Discord, or Telegram.
Config: Add 'webhook' to providers array + enable the server handler:
events: {
providers: ['webhook'],
webhook: {
enabled: true,
platforms: ['discord'],
},
}
Behavior:
- Filters events — only processes
form_submittedtype - POSTs
{ formData, antiSpam }to/api/v1/webhook - Sets
payload.responseon success - Other event types are ignored by this provider
Using Multiple Providers
Providers compose naturally. A typical production setup:
events: {
providers: ['umami', 'webhook'], // Console removed for production
webhook: {
enabled: true,
platforms: ['discord', 'telegram'],
},
}
When a user submits a form:
- Umami records the event for analytics dashboards
- Webhook delivers the form data to Discord and Telegram
Both happen independently from the same trackEvent() call.
Provider Registration (Custom)
Providers are Nuxt client-side plugins that register themselves on the events:track hook:
// Simplified provider structure
export default defineNuxtPlugin({
name: 'events-provider-example',
dependsOn: ['events-core'],
setup(nuxtApp) {
nuxtApp.hook('events:track', (payload) => {
// Handle the event (send to analytics, log, etc.)
})
},
})
The events-core plugin must load first (enforced via dependsOn), followed by any number of providers.
Webhooks
When webhook.enabled is true, Foundry registers a server handler at POST /api/v1/webhook. This receives form submissions from the webhook provider and delivers them to your configured destinations.
Environment Variables
| Variable | Required | Description |
|---|---|---|
NUXT_WEBHOOK_URL | Yes | Webhook destination URL(s). Comma-separated for multiple. |
NUXT_TELEGRAM_CHAT_ID | Telegram only | Your Telegram chat ID |
Platform Auto-Detection
The handler detects the platform from the webhook URL:
| URL Contains | Platform | Format |
|---|---|---|
discord.com | Discord | Embeds with color-coded risk score |
hooks.slack.com | Slack | Block Kit with mrkdwn sections |
api.telegram.org | Telegram | Plain text with emoji indicators |
| Anything else | Unknown | JSON POST with raw data |
Multiple Destinations
Send to multiple platforms by comma-separating URLs:
NUXT_WEBHOOK_URL=https://discord.com/api/webhooks/...,https://api.telegram.org/bot.../sendMessage
NUXT_TELEGRAM_CHAT_ID=your-chat-id
All webhooks fire in parallel via Promise.allSettled. The request succeeds if at least one webhook delivery succeeds.
Message Formatting
Discord: Rich embed with color-coded spam risk score (green/yellow/red), email shown prominently, form fields as embed fields, timestamp.
Slack: Block Kit message with header section, fields section with form data, context section with timestamp and risk score.
Telegram: Plain text with emoji indicators for each field, risk score indicator, chat ID from NUXT_TELEGRAM_CHAT_ID.
Server-Side Flow
POST /api/v1/webhook
↓
1. Validate body (Zod: { formData, antiSpam? })
↓
2. Anti-spam pipeline:
a. Honeypot check → hard reject (silent 200)
b. Rate limiter → 429 if exceeded
c. JS token validation → scored
↓
3. Parse webhook URLs from NUXT_WEBHOOK_URL
↓
4. Auto-detect platform for each URL
↓
5. Format message per platform
↓
6. Send all webhooks in parallel
↓
7. Return success if ≥1 delivery succeeded
Testing Webhooks
In development, use the DevEvents component to fire test events without filling out forms. You can also use a tool like webhook.site as a temporary destination:
NUXT_WEBHOOK_URL=https://webhook.site/your-unique-url
Structured Logging
The webhook handler uses evlog for structured logging throughout the request lifecycle. Every step (validation, anti-spam, delivery) is logged with context, making debugging straightforward.
Anti-Spam
Foundry uses three complementary techniques to stop spam without CAPTCHAs.
Honeypot Field
A hidden form field that is invisible to humans but visible to bots. If a bot fills it, the submission is silently rejected — the bot receives a success response and is redirected, but no webhook is sent.
Client side: Hidden <input> in ConvertForm, tracked by useFormCaptureServer side: Hard reject in webhook handler (returns 200 to avoid bot retry)
Time-on-Form
Tracks how long the form was visible before submission. Legitimate users take at least a few seconds. Bots submit instantly.
Threshold: Submissions under 2000ms are flagged (+40 to spam score)
JS Token
A crypto.randomUUID() generated when the form mounts. Bots without JavaScript execution cannot produce a valid UUID v4.
Check: Missing or invalid UUID adds +30 to spam score
Spam Scoring
The server combines all signals into a single score (0–100):
| Signal | Score | Description |
|---|---|---|
| Honeypot filled | 100 | Instant bot detection (hard reject) |
| Fast submission | +40 | Under 2000ms on form |
| No JS / invalid token | +30 | Missing or invalid UUID |
Scores above 0 are included in the webhook message so you can see the risk level. The spam score is color-coded in Discord (green < 30, yellow 30–69, red 70+).
Rate Limiting
IP-based rate limiting prevents abuse:
| Setting | Default | Description |
|---|---|---|
| Max submissions | 5 | Per time window per IP |
| Time window | 15 minutes | Sliding window |
When rate limited, the server returns a 429 error.
The rate limiter runs in-memory with a probabilistic cleanup (1% chance per request) to prevent unbounded memory growth.
Anti-Spam Configuration
Rate limiting constants are defined in modules/events/server/utils/anti-spam.ts:
export const RATE_LIMIT_CONFIG = {
max: 5,
windowMin: 15,
minFormTime: 2000,
} as const
DevEvents Component
In development mode, a DevEvents component is available for testing events without a real form submission. It provides:
- Event type selector grouped by category
- Auto-generated mock data for each event type
- Event chain status tracking (pending, success, error)
- Full payload inspection
This component is dev-only and throws an error if used in production.