Merge branch 'next' of https://github.com/5h0ov/plunk into multipart/related-support-embed-image
This commit is contained in:
@@ -29,10 +29,6 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||
|
||||
signale.info(
|
||||
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
|
||||
);
|
||||
|
||||
// Process tracked segments with full membership computation (creates events)
|
||||
let updatedSegmentCount = 0;
|
||||
let totalAdded = 0;
|
||||
@@ -41,13 +37,7 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
||||
if (trackedSegments.length > 0) {
|
||||
for (const segment of trackedSegments) {
|
||||
try {
|
||||
signale.info(
|
||||
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
|
||||
);
|
||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||
signale.success(
|
||||
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
|
||||
);
|
||||
|
||||
// Track segments with actual changes for bundled notification
|
||||
if (result.added > 0 || result.removed > 0) {
|
||||
@@ -77,7 +67,6 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
||||
if (nonTrackedSegments.length > 0) {
|
||||
try {
|
||||
await SegmentService.refreshAllMemberCounts(projectId);
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||
}
|
||||
@@ -90,14 +79,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||
const {projectId} = job.data;
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
|
||||
|
||||
try {
|
||||
if (projectId) {
|
||||
// Process specific project
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
|
||||
await processProjectSegments(projectId);
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
|
||||
} else {
|
||||
// Process all active projects
|
||||
const projects = await prisma.project.findMany({
|
||||
@@ -105,7 +90,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
||||
select: {id: true, name: true},
|
||||
});
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
|
||||
|
||||
// Process projects in batches to avoid overwhelming the database
|
||||
const PROJECT_BATCH_SIZE = 10;
|
||||
@@ -115,9 +100,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
||||
await Promise.all(
|
||||
batch.map(async project => {
|
||||
try {
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
|
||||
await processProjectSegments(project.id, project.name);
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||
// Don't throw - continue with other projects
|
||||
@@ -130,8 +113,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||
@@ -158,10 +139,6 @@ export function createSegmentCountWorker(): Worker {
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,13 @@ const SECURITY_THRESHOLDS = {
|
||||
COMPLAINT_7DAY_CRITICAL: 0.15,
|
||||
COMPLAINT_ALLTIME_WARNING: 0.03,
|
||||
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
||||
|
||||
// Minimum absolute counts (prevents small sample size false positives)
|
||||
// Both percentage AND absolute count must be exceeded to trigger
|
||||
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||
MIN_BOUNCES_FOR_WARNING: 5,
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
} as const;
|
||||
|
||||
interface RateData {
|
||||
@@ -280,51 +287,77 @@ export class SecurityService {
|
||||
|
||||
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
|
||||
// Critical: requires BOTH rate AND absolute count thresholds
|
||||
if (
|
||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) {
|
||||
} else if (
|
||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
|
||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
||||
// Critical: requires BOTH rate AND absolute count thresholds
|
||||
if (
|
||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
||||
} else if (
|
||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
|
||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check all-time rates (only if all-time volume is sufficient)
|
||||
if (hasMinimumVolumeAllTime) {
|
||||
// Check all-time bounce rate
|
||||
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
|
||||
// Check all-time bounce rate - requires BOTH rate AND absolute count
|
||||
if (
|
||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
|
||||
} else if (
|
||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
|
||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`,
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check all-time complaint rate
|
||||
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
|
||||
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
||||
if (
|
||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`,
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) {
|
||||
} else if (
|
||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
|
||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`,
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
setActivities([]);
|
||||
// Don't clear activities immediately - we'll do a smart merge
|
||||
// This preserves React component instances and their local state
|
||||
setNextCursor(undefined);
|
||||
setHasMore(true);
|
||||
}
|
||||
@@ -58,11 +59,25 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
|
||||
|
||||
if (cursor) {
|
||||
// Append to existing activities
|
||||
// Append to existing activities (pagination)
|
||||
setActivities(prev => [...prev, ...result.data]);
|
||||
} else {
|
||||
// Replace activities
|
||||
setActivities(result.data);
|
||||
// Smart merge: preserve existing activity objects by ID to maintain component state
|
||||
setActivities(prev => {
|
||||
// If no previous activities, just use new data
|
||||
if (prev.length === 0) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// Create a map of existing activities by ID for fast lookup
|
||||
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||
|
||||
// For each new activity, reuse existing object if ID matches (preserves React component instances)
|
||||
// Otherwise use new object. This maintains correct ordering while preserving component state.
|
||||
return result.data.map(newActivity =>
|
||||
existingMap.get(newActivity.id) ?? newActivity
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
setNextCursor(result.cursor);
|
||||
@@ -95,7 +110,21 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
|
||||
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
|
||||
|
||||
setUpcomingActivities(result.activities);
|
||||
// Smart merge: preserve existing activity objects by ID to maintain component state
|
||||
setUpcomingActivities(prev => {
|
||||
// If no previous activities, just use new data
|
||||
if (prev.length === 0) {
|
||||
return result.activities;
|
||||
}
|
||||
|
||||
// Create a map of existing activities by ID for fast lookup
|
||||
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||
|
||||
// For each new activity, reuse existing object if ID matches
|
||||
return result.activities.map(newActivity =>
|
||||
existingMap.get(newActivity.id) ?? newActivity
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching upcoming activities:', err);
|
||||
// Don't set error state for upcoming - just fail silently
|
||||
|
||||
+14
-11
@@ -5,6 +5,7 @@ import React, {useEffect} from 'react';
|
||||
import {Toaster} from 'sonner';
|
||||
import {SWRConfig} from 'swr';
|
||||
import {DefaultSeo} from 'next-seo';
|
||||
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
|
||||
import {Loader} from '@plunk/ui';
|
||||
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useProjects} from '../lib/hooks/useProject';
|
||||
@@ -103,17 +104,19 @@ function ProjectGuard({children}: {children: React.ReactNode}) {
|
||||
*/
|
||||
export default function WithProviders(props: AppProps) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (url: string) => network.fetch('GET', url),
|
||||
shouldRetryOnError: false,
|
||||
}}
|
||||
>
|
||||
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
||||
<ActiveProjectProvider>
|
||||
<Root {...props} />
|
||||
</ActiveProjectProvider>
|
||||
</SWRConfig>
|
||||
<NuqsAdapter>
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (url: string) => network.fetch('GET', url),
|
||||
shouldRetryOnError: false,
|
||||
}}
|
||||
>
|
||||
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
||||
<ActiveProjectProvider>
|
||||
<Root {...props} />
|
||||
</ActiveProjectProvider>
|
||||
</SWRConfig>
|
||||
</NuqsAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useState} from 'react';
|
||||
import {useQueryState, parseAsString} from 'nuqs';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ActivityStats {
|
||||
@@ -28,8 +28,8 @@ interface ActivityStats {
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [typeFilter, setTypeFilter] = useState<string>('ALL');
|
||||
const [dateRange, setDateRange] = useState<string>('30');
|
||||
const [typeFilter, setTypeFilter] = useQueryState('type', parseAsString.withDefault('ALL'));
|
||||
const [dateRange, setDateRange] = useQueryState('days', parseAsString.withDefault('30'));
|
||||
|
||||
// Fetch activity stats
|
||||
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
|
||||
|
||||
@@ -43,4 +43,32 @@ Certain keys are reserved by the system and automatically set by Plunk:
|
||||
### Special keys
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers |
|
||||
| locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers |
|
||||
|
||||
## Subscription State
|
||||
|
||||
Every contact has a `subscribed` field that determines which types of emails they will receive. A newly created contact is subscribed by default.
|
||||
|
||||
### How contacts become unsubscribed
|
||||
|
||||
A contact can become unsubscribed in several ways:
|
||||
- **Manually** through the dashboard or via the API
|
||||
- **Self-service** by clicking the unsubscribe link in an email
|
||||
- **Automatically** when an email to the contact bounces or results in a complaint
|
||||
|
||||
### Emails by subscription state
|
||||
|
||||
The subscription state controls whether a contact receives marketing emails. Transactional emails are always delivered regardless of subscription state.
|
||||
|
||||
| Email type | Subscribed | Unsubscribed |
|
||||
|---|---|---|
|
||||
| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered |
|
||||
| **Campaigns** | Delivered | Not delivered |
|
||||
| **Automations** (transactional template) | Delivered | Delivered |
|
||||
| **Automations** (marketing template) | Delivered | Not delivered |
|
||||
|
||||
<Callout
|
||||
title="Transactional emails and marketing templates"
|
||||
variant="warn">
|
||||
Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed contact. Use a transactional template instead if the email must reach unsubscribed contacts.
|
||||
</Callout>
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization"]
|
||||
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization", "webhooks"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: Send real-time event data from Plunk to your own application using webhooks
|
||||
icon: Webhook
|
||||
---
|
||||
|
||||
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a [workflow](/concepts/workflows) that uses the **Webhook** step to forward event data to your own endpoint.
|
||||
|
||||
## How it works
|
||||
|
||||
Webhooks in Plunk are powered by the workflow system. The basic flow is:
|
||||
|
||||
1. An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
|
||||
2. A workflow is triggered by that event
|
||||
3. The workflow executes a **Webhook** step, sending an HTTP request to your URL with relevant data
|
||||
|
||||
This means you can receive notifications for any event Plunk tracks, including both system events and your own custom events.
|
||||
|
||||
## Internal events
|
||||
|
||||
Plunk automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
|
||||
|
||||
### Email events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `email.sent` | An email was successfully sent |
|
||||
| `email.delivery` | An email was delivered to the recipient |
|
||||
| `email.open` | A contact opened an email for the first time |
|
||||
| `email.click` | A contact clicked a link in an email for the first time |
|
||||
| `email.bounce` | An email bounced (hard or soft bounce) |
|
||||
| `email.complaint` | A contact marked an email as spam |
|
||||
|
||||
### Contact events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `contact.subscribed` | A contact's subscription status changed to subscribed |
|
||||
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |
|
||||
|
||||
### Segment events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `segment.<name>.entry` | A contact entered a segment |
|
||||
| `segment.<name>.exit` | A contact exited a segment |
|
||||
|
||||
<Callout
|
||||
title="Segment event names"
|
||||
variant="idea">
|
||||
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`.
|
||||
</Callout>
|
||||
|
||||
## Setting up a webhook
|
||||
|
||||
<div className='fd-steps [&_h3]:fd-step'>
|
||||
|
||||
### Create the workflow
|
||||
|
||||
Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event.
|
||||
|
||||
### Add a Webhook step
|
||||
|
||||
After the trigger, add a **Webhook** step and configure it:
|
||||
|
||||
- **URL**: The endpoint on your server that will receive the webhook (e.g. `https://api.example.com/webhooks/plunk`)
|
||||
- **Method**: The HTTP method to use. Defaults to `POST`, which is recommended for most use cases.
|
||||
- **Headers** (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
|
||||
|
||||
```json
|
||||
{
|
||||
"Authorization": "Bearer your-secret-token"
|
||||
}
|
||||
```
|
||||
|
||||
### Enable the workflow
|
||||
|
||||
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
|
||||
|
||||
</div>
|
||||
|
||||
## Webhook payload
|
||||
|
||||
When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"contact": {
|
||||
"email": "[email protected]",
|
||||
"subscribed": true,
|
||||
"data": {
|
||||
"name": "John",
|
||||
"plan": "pro"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"id": "wf_abc123",
|
||||
"name": "Bounce Notifications"
|
||||
},
|
||||
"execution": {
|
||||
"id": "exec_xyz789",
|
||||
"startedAt": "2025-01-15T10:30:00.000Z"
|
||||
},
|
||||
"event": {
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "[email protected]",
|
||||
"bounceType": "Permanent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `event` field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
|
||||
|
||||
### Event data by type
|
||||
|
||||
The `event` field varies depending on which event triggered the workflow:
|
||||
|
||||
| Event | Fields in `event` |
|
||||
|-------|-------------------|
|
||||
| `email.sent` | `subject`, `from`, `messageId`, `templateId`, `campaignId`, `sourceType` |
|
||||
| `email.open` | `subject`, `from`, `openedAt`, `isFirstOpen` |
|
||||
| `email.click` | `subject`, `from`, `clickedAt`, `clicks`, `isFirstClick` |
|
||||
| `email.bounce` | `subject`, `from`, `bounceType`, `bouncedAt` |
|
||||
| `email.complaint` | `subject`, `from`, `complainedAt` |
|
||||
| Custom events | Whatever data you passed when tracking the event |
|
||||
|
||||
## Common use cases
|
||||
|
||||
### Bounce and complaint monitoring
|
||||
|
||||
Create a workflow triggered by `email.bounce` or `email.complaint` to forward these events to your application. This allows you to keep your own database in sync with Plunk's contact statuses.
|
||||
|
||||
You can use additional workflow steps before the webhook to add logic:
|
||||
|
||||
- **Condition**: Only send the webhook for hard bounces by checking the `bounceType` field
|
||||
- **Delay**: Add a short delay to batch-process related events
|
||||
- **Update Contact**: Mark the contact with metadata before sending the webhook
|
||||
|
||||
### Syncing unsubscribes
|
||||
|
||||
Trigger a workflow on `contact.unsubscribed` to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
|
||||
|
||||
### Custom event forwarding
|
||||
|
||||
If you track custom events in Plunk (e.g. `user.signup`, `order.completed`), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
|
||||
|
||||
## Adding conditions and delays
|
||||
|
||||
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
|
||||
|
||||
- Use a **Condition** step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
|
||||
- Use a **Wait for Event** step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
|
||||
- Use a **Delay** step to add a time buffer before the webhook fires
|
||||
Reference in New Issue
Block a user