Update wiki

This commit is contained in:
Dries Augustyns
2025-12-07 13:36:31 +01:00
parent 0003e44db8
commit 683356c17c
48 changed files with 633 additions and 8620 deletions
@@ -9,127 +9,38 @@ icon: GitBranch
Conditions split workflows into two paths based on contact data.
```
[Condition: plan equals "premium"]
├─ True → [Send: Premium features]
└─ False → [Send: Upgrade offer]
[Condition: plan = "premium"?]
├─ True → [Premium email]
└─ False → [Upgrade offer]
```
Both paths required.
## Operators
| Operator | Use | Example |
|----------|-----|---------|
| `equals` | Exact match | `plan equals "pro"` |
| `notEquals` | Not matching | `plan notEquals "free"` |
| `contains` | Substring | `company contains "tech"` |
| `greaterThan` | Greater than | `mrr greaterThan 100` |
| `lessThan` | Less than | `loginCount lessThan 5` |
| `greaterThanOrEqual` | Greater than or equal | `age greaterThanOrEqual 18` |
| `lessThanOrEqual` | Less than or equal | `daysInactive lessThanOrEqual 30` |
| `exists` | Has value | `company exists` |
| `notExists` | Missing/null | `lastName notExists` |
| `startsWith` | Prefix | `coupon startsWith "SAVE"` |
| `endsWith` | Suffix | `email endsWith "@company.com"` |
| Operator | Example |
|----------|---------|
| `equals` | `plan equals "pro"` |
| `notEquals` | `plan notEquals "free"` |
| `contains` | `email contains "@company.com"` |
| `greaterThan` | `mrr greaterThan 100` |
| `lessThan` | `loginCount lessThan 5` |
| `exists` | `company exists` |
| `notExists` | `lastName notExists` |
**Important:** Numeric operators require field stored as number, not string.
## Common patterns
### Filter by plan
```
[Condition: plan equals "enterprise"]
├─ True → [Send: Enterprise onboarding]
└─ False → [Send: Standard onboarding]
```
### Activity check
```
[Condition: loginCount greaterThan 10]
├─ True → [Send: Power user tips]
└─ False → [Send: Getting started]
```
### Nested conditions
## Nested conditions
Chain for multi-tier logic:
```
[Condition: plan equals "enterprise"]
├─ True → [Send: Enterprise email]
└─ False
[Condition: plan equals "pro"]
├─ True → [Send: Pro email]
└─ False → [Send: Free email]
[Condition: enterprise?]
├─ True → [Enterprise email]
└─ False → [Condition: pro?]
├─ True → [Pro email]
└─ False → [Free email]
```
### Multiple field checks (AND)
## Tips
Nest conditions:
```
[Condition: plan equals "pro"]
├─ True ↓
│ [Condition: trialDaysLeft lessThan 3]
│ ├─ True → [Send: Trial ending]
│ └─ False → [Exit]
└─ False → [Exit]
```
Matches: `plan = "pro"` AND `trialDaysLeft < 3`
## Nested data
Access with dot notation:
```json
{
"field": "preferences.newsletter",
"operator": "equals",
"value": true
}
```
## Best practices
**Store correct types** — `99` (number) not `"99"` (string) for numeric comparisons.
**Check existence first** — If field might not exist:
```
[Condition: mrr exists]
├─ True → [Condition: mrr greaterThan 100]
└─ False → [Exit]
```
**Limit nesting** — More than 3 levels gets hard to maintain. Use separate workflows.
**Test both paths** — Verify true and false outcomes work.
**Use segments when filtering many** — Segment-based triggers more efficient than in-workflow conditions for large audiences.
## Common mistakes
**Case sensitivity** — `equals "Pro"` doesn't match `"pro"`
**Type mismatch** — `"100" greaterThan 50` fails (string vs number)
**Missing both paths** — Every condition needs true AND false connections
## Debugging
Check contact data first:
```bash
curl -X GET {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Verify field names and types match your condition.
## Next steps
- [See workflow patterns](/automation-patterns/workflow-patterns)
- [Build segments](/guides/segments) for trigger filtering
- Store numbers as numbers, not strings
- Check field exists before comparing
- Limit to 3 levels of nesting
- Test both paths
@@ -1,53 +0,0 @@
---
title: Automation Patterns
description: Workflow patterns and examples
icon: Zap
---
## Learn workflows
<Cards>
<Card icon="Workflow" title="Visual Builder Guide" href="/automation-patterns/visual-builder-guide">
Use the drag-and-drop workflow editor
</Card>
<Card icon="Layers" title="Workflow Patterns" href="/automation-patterns/workflow-patterns">
Common workflow examples
</Card>
</Cards>
## By use case
<Cards>
<Card icon="Users" title="Onboarding" href="/automation-patterns/onboarding-sequences">
Welcome and activate new users
</Card>
<Card icon="Lightbulb" title="Engagement" href="/automation-patterns/engagement-campaigns">
Drive feature adoption
</Card>
<Card icon="RefreshCw" title="Retention" href="/automation-patterns/retention-automation">
Prevent churn and win back users
</Card>
<Card icon="CreditCard" title="Transactional" href="/automation-patterns/transactional-workflows">
Order confirmations and receipts
</Card>
</Cards>
## Advanced
<Cards>
<Card icon="GitBranch" title="Conditional Branching" href="/automation-patterns/conditional-branching">
If/then logic in workflows
</Card>
<Card icon="Database" title="Execution Context" href="/automation-patterns/advanced-execution-context">
Pass event data through workflows
</Card>
<Card icon="Bug" title="Troubleshooting" href="/automation-patterns/troubleshooting-workflows">
Debug workflow issues
</Card>
</Cards>
@@ -1,15 +1,4 @@
{
"title": "Automation Patterns",
"pages": [
"index",
"visual-builder-guide",
"workflow-patterns",
"onboarding-sequences",
"engagement-campaigns",
"retention-automation",
"transactional-workflows",
"conditional-branching",
"advanced-execution-context",
"troubleshooting-workflows"
]
"title": "Automation",
"pages": ["visual-builder-guide", "workflow-patterns", "conditional-branching"]
}
@@ -1,258 +1,37 @@
---
title: Visual Workflow Builder
title: Visual Builder
description: Build workflows with drag-and-drop
icon: Workflow
---
## Overview
The workflow builder is a node-based visual editor for creating email automation. Each node is a step (send email, delay, condition, etc.) and connections define the flow.
## Canvas controls
- **-** / **+** - Zoom out/in
- **Fit** - Center and zoom to show entire workflow
- **Auto-Layout** - Automatically arrange nodes
- **Minimap** - Toggle overview map (bottom right)
- **+/-** Zoom in/out
- **Fit** Center workflow
- **Auto-Layout** Arrange nodes
## Step types
### Trigger
The starting point. Every workflow has one trigger.
**Types:**
- Event (e.g., `user_signed_up`)
- Segment entry/exit
- Schedule (cron)
Cannot be deleted.
### Send Email
Sends an email to the contact.
**Configuration:**
- Template (required)
- Variables (optional overrides)
Contact must be subscribed for marketing templates. Transactional templates send regardless.
### Delay
Pauses execution for a specified time.
**Configuration:**
- Duration: Number
- Unit: Minutes, Hours, or Days
**Example:** Delay 24 hours before next email.
### Wait for Event
Pauses until an event occurs or timeout is reached.
**Configuration:**
- Event name
- Timeout duration (optional)
**Requires two outgoing connections:**
- Event triggered path
- Timeout path
**Example:**
```
[Wait for: purchase_completed, timeout: 48h]
├─ Purchased → Send thank you
└─ Timeout → Send discount reminder
```
### Condition
Branches workflow based on contact data.
**Configuration:**
- Field to check
- Operator (equals, contains, greaterThan, etc.)
- Value to compare
**Requires two outgoing connections:**
- True path
- False path
**Example:**
```
[Condition: plan equals "premium"]
├─ True → Send premium features
└─ False → Send upgrade offer
```
### Webhook
Sends HTTP request to external URL.
**Configuration:**
- URL
- Method (GET, POST, PUT, DELETE)
- Headers (optional)
- Body (JSON, optional)
**Example:** Update CRM when workflow completes.
### Update Contact
Updates contact data fields.
**Configuration:**
- Fields: Key-value pairs
**Example:**
```
Fields:
onboardingCompleted: true
lastWorkflowStep: "welcome_series_done"
```
### Exit
Ends workflow execution. Can have multiple exit points for different paths.
## Building a workflow
### Add a step
**Method 1:** Click **+** button below any node
**Method 2:** Drag step type from sidebar onto canvas
### Configure a step
1. Click node to select
2. Right panel opens
3. Fill required fields
4. Save
### Connect steps
Connections auto-create when using + button. To manually connect:
1. Drag from node's bottom handle
2. Drop on another node's top handle
### Arrange layout
Click **Auto-Layout** for automatic top-to-bottom arrangement.
## Example workflows
### Linear sequence
```
[Trigger: user_signed_up]
[Send: Welcome]
[Delay: 24h]
[Send: Feature tour]
[Exit]
```
### With condition
```
[Trigger: trial_started]
[Send: Welcome]
[Condition: plan equals "enterprise"]
├─ True → [Send: Enterprise onboarding]
└─ False → [Send: Standard onboarding]
```
### With event wait
```
[Trigger: cart_abandoned]
[Delay: 1h]
[Send: First reminder]
[Wait for: purchase_completed, timeout: 23h]
├─ Purchased → [Exit]
└─ Timeout → [Send: Discount offer] → [Exit]
```
## Validation
The builder validates in real-time. Red border = error.
**Common errors:**
- "No outgoing connections" → Add connection to next step
- "Wait for Event requires two paths" → Add event + timeout paths
- "Condition requires true and false paths" → Add both paths
- "Template not found" → Select valid template
## Testing
### Manual execution
1. **Workflows** → Your workflow → **Executions**
2. **Create Execution**
3. Select test contact
4. Provide test data (JSON)
5. **Start**
Watch execution progress in real-time.
### Faster testing
For testing delays:
- Temporarily change to 5 minutes instead of days
- Test the flow
- Change back to real durations
## Keyboard shortcuts
| Key | Action |
|-----|--------|
| Delete | Delete selected node |
| Cmd/Ctrl + Z | Undo |
| Cmd/Ctrl + Shift + Z | Redo |
| + / - | Zoom |
| F | Fit to screen |
| Esc | Deselect |
## Troubleshooting
**Workflow not triggering**
- Workflow is enabled (toggle ON)
- Event name matches exactly (case-sensitive)
- Contact exists and is subscribed
**Execution stuck**
- Check delay configuration
- Verify wait timeout hasn't expired
- No circular references
**Email not sending**
- Template exists
- Contact is subscribed
- Variables in data match template
## Best practices
**Keep it simple** - 5-10 steps per workflow. Break complex flows into multiple workflows.
**Descriptive names** - "Send Welcome Email - Day 0" not "Email 1"
**Test first** - Always test with a test contact before enabling.
**Space emails** - Minimum 12-24 hours between emails to avoid fatigue.
## Next steps
- [Build your first workflow](/tutorials/welcome-series-workflow)
- [Workflow patterns](/automation-patterns/workflow-patterns)
- [Conditional branching](/automation-patterns/conditional-branching)
| Step | Description |
|------|-------------|
| **Trigger** | Starting point (event, segment, schedule) |
| **Send Email** | Send a template |
| **Delay** | Wait minutes, hours, or days |
| **Wait for Event** | Pause until event or timeout |
| **Condition** | Branch based on contact data |
| **Webhook** | Send HTTP request |
| **Update Contact** | Modify contact fields |
| **Exit** | End workflow |
## Building
1. Click step to configure
2. Connect steps by dragging
3. Conditions require both true/false paths
## Tips
- Keep workflows to 5-10 steps
- Use descriptive step names
- Test with a test contact before enabling
- Space emails 12-24 hours apart
@@ -1,294 +1,49 @@
---
title: Common Workflow Patterns
description: Reusable workflow templates
title: Workflow Patterns
description: Common workflow templates
icon: Layers
---
## Linear sequence
Send a series of emails with delays between them.
```
[Trigger: event]
[Send Email 1]
[Delay]
[Send Email 2]
[Delay]
[Send Email 3]
[Exit]
[Trigger] → [Email] → [Delay] → [Email] → [Exit]
```
**Use for:**
- Onboarding series
- Educational drip campaigns
- Feature introduction sequences
**Example timing:**
- Day 0: Welcome
- Day 1: Getting started guide
- Day 3: Tips and tricks
- Day 7: Feature deep dive
Use for: onboarding, drip campaigns
## Wait and branch
Pause for an event, then branch based on outcome.
```
[Trigger: event]
[Wait for Event: timeout X days]
├─ Event occurred → [Send success email] → [Exit]
└─ Timeout → [Send reminder email] → [Exit]
[Trigger] → [Wait for Event]
├─ Event → [Success email] → [Exit]
└─ Timeout → [Reminder] → [Exit]
```
**Use for:**
- Trial conversion (waiting for subscription)
- Activation campaigns (waiting for key action)
- Re-engagement (waiting for login)
**Example:**
```
[Trigger: trial_started]
[Send: Welcome to trial]
[Wait for: subscription_created, timeout: 7 days]
├─ Subscribed → [Send: Thanks for subscribing] → [Exit]
└─ Timeout → [Send: Last chance offer] → [Exit]
```
Use for: trial conversion, activation
## Conditional branch
Split workflow based on contact data.
```
[Trigger: event]
[Condition: check contact field]
├─ True → [Send email A] → [Exit]
└─ False → [Send email B] → [Exit]
[Trigger] → [Condition: plan = premium?]
├─ True → [Premium email] → [Exit]
└─ False → [Upgrade offer] → [Exit]
```
**Use for:**
- Personalization by plan/tier
- Segmented messaging
- Feature availability checks
Use for: personalization by tier
**Example:**
```
[Trigger: signed_up]
[Condition: plan equals "enterprise"]
├─ True → [Send: Enterprise onboarding] → [Exit]
└─ False → [Send: Standard onboarding] → [Exit]
```
## Multi-condition cascade
Chain multiple conditions for complex logic.
```
[Trigger: event]
[Condition: is premium?]
├─ True → [Send: Premium content] → [Exit]
└─ False ↓
[Condition: trial active?]
├─ True → [Send: Trial content] → [Exit]
└─ False → [Send: Free content] → [Exit]
```
**Use for:**
- Tiered content delivery
- Progressive feature reveals
- Access-based messaging
## Wait chain
Multiple wait steps with progressive urgency.
## Multi-step recovery
```
[Trigger: cart_abandoned]
[Delay: 1 hour]
[Send: Gentle reminder]
[Send: Reminder]
[Wait for: purchase, timeout: 23 hours]
[Wait for: purchase, timeout: 24h]
├─ Purchased → [Exit]
└─ Timeout
[Send: Discount offer]
[Wait for: purchase, timeout: 48 hours]
├─ Purchased → [Exit]
└─ Timeout → [Send: Final reminder] → [Exit]
└─ Timeout → [Send: Discount] → [Exit]
```
**Use for:**
- Cart abandonment
- Multi-touch re-engagement
- Escalating incentives
## Segment entry drip
Triggered when contact joins segment.
```
[Trigger: enters "Inactive Users" segment]
[Send: We miss you]
[Delay: 3 days]
[Condition: still in segment?]
├─ Yes → [Send: Special offer] → [Exit]
└─ No → [Exit]
```
**Use for:**
- Churn prevention
- Win-back campaigns
- Behavior-based sequences
**Important:** Segment must have `trackMembership: true` enabled.
## Schedule with filter
Runs on schedule, filtered by conditions.
```
[Trigger: daily at 9am]
[Condition: trial expires in 3 days]
├─ True → [Send: Trial expiring] → [Exit]
└─ False → [Exit]
```
**Use for:**
- Trial expiration reminders
- Subscription renewal notices
- Scheduled checks with conditional sends
## Update and continue
Modify contact data mid-workflow.
```
[Trigger: signed_up]
[Send: Welcome]
[Update Contact: onboardingStep = 1]
[Delay: 1 day]
[Send: Getting started]
[Update Contact: onboardingStep = 2]
[Exit]
```
**Use for:**
- Tracking workflow progress
- Triggering other systems via segment changes
- Marking completion milestones
## Webhook integration
Call external API during workflow.
```
[Trigger: purchase_completed]
[Send: Order confirmation]
[Delay: 7 days]
[Webhook: Create review request in external system]
[Send: Review request]
[Exit]
```
**Use for:**
- Syncing with CRM
- Triggering other automation tools
- Logging workflow events
## Complex multi-path
Combine patterns for sophisticated flows.
```
[Trigger: trial_started]
[Send: Welcome]
[Delay: 1 day]
[Send: Feature tour]
[Wait for: feature_used, timeout: 3 days]
├─ Used ↓
│ [Send: Great job]
│ ↓
│ [Delay: 3 days]
│ ↓
│ [Condition: trial ending in 1 day]
│ ├─ True → [Send: Upgrade reminder] → [Exit]
│ └─ False → [Exit]
└─ Timeout ↓
[Send: Help offer]
[Wait for: feature_used, timeout: 3 days]
├─ Used → [Send: Nice work] → [Exit]
└─ Timeout → [Update Contact: needs_help = true] → [Exit]
```
**Use for:**
- Advanced onboarding
- Complex user journeys
- Adaptive content delivery
## Best practices
**Keep it simple** — Start with 3-5 steps. Add complexity only when needed.
**Exit explicitly** — Every path should end at an Exit step.
**Handle both outcomes** — Conditions and waits need success + failure paths.
**Test incrementally** — Build and test each section before adding more.
**Use meaningful delays** — Don't spam. Minimum 12-24 hours between emails.
**Name clearly** — "Send Welcome Email - Day 0" not "Email 1".
**Monitor drop-off** — Check execution stats to see where contacts exit.
**Prevent loops** — Never create circular paths. Workflow should always progress forward.
## Performance tips
**Batch schedule triggers** — If using schedules, run at off-peak hours to spread load.
**Limit concurrent waits** — Long waits (weeks/months) keep executions in memory. Use scheduled workflows for very long delays.
**Avoid deep nesting** — More than 3-4 condition levels becomes hard to maintain.
**Use segments over conditions** — If filtering many contacts, segment-based triggers are more efficient than in-workflow conditions.
## Next steps
- [Build conditional logic](/automation-patterns/conditional-branching)
- [Understand execution context](/automation-patterns/advanced-execution-context)
- [Troubleshoot workflows](/automation-patterns/troubleshooting-workflows)
Use for: cart abandonment, re-engagement
@@ -0,0 +1,46 @@
---
title: Email Attachments
description: Send files with emails
icon: Paperclip
---
## Limits
- Max 10 attachments per email
- Max 10MB total size
- Any file type supported
## API usage
```json
{
"to": "[email protected]",
"subject": "Your Invoice",
"body": "<p>Invoice attached.</p>",
"attachments": [
{
"filename": "invoice.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
}
]
}
```
## Attachment fields
| Field | Description |
|-------|-------------|
| `filename` | Display name (max 255 chars) |
| `content` | Base64-encoded file content |
| `contentType` | MIME type (e.g., `application/pdf`) |
## Base64 encoding
```javascript
import fs from 'fs';
const fileBuffer = fs.readFileSync('invoice.pdf');
const base64Content = fileBuffer.toString('base64');
```
@@ -1,37 +1,26 @@
---
title: Campaigns vs Workflows
description: Choose the right tool for sending emails
description: Choose the right sending method
icon: GitCompare
---
## When to use each
## Quick comparison
**Transactional API** (`/v1/send`) — Immediate one-off emails from your code
- Password resets, confirmations, receipts
- Triggered directly by user actions
- Instant delivery
**Campaigns** — One-time broadcasts to many contacts
- Newsletters, announcements, promotions
- Created in dashboard, send now or schedule
- No code required
**Workflows** — Automated multi-email sequences
- Onboarding series, abandoned cart, trial reminders
- Triggered by events, segment changes, or schedules
- Delays, conditions, multiple emails
| Need | Use |
|------|-----|
| Password reset now | **API** `/v1/send` |
| Monthly newsletter | **Campaign** |
| Welcome series over 3 days | **Workflow** |
| Order confirmation | **API** `/v1/send` |
| Abandoned cart recovery | **Workflow** |
## Transactional API
Send emails directly from your application code.
Immediate one-off emails from your code.
```javascript
await fetch('{{API_URL}}/v1/send', {
await fetch('/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: user.email,
subject: 'Reset your password',
@@ -40,90 +29,19 @@ await fetch('{{API_URL}}/v1/send', {
});
```
**Use for:**
- Emails that must send immediately
- One-to-one triggered emails
- Context-specific data (reset tokens, order IDs)
## Campaigns
Create and send broadcasts in the dashboard.
One-time broadcasts to many contacts. Created in dashboard.
1. Create campaign
2. Write email content
3. Select audience (all contacts, segment, or filter)
4. Send now or schedule
**Use for:**
- One-time sends to many people
- Scheduled announcements
- Manual email sends
**Can't do:**
- Automation or triggers
- Multi-step sequences
- Delays between emails
- Send now or schedule
- Target all contacts, segments, or filters
- No code required
## Workflows
Build automated sequences with the visual workflow builder.
Automated multi-email sequences.
**Example workflow:**
```
[Trigger: user_signed_up]
[Send Email: Welcome]
[Delay: 24 hours]
[Send Email: Feature tour]
[Delay: 48 hours]
[Send Email: Help offer]
```
**Use for:**
- Multi-email sequences
- Time-delayed follow-ups
- Event-triggered automation
- Triggered by events or segment changes
- Delays between emails
- Conditional logic (if/then)
**Setup required:**
- Track events from your app via `/v1/track`
- Create workflow in dashboard
- Enable workflow
## Decision matrix
| Need | Use |
|------|-----|
| Send password reset now | Transactional API |
| Send monthly newsletter | Campaign |
| Send welcome series over 3 days | Workflow |
| Send order confirmation | Transactional API |
| Send announcement to all users | Campaign |
| Send trial reminder 3 days before expiration | Workflow |
| Send receipt after payment | Transactional API |
| Send seasonal promotion | Campaign |
| Send abandoned cart recovery (1hr + 24hr) | Workflow |
## Using them together
Most apps use all three:
**SaaS example:**
- **Transactional**: Password resets, email verification
- **Campaigns**: Monthly product updates
- **Workflows**: Trial onboarding, churn prevention
**E-commerce example:**
- **Transactional**: Order confirmations, shipping updates
- **Campaigns**: Weekly deals newsletter
- **Workflows**: Abandoned cart, review requests
## Next steps
- [Send a transactional email](/tutorials/first-transactional-email)
- [Create your first workflow](/tutorials/welcome-series-workflow)
- [Build a campaign](/tutorials/newsletter-campaign)
- Re-entry control
@@ -1,18 +1,10 @@
---
title: Contacts and Data
title: Contacts
description: Store and manage your audience
icon: Users
---
## What are contacts
Contacts are people in your email list. Each contact has:
- **Email** — Unique identifier
- **Subscription status** — Subscribed or unsubscribed
- **Custom data** — Any fields you need
## Data structure
## Structure
```json
{
@@ -27,188 +19,42 @@ Contacts are people in your email list. Each contact has:
}
```
The `data` field stores custom information as key-value pairs.
## Adding contacts
## Data types
- **Dashboard:** Contacts → Add Contact
- **CSV import:** Contacts → Import
- **API:** `POST /contacts`
- **Events:** Auto-created when tracking events
**Strings:**
```json
{ "firstName": "Sarah", "company": "Acme Inc" }
## Contact data
The `data` field stores custom key-value pairs.
**Best practices:**
- Use consistent naming (camelCase or snake_case)
- Store dates as ISO strings: `"2024-03-15T10:30:00Z"`
- Use numbers for numeric values (enables comparisons)
## Template variables
Use `{{fieldName}}` in emails:
```html
<p>Hello {{firstName}}!</p>
```
**Numbers:**
```json
{ "mrr": 99, "loginCount": 15 }
```
**Fallback:** `{{firstName ?? 'there'}}`
Store as numbers for `greaterThan`/`lessThan` comparisons.
**Reserved:** `{{email}}`, `{{id}}`
**Booleans:**
```json
{ "verified": true, "newsletter": false }
```
## Temporary data
**Dates:**
```json
{ "signupDate": "2024-03-15T10:30:00Z" }
```
Data that won't save to contact:
Use ISO 8601 format.
**Arrays:**
```json
{ "tags": ["vip", "enterprise"] }
```
**Objects:**
```json
{
"address": {
"city": "San Francisco",
"country": "US"
}
```javascript
data: {
resetCode: { value: 'ABC123', persistent: false }
}
```
Access nested fields: `address.country`
## Creating contacts
### Via API
```bash
curl -X POST {{API_URL}}/contacts \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Sarah",
"plan": "pro"
}
}'
```
### Automatic upsert
If email exists, updates instead of creating duplicate.
```javascript
// First call
POST /contacts { email: "[email protected]", data: { plan: "free" } }
// Second call - updates same contact
POST /contacts { email: "[email protected]", data: { mrr: 99 } }
// Result: { plan: "free", mrr: 99 }
```
Data merges automatically.
### Via event tracking
```bash
curl -X POST {{API_URL}}/v1/track \
-H "Authorization: Bearer pk_your_public_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"event": "signed_up",
"data": {
"plan": "pro",
"source": "landing"
}
}'
```
Creates contact if doesn't exist, updates if does.
## Updating contacts
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"data": {
"plan": "premium",
"mrr": 199
}
}'
```
New fields added, existing fields overwritten, unmentioned fields preserved.
### Remove fields
Set to `null`:
```json
{ "data": { "temporaryToken": null } }
```
## Subscription status
**Subscribed (true):**
- Receives marketing emails
- Receives transactional emails
**Unsubscribed (false):**
- Does NOT receive marketing emails
- Still receives transactional emails
Template type controls this behavior. See [Template Types](/concepts/templates-types).
## Using contact data
### In templates
```html
<h1>Hi {{firstName}}!</h1>
<p>Your {{plan}} plan renews on {{renewalDate}}.</p>
```
### In segments
Filter by data fields:
- `plan equals "premium"`
- `mrr greaterThan 100`
- `loginCount lessThan 5`
### In workflows
```
[Condition: plan equals "enterprise"]
├─ True → [Send: Enterprise content]
└─ False → [Send: Standard content]
```
## Best practices
**Consistent naming** — Use camelCase or snake_case, not both.
**Correct types** — Use `99` not `"99"` for numbers.
**ISO dates** — `"2024-03-15T10:30:00Z"` for date fields.
**Sync critical fields only** — Don't mirror entire database. Only fields used in emails, segments, or workflows.
**Update in real-time** — When user data changes, update contact immediately.
**Respect unsubscribes** — Never re-subscribe automatically.
## Deleting contacts
```bash
curl -X DELETE {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Permanent deletion. Consider unsubscribing instead to preserve history.
## Next steps
- [Create segments](/concepts/segments-and-filters)
- [Track events](/concepts/events-and-triggers)
- [Use templates](/concepts/templates-types)
Use for: one-time codes, tokens, session data.
@@ -0,0 +1,43 @@
---
title: Custom Domains
description: Send from your own domain
icon: Globe
---
## Why use custom domains
- Better deliverability
- Brand consistency
- Builds sender reputation
## Setup
1. Go to **Settings → Domains**
2. Click **Add Domain**
3. Enter your domain
4. Add the provided DNS records (DKIM, MX, TXT)
5. Wait for verification (usually 10-30 minutes, up to 48 hours)
## Using your domain
Specify in the `from` field when sending:
```json
{
"to": "[email protected]",
"from": "[email protected]",
"subject": "Order confirmed",
"body": "<p>Your order has been confirmed.</p>"
}
```
## Troubleshooting
**Domain won't verify:**
- Check DNS records are correct
- Wait for DNS propagation (up to 48 hours)
- Remove conflicting DKIM records from other services
**Emails going to spam:**
- Warm up new domains with small volumes first
- Monitor bounce and complaint rates
@@ -1,180 +1,53 @@
---
title: Events and Triggers
description: Track behavior and trigger workflows
title: Events
description: Track actions and trigger workflows
icon: Activity
---
## What are events
## Overview
Events track user actions from your application. Use them to update contact data, trigger workflows, and build segments.
Events track user actions. Use them to trigger workflows and update contact data.
## Tracking events
### Basic event
## Tracking
```javascript
await fetch('{{API_URL}}/v1/track', {
await fetch('/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
'Authorization': 'Bearer pk_your_public_key'
},
body: JSON.stringify({
event: 'signed_up',
email: user.email
})
});
```
Creates or updates contact. Event is recorded.
### Event with data
```javascript
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'purchase_completed',
email: user.email,
data: {
plan: 'premium',
mrr: 99
}
data: { plan: 'pro' }
})
});
```
Updates contact data fields. Data persists on contact record.
Creates/updates contact and records the event.
## Public vs Secret keys
## Keys
### Public Key (pk_*)
- **Public key (pk_*):** Safe for client-side, only works with `/v1/track`
- **Secret key (sk_*):** Server-side only, full API access
- Safe for client-side code
- Only works with `/v1/track`
- Use in browser/mobile apps
## Event data
### Secret Key (sk_*)
- Server-side only
- Works with all endpoints
- Full API access
## Event naming
Use clear, past-tense verbs with underscores:
✅ `signed_up`, `purchase_completed`, `feature_activated`
❌ `signup`, `buy`, `feature`
## Workflow triggers
### Event trigger
Workflow starts when event is tracked.
```
Trigger: event = "signed_up"
```
Track event:
**Persistent (default):** Saved to contact
```javascript
track('signed_up', '[email protected]');
data: { plan: 'premium' }
```
Workflow starts for that contact.
### Wait for event
Workflow pauses until event occurs.
```
[Wait for Event: purchase_completed, timeout: 7 days]
├─ Event occurred → [Send: Thank you]
└─ Timeout → [Send: Discount offer]
```
## Common patterns
### User lifecycle
**Non-persistent:** Available only to workflow
```javascript
// Signup
track('signed_up', email, { source: 'landing' });
// First login
track('first_login', email, { lastLoginAt: new Date().toISOString() });
// Subscription
track('subscription_created', email, { plan: 'premium', mrr: 99 });
data: {
orderId: { value: 'order-123', persistent: false }
}
```
### E-commerce
## Automatic events
```javascript
// Cart abandonment
track('cart_abandoned', email, {
cartTotal: 149.99,
cartUrl: `https://store.com/cart/${cartId}`
});
// Purchase
track('purchase_completed', email, {
orderId: order.id,
orderTotal: order.total
});
```
## Event-based segments
Track events to update contact data, then segment on that data.
1. Track login:
```javascript
track('login', email, {
lastLoginAt: new Date().toISOString()
});
```
2. Create segment:
```
Field: lastLoginAt
Operator: greaterThan
Value: {{7_days_ago}}
```
Contacts auto-join segment when they log in.
## Testing events
**Dashboard:** Contacts → Search email → Events tab
Shows all events for that contact.
**Workflows:** Check executions after tracking event to verify workflow triggered.
## Rate limits
- Public key: 100 requests/minute
- Secret key: 1000 requests/minute
## Common issues
**Workflow not triggering:**
- Workflow is enabled
- Event name matches exactly (case-sensitive)
- Contact email is correct
**Contact data not updating:**
- Field names are case-sensitive
- Values are correct type (number vs string)
## Next steps
- [Build event-triggered workflows](/tutorials/welcome-series-workflow)
- [Create event-based segments](/concepts/segments-and-filters)
- [Track events from your app](/tutorials/event-tracking-integration)
Plunk tracks these automatically:
- `email.sent`, `email.opened`, `email.clicked`
- `email.bounced`, `email.complained`
- `segment.entered`, `segment.exited`
-49
View File
@@ -1,49 +0,0 @@
---
title: Core Concepts
description: Understand how Plunk works
icon: Lightbulb
---
## Email methods
<Cards>
<Card icon="GitCompare" title="Campaigns vs Workflows" href="/concepts/campaigns-vs-workflows">
When to use each email method
</Card>
</Cards>
## Data & contacts
<Cards>
<Card icon="Users" title="Contacts and Data" href="/concepts/contacts-and-data">
Store and manage your audience
</Card>
<Card icon="Filter" title="Segments" href="/concepts/segments-and-filters">
Create dynamic audience groups
</Card>
</Cards>
## Sending emails
<Cards>
<Card icon="Mail" title="Template Types" href="/concepts/templates-types">
Marketing vs Transactional templates
</Card>
<Card icon="Activity" title="Events and Triggers" href="/concepts/events-and-triggers">
Track behavior and trigger workflows
</Card>
</Cards>
## Performance & delivery
<Cards>
<Card icon="Send" title="Email Deliverability" href="/concepts/email-deliverability">
Reach the inbox
</Card>
<Card icon="Zap" title="Scale and Performance" href="/concepts/scale-and-performance">
Optimize for millions of contacts
</Card>
</Cards>
+4 -3
View File
@@ -1,13 +1,14 @@
{
"title": "Core Concepts",
"pages": [
"index",
"contacts-and-data",
"templates-types",
"campaigns-vs-workflows",
"segments-and-filters",
"events-and-triggers",
"email-deliverability",
"scale-and-performance"
"webhooks",
"custom-domains",
"attachments",
"troubleshooting"
]
}
@@ -1,150 +1,40 @@
---
title: Segments and Filters
description: Create dynamic audience groups
icon: Filter
title: Segments
description: Dynamic audience groups
icon: Funnel
---
## What are segments
## Overview
Segments are dynamic groups of contacts based on data filters. They update automatically when contact data changes.
Segments are dynamic groups based on filters. They update automatically when contact data changes.
Use segments to:
- Target specific audiences in campaigns
- Trigger workflows when contacts enter/exit
- Filter contacts in dashboard
## Creating
## Creating segments
1. Go to **Segments** → **Create Segment**
2. Name your segment
3. Add filter conditions
4. Save
### In dashboard
## Operators
**Contacts** → **Segments** → **Create Segment**
| Operator | Example |
|----------|---------|
| `equals` | `plan equals "pro"` |
| `not equals` | `plan not equals "free"` |
| `contains` | `email contains "@gmail"` |
| `greater than` | `mrr greater than 100` |
| `less than` | `loginCount less than 5` |
| `exists` | `company exists` |
1. Name your segment
2. Add filters
3. Save
## Combining filters
### Via API
- **AND:** All conditions must match
- **OR:** Any condition can match
```bash
curl -X POST {{API_URL}}/segments \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Users",
"filters": [
{
"field": "plan",
"operator": "equals",
"value": "premium"
}
]
}'
```
## Membership tracking
## Filter operators
Enable **Track membership changes** to:
- Trigger workflows on segment entry/exit
- Generate `segment.entered` and `segment.exited` events
| Operator | Use | Example |
|----------|-----|---------|
| `equals` | Exact match | `plan equals "pro"` |
| `notEquals` | Not matching | `plan notEquals "free"` |
| `contains` | Substring (case-insensitive) | `email contains "@company.com"` |
| `greaterThan` | Number comparison | `mrr greaterThan 100` |
| `lessThan` | Number comparison | `loginCount lessThan 5` |
| `greaterThanOrEqual` | Inclusive comparison | `age greaterThanOrEqual 18` |
| `lessThanOrEqual` | Inclusive comparison | `daysInactive lessThanOrEqual 30` |
| `exists` | Field has value | `company exists` |
| `notExists` | Field missing/null | `lastName notExists` |
| `startsWith` | String prefix | `coupon startsWith "SAVE"` |
| `endsWith` | String suffix | `email endsWith ".edu"` |
## Multiple filters (AND logic)
All filters must match.
```json
{
"name": "Active Premium Users",
"filters": [
{ "field": "plan", "operator": "equals", "value": "premium" },
{ "field": "loginCount", "operator": "greaterThan", "value": 5 }
]
}
```
Contact matches only if: `plan = "premium"` AND `loginCount > 5`
## Dynamic updates
Segments update automatically.
**Example:**
1. Segment: `plan equals "premium"`
2. User upgrades: `plan` changes from "free" to "premium"
3. Contact auto-added to segment
No manual refresh needed.
## Workflow triggers
Trigger workflows when contacts enter/exit segments.
### Enable tracking
```bash
curl -X PATCH {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"trackMembership": true}'
```
Only enable for segments used as triggers.
### Entry trigger
```
[Trigger: contact enters "Premium Users"]
[Send: Welcome to premium]
```
### Exit trigger
```
[Trigger: contact exits "Trial Users"]
[Send: Trial ended offer]
```
## Nested fields
Access nested data with dot notation.
```json
{
"field": "preferences.newsletter",
"operator": "equals",
"value": true
}
```
## Best practices
**Store correct types** — Use `99` not `"99"` for numbers. Use `true` not `"true"` for booleans.
**Track membership sparingly** — Only enable on segments used as workflow triggers.
**Name descriptively** — "High-Value Customers (MRR >$200)" not "Segment 3"
## Common issues
**Numeric filters not working** — Ensure field is stored as number, not string.
**Case-sensitive matching** — `equals "Pro"` doesn't match `plan = "pro"`. Use `contains` for case-insensitive.
**Missing field** — If field doesn't exist on contact, filter won't match.
## Next steps
- [Use segments in campaigns](/guides/campaigns)
- [Trigger workflows on segment entry](/guides/workflows)
- [Store contact data](/concepts/contacts-and-data)
Only enable for segments used as workflow triggers.
@@ -1,112 +1,36 @@
---
title: Template Types
description: Marketing vs Transactional templates
description: Marketing vs Transactional
icon: Mail
---
## Two template types
## Two types
| Template Type | Sends to Unsubscribed? | Use For |
|--------------|------------------------|---------|
| **Marketing** | No | Newsletters, promotions, announcements |
| **Transactional** | Yes | Receipts, confirmations, password resets |
| Type | Sends to Unsubscribed? | Use For |
|------|------------------------|---------|
| **Marketing** | No | Newsletters, promotions |
| **Transactional** | Yes | Receipts, password resets |
## Marketing templates
## Marketing
Only sends to subscribed contacts.
Only sends to subscribed contacts. Automatically includes unsubscribe link.
**Use for:** Newsletters, product updates, promotional emails.
## Transactional
**Behavior:**
```javascript
// Contact is unsubscribed
POST /v1/send { to: "[email protected]", template: "newsletter" }
// → Email NOT sent
```
Sends regardless of subscription status. Use only for essential emails.
## Transactional templates
Sends regardless of subscription status.
**Use for:** Order confirmations, password resets, account alerts.
**Behavior:**
```javascript
// Contact is unsubscribed
POST /v1/send { to: "[email protected]", template: "receipt" }
// → Email sent
```
## Setting template type
### In dashboard
**Templates** → Create/Edit → **Type** dropdown
### Via API
```bash
curl -X POST {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Password Reset",
"type": "TRANSACTIONAL",
"subject": "Reset your password",
"body": "<p>Click: {{resetLink}}</p>"
}'
```
Type: `MARKETING` or `TRANSACTIONAL`
## Changing type
Update anytime:
```bash
curl -X PATCH {{API_URL}}/templates/template_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"type": "MARKETING"}'
```
**Warning:** Changing transactional → marketing stops sending to unsubscribed contacts.
## Unsubscribe links
Marketing templates auto-include unsubscribe link in footer.
Custom placement:
```html
<a href="{{unsubscribeUrl}}">Unsubscribe</a>
```
Transactional templates don't need unsubscribe links.
## Choosing the right type
## Choosing
Ask: "Would user be frustrated if they didn't receive this after unsubscribing?"
**If yes → Transactional**
- Password resets
- Order confirmations
- Account alerts
- **Yes → Transactional** (password resets, order confirmations)
- **No → Marketing** (newsletters, promotions)
**If no → Marketing**
- Newsletters
- Product announcements
- Promotions
## Variables
## Best practices
```html
<h1>Hello {{firstName}}!</h1>
<p>Your {{plan}} plan renews on {{renewalDate}}.</p>
```
**Default to marketing** — Only use transactional for truly necessary emails.
**Don't abuse transactional** — Sending marketing content via transactional templates violates regulations and damages reputation.
**Test both states** — Verify behavior with subscribed and unsubscribed contacts.
## Next steps
- [Create templates](/guides/templates)
- [Manage subscriptions](/concepts/contacts-and-data)
- [Send emails](/tutorials/first-transactional-email)
**Fallback:** `{{firstName ?? 'there'}}`
@@ -0,0 +1,44 @@
---
title: Troubleshooting
description: Common issues and solutions
icon: Bug
---
## Authentication
**Invalid API key:**
- Secret keys start with `sk_`, public keys with `pk_`
- Use `Authorization: Bearer sk_your_secret_key`
**Wrong key type:**
- Public keys (`pk_`) only work with `/v1/track`
- All other endpoints require secret keys (`sk_`)
## Emails not sending
1. **Billing limit reached** — Check Settings → Billing
2. **Template not found** — Verify template ID exists
3. **Contact unsubscribed** — Marketing templates skip unsubscribed contacts
## Emails going to spam
1. Set up a custom domain
2. Clean your list (remove bounces)
3. Warm up new domains gradually
## Workflows not triggering
1. **Workflow not enabled** — Check toggle is ON
2. **Wrong event name** — Names are case-sensitive
3. **Already entered** — If re-entry disabled, contact can only enter once
## Rate limiting (429)
- Wait and retry
- Batch multiple recipients in one request
- Spread requests over time
## Request IDs
Every API response includes `X-Request-ID` header. Include this when contacting support.
@@ -0,0 +1,47 @@
---
title: Webhooks
description: Send HTTP requests from workflows
icon: Webhook
---
## Overview
Webhooks send HTTP requests to external services from within workflows.
## Adding a webhook step
1. Edit a workflow
2. Add a **Webhook** step
3. Configure URL, method, headers, and body
## Configuration
```json
{
"url": "https://your-api.com/webhook",
"method": "POST",
"headers": {
"Authorization": "Bearer your_api_token"
},
"body": {
"email": "{{email}}",
"firstName": "{{data.firstName}}"
}
}
```
**Methods:** POST, PUT, PATCH, GET, DELETE
## Variables
- `{{email}}`, `{{id}}` — Contact fields
- `{{data.fieldName}}` — Custom data
- `{{workflowName}}`, `{{now}}` — Workflow context
## Error handling
- Failed webhooks don't block the workflow
- Errors logged in execution details
- 30 second timeout
- No automatic retries
-181
View File
@@ -1,181 +0,0 @@
---
title: Analytics
description: Track and analyze email performance
---
## What you can track
Plunk tracks comprehensive email metrics across all campaigns, workflows, and transactional emails:
**Delivery metrics:**
- Sent, delivered, bounced
**Engagement metrics:**
- Opens, clicks, unsubscribes
**Quality metrics:**
- Open rate, click rate, bounce rate
## Campaign analytics
View detailed performance for specific campaigns:
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id/stats \
-H "Authorization: Bearer sk_your_secret_key"
```
Response:
```json
{
"totalRecipients": 5000,
"sentCount": 5000,
"deliveredCount": 4980,
"openedCount": 2100,
"clickedCount": 450,
"bouncedCount": 20,
"unsubscribedCount": 8,
"openRate": 0.422,
"clickRate": 0.090,
"bounceRate": 0.004
}
```
**Metrics update in real-time** as recipients engage.
View detailed analytics in your dashboard to:
- Monitor daily performance trends
- Identify engagement patterns
- Spot deliverability issues
- Compare time periods
## Understanding metrics
### Open rate
**Formula:** (Unique opens / Delivered) × 100
**Industry benchmarks:**
- B2B: 15-25%
- B2C: 20-30%
- E-commerce: 15-20%
**What affects it:**
- Subject line quality
- Sender reputation
- Send timing
- Audience engagement
### Click rate
**Formula:** (Unique clicks / Delivered) × 100
**Industry benchmarks:**
- B2B: 2-5%
- B2C: 3-7%
- E-commerce: 2-4%
**What affects it:**
- Content relevance
- Call-to-action clarity
- Email design
- Link placement
### Bounce rate
**Formula:** (Bounces / Sent) × 100
**Target:** < 2%
**Types:**
- **Hard bounce** — Invalid email, never retry
- **Soft bounce** — Temporary issue, retry later
**High bounce rate causes:**
- Outdated email list
- Invalid addresses
- Domain issues
### Unsubscribe rate
**Formula:** (Unsubscribes / Delivered) × 100
**Target:** < 0.5%
**High unsubscribe causes:**
- Too frequent emails
- Irrelevant content
- Misleading subject lines
- No segmentation
## Improving performance
### Boost open rates
**Write compelling subject lines:**
- Keep under 50 characters
- Create urgency or curiosity
- Personalize with `{{firstName}}`
- Test different approaches
**Optimize send timing:**
- Test different days/times
- Segment by timezone
- Avoid weekends (for B2B)
- Consider user behavior
**Build sender reputation:**
- Use custom domain
- Maintain consistent volume
- Keep bounce rate low
- Avoid spam triggers
### Increase click rates
**Clear call-to-action:**
- One primary CTA
- Use buttons, not just links
- Action-oriented text ("Get Started" not "Click Here")
- Make it prominent
**Relevant content:**
- Segment audience
- Personalize messaging
- Match subject line promise
- Keep it focused
**Mobile-responsive:**
- Test on mobile devices
- Use large tap targets
- Single column layout
- Readable font sizes
### Reduce bounce rate
**Clean your list:**
```javascript
// Remove hard bounces immediately
const bounced = await fetch('{{API_URL}}/events?name=email.bounced&limit=1000');
for (const event of bounced.data.events) {
if (event.data.bounceType === 'hard') {
await fetch(`{{API_URL}}/contacts/${event.contactId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${apiKey}` }
});
}
}
```
**Verify emails:**
- Use email verification service
- Double opt-in for signups
- Remove invalid formats
- Re-engage inactive users before removing
## Next Steps
- [Set up custom domains](/guides/custom-domains) for better deliverability
- [Build segments](/guides/segments) for targeted campaigns
- [Scale your email](/guides/scaling-email) with best practices
@@ -1,115 +0,0 @@
---
title: Billing & Usage Limits
description: Control monthly email usage and costs
---
## What are billing limits
Billing limits let you cap monthly email sends by category to control costs. Set maximum emails per month for transactional, campaigns, and workflows separately.
## Email categories
Plunk tracks usage across three categories:
**Transactional** — Emails sent via `/v1/send` API
- Order confirmations, password resets
- Account notifications
- Any direct API sends
**Campaigns** — One-time broadcast emails
- Newsletters, announcements
- Promotional campaigns
- Marketing blasts
**Workflows** — Automated sequence emails
- Onboarding flows
- Drip campaigns
- Behavior-triggered emails
**Note:** The category is determined by how you send (API, campaign, or workflow), not the template type.
## How limits work
### Monthly reset
Usage resets on the 1st of each month (UTC). Starts fresh at 0.
### Enforcement
When sending emails:
- **Under 80%** — Sends normally
- **80-99%** — Sends with warning flag
- **100%+** — Blocked with 429 error
### Unlimited
Set limit to unlimited for any category (default for all categories).
## Manage limits
You can view and update your billing limits in the dashboard:
1. Go to **Settings** → **Billing**
2. View current usage for each category
3. Update limits as needed (requires Admin or Owner role)
## When limit is reached
### API error
```json
{
"code": 429,
"error": "Too Many Requests",
"message": "Monthly limit exceeded for campaigns (50000/50000). Resets on 2025-12-01."
}
```
### Handle in code
```javascript
try {
const response = await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
if (response.status === 429) {
const error = await response.json();
// Option 1: Notify admin
await notifyAdmin(`Limit reached: ${error.message}`);
// Option 2: Increase limit
await increaseBillingLimit('transactional', 200000);
// Option 3: Queue for next month
await queueForNextMonth(emailData);
}
} catch (error) {
console.error('Send failed:', error);
}
```
## Best practices
**Monitor usage regularly** — Check dashboard weekly to avoid surprises.
**Set alerts** — Configure notifications at 80% usage.
**Plan for growth** — Increase limits before campaigns, not during.
**Use categories wisely** — Critical transactional emails might need higher limits.
**Review monthly** — Adjust limits based on actual usage patterns.
## Next Steps
- [Track usage analytics](/guides/analytics)
- [Scale email delivery](/guides/scaling-email)
- [Troubleshooting limits](/guides/troubleshooting)
-247
View File
@@ -1,247 +0,0 @@
---
title: Campaigns
description: Send one-time email broadcasts
---
## What are campaigns
Campaigns are one-time email broadcasts sent to your audience. Use them for:
- Product announcements
- Newsletter distributions
- Seasonal promotions
- Feature launches
Unlike workflows (automated sequences), campaigns send once to a snapshot of your audience.
## Creating campaigns
### In the dashboard
1. Go to **Campaigns**
2. Click **Create Campaign**
3. Name your campaign
4. Choose your audience
5. Select a template or compose inline
6. Preview and test
7. Send or schedule
### Via API
```bash
curl -X POST {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "March Newsletter",
"subject": "New features this month",
"body": "<h1>What'\''s new</h1><p>Check out our latest updates...</p>",
"from": "[email protected]",
"fromName": "Acme Inc",
"audienceType": "ALL",
"status": "DRAFT"
}'
```
## Choosing your audience
### All contacts
Sends to everyone in your project:
```json
{
"audienceType": "ALL"
}
```
### Specific segment
Sends to contacts in a saved segment:
```json
{
"audienceType": "SEGMENT",
"segmentId": "premium-users"
}
```
### Custom filters
Sends to contacts matching conditions:
```json
{
"audienceType": "FILTERED",
"audienceFilter": {
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "pro" },
{ "field": "data.lastLoginAt", "operator": "greaterThan", "value": "2024-01-01" }
]
}
}
```
## Subscription handling
Campaign delivery respects your **template type**:
**Marketing templates** (default)
- Only sends to subscribed contacts
- Unsubscribed contacts are skipped automatically
- Includes unsubscribe link
**Transactional templates**
- Sends to all contacts, even if unsubscribed
- Use only for critical business emails
- No unsubscribe link
Choose template type based on content, not audience size.
## Campaign states
**DRAFT** — Being created, can edit freely
**SCHEDULED** — Queued for future send, can cancel
**SENDING** — Currently delivering, cannot stop
**SENT** — Completed successfully
**CANCELLED** — Scheduled campaign was cancelled
## Sending campaigns
### Send immediately
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/send \
-H "Authorization: Bearer sk_your_secret_key"
```
Status changes to SENDING, emails deliver within minutes.
### Schedule for later
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"scheduledAt": "2024-03-20T10:00:00Z"
}'
```
Status changes to SCHEDULED. Campaign sends at the specified time.
### Cancel scheduled campaign
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/cancel \
-H "Authorization: Bearer sk_your_secret_key"
```
Only works if status is SCHEDULED.
## Testing campaigns
Send a test email before broadcasting:
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/test \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]"
}'
```
This sends to the test email without affecting campaign status.
## Campaign analytics
View campaign performance:
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id/stats \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns:
```json
{
"totalRecipients": 5000,
"sentCount": 5000,
"deliveredCount": 4980,
"openedCount": 2100,
"clickedCount": 450,
"bouncedCount": 20,
"openRate": 0.42,
"clickRate": 0.09
}
```
Metrics update in real-time as recipients engage.
## Managing campaigns
### List campaigns
```bash
curl -X GET {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key"
```
Filter by status:
```bash
curl -X GET "{{API_URL}}/campaigns?status=SENT" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Get campaign details
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key"
```
### Update draft campaign
```bash
curl -X PATCH {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated name",
"subject": "New subject line"
}'
```
Only works for DRAFT campaigns.
### Duplicate campaign
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/duplicate \
-H "Authorization: Bearer sk_your_secret_key"
```
Creates a new draft campaign with the same content.
### Delete campaign
```bash
curl -X DELETE {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Can only delete DRAFT or CANCELLED campaigns.
## Next Steps
- [Build automated workflows](/guides/workflows)
- [Create dynamic segments](/guides/segments)
- [Track campaign analytics](/guides/analytics)
- [Set up custom domains](/guides/custom-domains)
@@ -1,205 +0,0 @@
---
title: Working with Contact Data
description: Use persistent and temporary contact data for personalized emails
---
## Contact Data Basics
Every contact has:
- **email**: Required, unique identifier
- **subscribed**: Boolean for subscription status
- **data**: JSON object for custom fields
```javascript
{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Jane",
"plan": "professional",
"signupDate": "2024-03-15"
}
}
```
## Persistent vs. Temporary Data
When sending emails, you can pass data that either saves to the contact or is used only for that email.
### Persistent Data (Default)
```javascript
fetch('/v1/send', {
method: 'POST',
body: JSON.stringify({
to: '[email protected]',
subject: 'Welcome',
body: '<p>Hi {'{{firstName}}'}!</p>',
data: {
firstName: 'John' // Saved to contact.data.firstName
}
})
});
```
### Temporary Data (Non-Persistent)
```javascript
fetch('/v1/send', {
method: 'POST',
body: JSON.stringify({
to: '[email protected]',
subject: 'Password Reset',
body: '<p>Your code: {'{{resetCode}}'}</p>',
data: {
resetCode: {
value: 'ABC123',
persistent: false // NOT saved to contact
}
}
})
});
```
**Use temporary data for**:
- Password reset codes
- One-time verification tokens
- Session-specific information
- Temporary discount codes
## Template Variables
Use `{'{{fieldName}}'}` to insert contact data into emails.
### Basic Variables
```html
<p>Hello {'{{firstName}}'}!</p>
<p>Your plan: {'{{plan}}'}</p>
```
### Fallback Values
Provide defaults when data might be missing:
```html
<p>Hello {'{{firstName ?? \'there\'}}'}!</p>
<p>Plan: {'{{plan ?? \'Free\'}}'}</p>
```
### Reserved Fields
Two fields are always available:
```html
<p>Contact ID: {'{{plunk_id}}'}</p>
<p>Email: {'{{plunk_email}}'}</p>
```
## Data Merging
Updates merge with existing data:
```javascript
// Contact has: { firstName: 'John', plan: 'free' }
// Update with:
{ data: { lastName: 'Doe', plan: 'pro' } }
// Result: { firstName: 'John', lastName: 'Doe', plan: 'pro' }
```
## Best Practices
### Keep Data Flat
```javascript
// Good
{
"firstName": "Jane",
"plan": "pro",
"mrr": 99
}
// Avoid nesting (harder to use in templates)
{
"user": {
"profile": {
"name": "Jane"
}
}
}
```
### Use Consistent Naming
Pick a style and stick to it:
```javascript
// camelCase (recommended)
{ "firstName": "Jane", "lastLogin": "2024-03-15" }
// or snake_case
{ "first_name": "Jane", "last_login": "2024-03-15" }
```
### Store Dates as ISO Strings
```javascript
// Good (filterable, sortable)
{ "signupDate": "2024-03-15T10:30:00Z" }
// Avoid
{ "signupDate": "March 15, 2024" }
```
## Discovering Available Fields
Get all fields across your contacts:
```bash
curl -X GET "{{API_URL}}/contacts/fields" \
-H "Authorization: Bearer sk_your_secret_key"
```
Response:
```json
{
"success": true,
"data": {
"fields": [
"email",
"subscribed",
"firstName",
"plan",
"signupDate"
],
"count": 5
}
}
```
Get unique values for a field:
```bash
curl -X GET "{{API_URL}}/contacts/fields/data.plan/values" \
-H "Authorization: Bearer sk_your_secret_key"
```
Response:
```json
{
"success": true,
"data": {
"field": "data.plan",
"values": ["free", "professional", "enterprise"],
"count": 3
}
}
```
## Next Steps
- [Send personalized emails](/guides/templates)
- [Create segments](/guides/segments) based on contact data
- [Track events](/guides/events) to enrich contact profiles
-436
View File
@@ -1,436 +0,0 @@
---
title: Contacts
description: Manage your audience at scale
---
## What are contacts
Contacts are people in your audience. Each contact has an email address, subscription status, and custom data fields you define. Use contacts to personalize emails, build segments, and track engagement.
## Creating contacts
### Add a single contact
```bash
curl -X POST {{API_URL}}/contacts \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Sarah",
"plan": "pro",
"signupDate": "2024-03-15"
}
}'
```
### Automatic upsert
If the email already exists, the contact is updated instead of creating a duplicate:
```javascript
// First call - creates contact
POST /contacts { email: '[email protected]', data: { plan: 'free' } }
// Second call - updates same contact
POST /contacts { email: '[email protected]', data: { plan: 'pro' } }
// Result: One contact with plan: 'pro'
```
This is useful when syncing user data from your application.
## Contact data fields
Store custom data in the `data` field. Use it for:
- User profile (name, company, role)
- Subscription info (plan, MRR, renewal date)
- Behavior tracking (last login, feature usage)
- Preferences (newsletter, notifications)
**Example:**
```json
{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Sarah",
"lastName": "Chen",
"company": "Acme Inc",
"plan": "premium",
"mrr": 99,
"lastLoginAt": "2024-03-15T10:30:00Z",
"preferences": {
"newsletter": true,
"productUpdates": false
}
}
}
```
### Best practices
**Use consistent naming** — Pick camelCase or snake_case and stick with it.
**Store dates as ISO strings** — `"2024-03-15T10:30:00Z"` enables date range filtering in segments.
**Keep it relatively flat** — Nested objects work, but flat structures are easier to query in segments.
**Use numbers for numeric data** — Store `99` not `"99"` to enable greater than/less than comparisons.
## Using contact data in emails
### Template variables
Access contact data in email templates using `{{variableName}}` syntax:
```html
<h1>Hello {{firstName}}!</h1>
<p>Your {{plan}} plan renews on {{renewalDate}}.</p>
<p>Total: ${{mrr}}</p>
```
When sending, contact data automatically populates variables:
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Renewal reminder",
"body": "<p>Hi {{firstName}}, your {{plan}} plan renews soon.</p>"
}'
```
The `firstName` and `plan` values come from the contact's `data` field.
### Fallback values
Provide defaults when data might be missing:
```html
<p>Hello {{firstName ?? 'there'}}!</p>
<p>Plan: {{plan ?? 'Free'}}</p>
```
If `firstName` is not set, displays "Hello there!" instead of blank.
### Passing additional data
Send extra data for a specific email without saving it to the contact:
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your verification code",
"body": "<p>Your code: {{verificationCode}}</p>",
"data": {
"verificationCode": "ABC123"
}
}'
```
The `verificationCode` is used in the email but not saved to the contact. This is useful for:
- One-time codes (password reset, verification)
- Session-specific data
- Temporary discount codes
- Order-specific details
### Reserved variables
These are always available in templates:
- `{{email}}` — Contact email address
- `{{id}}` — Contact ID
Example:
```html
<p>Your account: {{email}}</p>
<p><a href="https://app.example.com/contacts/{{id}}">Manage preferences</a></p>
```
## Listing contacts
### Get all contacts
```bash
curl -X GET "{{API_URL}}/contacts?limit=50" \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns:
```json
{
"success": true,
"data": {
"items": [...],
"nextCursor": "abc123",
"hasMore": true,
"total": 10000
}
}
```
### Pagination
For large lists, use cursor-based pagination:
```javascript
let allContacts = [];
let cursor = null;
do {
const params = new URLSearchParams({ limit: 100 });
if (cursor) params.append('cursor', cursor);
const response = await fetch(`{{API_URL}}/contacts?${params}`, {
headers: { 'Authorization': `Bearer ${PLUNK_SECRET_KEY}` }
});
const { data } = await response.json();
allContacts.push(...data.items);
cursor = data.nextCursor;
} while (cursor);
```
### Filter by subscription
```bash
# Only subscribed
curl -X GET "{{API_URL}}/contacts?subscribed=true" \
-H "Authorization: Bearer sk_your_secret_key"
# Only unsubscribed
curl -X GET "{{API_URL}}/contacts?subscribed=false" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Search by email
```bash
curl -X GET "{{API_URL}}/contacts?search=sarah" \
-H "Authorization: Bearer sk_your_secret_key"
```
Searches for emails containing "sarah".
## Getting a contact
### By ID
```bash
curl -X GET {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
## Updating contacts
### Update contact data
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"subscribed": true,
"data": {
"plan": "premium",
"mrr": 99
}
}'
```
### Data merging
Updates merge with existing data:
```javascript
// Current contact data
{ "firstName": "Sarah", "company": "Acme" }
// Update with
{ "lastName": "Chen", "plan": "pro" }
// Result
{ "firstName": "Sarah", "company": "Acme", "lastName": "Chen", "plan": "pro" }
```
To remove a field, set it to `null`.
### Change subscription status
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"subscribed": false}'
```
**Marketing templates** only send to subscribed contacts. **Transactional templates** send to everyone, regardless of subscription status.
## Deleting contacts
```bash
curl -X DELETE {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
**Warning:** Deletion is permanent. Consider unsubscribing instead of deleting.
## Bulk operations
### Import from CSV
Prepare a CSV file:
```csv
email,firstName,lastName,plan
[email protected],Sarah,Chen,pro
[email protected],John,Doe,free
```
Upload via dashboard:
1. Go to **Contacts**
2. Click **Import CSV**
3. Upload file
4. Map columns
5. Set default subscription status
6. Import
The import runs in the background. You'll receive a summary when complete.
## Available fields
### Get all custom fields
See what data fields your contacts have:
```bash
curl -X GET {{API_URL}}/contacts/fields \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns unique field names across all contacts:
```json
{
"fields": [
"firstName",
"lastName",
"company",
"plan",
"mrr",
"signupDate"
]
}
```
### Get field values
See all unique values for a specific field:
```bash
curl -X GET {{API_URL}}/contacts/fields/plan/values \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns:
```json
{
"values": ["free", "pro", "premium", "enterprise"]
}
```
Useful for building segment filters and understanding your data.
## Syncing with your app
Keep contacts in sync with your user database:
```javascript
// When user signs up
async function onUserSignup(user) {
await fetch('{{API_URL}}/contacts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: user.email,
subscribed: true,
data: {
firstName: user.firstName,
lastName: user.lastName,
signupDate: new Date().toISOString()
}
})
});
}
// When user updates profile
async function onUserUpdate(user) {
await fetch(`{{API_URL}}/contacts/${user.contactId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
firstName: user.firstName,
lastName: user.lastName,
company: user.company
}
})
});
}
// When user subscribes to plan
async function onSubscriptionChange(user, plan, mrr) {
await fetch(`{{API_URL}}/contacts/${user.contactId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
plan,
mrr,
subscriptionDate: new Date().toISOString()
}
})
});
}
```
## Best practices
**Sync critical data only** — Don't sync every field. Focus on data used in segments, workflows, and personalization.
**Use webhooks for real-time sync** — Update contacts immediately when user data changes.
**Track subscription separately** — Use the `subscribed` field for email preferences, not app subscription status.
**Clean your list regularly** — Remove or unsubscribe bounced and inactive contacts.
**Respect opt-outs** — When users unsubscribe, update immediately. Don't re-subscribe them automatically.
**Test with real emails** — Use your own email addresses to test the contact experience.
## Next Steps
- [Build segments](/guides/segments) to group contacts
- [Send campaigns](/guides/campaigns) to your contacts
- [Track events](/guides/events) to update contact data automatically
@@ -1,209 +0,0 @@
---
title: Custom Domains
description: Send emails from your own domain
---
## Why use custom domains
Sending from your own domain (e.g., `[email protected]`) instead of a shared domain:
- **Better deliverability** — Email providers trust emails from verified domains
- **Brand consistency** — Recipients see your brand, not Plunk
- **Higher trust** — Your domain builds its own sender reputation
- **Professional appearance** — Custom addresses look more legitimate
## Requirements
- **Domain ownership** — You own or control the domain
- **DNS access** — Ability to add DNS records
- **Verification** — Add DKIM records to prove ownership
## Adding a domain
1. Go to **Settings > Domains**
2. Click **Add Domain**
3. Enter your domain (e.g., `yourdomain.com`)
4. Copy the provided DNS records
## DNS configuration
After adding your domain, you'll receive 3 DKIM tokens. Add them as CNAME records to your DNS.
### Common DNS providers
#### Cloudflare
1. Log into Cloudflare
2. Select your domain
3. Go to **DNS > Records**
4. Click **Add record**
5. Select **CNAME** type
6. Paste name and value from Plunk
7. Click **Save**
8. Repeat for all 3 records
#### Namecheap
1. Log into Namecheap
2. Go to **Domain List**
3. Click **Manage** next to your domain
4. Select **Advanced DNS**
5. Click **Add New Record**
6. Choose **CNAME Record**
7. Enter host and value
8. Repeat for all 3 records
#### GoDaddy
1. Log into GoDaddy
2. Go to **My Products**
3. Click **DNS** next to your domain
4. Click **Add** under Records
5. Select **CNAME** type
6. Enter name and value
7. Repeat for all 3 records
#### Route 53 (AWS)
1. Open Route 53 console
2. Select your hosted zone
3. Click **Create record**
4. Enter record name
5. Select **CNAME** type
6. Paste value
7. Create record
8. Repeat for all 3 records
## Verification
### Automatic verification
Plunk checks DNS records every 5 minutes automatically. Verification typically completes within 10-30 minutes after adding DNS records.
**Note:** DNS propagation can take up to 48 hours, though it's usually much faster.
Check verification status in your dashboard at **Settings > Domains**.
## Using your domain
Once verified, specify your domain in the `from` field:
### In transactional emails
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"from": "[email protected]",
"fromName": "Your Company",
"subject": "Order confirmed",
"body": "<p>Your order has been confirmed.</p>"
}'
```
### In templates
Set default from address in template:
```bash
curl -X POST {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Order Confirmation",
"subject": "Order #{{orderNumber}} confirmed",
"body": "...",
"from": "[email protected]",
"fromName": "Your Company",
"type": "TRANSACTIONAL"
}'
```
### In campaigns
Campaigns use the template's from address, or you can override:
```bash
curl -X POST {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Newsletter",
"templateId": "template_id",
"from": "[email protected]",
"audienceType": "ALL"
}'
```
## Managing domains
### Remove domain
Go to **Settings > Domains**, select the domain, and click **Remove**.
**Warning:** Emails using this domain will fail to send after removal.
## Troubleshooting
### Domain won't verify
**1. Check DNS records are correct**
Verify records are added exactly as provided
**2. Wait for propagation**
DNS changes can take up to 48 hours to propagate globally. Check periodically.
**3. Remove conflicting records**
If you previously used another email service, remove their DKIM records to avoid conflicts.
**4. Check for typos**
Ensure record names and values match exactly. Common issues:
- Extra spaces in values
- Missing dots in record names
- Wrong subdomain
### Emails not sending from domain
**1. Verify domain is verified** - Check status in **Settings > Domains**.
**2. Use correct email format**
Must be `[email protected]`, not `@subdomain.yourdomain.com`.
**3. Check sender reputation**
New domains have no reputation. Start with small volumes and gradually increase.
### Emails going to spam
After adding custom domain:
**1. Warm up your domain** — See [Scaling Email](/guides/scaling-email)
**2. Monitor deliverability** — Check [Analytics](/guides/analytics) for bounce/complaint rates
**3. Clean your list** — Remove bounced addresses immediately
## Best practices
**Start small** — Send to engaged users first to build reputation.
**Monitor metrics** — Watch bounce and complaint rates closely.
**Use subdomains** — Consider `mail.yourdomain.com` for email to separate from main domain reputation.
**Keep DNS records** — Don't remove DKIM records even if verification is complete.
**Test thoroughly** — Send test emails to various providers (Gmail, Outlook, Yahoo).
## Next Steps
- [Scale email delivery](/guides/scaling-email) with your custom domain
- [Monitor analytics](/guides/analytics) for domain performance
- [Troubleshoot issues](/guides/troubleshooting) if problems arise
@@ -1,385 +0,0 @@
---
title: Email Attachments
description: Send emails with file attachments via API or SMTP
---
## Overview
Plunk supports sending emails with file attachments through both the HTTP API and SMTP relay. You can attach documents, images, PDFs, and other files to your transactional emails.
## Limits
- **Maximum attachments**: 10 per email
- **Total size limit**: 10MB (combined size of all attachments)
- **Supported formats**: Any file type (PDF, images, documents, etc.)
## API Usage
### Basic Example
Send an email with a single PDF attachment:
```bash
curl -X POST https://api.useplunk.com/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your Invoice",
"body": "<h1>Invoice Attached</h1><p>Please find your invoice attached.</p>",
"attachments": [
{
"filename": "invoice.pdf",
"content": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL...",
"contentType": "application/pdf"
}
]
}'
```
### Multiple Attachments
Send multiple files in a single email:
```json
{
"to": "[email protected]",
"subject": "Monthly Reports",
"body": "<p>Please find this month's reports attached.</p>",
"attachments": [
{
"filename": "sales-report.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
},
{
"filename": "logo.png",
"content": "iVBORw0KGgo...",
"contentType": "image/png"
},
{
"filename": "data.csv",
"content": "TmFtZSxFbWFp...",
"contentType": "text/csv"
}
]
}
```
## Attachment Format
Each attachment object requires three fields:
### filename
- **Type**: String
- **Max length**: 255 characters
- **Description**: The name of the file as it will appear to recipients
- **Example**: `"invoice-2024.pdf"`
### content
- **Type**: String (Base64 encoded)
- **Description**: The file content encoded in Base64 format
- **Example**: `"JVBERi0xLjQKJeLjz9MK..."`
### contentType
- **Type**: String (MIME type)
- **Max length**: 255 characters
- **Description**: The MIME type of the file
- **Examples**:
- PDF: `application/pdf`
- PNG image: `image/png`
- JPEG image: `image/jpeg`
- Word document: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
- Excel: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- CSV: `text/csv`
- ZIP: `application/zip`
## Base64 Encoding
Attachments must be Base64 encoded before sending. Here are examples in different languages:
### JavaScript/Node.js
```javascript
import fs from 'fs';
// Read file and convert to Base64
const fileBuffer = fs.readFileSync('invoice.pdf');
const base64Content = fileBuffer.toString('base64');
// Send email with attachment
await fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer sk_your_secret_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: '[email protected]',
subject: 'Invoice',
body: '<p>Your invoice is attached.</p>',
attachments: [{
filename: 'invoice.pdf',
content: base64Content,
contentType: 'application/pdf'
}]
})
});
```
### Python
```python
import base64
import requests
# Read and encode file
with open('invoice.pdf', 'rb') as file:
base64_content = base64.b64encode(file.read()).decode('utf-8')
# Send email
response = requests.post(
'https://api.useplunk.com/v1/send',
headers={
'Authorization': 'Bearer sk_your_secret_key',
'Content-Type': 'application/json'
},
json={
'to': '[email protected]',
'subject': 'Invoice',
'body': '<p>Your invoice is attached.</p>',
'attachments': [{
'filename': 'invoice.pdf',
'content': base64_content,
'contentType': 'application/pdf'
}]
}
)
```
### PHP
```php
<?php
// Read and encode file
$fileContent = file_get_contents('invoice.pdf');
$base64Content = base64_encode($fileContent);
// Send email
$ch = curl_init('https://api.useplunk.com/v1/send');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer sk_your_secret_key',
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'to' => '[email protected]',
'subject' => 'Invoice',
'body' => '<p>Your invoice is attached.</p>',
'attachments' => [[
'filename' => 'invoice.pdf',
'content' => $base64Content,
'contentType' => 'application/pdf'
]]
]));
$response = curl_exec($ch);
curl_close($ch);
```
## SMTP Usage
When using the SMTP relay, attachments are automatically parsed from the MIME multipart message and forwarded to the API.
### Standard Email Clients
Configure your email client with Plunk SMTP settings and attach files normally:
- **SMTP Server**: `smtp.yourdomain.com`
- **Port**: 587 (STARTTLS) or 465 (SSL/TLS)
- **Username**: `plunk`
- **Password**: Your Plunk API secret key
Attachments added through your email client will be automatically included.
### Programmatic SMTP
Using nodemailer (Node.js):
```javascript
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: 'smtp.yourdomain.com',
port: 587,
secure: false, // Use STARTTLS
auth: {
user: 'plunk',
pass: 'sk_your_secret_key'
}
});
await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Invoice',
html: '<p>Your invoice is attached.</p>',
attachments: [
{
filename: 'invoice.pdf',
path: '/path/to/invoice.pdf'
}
]
});
```
## Common MIME Types
| File Type | MIME Type |
|-----------|-----------|
| PDF | `application/pdf` |
| PNG | `image/png` |
| JPEG | `image/jpeg` |
| GIF | `image/gif` |
| Word (.docx) | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
| Word (.doc) | `application/msword` |
| Excel (.xlsx) | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
| Excel (.xls) | `application/vnd.ms-excel` |
| CSV | `text/csv` |
| Plain text | `text/plain` |
| HTML | `text/html` |
| ZIP | `application/zip` |
| JSON | `application/json` |
| XML | `application/xml` |
## Best Practices
### Size Optimization
- **Compress files**: Use ZIP compression for large files
- **Optimize images**: Reduce image dimensions and quality before attaching
- **Use links for large files**: For files >5MB, consider uploading to cloud storage and sending a download link instead
### Security
- **Scan for malware**: Ensure files are virus-free before sending
- **Avoid executable files**: Don't attach .exe, .bat, .sh files (often blocked by email providers)
- **Use password protection**: For sensitive documents, password-protect files and send password separately
### Deliverability
- **Mind the size**: Smaller emails have better deliverability
- **Avoid spam triggers**: Don't attach executable files or suspicious content
- **Test first**: Send test emails to verify attachments arrive correctly
## Troubleshooting
### Attachment Too Large
**Error**: `Total attachment size must not exceed 10MB`
**Solution**:
- Reduce file sizes
- Compress files
- Split into multiple emails
- Use cloud storage links instead
### Invalid Base64
**Error**: `Invalid attachment content - must be base64 encoded`
**Solution**:
- Ensure file is properly base64 encoded
- Don't include line breaks in base64 string (or use standard base64 encoding)
- Verify encoding matches content (binary files need binary encoding)
### Wrong Content Type
**Issue**: Attachments don't open correctly
**Solution**:
- Use correct MIME type for file format
- Verify file extension matches content type
- Test with common email clients
### Missing Attachment
**Issue**: Email sends but attachment missing
**Solution**:
- Check attachment array is properly formatted
- Verify all required fields (filename, content, contentType)
- Check email provider limits (some block certain types)
- Review AWS SES sending logs
## Examples by Use Case
### Invoice Email
```json
{
"to": "[email protected]",
"subject": "Invoice #12345",
"body": "<h1>Thank you for your purchase!</h1><p>Your invoice is attached.</p>",
"attachments": [{
"filename": "invoice-12345.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
}]
}
```
### Report with Charts
```json
{
"to": "[email protected]",
"subject": "Weekly Analytics Report",
"body": "<h1>Weekly Report</h1><p>See attached for details.</p>",
"attachments": [
{
"filename": "analytics-report.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
},
{
"filename": "sales-chart.png",
"content": "iVBORw0KGgo...",
"contentType": "image/png"
}
]
}
```
### Welcome Kit
```json
{
"to": "[email protected]",
"subject": "Welcome to Our Service!",
"body": "<h1>Welcome!</h1><p>Here's everything you need to get started.</p>",
"attachments": [
{
"filename": "getting-started-guide.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
},
{
"filename": "sample-data.csv",
"content": "TmFtZSxFbWFp...",
"contentType": "text/csv"
}
]
}
```
## Next Steps
- [Send your first email](/getting-started/quick-start)
- [SMTP relay setup](/self-hosting/introduction)
- [Email templates](/guides/templates)
-223
View File
@@ -1,223 +0,0 @@
---
title: Events
description: Track user actions and behavior
---
## What are events
Events track user actions in your application. Use them to:
- Trigger automated workflows
- Build behavior-based segments
- Analyze user engagement
- Track conversion funnels
Common events: signups, purchases, logins, feature usage, page views.
## Tracking events
Use your **public key** for event tracking:
```javascript
fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_your_public_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: '[email protected]',
event: 'button_clicked',
data: {
button: 'signup',
page: '/pricing'
}
})
});
```
This creates or updates the contact and tracks the event.
### Persistent vs. Non-Persistent Data
Event data can be either **persistent** (saved to contact) or **non-persistent** (available only to workflows):
**Simple values are persistent** — Saved to contact profile:
```javascript
{
email: '[email protected]',
event: 'subscription_created',
data: {
plan: 'premium', // Saved to contact.data.plan
mrr: 99.00 // Saved to contact.data.mrr
}
}
```
**Non-persistent values** — Available only to triggered workflows:
```javascript
{
email: '[email protected]',
event: 'order_placed',
data: {
totalSpent: 299.99, // Persistent - saved to contact
orderId: {value: 'order-12345', persistent: false}, // Non-persistent - workflows only
receiptUrl: {value: 'https://...', persistent: false} // Non-persistent - workflows only
}
}
```
**Why use non-persistent data?**
- Temporary tokens/codes (password reset, verification)
- One-time URLs or session data
- Data that shouldn't pollute contact profiles
- Information needed only for a specific workflow
Non-persistent data is available throughout the entire workflow execution but never stored on the contact record.
## Event structure
Each event stores:
```json
{
"id": "evt_abc123",
"name": "purchase",
"contactId": "contact_xyz",
"data": {
"product": "Premium Plan",
"amount": 99.00
},
"createdAt": "2024-03-15T10:30:00Z"
}
```
## Using events
You can use events to trigger workflows, create segments, and analyze user behavior.
## Common event patterns
### Lifecycle events
```javascript
// User signs up
track({ email, event: 'signed_up', data: { source: 'homepage' } });
// User activates account
track({ email, event: 'account_activated' });
// User completes onboarding
track({ email, event: 'onboarding_completed', data: { steps: 5 } });
```
### Commerce events
```javascript
// Add to cart
track({ email, event: 'cart_added', data: { productId: '123', price: 49 } });
// Purchase
track({ email, event: 'purchase', data: { orderId: '456', total: 99 } });
// Subscription created
track({ email, event: 'subscription_created', data: { plan: 'pro', mrr: 29 } });
```
### Engagement events
```javascript
// Feature used
track({ email, event: 'feature_used', data: { feature: 'export' } });
// Page viewed
track({ email, event: 'page_view', data: { path: '/dashboard' } });
// Login
track({ email, event: 'logged_in' });
```
### Automatic events
Plunk sends these automatically:
**Email events:**
- `email.sent` — Email delivered to inbox
- `email.opened` — Email opened (first time)
- `email.clicked` — Link clicked in email
- `email.bounced` — Email bounced
- `email.complained` — Spam complaint
**Segment events** (if membership tracking enabled):
- `segment.entered` — Contact joined segment
- `segment.exited` — Contact left segment
## Event naming conventions
**Use lowercase with underscores:**
```
✓ user_signed_up
✓ purchase_completed
✗ UserSignedUp
✗ purchaseCompleted
```
**Be specific but concise:**
```
✓ trial_started
✓ subscription_cancelled
✗ user_started_a_trial
✗ sub_cancel
```
**Group related events:**
```
user_signed_up
user_logged_in
user_deleted_account
subscription_created
subscription_renewed
subscription_cancelled
```
## Managing events
### List events
```bash
curl -X GET "{{API_URL}}/events?limit=100" \
-H "Authorization: Bearer sk_your_secret_key"
```
### List unique event names
```bash
curl -X GET {{API_URL}}/events/names \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns all event names tracked in your project.
### Get events for a contact
```bash
curl -X GET {{API_URL}}/events?contactId=contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
## Best practices
**Track meaningful actions** — Focus on events that indicate intent or value (signups, purchases, key features).
**Include context** — Add relevant data to understand the event better (product, amount, source).
**Be consistent** — Use the same event names and data structure across your application.
**Don't over-track** — Tracking every click creates noise. Focus on conversion events and key milestones.
**Test your tracking** — Verify events appear in dashboard before building workflows around them.
## What's next
- [Build workflows](/guides/workflows) triggered by events
- [Create segments](/guides/segments) based on event data
- [Analyze events](/guides/analytics) to understand user behavior
-17
View File
@@ -1,17 +0,0 @@
{
"title": "Guides",
"pages": [
"contacts",
"templates",
"campaigns",
"segments",
"workflows",
"events",
"webhooks",
"analytics",
"custom-domains",
"billing-limits",
"scaling-email",
"troubleshooting"
]
}
@@ -1,307 +0,0 @@
---
title: Request IDs & Debugging
description: How to use request IDs for debugging and tracing API requests
---
## Overview
Every API request to Plunk receives a unique request ID that follows the request through the entire system. Request IDs are essential for debugging, support, and monitoring.
## What are Request IDs?
A request ID is a UUID (e.g., `f47ac10b-58cc-4372-a567-0e02b2c3d479`) that:
- Is generated for every API request
- Appears in all related log entries
- Is included in both success and error responses
- Can be used to trace requests across services
## Where to Find Request IDs
### In API Responses
**Error responses** (in the `error.requestId` field):
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
...
}
}
```
**Response headers** (always present, even on success):
```bash
X-Request-ID: f47ac10b-58cc-4372-a567-0e02b2c3d479
```
### In Your Application
You can capture and log request IDs for correlation:
```javascript
const response = await fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ to, subject, body })
});
// Get request ID from response header
const requestId = response.headers.get('X-Request-ID');
// Log it for correlation
console.log(`[${requestId}] Email send request initiated`);
const data = await response.json();
if (!data.success) {
// Request ID is also in error response
console.error(`[${data.error.requestId}] Error:`, data.error.message);
}
```
## Database Request Logging
In addition to console/file logs, Plunk stores all API requests in the database with their request IDs. This provides:
- **Historical audit trail** - See all API calls made to your project
- **Analytics** - Analyze API usage patterns, error rates, popular endpoints
- **User-facing logs** - Display API request history in your dashboard
- **Long-term debugging** - Investigate issues that occurred days or weeks ago
- **Compliance** - Meet audit requirements for API access logs
### Database Schema
Each request is stored with:
- Request ID (primary key)
- HTTP method and path
- Status code and response time
- Project ID and user ID (if authenticated)
- IP address and user agent
- Error code and message (if failed)
- Request/response sizes
- Timestamp
### Retention Policy
API request logs are retained for **30 days** by default. A background job runs daily at 3 AM to delete older logs. This prevents unbounded table growth while maintaining recent history for debugging.
You can query your request logs via SQL if self-hosting:
```sql
-- Find all failed requests in the last 24 hours
SELECT * FROM api_requests
WHERE "statusCode" >= 400
AND "createdAt" > NOW() - INTERVAL '24 hours'
ORDER BY "createdAt" DESC;
-- Find all requests for a specific project
SELECT * FROM api_requests
WHERE "projectId" = 'prj_abc123'
ORDER BY "createdAt" DESC
LIMIT 100;
-- Analyze error rates by endpoint
SELECT
path,
COUNT(*) as total_requests,
COUNT(*) FILTER (WHERE "statusCode" >= 400) as errors,
ROUND(100.0 * COUNT(*) FILTER (WHERE "statusCode" >= 400) / COUNT(*), 2) as error_rate_pct
FROM api_requests
WHERE "createdAt" > NOW() - INTERVAL '7 days'
GROUP BY path
ORDER BY error_rate_pct DESC;
```
## How Request IDs Help with Debugging
### Example Scenario
You send an email via the API and receive an error. Here's how request IDs help:
**1. Your application receives an error:**
```json
{
"success": false,
"error": {
"code": "TEMPLATE_NOT_FOUND",
"message": "Template with ID \"tpl_abc123\" was not found",
"requestId": "a1b2c3d4-e5f6-7890-gh12-i34567890jkl",
...
}
}
```
**2. You contact support with the request ID**
**3. We search our logs for that request ID and see:**
```
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] POST /v1/send → Request received
└─ authType: apiKey
└─ projectId: prj_xyz789
└─ ip: 192.168.1.1
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] Looking up template: tpl_abc123
└─ projectId: prj_xyz789
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] Template not found
└─ errorCode: TEMPLATE_NOT_FOUND
└─ statusCode: 404
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] POST /v1/send → 404 (45ms)
```
From this, we can immediately see:
- You're authenticated correctly (authType: apiKey)
- The template ID doesn't exist in your project
- The request took 45ms to process
- No database errors or system issues
**Result:** We can quickly tell you "That template doesn't exist in your project" without back-and-forth debugging.
## Using Request IDs in Self-Hosted Deployments
If you're self-hosting Plunk, you can use request IDs to debug issues in your own logs.
### Searching Logs
**With Docker logs:**
```bash
# Find all logs for a specific request
docker logs plunk-api 2>&1 | grep "a1b2c3d4-e5f6-7890-gh12-i34567890jkl"
```
**With standard logs:**
```bash
# Search application logs
grep "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" /var/log/plunk/api.log
# Search with context (10 lines before and after)
grep -C 10 "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" /var/log/plunk/api.log
```
### Log Structure
Every log entry includes the request ID in brackets:
```
[f47ac10b-58cc-4372-a567-0e02b2c3d479] POST /v1/track → Request received
[f47ac10b-58cc-4372-a567-0e02b2c3d479] Contact created: cnt_abc123
[f47ac10b-58cc-4372-a567-0e02b2c3d479] Event tracked: evt_xyz789
[f47ac10b-58cc-4372-a567-0e02b2c3d479] POST /v1/track → 200 (127ms)
```
This makes it easy to trace a single request from start to finish.
## Providing Request IDs with Load Balancers
If you use a load balancer or API gateway, you can pass your own request IDs:
```bash
curl -X POST https://api.useplunk.com/v1/send \
-H "X-Request-ID: your-custom-request-id" \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '...'
```
Plunk will use your provided request ID instead of generating a new one. This allows you to:
- Correlate requests across your entire system
- Trace requests from your frontend → your backend → Plunk → email delivery
- Maintain consistent request IDs in your monitoring tools
## Best Practices
### 1. Always Log Request IDs
```javascript
// ✅ Good: Log request ID for correlation
const response = await plunk.send(email);
const requestId = response.headers.get('X-Request-ID');
logger.info(`Email sent to ${email.to}`, { requestId });
```
```javascript
// ❌ Bad: Discard request ID
await plunk.send(email);
// No way to correlate this with Plunk's logs
```
### 2. Include in Error Reporting
```javascript
// ✅ Good: Include request ID in error reports
try {
await plunk.send(email);
} catch (error) {
Sentry.captureException(error, {
extra: {
requestId: error.requestId,
emailTo: email.to
}
});
}
```
### 3. Store for Audit Trails
```javascript
// ✅ Good: Store request ID in your database
await db.emailLog.create({
to: email.to,
subject: email.subject,
plunkRequestId: requestId,
sentAt: new Date()
});
```
### 4. Return to End Users (Optional)
For customer-facing applications, you can show request IDs to users:
```
❌ Error sending email. Please try again.
```
```
❌ Error sending email. Please contact support and provide this reference: a1b2c3d4-e5f6
```
## Monitoring and Observability
Request IDs are essential for:
- **Distributed tracing** - Follow requests across services
- **Error correlation** - Link errors to specific API calls
- **Performance monitoring** - Identify slow requests
- **Debugging production** - Reproduce issues without PII
- **Rate limit tracking** - Monitor usage patterns per project
## FAQ
### Do request IDs expire?
No, request IDs are logged indefinitely (subject to your log retention policy).
### Can I reuse request IDs?
No, each request should have a unique ID. If you send the same request ID twice, logs will be mixed.
### Are request IDs sequential?
No, they are random UUIDs. This prevents information leakage about request volume.
### Can I search by request ID in the dashboard?
This feature is planned but not yet available. For now, contact support with the request ID.
## Related Documentation
- [Error Codes](/api-reference/errors) - Understanding API errors
- [API Reference](/api-reference/overview) - Complete API documentation
- [Troubleshooting](/guides/troubleshooting) - Common issues and solutions
@@ -1,277 +0,0 @@
---
title: Scaling Email Delivery
description: Best practices for high-volume email sending
---
## Email delivery at scale
Plunk is built on AWS SES and handles millions of emails. Follow these practices to maintain high deliverability and performance at scale.
## Deliverability best practices
### Use custom domains
Emails from custom domains have higher trust and better deliverability than shared domains.
**Setup:**
1. Go to **Settings > Domains**
2. Add your domain
3. Configure DNS records (DKIM, SPF)
4. Wait for verification
[Learn more about custom domains →](/guides/custom-domains)
### Warm up new domains
Start small and gradually increase volume.
This builds sender reputation with email providers.
### Clean your list regularly
Remove bounced and inactive contacts:
```javascript
// Get bounced contacts
const bounced = await fetch('{{API_URL}}/events?name=email.bounced&limit=1000', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
// Unsubscribe them
for (const event of bounced.data.events) {
await fetch(`{{API_URL}}/contacts/${event.contactId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscribed: false })
});
}
```
**When to clean:**
- Hard bounces: Immediately unsubscribe
- Soft bounces: After 3 attempts
- No engagement: After 6-12 months
### Segment your audience
Send relevant content to engaged users:
```javascript
// Create engaged users segment
{
"name": "Engaged Users",
"filters": {
"operator": "OR",
"conditions": [
{ "field": "data.lastOpenedAt", "operator": "greaterThan", "value": "{{90 days ago}}" },
{ "field": "data.lastClickedAt", "operator": "greaterThan", "value": "{{90 days ago}}" }
]
}
}
```
Send campaigns to engaged segments for better rates.
## Tracking control
You can disable tracking in your project settings for privacy-focused audiences or to reduce email size.
**When tracking is disabled:**
- No tracking pixel (no open tracking)
- Links not rewritten (no click tracking)
- Smaller email size
- May improve deliverability for privacy-conscious audiences
**When to disable:**
- Regulated industries (healthcare, finance)
- Privacy-focused users
- Transactional emails where tracking isn't needed
- High-volume sends where analytics aren't critical
## Rate limits
### AWS SES limits
Plunk automatically queues emails to stay within limits. Large sends process in background.
### Increase limits
For higher throughput:
1. Maintain good sender reputation
2. Consistent sending volume
3. Low bounce/complaint rates
4. Request limit increase from AWS
### Batch operations
For bulk operations, use appropriate endpoints:
```javascript
// ✓ Good: Single request for multiple recipients
fetch('{{API_URL}}/v1/send', {
method: 'POST',
body: JSON.stringify({
to: ['[email protected]', '[email protected]', '[email protected]'],
subject: 'Update',
body: 'Message'
})
});
// ✗ Avoid: Multiple requests
for (const email of emails) {
await fetch('{{API_URL}}/v1/send', {...}); // Sequential, slow
}
```
## Campaign targeting
### Dynamic filtering
Target audiences without creating segments:
```javascript
{
"name": "Premium Launch",
"audienceType": "FILTERED",
"audienceFilter": {
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "premium" },
{ "field": "data.signupDate", "operator": "greaterThan", "value": "2025-01-01" },
{ "field": "subscribed", "operator": "equals", "value": true }
]
},
"templateId": "template_id"
}
```
**Use FILTERED for:**
- One-time sends
- Testing targeting
- Very specific criteria
**Use SEGMENT for:**
- Repeated targeting
- Workflow triggers
- Segment analytics
## Segment membership tracking
For segments used in workflows, enable membership tracking:
```bash
curl -X POST {{API_URL}}/segments \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Active Premium Users",
"filters": {...},
"trackMembership": true
}'
```
**Enable for:**
- Workflow trigger segments
- Lifecycle stage tracking
- Cohort analysis
**Disable for:**
- Large segments (100k+ contacts)
- Campaign-only segments
- Frequently changing segments
Membership updates run every 5 minutes in background.
## Performance optimization
### Cache contact data
For high-volume API sends, cache contact lookups:
```javascript
// Cache contact IDs locally
const contactCache = new Map();
async function getContactId(email) {
if (contactCache.has(email)) {
return contactCache.get(email);
}
const contact = await fetch(`{{API_URL}}/contacts?search=${email}`);
contactCache.set(email, contact.id);
return contact.id;
}
```
### Use webhooks for async processing
Instead of waiting for email sends:
```javascript
// Workflow with webhook for confirmation
{
"steps": [
{ "type": "SEND_EMAIL", "config": {...} },
{
"type": "WEBHOOK",
"config": {
"url": "https://your-api.com/email-sent",
"method": "POST",
"body": {
"contactId": "{{id}}",
"emailId": "{{emailId}}"
}
}
}
]
}
```
### Batch workflow triggers
Trigger workflows in batches instead of one at a time:
```javascript
// Batch event tracking
const events = users.map(user => ({
email: user.email,
event: 'welcome_campaign',
data: { userId: user.id }
}));
// Send in parallel (respecting rate limits)
await Promise.all(
events.map(event =>
fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(event)
})
)
);
```
## Monitoring
### Track key metrics
Monitor these regularly:
- **Bounce rate** — Should be < 2%
- **Complaint rate** — Should be < 0.1%
- **Open rate** — Industry average 15-25%
- **Click rate** — Industry average 2-5%
Monitor these metrics in your dashboard analytics page to track deliverability and engagement over time.
## Next Steps
- [Set billing limits](/guides/billing-limits) to control costs
- [Monitor analytics](/guides/analytics) for deliverability
- [Troubleshoot issues](/guides/troubleshooting) if problems arise
-342
View File
@@ -1,342 +0,0 @@
---
title: Segments
description: Create dynamic audience groups with filters
---
## What are segments
Segments are dynamic groups of contacts based on filter conditions. Unlike static lists, segments automatically update as contact data changes.
Use segments to:
- Target specific audiences in campaigns
- Trigger workflows when contacts enter/exit
- Analyze cohorts and user behavior
- Personalize communications
## Creating segments
### In the dashboard
1. Go to **Segments**
2. Click **Create Segment**
3. Name your segment
4. Add filter conditions
5. Enable membership tracking (optional)
6. Save
### Via API
```bash
curl -X POST {{API_URL}}/segments \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Active Premium Users",
"filters": {
"operator": "AND",
"conditions": [
{
"field": "data.plan",
"operator": "equals",
"value": "premium"
},
{
"field": "subscribed",
"operator": "equals",
"value": true
}
]
}
}'
```
## Filter conditions
Combine conditions with `AND` or `OR` operators to build precise segments.
### Available operators
**Equality**
- `equals` — Exact match
- `notEquals` — Does not match
**Text**
- `contains` — String includes value
- `notContains` — String excludes value
**Numeric/Date**
- `greaterThan` — Larger than value
- `lessThan` — Smaller than value
- `greaterThanOrEquals` — At least value
- `lessThanOrEquals` — At most value
**Arrays**
- `in` — Value in array
- `notIn` — Value not in array
**Existence**
- `exists` — Field has a value
- `notExists` — Field is missing or null
### Field paths
Access contact fields with dot notation:
- `email` — Contact email address
- `subscribed` — Subscription status
- `createdAt` — When contact was created
- `data.firstName` — Custom field
- `data.preferences.newsletter` — Nested field
- `data.lastPurchaseDate` — Custom date field
## Example segments
### Premium subscribers
All contacts on premium plan who are subscribed:
```json
{
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "premium" },
{ "field": "subscribed", "operator": "equals", "value": true }
]
}
```
### Recent signups
Contacts who joined in the last 7 days:
```json
{
"operator": "AND",
"conditions": [
{
"field": "createdAt",
"operator": "greaterThan",
"value": "{{now - 7 days}}"
}
]
}
```
### Inactive users
Users who haven't logged in for 30+ days:
```json
{
"operator": "AND",
"conditions": [
{
"field": "data.lastLoginAt",
"operator": "lessThan",
"value": "{{now - 30 days}}"
},
{
"field": "data.lastLoginAt",
"operator": "exists",
"value": true
}
]
}
```
### High-value customers
Total spending over $1000:
```json
{
"operator": "AND",
"conditions": [
{
"field": "data.totalSpent",
"operator": "greaterThanOrEquals",
"value": 1000
},
{
"field": "subscribed",
"operator": "equals",
"value": true
}
]
}
```
### Free tier churned users
Users who downgraded from paid to free:
```json
{
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "free" },
{ "field": "data.previousPlan", "operator": "in", "value": ["pro", "premium"] },
{ "field": "data.downgradedAt", "operator": "exists", "value": true }
]
}
```
## Membership tracking
When you enable `trackMembership`:
**What happens:**
- Plunk computes and stores segment membership
- When contacts enter, sends `segment.entered` event
- When contacts exit, sends `segment.exited` event
- Provides historical membership data
**Use when:**
- You want to trigger workflows on entry/exit
- You need to track cohort changes over time
- Segment is relatively stable (not millions of changes per day)
**Disable when:**
- Segment changes very frequently
- You only need current membership (not history)
- You have millions of contacts and want to save storage
### Using entry/exit events
With membership tracking enabled, use segment events to trigger workflows:
```json
{
"triggerType": "SEGMENT_ENTRY",
"triggerConfig": {
"segmentId": "high-value-customers"
}
}
```
Or use the generic event trigger:
```json
{
"triggerType": "EVENT",
"triggerConfig": {
"eventName": "segment.entered"
}
}
```
Event data includes:
```json
{
"segmentId": "seg_abc123",
"segmentName": "High Value Customers"
}
```
## Using segments
### In campaigns
Send a campaign to a segment:
```bash
curl -X POST {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Feature Announcement",
"audienceType": "SEGMENT",
"segmentId": "premium-users",
"templateId": "feature-announcement"
}'
```
### In workflows
Trigger workflows when contacts enter a segment:
```bash
curl -X POST {{API_URL}}/workflows \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "VIP Welcome",
"triggerType": "SEGMENT_ENTRY",
"triggerConfig": {
"segmentId": "high-value-customers"
}
}'
```
## Managing segments
### Get segment with count
```bash
curl -X GET {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns member count:
```json
{
"id": "segment_id",
"name": "Premium Users",
"memberCount": 1542,
"filters": {...}
}
```
### List segment members
```bash
curl -X GET "{{API_URL}}/segments/segment_id/contacts?limit=50" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Update segment
```bash
curl -X PATCH {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated name",
"filters": {
"operator": "AND",
"conditions": [...]
}
}'
```
When you update filters, membership is recomputed automatically.
### Delete segment
```bash
curl -X DELETE {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Active campaigns and workflows using this segment will stop working.
## Best practices
**Start broad, then narrow** — Create general segments first, then add specific conditions.
**Test your filters** — Preview segment size before using in campaigns to avoid sending to wrong audience.
**Name clearly** — Use descriptive names: "Q4 2024 Premium Signups" not "Segment 3".
**Avoid overlapping segments** — If using in workflows, ensure segments don't overlap to prevent duplicate emails.
**Use for analysis** — Create segments to understand user cohorts, even if not used in campaigns.
**Combine with events** — Track events and use them in segment conditions for behavior-based targeting.
## What's next
- [Build campaigns](/guides/campaigns) to send to segments
- [Create workflows](/guides/workflows) triggered by segment entry
- [Track events](/guides/events) to use in segment filters
-287
View File
@@ -1,287 +0,0 @@
---
title: Templates
description: Create reusable email templates
---
## Why use templates
Templates let you design emails once and reuse them across:
- Transactional API sends (`/v1/send`)
- Automated workflows
Benefits:
- Update design in one place, applies everywhere
- Maintain consistent branding
- Separate content from code
## Template types
### Marketing templates
**Use for:** Newsletters, promotions, announcements
- Only sends to subscribed contacts
- Automatically includes unsubscribe link
- Respects subscription preferences
### Transactional templates
**Use for:** Order confirmations, password resets, receipts
- Sends to all contacts, even if unsubscribed
- No unsubscribe link required
- Critical business communications
Choose the right type based on content, not delivery method. You can use both types in campaigns, workflows, and API calls. The type determines subscription enforcement.
## Creating templates
### In the dashboard
1. Go to **Templates**
2. Click **Create Template**
3. Choose type (Marketing or Transactional)
4. Set subject, from address, and body
5. Add variables using `{{variableName}}`
6. Save
### Via API
```bash
curl -X POST {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "order-confirmation",
"subject": "Order #{{orderNumber}} confirmed",
"body": "<h1>Thanks for your order!</h1><p>Order #{{orderNumber}} will arrive by {{deliveryDate}}.</p>",
"from": "[email protected]",
"fromName": "Acme Store",
"type": "TRANSACTIONAL"
}'
```
## Using variables
Variables let you personalize each email. Use `{{variableName}}` syntax:
```html
<h1>Hello {{firstName}}!</h1>
<p>Your {{plan}} subscription renews on {{renewalDate}}.</p>
<p>Total: ${{amount}}</p>
```
When sending, provide values in the `data` object:
```json
{
"template": "subscription-renewal",
"data": {
"firstName": "Sarah",
"plan": "Pro",
"renewalDate": "April 15, 2024",
"amount": "29.00"
}
}
```
## Persistent vs. Temporary Data
Template variables can come from **persistent contact data** or **temporary non-persistent data**:
**Persistent data** — Saved to contact profile:
```json
{
"to": "[email protected]",
"subject": "Welcome {{firstName}}!",
"body": "<p>Your {{plan}} subscription is active.</p>",
"data": {
"firstName": "John", // Saved to contact
"plan": "Pro" // Saved to contact
}
}
```
**Non-persistent data** — Used only for this email:
```json
{
"to": "[email protected]",
"subject": "Password Reset",
"body": "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
"data": {
"firstName": "John", // Saved to contact
"resetCode": {value: "ABC123", persistent: false} // NOT saved to contact
}
}
```
**When to use non-persistent data:**
- One-time verification codes or tokens
- Temporary URLs (password reset, magic links)
- Session-specific information
- Data that shouldn't pollute contact profiles
**In workflows:**
Non-persistent data from events is available throughout the entire workflow execution via the execution context, allowing you to use tokens/URLs across multiple workflow steps.
### Fallback values
Provide defaults for missing data:
```html
<h1>Hello {{firstName ?? 'there'}}!</h1>
```
If `firstName` isn't provided, displays "Hello there!" instead.
### Nested data
Access nested objects with dot notation:
```html
<p>{{user.email}}</p>
<p>{{order.items.0.name}}</p>
<p>{{preferences.newsletter}}</p>
```
## Sending with templates
### Transactional emails
Use the `template` field with the **template ID** (not the template name):
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"template": "clx123abc456",
"data": {
"orderNumber": "12345",
"deliveryDate": "March 20"
}
}'
```
**Finding your template ID:**
- In the dashboard: Go to Templates → Click on your template → Copy the ID from the URL or template details
- Via API: Use `GET /templates` to list all templates with their IDs
When using a template:
- **Subject, body, from, and reply-to** are automatically taken from the template
- **Template variables** (e.g., `{{orderNumber}}`) are populated from the `data` field
- You can **override** any template value by explicitly providing it in the request (see example below)
### Overriding template values
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"template": "clx123abc456",
"subject": "Custom Subject (overrides template)",
"from": {
"name": "Custom Sender",
"email": "[email protected]"
},
"data": {
"orderNumber": "12345"
}
}'
```
### In workflows
When creating a **Send Email** workflow step, select the template from the dropdown. Variables are automatically filled from contact data and workflow context.
### In campaigns
When creating a campaign, choose a template instead of composing inline. All campaign recipients get the same template with personalized variables.
## When to use inline vs templates
**Use templates when:**
- Sending the same email repeatedly
- Multiple workflows/campaigns use same design
- Design may change over time
- Want to centralize branding
**Use inline content when:**
- One-off transactional emails
- Unique per-user content
- Testing or prototyping
- Content is generated dynamically
Example inline send:
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your verification code",
"body": "<p>Your code: {{code}}</p>",
"data": {"code": "ABC123"}
}'
```
## Managing templates
### List templates
```bash
curl -X GET {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key"
```
Filter by type:
```bash
curl -X GET "{{API_URL}}/templates?type=MARKETING" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Update template
```bash
curl -X PATCH {{API_URL}}/templates/template_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"subject": "New subject line",
"body": "<p>Updated content</p>"
}'
```
Changes apply to all future sends using this template.
### Delete template
```bash
curl -X DELETE {{API_URL}}/templates/template_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Templates used in active workflows are not deleted—you'll need to update workflows first.
## Best practices
**Keep templates simple** — Focus on content, avoid complex layouts that break in email clients.
**Test across clients** — Email rendering varies. Preview in Gmail, Outlook, Apple Mail, and mobile devices.
**Use semantic HTML** — Use `<h1>`, `<p>`, `<strong>` instead of styled `<div>` elements.
**Provide all variables** — Missing variables display as empty. Use fallbacks: `{{name ?? 'Customer'}}`.
**Version your templates** — For critical transactional emails, create new templates rather than editing existing ones.
## Next Steps
- [Create campaigns](/guides/campaigns) with your templates
- [Build workflows](/guides/workflows) with automated emails
- [Track performance](/guides/analytics) of your templates
@@ -1,427 +0,0 @@
---
title: Troubleshooting
description: Common issues and solutions
---
## Authentication issues
### Invalid API key error
**Error:**
```json
{
"code": 401,
"error": "Unauthorized",
"message": "Invalid API key"
}
```
**Solutions:**
1. **Verify key format** — Secret keys start with `sk_`, public keys start with `pk_`
2. **Check Authorization header** — Must use `Bearer` format: `Authorization: Bearer sk_your_secret_key`
3. **Ensure key hasn't been regenerated** — If you regenerated keys in dashboard, update your application
4. **Verify project access** — Key must belong to the project you're accessing
**Test your key:**
```bash
curl -X GET {{API_URL}}/contacts?limit=1 \
-H "Authorization: Bearer sk_your_secret_key"
```
### Wrong key type for endpoint
**Error:**
```json
{
"code": 401,
"error": "Unauthorized",
"message": "This endpoint requires a secret key (sk_*)"
}
```
**Solution:**
You're using a public key (`pk_*`) for an endpoint that requires a secret key.
- **Public keys** — Only work with `/v1/track` endpoint
- **Secret keys** — Required for all other endpoints
Use your secret key from **Settings > API Keys** in the dashboard.
## Email sending issues
### Emails not sending
**Check these common causes:**
1. **Billing limit reached** - Check your billing limits in Settings → Billing. If you hit your monthly limit, increase it or wait for monthly reset.
2. **Template not found**
```json
{
"code": 404,
"error": "Not Found",
"message": "Template not found"
}
```
Verify template ID exists and belongs to your project.
3. **Contact unsubscribed**
Marketing templates skip unsubscribed contacts. Check contact subscription status:
```bash
curl -X GET {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
If `subscribed: false`, use a transactional template for critical emails.
4. **Project disabled**
If your project is disabled, all email sends will fail. Contact support.
### Emails going to spam
**Common causes:**
1. **No custom domain** — Emails from default domain may have lower trust
2. **Low engagement** — Recipients not opening/clicking emails
3. **High bounce rate** — Too many invalid email addresses
4. **Spam complaints** — Recipients marking as spam
**Solutions:**
1. **Set up custom domain** — [Add your domain](/guides/custom-domains) for better deliverability
2. **Clean your list** — Remove bounced and inactive contacts
3. **Improve content** — Avoid spam trigger words, include clear unsubscribe link
4. **Warm up your domain** — Start with small volumes, gradually increase
5. **Segment your audience** — Only send relevant content to engaged users
### Tracking not working
**Open tracking:**
Requires:
- `trackingEnabled: true` on project
- Recipient email client loads images
- HTML email (not plain text)
Some email clients block tracking pixels. Open rates are estimates, not exact.
**Click tracking:**
Requires:
- Tracking enabled on project (check your project settings in dashboard)
- Links in email body (not subject)
- HTML email format
## Campaign issues
### Campaign won't send
**Error:**
```json
{
"code": 400,
"error": "Bad Request",
"message": "Campaign must be in DRAFT or SCHEDULED status"
}
```
**Solution:**
Can only send campaigns with status `DRAFT`. If campaign is `SENT` or `CANCELLED`, duplicate it:
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/duplicate \
-H "Authorization: Bearer sk_your_secret_key"
```
### No recipients for campaign
**Cause:**
Campaign targets a segment or filter with zero matching contacts.
**Solutions:**
1. **Check segment size**
```bash
curl -X GET {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Look at `memberCount` field.
2. **Verify filters** — Test filters on segments page to see matches
3. **Check subscription status** — Marketing templates only send to subscribed contacts
### Scheduled campaign didn't send
**Check:**
1. **Verify schedule time** — Must be in future when scheduled
2. **Campaign status** — Should be `SCHEDULED`, not `CANCELLED`
3. **Project limits** — Billing limits may block sends
View campaign details:
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key"
```
## Workflow issues
### Workflow not triggering
**Common causes:**
1. **Workflow not enabled**
Check `enabled: true`:
```bash
curl -X GET {{API_URL}}/workflows/workflow_id \
-H "Authorization: Bearer sk_your_secret_key"
```
2. **Wrong event name**
Event names are case-sensitive. `user_signed_up` ≠ `User_Signed_Up`
Verify event name exactly matches workflow trigger.
3. **Contact already entered (allowReentry: false)**
If `allowReentry: false`, contact can only enter once. Check execution history:
```bash
curl -X GET {{API_URL}}/workflows/workflow_id/executions?contactId=contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
4. **Segment membership not tracked**
For `SEGMENT_ENTRY` triggers, segment must have `trackMembership: true`.
### Workflow emails not sending
**Check each email step execution:**
```bash
curl -X GET {{API_URL}}/workflows/workflow_id/executions/execution_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Look for step failures in response.
**Common causes:**
1. **Template not found** — Template was deleted
2. **Contact unsubscribed** — Marketing templates skip unsubscribed contacts
3. **Billing limit reached** — Monthly workflow email limit exceeded
4. **Workflow execution stopped** — Contact was deleted or execution was cancelled
### Workflow stuck on delay step
Delays are processed by background jobs. Check:
1. **scheduledFor** time — When should it execute?
2. **Current time** — Has the scheduled time passed?
Delays process every minute. Wait a few minutes and check again.
## Contact issues
### Contact not found
**Error:**
```json
{
"code": 404,
"error": "Not Found",
"message": "Contact not found"
}
```
**Solutions:**
1. **Verify contact ID** — Check for typos in the ID
2. **Check project** — Contact may belong to different project
3. **Contact deleted** — Contact may have been deleted
Search by email instead:
```bash
curl -X GET "{{API_URL}}/[email protected]" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Duplicate contacts
Plunk automatically prevents duplicates. Creating a contact with existing email updates that contact instead of creating a new one.
If you see duplicates:
1. Check email addresses carefully (they may differ slightly)
2. Verify you're viewing the same project
### Contact data not updating
**Verify data format:**
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"data": {
"plan": "premium"
}
}'
```
**Common issues:**
1. **Missing `data` wrapper** — Custom fields must be inside `data` object
2. **Wrong data types** — Numbers should be `99`, not `"99"`
3. **Nested too deeply** — Keep data relatively flat
## Segment issues
### Segment shows wrong count
Segment counts update every 5 minutes. Wait a few minutes and refresh.
For real-time count, query members directly:
```bash
curl -X GET "{{API_URL}}/segments/segment_id/contacts?limit=1" \
-H "Authorization: Bearer sk_your_secret_key"
```
Check `total` field in response.
### Segment filter not working
**Test your filter:**
1. **Check field names** — Use `data.fieldName` for custom fields
2. **Verify operators** — `equals` for strings, `greaterThan` for numbers
3. **Match data types** — Don't compare string `"99"` with number `99`
**Example filters:**
```javascript
// ❌ Wrong
{ "field": "plan", "operator": "equals", "value": "pro" }
// ✓ Correct
{ "field": "data.plan", "operator": "equals", "value": "pro" }
```
### Segment entry/exit events not firing
Requires `trackMembership: true` on segment:
```bash
curl -X PATCH {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"trackMembership": true}'
```
Membership is computed every 5 minutes. Events fire on the next computation cycle after a contact's segment membership changes.
## Domain verification issues
### Domain won't verify
**Common causes:**
1. **DNS not propagated** — Can take up to 48 hours
2. **Wrong DNS records** — Double-check DKIM tokens
3. **Conflicting records** — Remove old DKIM records from other email services
**Check DNS propagation:**
```bash
dig TXT _domainkey.yourdomain.com
```
Records should match the DKIM tokens provided by Plunk.
**Force verification check:**
```bash
curl -X POST {{API_URL}}/domains/domain_id/verify \
-H "Authorization: Bearer sk_your_secret_key"
```
### Emails not sending from custom domain
1. **Verify domain is verified** — Check `verified: true` in domain settings
2. **Use correct `from` address** — Must be `@yourdomain.com`
3. **Check SPF and DKIM** — Ensure DNS records are correct
## Rate limiting
### 429 Too Many Requests
**Error:**
```json
{
"code": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded"
}
```
**Solutions:**
1. **Implement exponential backoff** — Wait and retry with increasing delays
2. **Batch operations** — Group multiple operations when possible
3. **Spread requests** — Distribute load over time instead of bursts
**Example retry logic:**
```javascript
async function sendWithRetry(data, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
const waitTime = Math.pow(2, i) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return response;
} catch (error) {
if (i === maxRetries - 1) throw error;
}
}
}
```
## Getting help
If you're still experiencing issues:
1. **Check API status** — Is there a known outage?
2. **Review error message** — Error messages usually indicate the problem
3. **Search documentation** — Look for specific error codes or messages
4. **Check GitHub issues** — Similar issues may already be reported
5. **Join Discord community** — Ask for help from other users
6. **Contact support** — Provide error details, request IDs, and steps to reproduce
**Include in support requests:**
- Error message and status code
- API endpoint and method
- Request body (remove sensitive data)
- Timestamp of the issue
- Project ID (if applicable)
-262
View File
@@ -1,262 +0,0 @@
---
title: Webhooks
description: Send real-time notifications to external services
---
## What are webhooks
Webhooks let you send HTTP requests to external services from within workflows. Use them to:
- Notify your CRM when workflows complete
- Update external databases with contact actions
- Trigger third-party automations
- Sync data across systems
- Track workflow progress in analytics tools
## Using webhooks in workflows
Add a **Webhook** step to any workflow:
1. Go to **Workflows**
2. Create or edit a workflow
3. Add a **Webhook** step
4. Configure the HTTP request
5. Connect to other steps
### Basic webhook configuration
```json
{
"type": "WEBHOOK",
"config": {
"url": "https://your-api.com/webhook",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer your_api_token"
},
"body": {
"email": "{{email}}",
"event": "workflow_completed",
"contactId": "{{id}}"
}
}
}
```
### Supported HTTP methods
- **POST** — Most common, sends data to endpoint
- **PUT** — Update existing resource
- **PATCH** — Partial update
- **GET** — Retrieve data (rarely used in workflows)
- **DELETE** — Remove resource
## Using contact variables
Access contact data in your webhook using template variables:
```json
{
"url": "https://crm.example.com/contacts",
"method": "POST",
"body": {
"email": "{{email}}",
"firstName": "{{data.firstName}}",
"lastName": "{{data.lastName}}",
"plan": "{{data.plan}}",
"workflowName": "{{workflowName}}",
"completedAt": "{{now}}"
}
}
```
Available variables:
- `{{email}}` — Contact email
- `{{id}}` — Contact ID
- `{{data.fieldName}}` — Any custom data field
- `{{workflowName}}` — Current workflow name
- `{{now}}` — Current timestamp
## Common use cases
### Notify Slack
```json
{
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"method": "POST",
"body": {
"text": "New user completed onboarding: {{email}}"
}
}
```
### Update CRM
```json
{
"url": "https://api.crm.com/contacts/{{data.crmId}}",
"method": "PATCH",
"headers": {
"Authorization": "Bearer crm_api_token"
},
"body": {
"onboardingCompleted": true,
"lastEngaged": "{{now}}"
}
}
```
### Track analytics
```json
{
"url": "https://analytics.example.com/events",
"method": "POST",
"headers": {
"X-API-Key": "analytics_key"
},
"body": {
"event": "workflow_milestone",
"userId": "{{email}}",
"properties": {
"workflow": "{{workflowName}}",
"step": "purchase_completed"
}
}
}
```
### Trigger Zapier
```json
{
"url": "https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/",
"method": "POST",
"body": {
"email": "{{email}}",
"firstName": "{{data.firstName}}",
"event": "trial_ended"
}
}
```
## Error handling
### Webhook failures
If a webhook request fails:
- Workflow continues to next step
- Error is logged in workflow execution
- Contact is not blocked
Check workflow execution logs to see webhook errors:
```bash
curl -X GET {{API_URL}}/workflows/workflow_id/executions \
-H "Authorization: Bearer sk_your_secret_key"
```
### Timeouts
Webhooks timeout after 30 seconds. If your endpoint takes longer:
- Use async processing on your end
- Return 202 Accepted immediately
- Process in background job
### Retry logic
Webhooks are **not automatically retried**. If you need guaranteed delivery:
- Implement retry logic in your endpoint
- Use a message queue (SQS, RabbitMQ)
- Track webhook status in your database
## Receiving email event webhooks
Plunk tracks email events automatically (opens, clicks, bounces). Access them via:
### Events API
```bash
curl -X GET "{{API_URL}}/events?contactId=contact_id" \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns email events:
```json
{
"events": [
{
"name": "email.opened",
"data": { "emailId": "...", "timestamp": "..." }
},
{
"name": "email.clicked",
"data": { "emailId": "...", "url": "...", "timestamp": "..." }
}
]
}
```
### Trigger workflows on email events
Create a workflow triggered by email events:
```json
{
"triggerType": "EVENT",
"triggerConfig": {
"eventName": "email.clicked"
}
}
```
Then use a webhook step to forward to your system.
## Security best practices
**Use HTTPS only** — Never send sensitive data over HTTP.
**Authenticate requests** — Include API tokens in headers:
```json
{
"headers": {
"Authorization": "Bearer your_secret_token"
}
}
```
**Validate on receiving end** — Don't trust webhook data blindly. Verify it matches your expectations.
**Don't expose secrets** — Store API tokens as environment variables, not in workflow config.
**Rate limit your endpoint** — Protect against webhook floods.
## Testing webhooks
### Use webhook.site
For testing, use [webhook.site](https://webhook.site):
1. Go to webhook.site
2. Copy your unique URL
3. Use it in your workflow webhook step
4. Trigger the workflow
5. See the request in webhook.site
### Test with ngrok
For local development:
```bash
ngrok http 3000
```
Use the ngrok URL in your webhook configuration. Requests will tunnel to your local server.
## What's next
- [Build workflows](/guides/workflows) with webhook steps
- [Track events](/guides/events) to trigger webhooks
- [Monitor analytics](/guides/analytics) for webhook success rates
@@ -1,211 +0,0 @@
---
title: Advanced Workflow Automation
description: Control workflow re-entry and pass execution context data
---
## Workflow Re-Entry Control
Control whether contacts can enter the same workflow multiple times.
### Allow Re-Entry
Use when the workflow represents a repeating process:
```javascript
{
"name": "Weekly Newsletter",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "newsletter.send" },
"allowReentry": true // Contacts can re-enter
}
```
**When to use**:
- Recurring campaigns (weekly newsletters)
- Event-based sequences (cart abandoned)
- Behavior triggers that can happen multiple times
**What happens**: Contact can have multiple active executions of the same workflow.
### Prevent Re-Entry (Default)
Use for one-time journeys:
```javascript
{
"name": "Onboarding Series",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "user.signup" },
"allowReentry": false // One-time only (default)
}
```
**When to use**:
- User onboarding
- Trial expiration
- Welcome sequences
**What happens**: Contact can only enter once, even if the trigger fires again.
## Execution Context
Pass event-specific data when starting a workflow execution.
### Basic Example
```javascript
// Start workflow with order-specific context
fetch('/workflows/workflow_id/executions', {
method: 'POST',
body: JSON.stringify({
contactId: 'contact_id',
context: {
orderNumber: 'ORD-12345',
orderTotal: 299.99,
deliveryDate: '2024-03-30'
}
})
});
```
In your workflow email templates:
```html
<p>Hi {'{{firstName}}'}!</p>
<p>Order #{'{{orderNumber}}'}: ${'{{orderTotal}}'}</p>
<p>Delivery: {'{{deliveryDate}}'}</p>
```
The template accesses both contact data (`firstName`) and context data (`orderNumber`, `orderTotal`, `deliveryDate`).
### When to Use Context
**Use execution context for**:
- Order-specific details
- Event registration info
- Session data
- Campaign-specific values
**Update contact data for**:
- Persistent user attributes
- Cumulative metrics (total orders, lifetime value)
- Segment-able fields
### Example: Order Confirmation Workflow
```javascript
// Trigger workflow on purchase
{
"name": "Order Confirmation",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "purchase.completed" },
"allowReentry": true // Can purchase multiple times
}
// Start execution with order context
fetch('/workflows/order_workflow_id/executions', {
method: 'POST',
body: JSON.stringify({
contactId: 'contact_id',
context: {
orderNumber: 'ORD-456',
total: 199.99,
trackingUrl: 'https://track.example.com/456'
}
})
});
```
Workflow sends emails with order-specific details while tracking cumulative purchase data on the contact.
## Real-World Patterns
### Pattern 1: Trial Workflow
```javascript
{
"name": "14-Day Trial",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "trial.started" },
"allowReentry": false // Only trial once
}
// Workflow steps:
// Day 0: Welcome email
// Day 7: Mid-trial check-in
// Day 13: Upgrade reminder
```
### Pattern 2: Cart Abandonment
```javascript
{
"name": "Cart Abandoned",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "cart.abandoned" },
"allowReentry": true // Can abandon multiple times
}
// Pass cart data as context
context: {
cartTotal: 99.99,
cartUrl: 'https://app.example.com/cart/abc'
}
```
### Pattern 3: Event Reminders
```javascript
{
"name": "Webinar Reminders",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "webinar.registered" },
"allowReentry": true // Can register for multiple webinars
}
// Pass webinar details as context
context: {
webinarTitle: 'Email Automation Masterclass',
webinarDate: '2024-04-10',
webinarLink: 'https://zoom.us/j/12345'
}
```
## Monitoring Executions
Check workflow execution status:
```bash
curl -X GET "/workflows/workflow_id/executions/execution_id" \
-H "Authorization: Bearer sk_your_secret_key"
```
Response shows step progress:
```json
{
"success": true,
"data": {
"id": "execution_id",
"status": "active",
"steps": [
{
"stepId": "step_1",
"status": "completed",
"completedAt": "2024-03-15T10:00:00Z"
},
{
"stepId": "step_2",
"status": "waiting",
"waitingUntil": "2024-03-16T10:00:00Z"
}
]
}
}
```
## Next Steps
- [Create workflows](/guides/workflows) to automate sequences
- [Track events](/guides/events) to trigger workflows
- [Set up segments](/guides/segments) for segment-based triggers
-445
View File
@@ -1,445 +0,0 @@
---
title: Workflows
description: Automate email sequences with triggers and conditions
---
## What are workflows
Workflows automate email sequences based on user behavior. Build onboarding drips, re-engagement campaigns, and event-triggered emails—all without writing code.
A workflow consists of:
- **Trigger** — What starts the workflow (event, segment entry, schedule)
- **Steps** — Actions like sending emails, waiting, or checking conditions
- **Transitions** — Connections between steps that define the flow
## Common use cases
### Welcome series
Send a 3-email onboarding sequence when users sign up:
1. User triggers `signed_up` event
2. Send welcome email immediately
3. Wait 1 day
4. Send getting started guide
5. Wait 2 days
6. Send feature tips
### Abandoned cart recovery
Re-engage users who add items but don't purchase:
1. User triggers `cart_abandoned` event
2. Wait 1 hour
3. Send reminder email with cart contents
4. Wait for `purchase` event (timeout: 24 hours)
5. If purchased → Exit workflow
6. If timeout → Send discount offer
### Trial expiration
Notify users before trial ends and encourage upgrade:
1. Trigger daily at 9am
2. Check if trial expires in 3 days
3. If yes → Send upgrade reminder
4. Wait for `subscription_created` event (timeout: 3 days)
5. If subscribed → Send thank you email
6. If timeout → Send last chance offer
### Re-engagement campaign
Win back inactive users:
1. Contact enters "Inactive Users" segment
2. Send "We miss you" email
3. Wait for `login` event (timeout: 7 days)
4. If logged in → Exit workflow
5. If timeout → Send special offer
## Creating workflows
### In the dashboard
1. Go to **Workflows**
2. Click **Create Workflow**
3. Name your workflow
4. Choose trigger type
5. Add steps using visual builder
6. Connect steps with transitions
7. Activate workflow
### Via API
```bash
curl -X POST {{API_URL}}/workflows \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome Series",
"eventName": "signed_up",
"enabled": true
}'
```
Then add steps and transitions through the dashboard or API.
## Trigger types
### Event trigger
Starts when a specific event is tracked:
```json
{
"triggerType": "EVENT",
"triggerConfig": {
"eventName": "signed_up"
}
}
```
Track the event with `/v1/track` to start the workflow.
### Segment entry
Starts when contact joins a segment:
```json
{
"triggerType": "SEGMENT_ENTRY",
"triggerConfig": {
"segmentId": "premium-users"
}
}
```
Requires segment to have `trackMembership: true`.
### Segment exit
Starts when contact leaves a segment:
```json
{
"triggerType": "SEGMENT_EXIT",
"triggerConfig": {
"segmentId": "trial-users"
}
}
```
### Schedule
Runs on a cron schedule:
```json
{
"triggerType": "SCHEDULE",
"triggerConfig": {
"schedule": "0 9 * * *"
}
}
```
Evaluates all contacts—use conditions to filter who continues.
## Workflow steps
### Send Email
Send a template to the contact:
```json
{
"type": "SEND_EMAIL",
"config": {
"templateId": "welcome-template"
}
}
```
### Delay
Wait before continuing:
```json
{
"type": "DELAY",
"config": {
"duration": 86400,
"unit": "seconds"
}
}
```
Common durations:
- 1 hour: `3600`
- 1 day: `86400`
- 1 week: `604800`
### Wait for Event
Pause until an event occurs or timeout:
```json
{
"type": "WAIT_FOR_EVENT",
"config": {
"eventName": "purchase",
"timeout": 604800
}
}
```
Create two transitions: one for "success" (event occurred) and one for "timeout".
### Condition
Branch based on contact data:
```json
{
"type": "CONDITION",
"config": {
"field": "data.plan",
"operator": "equals",
"value": "premium"
}
}
```
Create two transitions: "true" and "false".
### Update Contact
Modify contact fields:
```json
{
"type": "UPDATE_CONTACT",
"config": {
"data": {
"onboardingCompleted": true,
"completedAt": "{{now}}"
}
}
}
```
### Webhook
Call an external API:
```json
{
"type": "WEBHOOK",
"config": {
"url": "https://api.example.com/webhook",
"method": "POST",
"body": {
"email": "{{email}}",
"event": "workflow_completed"
}
}
}
```
### Exit
End the workflow:
```json
{
"type": "EXIT"
}
```
## Re-entry behavior
Control whether contacts can enter a workflow multiple times:
### Prevent re-entry (default)
**allowReentry: false**
- Contact can only enter once, ever
- Subsequent triggers are ignored
- Use for one-time journeys
**Best for:**
- User onboarding sequences
- Welcome series
- Trial expiration flows
- One-time educational content
**Example:**
```json
{
"name": "Onboarding Series",
"eventName": "user_signed_up",
"allowReentry": false,
"enabled": true
}
```
Even if the `user_signed_up` event fires multiple times for the same contact, they'll only enter once.
### Allow re-entry
**allowReentry: true**
- Contact can enter multiple times
- Each trigger starts a new execution
- Multiple executions can run simultaneously
**Best for:**
- Recurring events (weekly newsletters, monthly reports)
- Behavior-triggered campaigns (cart abandonment, content engagement)
- Event-specific sequences (order confirmations, webinar reminders)
**Example:**
```json
{
"name": "Cart Abandoned Reminder",
"eventName": "cart_abandoned",
"allowReentry": true,
"enabled": true
}
```
User abandons cart multiple times → Each triggers a new workflow execution.
## Execution context
Pass event-specific data when workflows are triggered automatically.
### What is context?
Context is temporary data passed with a workflow execution that's available in templates but not saved to the contact record.
**Contact data vs Context:**
- **Contact data** — Persistent, saved to contact, used for segmentation
- **Execution context** — Temporary, specific to this workflow run, not saved
### Using context in templates
Context data is available in workflow email templates alongside contact data:
```html
<h1>Hi {{firstName}}!</h1>
<p>Order #{{orderNumber}} confirmed!</p>
<p>Total: ${{orderTotal}}</p>
<p>Tracking: <a href="{{trackingUrl}}">View shipment</a></p>
```
Where:
- `{{firstName}}` comes from contact.data
- `{{orderNumber}}`, `{{orderTotal}}`, `{{trackingUrl}}` come from execution context
### Common patterns
**Order confirmations:**
```javascript
// Event includes order details
{
event: 'purchase_completed',
email: '[email protected]',
data: {
// Saved to contact
totalPurchases: 5,
lifetimeValue: 599
}
}
// Workflow execution receives context (not saved)
context: {
orderNumber: 'ORD-12345',
orderTotal: 99.99,
trackingUrl: 'https://track.example.com/12345',
deliveryDate: '2025-12-05'
}
```
**Event registrations:**
```javascript
context: {
eventTitle: 'Email Marketing Workshop',
eventDate: '2025-12-10T14:00:00Z',
eventUrl: 'https://zoom.us/j/123456',
speakerName: 'Jane Doe'
}
```
**Cart abandonment:**
```javascript
context: {
cartTotal: 149.99,
cartUrl: 'https://app.example.com/cart/abc123',
itemCount: 3,
expiresAt: '2025-12-01T10:00:00Z'
}
```
## Workflow execution
When a workflow triggers:
1. Contact enters at the trigger step
2. Executes each step in sequence
3. Follows transitions between steps
4. Continues until reaching Exit step
5. Status changes from RUNNING to COMPLETED
If contact unsubscribes or is deleted, workflow execution stops immediately.
## Managing workflows
### List workflows
```bash
curl -X GET {{API_URL}}/workflows \
-H "Authorization: Bearer sk_your_secret_key"
```
### Get workflow details
```bash
curl -X GET {{API_URL}}/workflows/workflow_id \
-H "Authorization: Bearer sk_your_secret_key"
```
### Activate/deactivate
```bash
curl -X PATCH {{API_URL}}/workflows/workflow_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
```
### View executions
```bash
curl -X GET "{{API_URL}}/workflows/workflow_id/executions" \
-H "Authorization: Bearer sk_your_secret_key"
```
## Best practices
**Start simple** — Begin with 2-3 emails before adding complex conditions.
**Test with yourself** — Create a test contact and trigger the workflow to verify timing and content.
**Monitor execution stats** — Check completion rates to identify where contacts drop off.
**Use meaningful names** — Name steps clearly: "Send Welcome Email" not "Step 1".
**Set appropriate timeouts** — For "Wait for Event" steps, choose realistic timeouts based on user behavior.
**Handle both paths** — Every condition and wait should have both success and failure paths defined.
## Next Steps
- [Track events](/guides/events) to trigger workflows
- [Create segments](/guides/segments) for segment-based triggers
- [Build templates](/guides/templates) to use in workflow emails
- [Set up webhooks](/guides/webhooks) for external integrations
+1 -3
View File
@@ -7,9 +7,7 @@
"tutorials",
"---Core Concepts---",
"concepts",
"---Guides---",
"guides",
"---Automation Patterns---",
"---Automation---",
"automation-patterns",
"---API Reference---",
"api-reference",
@@ -1,228 +0,0 @@
---
title: Database Setup
description: Configure and manage your PostgreSQL database
---
## Prerequisites
- PostgreSQL 14 or higher
- Database created
- Database user with permissions
## Installation
### Using Docker
```bash
docker run -d \
--name plunk-postgres \
-e POSTGRES_PASSWORD=your-password \
-e POSTGRES_DB=plunk \
-p 5432:5432 \
-v postgres_data:/var/lib/postgresql/data \
postgres:14
```
### Using Package Manager
#### Ubuntu/Debian
```bash
sudo apt update
sudo apt install postgresql postgresql-contrib
```
#### macOS
```bash
brew install postgresql@14
brew services start postgresql@14
```
## Database Creation
```sql
CREATE DATABASE plunk;
CREATE USER plunk WITH ENCRYPTED PASSWORD 'your-password';
GRANT ALL PRIVILEGES ON DATABASE plunk TO plunk;
```
## Connection String
```bash
DATABASE_URL="postgresql://plunk:your-password@localhost:5432/plunk"
DIRECT_DATABASE_URL="postgresql://plunk:your-password@localhost:5432/plunk"
```
## Running Migrations
### Development
```bash
yarn workspace @plunk/db migrate:dev
```
### Production
```bash
yarn workspace @plunk/db migrate:prod
```
## Database Schema
Plunk uses Prisma ORM. The schema is located at:
```
packages/db/prisma/schema.prisma
```
### Core Tables
- **User**: User accounts
- **Project**: Workspaces/tenants
- **Membership**: User-project relationships
- **Contact**: Email contacts
- **Template**: Email templates
- **Campaign**: Email campaigns
- **Workflow**: Automated sequences
- **Email**: Email tracking
- **Event**: Custom events
## Indexes
Important indexes for performance:
```sql
-- Contact email index (unique per project)
CREATE INDEX idx_contact_email ON "Contact" (email, "projectId");
-- Contact subscription index
CREATE INDEX idx_contact_subscribed ON "Contact" (subscribed);
-- Email tracking indexes
CREATE INDEX idx_email_contact ON "Email" ("contactId");
CREATE INDEX idx_email_created ON "Email" ("createdAt");
-- Event indexes
CREATE INDEX idx_event_contact ON "Event" ("contactId");
CREATE INDEX idx_event_name ON "Event" (event);
```
## Performance Tuning
### For Large Datasets (1M+ contacts)
```sql
-- Increase shared buffers (25% of RAM)
ALTER SYSTEM SET shared_buffers = '2GB';
-- Increase work memory
ALTER SYSTEM SET work_mem = '50MB';
-- Increase maintenance work memory
ALTER SYSTEM SET maintenance_work_mem = '512MB';
-- Enable parallel queries
ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
-- Reload configuration
SELECT pg_reload_conf();
```
### Vacuum and Analyze
Run regularly for optimal performance:
```bash
# Manual vacuum
vacuumdb --analyze --verbose plunk
# Auto-vacuum (enabled by default)
```
## Backup and Restore
### Backup
```bash
pg_dump -U plunk -d plunk > backup-$(date +%Y%m%d).sql
```
### Restore
```bash
psql -U plunk -d plunk < backup.sql
```
### Automated Backups
Set up cron job:
```bash
0 2 * * * pg_dump -U plunk plunk > /backups/plunk-$(date +\%Y\%m\%d).sql
```
## Monitoring
### Connection Count
```sql
SELECT count(*) FROM pg_stat_activity;
```
### Database Size
```sql
SELECT pg_size_pretty(pg_database_size('plunk'));
```
### Table Sizes
```sql
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;
```
### Slow Queries
```sql
SELECT
query,
calls,
total_time,
mean_time
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;
```
## Troubleshooting
### Connection refused
Check PostgreSQL is running:
```bash
sudo systemctl status postgresql
```
### Authentication failed
Verify credentials in connection string.
### Too many connections
Increase max_connections:
```sql
ALTER SYSTEM SET max_connections = 200;
SELECT pg_reload_conf();
```
## Next Steps
- [Configure email delivery](/self-hosting/email-setup)
- [Deploy with Docker](/self-hosting/docker)
- [Environment variables](/self-hosting/environment-variables)
+49 -361
View File
@@ -1,394 +1,82 @@
---
title: Docker Deployment
description: Deploy Plunk with Docker Compose
description: Deploy with Docker Compose
---
## Prerequisites
- Docker installed
- Docker Compose installed
- Git installed
## Quick Start
### 1. Clone Repository
```bash
git clone https://github.com/useplunk/plunk.git
cd plunk
```
### 2. Copy Environment File
```bash
cp .env.self-host.example .env
```
### 3. Configure Environment
Edit `.env` and configure required variables:
```bash
# Database Password (PostgreSQL)
DB_PASSWORD="changeme123"
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET="your-secret-here"
# Domains (for subdomain-based routing)
API_DOMAIN="api.localhost"
DASHBOARD_DOMAIN="app.localhost"
LANDING_DOMAIN="www.localhost"
WIKI_DOMAIN="docs.localhost"
# Set to 'true' for HTTPS in production
USE_HTTPS="false"
# AWS SES (for email sending)
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-access-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
SES_CONFIGURATION_SET="plunk-configuration-set"
# S3-compatible storage (Minio is included by default)
# Leave defaults unless using external S3
S3_ENDPOINT="http://minio:9000"
S3_ACCESS_KEY_ID="plunk"
S3_ACCESS_KEY_SECRET="plunkminiopass"
S3_BUCKET="uploads"
S3_PUBLIC_URL="http://localhost:9000/uploads"
S3_FORCE_PATH_STYLE="true"
```
### 4. Start Services
```bash
# Edit .env (see Environment Variables)
docker compose up -d
```
This starts:
- PostgreSQL database
- Redis
- Minio (S3-compatible storage)
- Plunk application (all services in one container with nginx)
- API server
- Worker process
- Web dashboard
- Landing page
- Documentation
See [Environment Variables](/self-hosting/environment-variables) for all configuration options.
### 5. Access Services
## Services
The services are available at the configured domains:
- **Dashboard**: `http://app.localhost` (or your configured domain)
- **API**: `http://api.localhost`
- **Landing**: `http://www.localhost`
- **Docs**: `http://docs.localhost`
- **Minio Console**: `http://localhost:9001`
| Service | Purpose |
|---------|---------|
| `plunk` | All apps + nginx (API, Web, Landing, Wiki, SMTP) |
| `postgres` | PostgreSQL 16 database |
| `redis` | Redis 7 queue |
| `minio` | S3-compatible storage |
| `ntfy` | Notifications |
Create your first account and start sending emails!
## Ports
## Docker Compose Configuration
| Port | Service |
|------|---------|
| 80 | Nginx (HTTP) |
| 465 | SMTP (implicit TLS) |
| 587 | SMTP (STARTTLS) |
| 9000 | Minio API |
| 9001 | Minio Console |
The `docker-compose.yml` file uses the pre-built Plunk image from GitHub Container Registry:
## Running Individual Services
Set `SERVICE` environment variable:
```bash
SERVICE=api # API only
SERVICE=worker # Worker only
SERVICE=web # Dashboard only
SERVICE=all # Everything (default)
```
## SMTP TLS Certificates
For TLS on ports 465/587, provide certificates via one of these methods:
### Traefik acme.json (Dokploy, Coolify)
Mount the `acme.json` file and set `SMTP_DOMAIN`:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: plunk-postgres
restart: unless-stopped
environment:
POSTGRES_DB: plunk
POSTGRES_USER: plunk
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- plunk
redis:
image: redis:7-alpine
container_name: plunk-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- plunk
minio:
image: minio/minio:latest
container_name: plunk-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-plunk}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-plunkminiopass}
volumes:
- minio_data:/data
ports:
- "9000:9000" # API
- "9001:9001" # Console
networks:
- plunk
plunk:
image: ghcr.io/useplunk/plunk:latest
container_name: plunk
restart: unless-stopped
environment:
SERVICE: all # Runs all services (API, Worker, Web, Landing, Wiki)
DATABASE_URL: postgresql://plunk:${DB_PASSWORD}@postgres:5432/plunk
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
# Domain configuration for subdomain routing
API_DOMAIN: ${API_DOMAIN:-api.localhost}
DASHBOARD_DOMAIN: ${DASHBOARD_DOMAIN:-app.localhost}
LANDING_DOMAIN: ${LANDING_DOMAIN:-www.localhost}
WIKI_DOMAIN: ${WIKI_DOMAIN:-docs.localhost}
USE_HTTPS: ${USE_HTTPS:-false}
# AWS SES
AWS_SES_REGION: ${AWS_SES_REGION}
AWS_SES_ACCESS_KEY_ID: ${AWS_SES_ACCESS_KEY_ID}
AWS_SES_SECRET_ACCESS_KEY: ${AWS_SES_SECRET_ACCESS_KEY}
# S3/Minio storage
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-plunk}
S3_ACCESS_KEY_SECRET: ${S3_ACCESS_KEY_SECRET:-plunkminiopass}
ports:
- "465:465" # SMTP (implicit TLS)
- "587:587" # SMTP (STARTTLS)
depends_on:
- postgres
- redis
- minio
networks:
- plunk
environment:
SMTP_DOMAIN: "smtp.yourdomain.com"
volumes:
postgres_data:
redis_data:
minio_data:
plunk_data:
networks:
plunk:
driver: bridge
- /path/to/acme.json:/certs/acme.json:ro
```
**Note**: The Plunk image contains all applications (API, Worker, Web, Landing, Wiki) and uses nginx for subdomain-based routing.
Plunk automatically extracts the certificate for `SMTP_DOMAIN` from acme.json.
## Production Deployment
### PEM Files
### Using Pre-built Image
Mount certificate files directly:
The easiest way to deploy is using the pre-built image from GitHub Container Registry:
```bash
docker pull ghcr.io/useplunk/plunk:latest
docker compose up -d
```yaml
volumes:
- /path/to/privkey.pem:/certs/privkey.pem:ro
- /path/to/fullchain.pem:/certs/fullchain.pem:ro
```
### Building Your Own Image
If no certificates are mounted, SMTP runs without TLS.
If you want to build from source:
## Building from Source
```bash
docker build -t plunk:custom .
```
Then update `docker-compose.yml` to use your custom image:
```yaml
plunk:
image: plunk:custom
# ... rest of configuration
```
### Security Hardening
1. **Use strong passwords**
```bash
DB_PASSWORD=$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -base64 32)
```
2. **Use HTTPS**
- Set `USE_HTTPS=true` in your `.env`
- Set up reverse proxy (Traefik, Caddy, or nginx)
- Configure SSL certificates (Let's Encrypt)
3. **Configure domains**
```bash
API_DOMAIN=api.yourdomain.com
DASHBOARD_DOMAIN=app.yourdomain.com
LANDING_DOMAIN=www.yourdomain.com
WIKI_DOMAIN=docs.yourdomain.com
USE_HTTPS=true
```
4. **Restrict network access**
- Don't expose database and Redis ports publicly
- Use internal Docker networks
- Only expose port 80/443 (via reverse proxy) and SMTP ports
5. **Regular backups**
- Database backups (automated via cron)
- Minio data backups
### Scaling
The Plunk image runs all services in a single container by default. For higher scale:
#### Separate Services
You can run services separately by setting the `SERVICE` environment variable:
```yaml
# API only
plunk-api:
image: ghcr.io/useplunk/plunk:latest
environment:
SERVICE: api
# ... configuration
# Worker only
plunk-worker:
image: ghcr.io/useplunk/plunk:latest
environment:
SERVICE: worker
# ... configuration
# Web only
plunk-web:
image: ghcr.io/useplunk/plunk:latest
environment:
SERVICE: web
# ... configuration
```
#### Scale Workers
For higher email throughput, run multiple worker containers:
```bash
docker compose up -d --scale plunk-worker=3
```
#### External Services
For production at scale, use managed services:
- Managed PostgreSQL (AWS RDS, DigitalOcean, Supabase)
- Managed Redis (AWS ElastiCache, Redis Cloud, Upstash)
- AWS S3 (instead of Minio)
## Maintenance
### View Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f plunk
docker compose logs -f postgres
```
### Restart Services
```bash
docker compose restart plunk
```
### Update Plunk
Pull the latest image and restart:
```bash
docker compose pull plunk
docker compose up -d plunk
```
If you built from source:
```bash
git pull
docker build -t plunk:custom .
docker compose up -d
```
### Backup Database
```bash
docker compose exec postgres pg_dump -U plunk plunk > backup-$(date +%Y%m%d).sql
```
### Restore Database
```bash
docker compose exec -T postgres psql -U plunk plunk < backup.sql
```
### Backup Minio Data
```bash
docker compose exec minio mc alias set local http://localhost:9000 plunk plunkminiopass
docker compose exec minio mc mirror local/uploads /backups/minio
```
## Troubleshooting
### Services won't start
Check logs:
```bash
docker compose logs plunk
```
### Database connection errors
Ensure DATABASE_URL is correct and database is running:
```bash
docker compose ps postgres
docker compose exec postgres psql -U plunk -d plunk -c "SELECT 1;"
```
### Worker not processing jobs
Check Plunk container logs for worker output:
```bash
docker compose logs plunk | grep worker
```
Verify Redis connection:
```bash
docker compose exec redis redis-cli PING
```
### Cannot access services
Check that your domains resolve correctly:
```bash
# For local development with *.localhost domains, these should work automatically
# For production domains, ensure DNS is configured correctly
curl http://api.localhost
curl http://app.localhost
```
### Minio not accessible
Check Minio is running:
```bash
docker compose ps minio
docker compose logs minio
```
Access Minio console at `http://localhost:9001` with credentials from `.env`.
## Next Steps
- [Configure environment variables](/self-hosting/environment-variables)
- [Set up email delivery](/self-hosting/email-setup)
- [Database setup and migrations](/self-hosting/database-setup)
@@ -1,218 +1,92 @@
---
title: Email Setup (AWS SES)
description: Configure AWS SES for email delivery
title: AWS SES Setup
description: Configure email delivery
---
## Prerequisites
## 1. Create IAM User
- AWS account
- AWS SES access
- Domain ownership (for custom domains)
1. Go to IAM Console → Users → Create user
2. Name: `plunk-ses`
3. Attach a custom policy with required permissions (see below)
4. Create access keys → Save credentials
## AWS SES Setup
### Required IAM Policy
### 1. Create AWS Account
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SetIdentityMailFromDomain",
"ses:GetIdentityDkimAttributes",
"ses:SendRawEmail",
"ses:GetIdentityVerificationAttributes",
"ses:VerifyDomainDkim",
"ses:ListIdentities",
"ses:SetIdentityFeedbackForwardingEnabled"
],
"Resource": "*"
}
]
}
```
Sign up at [aws.amazon.com](https://aws.amazon.com)
## 2. Request Production Access
### 2. Request Production Access
SES starts in sandbox mode (verified addresses only).
By default, SES is in sandbox mode (limited to verified addresses).
1. Go to AWS SES Console
1. Go to SES Console
2. Click "Request production access"
3. Fill out the form
4. Wait for approval (usually 24-48 hours)
3. Wait for approval (24-48 hours)
### 3. Create IAM User
## 3. Verify Domain
Create dedicated IAM user for Plunk:
1. SES Console → Verified Identities → Create identity
2. Choose "Domain" → Enter your domain
3. Add DNS records provided by AWS
4. Wait for verification
1. Go to IAM Console
2. Create new user: "plunk-ses"
3. Attach policy: `AmazonSESFullAccess`
4. Create access keys
5. Save access key ID and secret key
## 4. Enable DKIM (Recommended)
### 4. Configure Environment Variables
1. SES Console → Verified Identities → Your domain
2. Enable "Easy DKIM"
3. Add the 3 CNAME records to your DNS
## 5. Create SNS Topic
1. Go to SNS Console → Topics → Create topic
2. Type: Standard
3. Name: `plunk-ses-events`
4. Create topic
5. Create subscription:
- Protocol: HTTPS
- Endpoint: `https://api.yourdomain.com/webhooks/sns`
6. Plunk automatically confirms the subscription. If it fails, check your logs for the confirmation URL.
## 6. Create Configuration Sets
### Tracking Configuration Set
1. SES Console → Configuration sets → Create set
2. Name: `plunk-tracking`
3. Add event destination:
- Name: `sns-events`
- Event types: **Sends, Deliveries, Opens, Clicks, Bounces, Complaints**
- Destination: SNS → Select `plunk-ses-events` topic
### No-Tracking Configuration Set
1. Create another set named `plunk-no-tracking`
2. Add event destination with only: **Sends, Deliveries, Bounces, Complaints**
## 7. Configure Environment
```bash
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-access-key-id"
AWS_SES_SECRET_ACCESS_KEY="your-secret-access-key"
```
## Verify Email Addresses
### Single Email
```bash
aws ses verify-email-identity --email-address [email protected]
```
Check your inbox and click verification link.
### Domain Verification
1. Go to SES Console → Verified Identities
2. Click "Create identity"
3. Choose "Domain"
4. Enter your domain: `yourdomain.com`
5. Add DNS records provided by AWS
DNS records (example):
```
Type: TXT
Name: _amazonses.yourdomain.com
Value: provided-by-aws
```
Wait for verification (up to 72 hours).
## Configuration Sets
Create configuration sets for tracking:
### 1. Tracking Configuration Set
```bash
aws ses create-configuration-set \
--configuration-set-name plunk-tracking
```
### 2. No-Tracking Configuration Set
```bash
aws ses create-configuration-set \
--configuration-set-name plunk-no-tracking
```
### 3. Update Environment
```bash
AWS_SES_ACCESS_KEY_ID="your-access-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
SES_CONFIGURATION_SET="plunk-tracking"
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
```
## SNS for Email Events
Set up SNS to receive email events (opens, clicks, bounces):
### 1. Create SNS Topic
```bash
aws sns create-topic --name plunk-email-events
```
### 2. Subscribe Plunk Webhook
```bash
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:123456789:plunk-email-events \
--protocol https \
--notification-endpoint https://api.yourdomain.com/webhooks/sns
```
### 3. Configure SES Event Publishing
1. Go to SES Console → Configuration Sets
2. Select `plunk-tracking`
3. Add destination → SNS
4. Select your SNS topic
5. Enable events: Delivery, Bounce, Complaint, Open, Click
## DKIM Setup
Enable DKIM signing for better deliverability:
1. Go to SES Console → Verified Identities
2. Select your domain
3. Enable "Easy DKIM"
4. Add CNAME records to your DNS
```
Type: CNAME
Name: xxx._domainkey.yourdomain.com
Value: xxx.dkim.amazonses.com
```
Repeat for all 3 CNAME records provided.
## Testing Email Delivery
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Test Email",
"body": "Hello from Plunk!",
"subscribed": true
}'
```
Check AWS SES Console → Email sending → Sending statistics.
## Monitoring
### SES Dashboard
View in AWS Console:
- Sends
- Bounces
- Complaints
- Reputation
### CloudWatch Metrics
Set up alarms for:
- Bounce rate > 5%
- Complaint rate > 0.1%
- Send quota utilization > 80%
## Troubleshooting
### Emails in sandbox mode only
Request production access via SES Console.
### Domain not verified
Check DNS records and wait for propagation (up to 72 hours).
### High bounce rate
- Clean your contact list
- Use double opt-in
- Remove hard bounces immediately
### Low reputation score
- Reduce bounce and complaint rates
- Send only to engaged users
- Implement feedback loops
## Best Practices
1. **Warm up gradually**: Start with low volume, increase slowly
2. **Monitor metrics**: Watch bounces and complaints closely
3. **Clean lists**: Remove inactive and bounced addresses
4. **Use DKIM**: Enable for better deliverability
5. **Segment sends**: Don't send same content to everyone
## Cost Optimization
- First 62,000 emails/month: **FREE** (from EC2)
- Additional: **$0.10 per 1,000 emails**
- Data transfer: **$0.12 per GB**
Example:
- 100,000 emails/month: ~$3.80/month
- 1,000,000 emails/month: ~$94/month
## Next Steps
- [Complete environment setup](/self-hosting/environment-variables)
- [Deploy with Docker](/self-hosting/docker)
- [Send your first email](/getting-started/quick-start)
@@ -1,182 +1,66 @@
---
title: Environment Variables
description: Complete environment variable reference
description: Configuration reference
---
## Required Variables
### Database
## Required
```bash
# PostgreSQL connection string
DATABASE_URL="postgresql://user:password@host:5432/plunk"
# Direct connection (for Prisma migrations)
DIRECT_DATABASE_URL="postgresql://user:password@host:5432/plunk"
```
### Redis
```bash
# Redis connection URL
REDIS_URL="redis://host:6379"
```
### Security
```bash
# JWT signing secret (generate with: openssl rand -base64 32)
JWT_SECRET="your-secret-here"
```
### AWS SES (Email Delivery)
```bash
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-access-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
# SES Configuration Sets
SES_CONFIGURATION_SET="plunk-tracking"
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
```
### Application URLs
```bash
# Protocol configuration (auto-generates URIs with http:// or https://)
USE_HTTPS="false" # Set to "true" for HTTPS in production
# Application URIs (auto-generated from domains if not set)
API_URI="https://api.yourdomain.com"
DASHBOARD_URI="https://app.yourdomain.com"
LANDING_URI="https://www.yourdomain.com"
# For Next.js (build time)
NEXT_PUBLIC_API_URI="https://api.yourdomain.com"
NEXT_PUBLIC_DASHBOARD_URI="https://app.yourdomain.com"
NEXT_PUBLIC_LANDING_URI="https://www.yourdomain.com"
```
**Note**: When using domain-based configuration (e.g., `API_DOMAIN=api.yourdomain.com`), the application URIs are automatically generated. Set `USE_HTTPS=true` to use HTTPS protocol, otherwise HTTP will be used by default. You can also manually set the full URIs to override the auto-generation.
## Optional Variables
### Plunk API
If you're using the email package's `sendEmail` function to send emails via the Plunk API:
```bash
# Plunk API Key (obtained from dashboard)
PLUNK_API_KEY="sk_your_secret_key"
```
### S3-Compatible Storage (Minio)
**Note**: When using Docker Compose, Minio is included and these variables are automatically configured with defaults. You typically don't need to set these unless you want to use external S3 storage.
```bash
# Only configure if NOT using the bundled Minio
S3_ENDPOINT="http://minio:9000" # Default: uses bundled Minio
S3_ACCESS_KEY_ID="plunk" # Default: plunk
S3_ACCESS_KEY_SECRET="plunkminiopass" # Default: plunkminiopass
S3_BUCKET="uploads" # Default: uploads
S3_PUBLIC_URL="http://localhost:9000/uploads" # Default: Minio URL
S3_FORCE_PATH_STYLE="true" # Required for Minio
```
### OAuth Providers
```bash
# GitHub OAuth
GITHUB_OAUTH_CLIENT="your-client-id"
GITHUB_OAUTH_SECRET="your-client-secret"
# Google OAuth
GOOGLE_OAUTH_CLIENT="your-client-id"
GOOGLE_OAUTH_SECRET="your-client-secret"
```
### Stripe Billing
```bash
STRIPE_SK="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# Stripe Products
STRIPE_PRICE_ONBOARDING="price_..."
STRIPE_PRICE_EMAIL_USAGE="price_..."
# Stripe Metering
STRIPE_METER_EVENT_NAME="email_sent"
```
### Internal
```bash
# Node environment
NODE_ENV="production"
```
## Example .env File
```bash
# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/plunk"
DIRECT_DATABASE_URL="postgresql://postgres:password@localhost:5432/plunk"
# Redis
REDIS_URL="redis://localhost:6379"
# Security
JWT_SECRET="generated-secret-here"
JWT_SECRET="your-secret" # openssl rand -base64 32
DB_PASSWORD="your-password"
# Database (auto-configured in Docker)
DATABASE_URL="postgresql://plunk:password@postgres:5432/plunk"
REDIS_URL="redis://redis:6379"
# AWS SES
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
AWS_SES_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
AWS_SES_ACCESS_KEY_ID="your-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret"
SES_CONFIGURATION_SET="plunk-tracking"
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
# Protocol & URLs
USE_HTTPS="false"
API_URI="http://localhost:3001"
DASHBOARD_URI="http://localhost:3000"
LANDING_URI="http://localhost:3002"
# Next.js Public URLs
NEXT_PUBLIC_API_URI="http://localhost:3001"
NEXT_PUBLIC_DASHBOARD_URI="http://localhost:3000"
NEXT_PUBLIC_LANDING_URI="http://localhost:3002"
# Node Environment
NODE_ENV="development"
```
## Generating Secrets
### JWT Secret
## Domains
```bash
openssl rand -base64 32
# Subdomains for routing
API_DOMAIN="api.yourdomain.com"
DASHBOARD_DOMAIN="app.yourdomain.com"
LANDING_DOMAIN="www.yourdomain.com"
WIKI_DOMAIN="docs.yourdomain.com"
SMTP_DOMAIN="smtp.yourdomain.com"
# Protocol
USE_HTTPS="true" # false for local dev
```
### Strong Passwords
## Storage (Minio)
Defaults work with bundled Minio. Only set for external S3:
```bash
openssl rand -base64 24
S3_ENDPOINT="http://minio:9000"
S3_ACCESS_KEY_ID="plunk"
S3_ACCESS_KEY_SECRET="plunkminiopass"
S3_BUCKET="uploads"
S3_PUBLIC_URL="http://localhost:9000/uploads"
S3_FORCE_PATH_STYLE="true"
```
## Security Best Practices
## Optional
1. **Never commit secrets** to version control
2. **Use environment-specific files** (.env.production, .env.development)
3. **Rotate secrets regularly**
4. **Use secret management** (AWS Secrets Manager, HashiCorp Vault) in production
5. **Limit access** to production environment variables
```bash
# OAuth
GITHUB_OAUTH_CLIENT="client-id"
GITHUB_OAUTH_SECRET="client-secret"
GOOGLE_OAUTH_CLIENT="client-id"
GOOGLE_OAUTH_SECRET="client-secret"
## Next Steps
# Stripe
STRIPE_SK="sk_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
- [Set up database](/self-hosting/database-setup)
- [Configure email delivery](/self-hosting/email-setup)
- [Deploy with Docker](/self-hosting/docker)
# Notifications
NTFY_URL="http://ntfy/plunk-notifications"
```
@@ -1,151 +1,35 @@
---
title: Self-Hosting Introduction
title: Self-Hosting
description: Deploy Plunk on your own infrastructure
---
## Overview
Plunk is fully open-source and can be self-hosted on your own infrastructure. This gives you complete control over your data, email delivery, and infrastructure costs.
## Requirements
### System Requirements
- **Node.js**: Version 20 or higher
- **PostgreSQL**: Version 14 or higher
- **Redis**: Version 6 or higher
- **Docker** (recommended): For easy deployment
### AWS Services
- **AWS SES** (Required): For email sending
- **AWS S3** (Optional): For asset storage (Minio is included by default)
### Minimum Server Specs
For small to medium usage (up to 100K contacts):
- **CPU**: 1-2 cores
- **RAM**: 2GB
- **Storage**: 10GB (grows with contact data)
For larger scale (1M+ contacts):
- **CPU**: 2-4 cores
- **RAM**: 4GB
- **Storage**: 50GB+
## Architecture Components
Plunk uses a containerized architecture with the following components:
### 1. Plunk Application Container
Single container running all application services (API, Worker, Web, Landing, Wiki) with nginx reverse proxy for subdomain-based routing.
**Resources**: 1GB RAM, 1 CPU core (can scale up as needed)
**Services included**:
- **API Server**: Express.js application handling HTTP requests
- **Worker Process**: BullMQ worker processing background jobs
- **Web Dashboard**: Next.js application for the UI
- **Landing Page**: Marketing website
- **Documentation**: Wiki/docs site
- **SMTP Relay**: Email relay server (ports 465, 587)
**Note**: Services can be run separately by setting the `SERVICE` environment variable (`api`, `worker`, `web`, `landing`, `wiki`, or `all`).
### 2. PostgreSQL Database
Stores all data (contacts, campaigns, workflows, etc.).
**Resources**: 512MB-1GB RAM, SSD storage recommended
### 3. Redis
Queue system for background jobs (BullMQ).
**Resources**: 256MB-512MB RAM
### 4. Minio (S3-compatible Storage)
Object storage for file uploads and assets.
**Resources**: 256MB-512MB RAM, storage for uploaded files
## Deployment Options
### Docker Compose (Recommended)
Easiest way to get started. Includes all services pre-configured.
[View Docker Guide](/self-hosting/docker)
### Kubernetes
For production deployments at scale.
### Manual Deployment
Deploy each component separately on your infrastructure.
- Docker and Docker Compose
- AWS SES account (for sending emails)
- Domain name (for production)
## Quick Start
1. Clone the repository
2. Copy `.env.self-host.example` to `.env`
3. Configure environment variables (see [Environment Variables](/self-hosting/environment-variables))
4. Run `docker compose up -d`
5. Access dashboard at `http://app.localhost` (or your configured domain)
```bash
git clone https://github.com/useplunk/plunk.git
cd plunk
cp .env.self-host.example .env
# Edit .env with your settings
docker compose up -d
```
## What's Included
## Access
- ✅ Full API server
- ✅ Worker process
- ✅ Web dashboard
- ✅ PostgreSQL database
- ✅ Redis queue
- ✅ All features (campaigns, workflows, segments)
- ✅ No feature limitations
- ✅ No phone-home telemetry
## What's Not Included
- ❌ Managed infrastructure
- ❌ Automatic updates
- ❌ Support (community only)
- ❌ SLA guarantees
## Cost Considerations
### AWS SES Costs
- First 62,000 emails/month: **FREE** (when sent from EC2)
- Additional emails: **$0.10 per 1,000 emails**
### Infrastructure Costs
**All-in-One VPS** (recommended for small to medium usage):
- **Single VPS**: $5-12/month (2GB RAM, services like DigitalOcean, Hetzner, Vultr)
- Runs all containers (Plunk, PostgreSQL, Redis, Minio)
- **AWS SES**: $0-10/month (depends on volume)
- **Storage**: Included in VPS
**Total**: ~$5-20/month for self-hosting
**Managed Services** (for larger scale or production):
- **VPS**: $10-20/month (4GB RAM for Plunk application)
- **Managed PostgreSQL**: $10-15/month (512MB-1GB)
- **Managed Redis**: $5-10/month (256MB-512MB)
- **AWS SES**: $10-50/month (depends on volume)
- **AWS S3**: $1-5/month (if not using Minio)
**Total**: ~$35-100/month for production with managed services
## Support
### Community Support
- GitHub Issues
- Community Forum
- Documentation
| Service | URL |
|---------|-----|
| Dashboard | http://app.localhost |
| API | http://api.localhost |
| Docs | http://docs.localhost |
| Minio Console | http://localhost:9001 |
## Next Steps
- [Deploy with Docker](/self-hosting/docker)
- [Configure environment variables](/self-hosting/environment-variables)
- [Set up database](/self-hosting/database-setup)
- [Configure email delivery](/self-hosting/email-setup)
- [Docker Deployment](/self-hosting/docker) — Configuration details
- [Environment Variables](/self-hosting/environment-variables) — All settings
- [AWS SES Setup](/self-hosting/email-setup) — Email configuration
@@ -1,4 +1,4 @@
{
"title": "Self-Hosting",
"pages": ["introduction", "docker", "environment-variables", "database-setup", "email-setup"]
"pages": ["introduction", "docker", "environment-variables", "email-setup"]
}
@@ -1,227 +0,0 @@
---
title: Cart Abandonment Recovery
description: Recover abandoned carts with automated emails
icon: ShoppingCart
---
## Overview
Send automated recovery emails when users add items to cart but don't complete purchase. Uses a two-email sequence with a discount incentive.
## Prerequisites
- Track `cart_abandoned` and `purchase_completed` events from your app
- Create two email templates in dashboard
## Track cart events
### Cart abandoned
When user adds items but leaves without purchasing:
```javascript
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'cart_abandoned',
email: user.email,
data: {
cartTotal: cart.total,
cartUrl: `https://yourstore.com/cart/${cart.id}`,
itemCount: cart.items.length,
items: cart.items.map(i => ({
name: i.product.name,
price: i.price,
quantity: i.quantity,
imageUrl: i.product.imageUrl
}))
}
})
});
```
### Purchase completed
When user completes checkout:
```javascript
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'purchase_completed',
email: user.email,
data: {
orderId: order.id,
total: order.total
}
})
});
```
## Build the workflow
### 1. Create workflow
Go to **Workflows** → **Create Workflow**
- **Name:** Cart Abandonment Recovery
- **Trigger:** Event - `cart_abandoned`
- **Allow re-entry:** Yes (users can abandon multiple times)
### 2. Add workflow steps
```
[Trigger: cart_abandoned]
[Delay: 1 hour]
[Send Email: Cart Reminder]
[Wait for Event: purchase_completed, timeout: 23 hours]
├─ Purchased → [Exit]
└─ Timeout → [Send Email: Cart Discount] → [Exit]
```
**Step-by-step:**
1. **Delay** (1 hour)
- Duration: 1
- Unit: Hours
2. **Send Email** (Cart Reminder)
- Template: Cart Reminder
- Variables: (auto-populated from event data)
3. **Wait for Event**
- Event: `purchase_completed`
- Timeout: 23 hours
- Connect two paths:
- **Event triggered** → Exit
- **Timeout** → Continue to discount
4. **Send Email** (Cart Discount)
- Template: Cart Discount
- Variables:
- All cart data from event
- `discountedTotal`: Calculate in template or pass from backend
5. **Exit**
### 3. Enable workflow
Toggle workflow to **ON**
## Test the workflow
### Manual test
1. Go to **Workflows** → Cart Abandonment Recovery → **Executions**
2. Click **Create Execution**
3. Select test contact
4. Provide test data:
```json
{
"cartTotal": 149.99,
"cartUrl": "https://yourstore.com/cart/test123",
"itemCount": 2,
"items": [
{
"name": "Product A",
"price": 79.99,
"quantity": 1,
"imageUrl": "https://cdn.example.com/product-a.jpg"
},
{
"name": "Product B",
"price": 69.99,
"quantity": 1,
"imageUrl": "https://cdn.example.com/product-b.jpg"
}
],
"discountedTotal": 134.99
}
```
5. **Start Execution**
Watch the execution run. For faster testing, temporarily set delay to 1 minute instead of 1 hour.
### Live test
1. Trigger `cart_abandoned` event with your email
2. Wait 1 hour (or 1 minute if testing with shorter delay)
3. Check for first email
4. Either:
- Complete purchase → workflow ends
- Wait 23 hours → receive discount email
## Improve conversion
### Add cart item images
Pass product images in event data and display in email. Visual reminders increase clicks.
### Personalize timing
Test different delays:
- First email: 30min, 1hr, 2hr
- Second email: 12hr, 24hr, 48hr
Monitor which timing drives best conversion.
### Increase discount incrementally
Second email could offer 10%, third email (if you add one) could offer 15%.
### Segment by cart value
Create separate workflows for high-value carts (e.g., >$200) with different messaging or larger discounts.
### Track discount usage
When user applies discount code, track event:
```javascript
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'discount_applied',
email: user.email,
data: {
code: 'SAVE10',
source: 'cart_abandonment_email'
}
})
});
```
This lets you measure email-driven conversions.
## Common issues
**Email sends but cart is already purchased** — Add a condition before sending emails to check if purchase event already occurred.
**Cart URL expired** — Ensure cart sessions last at least 48 hours, or regenerate cart from saved items.
**Discount code doesn't work** — Verify code exists in your system before sending email. Auto-generate unique codes per user for better tracking.
**Too many emails** — Users abandoning multiple carts quickly will enter workflow multiple times. Consider adding a delay condition or rate limiting.
## Next steps
- [Track more events](/tutorials/event-tracking-integration) for behavior-based workflows
- [Build segments](/tutorials/segment-based-targeting) for high-value cart abandoners
- [Use conditions](/automation-patterns/conditional-branching) for cart value-based logic
@@ -1,475 +0,0 @@
---
title: Event Tracking Integration
description: Track user behavior to trigger workflows
icon: Activity
---
## Overview
Track events from your application to trigger workflows and update contact data. Events like `user_signed_up`, `purchase_completed`, `feature_used` can start automated email sequences.
## Get your public key
1. Go to [Settings → General]({{DASHBOARD_URL}}/settings)
2. Copy your **Public Key** (starts with `pk_`)
Public keys are safe to use in client-side code.
## Basic event tracking
### JavaScript (client-side)
```javascript
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'button_clicked',
email: user.email,
data: {
buttonName: 'Get Started',
page: '/pricing'
}
})
});
```
### Node.js (server-side)
```javascript
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'user_signed_up',
email: user.email,
data: {
name: user.name,
plan: 'free',
signupDate: new Date().toISOString()
}
})
});
```
### Python
```python
import requests
import os
requests.post('{{API_URL}}/v1/track',
headers={
'Authorization': f'Bearer {os.environ["PLUNK_PUBLIC_KEY"]}',
'Content-Type': 'application/json'
},
json={
'event': 'purchase_completed',
'email': user.email,
'data': {
'orderId': order.id,
'total': order.total,
'items': order.items
}
}
)
```
## Common events to track
### User lifecycle
```javascript
// Signup
await trackEvent('user_signed_up', user.email, {
name: user.name,
source: 'google',
plan: 'free'
});
// Activation
await trackEvent('first_value_achieved', user.email, {
action: 'created_first_project',
timestamp: new Date().toISOString()
});
// Upgrade
await trackEvent('subscription_upgraded', user.email, {
fromPlan: 'free',
toPlan: 'premium',
mrr: 99
});
// Churn
await trackEvent('subscription_cancelled', user.email, {
reason: user.cancellationReason,
cancelledAt: new Date().toISOString()
});
```
### Product engagement
```javascript
// Feature usage
await trackEvent('feature_used', user.email, {
featureName: 'data_export',
timestamp: new Date().toISOString()
});
// Content interaction
await trackEvent('video_watched', user.email, {
videoId: 'intro-101',
duration: 300,
completed: true
});
// Settings changes
await trackEvent('settings_updated', user.email, {
setting: 'notifications',
value: 'enabled'
});
```
### E-commerce
```javascript
// Cart
await trackEvent('cart_abandoned', user.email, {
cartId: cart.id,
cartTotal: cart.total,
items: cart.items.map(i => i.name)
});
// Purchase
await trackEvent('purchase_completed', user.email, {
orderId: order.id,
total: order.total,
paymentMethod: 'credit_card'
});
// Review
await trackEvent('review_submitted', user.email, {
productId: product.id,
rating: 5
});
```
## Event naming conventions
**Use lowercase with underscores:**
- ✅ `user_signed_up`
- ✅ `purchase_completed`
- ❌ `UserSignedUp`
- ❌ `purchase-completed`
**Be specific:**
- ✅ `trial_started`
- ❌ `event`
**Use past tense:**
- ✅ `email_opened`
- ❌ `email_open`
## Event data best practices
**Keep data flat when possible:**
```javascript
// Good
{
name: 'John',
plan: 'premium',
mrr: 99
}
// Works but harder to use
{
user: {
profile: {
name: 'John'
}
}
}
```
**Use consistent types:**
```javascript
// Good - number for numeric values
{ total: 99.99 }
// Bad - string for numeric values
{ total: "99.99" }
```
**Use ISO dates:**
```javascript
// Good
{ signupDate: new Date().toISOString() }
// Okay but less flexible
{ signupDate: '2024-03-15' }
```
## Integrate with React
### Context provider
```javascript
// EventTrackingContext.js
import { createContext, useContext } from 'react';
const EventTrackingContext = createContext();
export function EventTrackingProvider({ children }) {
const trackEvent = async (event, data = {}) => {
const user = getCurrentUser(); // Your auth logic
if (!user?.email) return;
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event,
email: user.email,
data: {
name: user.name,
...data
}
})
});
};
return (
<EventTrackingContext.Provider value={{ trackEvent }}>
{children}
</EventTrackingContext.Provider>
);
}
export const useEventTracking = () => useContext(EventTrackingContext);
```
### Use in components
```javascript
import { useEventTracking } from './EventTrackingContext';
function UpgradeButton() {
const { trackEvent } = useEventTracking();
const handleUpgrade = async () => {
await upgradePlan('premium');
await trackEvent('plan_upgraded', {
plan: 'premium',
source: 'pricing_page'
});
};
return <button onClick={handleUpgrade}>Upgrade</button>;
}
```
## Integrate with Next.js
### Client component
```javascript
'use client';
import { trackEvent } from '@/lib/plunk';
export function SignupForm() {
const handleSubmit = async (data) => {
const user = await createUser(data);
// Track event
await trackEvent('user_signed_up', user.email, {
name: user.name,
source: 'homepage'
});
};
return <form onSubmit={handleSubmit}>...</form>;
}
```
### Server action
```javascript
'use server';
import { trackEvent } from '@/lib/plunk';
export async function createProject(formData) {
const user = await getCurrentUser();
const project = await db.projects.create({
name: formData.get('name'),
userId: user.id
});
await trackEvent('project_created', user.email, {
projectId: project.id,
projectName: project.name
});
return project;
}
```
## Create a helper function
```javascript
// lib/plunk.js
const PLUNK_PUBLIC_KEY = process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY;
export async function trackEvent(event, email, data = {}) {
try {
const response = await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event,
email,
data
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to track event:', error);
// Don't throw - tracking shouldn't break your app
}
}
```
## Testing events
### View tracked events
1. Go to **Activity** in Plunk dashboard
2. Filter by event type
3. View event data payloads
### Test locally
```javascript
// Track a test event
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_your_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'test_event',
email: '[email protected]',
data: {
test: true,
timestamp: new Date().toISOString()
}
})
});
```
Check Plunk Activity - event should appear within seconds.
## Connect to workflows
Once events are tracked, create workflows that trigger on them:
1. **Workflows** → **Create Workflow**
2. Trigger: Event `user_signed_up`
3. Build your automation
4. Enable workflow
Now when you track `user_signed_up`, the workflow runs automatically.
## Performance considerations
**Don't block user actions:**
```javascript
// Good - fire and forget
handleClick() {
trackEvent('button_clicked', user.email);
// Don't await
}
// Bad - user waits for tracking
async handleClick() {
await trackEvent('button_clicked', user.email);
// User has to wait
}
```
**Batch events for bulk operations:**
```javascript
// If importing 1000 users, track events in background
async function importUsers(users) {
const imported = await db.users.bulkCreate(users);
// Queue for background processing
await queue.add('track-events', {
event: 'user_imported',
users: imported
});
}
```
**Add retry logic:**
```javascript
async function trackEventWithRetry(event, email, data, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await trackEvent(event, email, data);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
```
## Common issues
**Event tracked but workflow not triggering**
- Workflow is enabled
- Event name matches exactly (case-sensitive)
- Contact exists in Plunk
- Contact is subscribed
**CORS errors in browser**
- Use public key (not secret key)
- Plunk API allows CORS from all origins
**Contact not created**
- Email must be valid
- Contact is created automatically when event is tracked
## Next steps
- [Build a workflow](/tutorials/welcome-series-workflow) triggered by events
- [Campaigns vs Workflows](/concepts/campaigns-vs-workflows) decision guide
- [Stripe integration](/integrations/stripe-billing) for billing events
@@ -4,14 +4,12 @@ description: Send a transactional email in 5 minutes
icon: Mail
---
## Get your API key
## 1. Get your API key
1. Go to [Settings → General]({{DASHBOARD_URL}}/settings)
1. Go to **Settings → General**
2. Copy your **Secret Key** (starts with `sk_`)
**Important:** Use Secret Key server-side only. Never expose it in client code.
## Send an email
## 2. Send the email
```bash
curl -X POST {{API_URL}}/v1/send \
@@ -19,55 +17,12 @@ curl -X POST {{API_URL}}/v1/send \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Reset your password",
"body": "<p>Click here to reset: <a href=\"https://app.com/reset/abc123\">Reset Password</a></p>",
"subscribed": true
"subject": "Hello from Plunk",
"body": "<p>Your first email!</p>"
}'
```
Replace `sk_your_secret_key` and `[email protected]` with your values.
### With JavaScript
```javascript
await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: user.email,
subject: 'Reset your password',
body: `<p>Click here: <a href="${resetLink}">Reset Password</a></p>`,
subscribed: true
})
});
```
### With Python
```python
import requests
import os
requests.post('{{API_URL}}/v1/send',
headers={
'Authorization': f'Bearer {os.environ["PLUNK_SECRET_KEY"]}',
'Content-Type': 'application/json'
},
json={
'to': user.email,
'subject': 'Reset your password',
'body': f'<p>Click here: <a href="{reset_link}">Reset Password</a></p>',
'subscribed': True
}
)
```
## Use variables
Make emails dynamic with variables:
## 3. With variables
```javascript
await fetch('{{API_URL}}/v1/send', {
@@ -81,97 +36,21 @@ await fetch('{{API_URL}}/v1/send', {
subject: 'Reset your password',
body: '<p>Hi {{name}}, click here: <a href="{{resetLink}}">Reset</a></p>',
name: user.name,
resetLink: `https://app.com/reset/${token}`,
subscribed: true
resetLink: `https://app.com/reset/${token}`
})
});
```
Variables in the body (`{{name}}`, `{{resetLink}}`) are replaced with the values you provide.
## 4. Using a template
## Use templates
Instead of passing HTML every time, create reusable templates.
### Create a template
1. Go to **Templates** → **Create Template**
2. Name: `Password Reset`
3. Type: `Transactional`
4. Subject: `Reset your password`
5. Body:
```html
<p>Hi {{name}},</p>
<p>Click here to reset your password:</p>
<p><a href="{{resetLink}}">Reset Password</a></p>
<p>This link expires in 1 hour.</p>
```
### Send with template
1. Create template in **Templates → Create Template**
2. Send using template ID:
```javascript
await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: user.email,
template: 'password-reset', // Use template slug
name: user.name,
resetLink: `https://app.com/reset/${token}`,
subscribed: true
})
});
{
to: user.email,
template: 'clx123abc456',
name: user.name,
resetLink: resetUrl
}
```
No need to pass `subject` or `body` - they come from the template.
## Integration example
```javascript
// Express.js password reset endpoint
app.post('/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await db.users.findOne({ email });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const token = crypto.randomBytes(32).toString('hex');
await db.resetTokens.create({ userId: user.id, token, expiresAt: Date.now() + 3600000 });
// Send email via Plunk
await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: user.email,
subject: 'Reset your password',
body: `<p>Click here: <a href="https://app.com/reset/${token}">Reset Password</a></p>`,
subscribed: true
})
});
res.json({ success: true });
});
```
## Troubleshooting
**"Unauthorized" error** — Check your API key. Must be Secret Key (starts with `sk_`).
**Email not received** — Check spam folder. If using custom domain, verify it in Settings → Domains.
**Variables not replaced** — Ensure variable names match exactly (case-sensitive).
## Next steps
- [Build a workflow](/tutorials/welcome-series-workflow) for automated sequences
- [Set up custom domain](/guides/custom-domains) for better deliverability
- [Track events](/tutorials/event-tracking-integration) to trigger workflows
@@ -1,37 +0,0 @@
---
title: Tutorials
description: Step-by-step guides
icon: BookOpen
---
## Getting started
<Cards>
<Card icon="Mail" title="Send Your First Email" href="/tutorials/first-transactional-email">
Send a transactional email via API
</Card>
<Card icon="Workflow" title="Build a Welcome Series" href="/tutorials/welcome-series-workflow">
Create a 3-email automated workflow
</Card>
<Card icon="Send" title="Send a Campaign" href="/tutorials/newsletter-campaign">
Broadcast to your audience
</Card>
</Cards>
## Advanced
<Cards>
<Card icon="Users" title="Segment Targeting" href="/tutorials/segment-based-targeting">
Filter and target specific audiences
</Card>
<Card icon="Activity" title="Event Tracking" href="/tutorials/event-tracking-integration">
Track user behavior to trigger workflows
</Card>
<Card icon="ShoppingCart" title="Cart Abandonment" href="/tutorials/cart-abandonment-automation">
Recover abandoned carts automatically
</Card>
</Cards>
+1 -10
View File
@@ -1,13 +1,4 @@
{
"title": "Tutorials",
"pages": [
"index",
"first-transactional-email",
"welcome-series-workflow",
"newsletter-campaign",
"segment-based-targeting",
"cart-abandonment-automation",
"user-lifecycle-emails",
"event-tracking-integration"
]
"pages": ["first-transactional-email", "welcome-series-workflow", "newsletter-campaign", "segment-based-targeting"]
}
@@ -1,292 +1,39 @@
---
title: Send a Newsletter Campaign
title: Send a Newsletter
description: Broadcast an email to your audience
icon: Send
---
## Overview
## 1. Create a campaign
Campaigns let you send one-time broadcasts to your contacts from the dashboard. No code required.
1. Go to **Campaigns → Create Campaign**
2. Name: `March Newsletter`
## Create a campaign
## 2. Write your email
1. Go to **Campaigns** → **Create Campaign**
2. Fill in basic info:
- Name: `March Product Update`
- Description: `Monthly newsletter for March 2024`
- **From:** Your verified domain
- **Subject:** `March updates you'll love`
- Write your content
## Write your email
## 3. Select audience
### Email settings
Choose one:
- **All contacts:** Everyone subscribed
- **Segment:** A saved segment
- **Filtered:** Custom filters for this campaign
- **From**: Your email or verified domain
- **Subject**: `March product updates you'll love`
- **Preview text**: Shows in inbox preview
### Email content
Use the visual editor or write HTML:
```html
<h1>What's new in March</h1>
<p>Hi {{firstName ?? 'there'}},</p>
<p>We've been busy this month. Here's what's new:</p>
<h2>🚀 New Feature: Team Collaboration</h2>
<p>Invite team members and collaborate in real-time.</p>
<h2>⚡ Improved Performance</h2>
<p>Everything is now 2x faster.</p>
<h2>📊 New Analytics Dashboard</h2>
<p>Better insights into your data.</p>
<p><a href="https://yourapp.com/changelog">View Full Changelog</a></p>
<p>Thanks,<br>The Team</p>
<p><small>
You're receiving this because you subscribed to updates.
<a href="{{unsubscribeUrl}}">Unsubscribe</a>
</small></p>
```
### Use variables
Available variables:
- `{{firstName}}` - Contact's first name
- `{{email}}` - Contact's email
- `{{id}}` - Contact ID
- `{{unsubscribeUrl}}` - Auto-generated unsubscribe link
- Any custom contact data fields
**Fallback values:**
```html
<p>Hi {{firstName ?? 'there'}},</p>
<!-- Shows "Hi John," or "Hi there," if firstName is missing -->
```
## Select your audience
### All contacts
Sends to everyone subscribed in your account.
### Specific segment
1. Select **Segment** audience type
2. Choose a segment (e.g., "Premium Users")
3. Campaign sends to all contacts in that segment
### Filtered audience
Create custom filters for this campaign only:
**Example filters:**
- `plan` equals `premium`
- `lastLoginAt` within `30` days
- `country` equals `United States`
Combine with AND/OR logic.
## Preview and test
### Send test email
## 4. Test
1. Click **Send Test**
2. Enter your email address
3. Check your inbox
2. Check your inbox
3. Verify links and content
Verify:
- Subject line
- Email content
- Variables are replaced
- Links work
- Unsubscribe link present
## 5. Send
## Send or schedule
- **Send Now:** Starts immediately
- **Schedule:** Pick date and time
### Send now
## 6. Monitor
1. Click **Send Now**
2. Confirm
3. Campaign starts sending immediately
### Schedule for later
1. Click **Schedule**
2. Select date and time
3. Confirm
Campaign will send automatically at scheduled time.
## Monitor performance
### Real-time stats
Go to **Campaigns** → Your campaign to see:
- **Recipients**: Total contacts targeted
- **Sent**: How many emails sent
- **Delivered**: Successfully delivered
- **Opened**: Unique opens
- **Clicked**: Unique clicks
- **Bounced**: Failed deliveries
### Open and click rates
- **Open rate** = Opened / Delivered × 100%
- **Click rate** = Clicked / Delivered × 100%
**Good benchmarks:**
- Open rate: 15-25%
- Click rate: 2-5%
### View in activity
Go to **Activity** to see:
- Individual email opens
- Link clicks
- Delivery timeline
## Campaign best practices
**Subject lines:**
- Keep under 50 characters
- Avoid spam words (FREE, $$, URGENT)
- Personalize: `{{name}}, check out our new feature`
- A/B test different subject lines
**Send timing:**
- Tuesday-Thursday perform best
- 10am-2pm in recipient's timezone
- Avoid Mondays and Fridays
- Test what works for your audience
**Content:**
- One clear call-to-action
- Mobile-friendly design
- Keep under 500 words
- Use images sparingly (slow loading)
- Always include unsubscribe link
**Frequency:**
- Weekly: Maximum for engaged audiences
- Monthly: Safe default
- Quarterly: Minimum to stay top-of-mind
- Don't email too often - causes unsubscribes
## Advanced: Segment-based campaigns
### Example: Product announcement to paying customers
1. Create segment "Paying Customers":
- Filter: `plan` is not `free`
- Filter: `subscribed` equals `true`
2. Create campaign:
- Subject: `New premium features just for you`
- Audience: Segment "Paying Customers"
3. Send campaign - only goes to paying customers
### Example: Re-engagement campaign
1. Create segment "Inactive Users":
- Filter: `lastLoginAt` within `90` days is `false`
- Filter: `subscribed` equals `true`
2. Create campaign:
- Subject: `We miss you! Here's what's new`
- Content: Highlight recent updates
- Special offer: 20% off upgrade
3. Send or schedule
## Campaign vs workflow
**Use Campaign when:**
- One-time send (newsletter, announcement)
- Manual timing
- Same message to everyone
- No automation needed
**Use Workflow when:**
- Multi-email sequence needed
- Trigger on user action
- Delays between emails
- Personalized paths (if/then logic)
See [Campaigns vs Workflows](/concepts/campaigns-vs-workflows) for full comparison.
## Duplicate and reuse
### Duplicate a campaign
1. Go to campaign
2. Click **Duplicate**
3. Edit content
4. Send to same or different audience
Useful for monthly newsletters - duplicate last month's, update content.
### Save as template
If you'll reuse the design:
1. **Templates** → **Create Template**
2. Paste your campaign HTML
3. Save
Now you can create campaigns faster using the template.
## Cancel a campaign
### Before sending
1. Go to campaign (status: Draft or Scheduled)
2. Click **Delete**
### While sending
1. Go to campaign (status: Sending)
2. Click **Cancel**
3. Stops queuing new emails (already sent emails can't be recalled)
### After sending
Cannot cancel or recall. Sent emails are delivered.
## Troubleshooting
**Campaign not sending**
- Check campaign status (Draft needs to be sent)
- Verify audience has contacts
- Ensure contacts are subscribed
- Custom domain must be verified
**Low open rate**
- Improve subject line
- Check spam folder placement
- Verify sender email/domain
- Review send time
**High unsubscribe rate**
- Sending too frequently
- Content not relevant
- Set better expectations at signup
- Review targeting
**Emails going to spam**
- Verify custom domain
- Avoid spam trigger words
- Don't use all caps or excessive punctuation
- Warm up new sending domain
## Next steps
- [Build a workflow](/tutorials/welcome-series-workflow) for automated sequences
- [Create segments](/tutorials/segment-based-targeting) for better targeting
- [Set up custom domain](/guides/custom-domains) for better deliverability
View stats at **Campaigns → Your campaign**:
- Sent, delivered, opened, clicked, bounced
@@ -1,298 +1,33 @@
---
title: Segment-Based Targeting
description: Target specific audiences with filters
title: Target with Segments
description: Send to specific audiences
icon: Users
---
## Overview
## 1. Create a segment
Segments are dynamic groups of contacts based on filters. Use them to send targeted campaigns or trigger workflows when contacts enter/exit segments.
## Create a segment
1. Go to **Segments** → **Create Segment**
1. Go to **Segments → Create Segment**
2. Name: `Premium Users`
3. Description: `Users on premium or enterprise plan`
## Add filters
## 2. Add filters
### Simple filter
Filter by a single field:
Example: Premium subscribers
- Field: `plan`
- Operator: `equals`
- Value: `premium`
All contacts where `plan` equals `premium` are in this segment.
Add more conditions with AND/OR.
### Multiple filters (AND logic)
All conditions must be true:
- `plan` equals `premium`
- **AND** `subscribed` equals `true`
- **AND** `lastLoginAt` within `30` days
Only premium users who are subscribed AND logged in recently.
### Multiple filters (OR logic)
Any condition can be true:
- `plan` equals `premium`
- **OR** `plan` equals `enterprise`
Users on either premium or enterprise plan.
### Complex filters (AND + OR)
Combine both:
- `subscribed` equals `true`
- **AND** (`plan` equals `premium` **OR** `plan` equals `enterprise`)
Subscribed users on premium OR enterprise plans.
## Filter operators
### Equals
Exact match:
- `plan` equals `premium`
- `country` equals `United States`
### Not equals
Everything except:
- `plan` not equals `free`
- `status` not equals `cancelled`
### Contains
Partial text match:
- `email` contains `@gmail.com`
- `companyName` contains `Inc`
### Greater than / Less than
Numeric comparisons:
- `mrr` greater than `100`
- `age` less than `30`
- `loginCount` greater than `10`
### Exists / Does not exist
Field has any value:
- `phoneNumber` exists
- `referralCode` does not exist
### Within
Time-based (requires ISO date):
- `signupDate` within `7` days
- `lastLoginAt` within `30` days
- `trialExpiresAt` within `3` days
## Example segments
### Active users
Users who logged in recently:
- `lastLoginAt` within `7` days
- **AND** `subscribed` equals `true`
### High-value customers
Users spending over $100/month:
- `mrr` greater than `100`
- **AND** `plan` is not `free`
### Trial expiring soon
Users whose trial ends in 3 days:
- `trialExpiresAt` within `3` days
- **AND** `plan` equals `trial`
### Inactive users
Haven't logged in for 30+ days:
- `lastLoginAt` within `30` days is `false`
- **AND** `subscribed` equals `true`
- **AND** `status` equals `active`
**Note:** To check "NOT within", set the within filter and toggle the NOT operator.
### Power users
High engagement score:
- `loginCount` greater than `50`
- **AND** `featureUsageCount` greater than `100`
### Geographic targeting
Specific country or region:
- `country` equals `United States`
- **AND** `state` equals `California`
### Feature adopters
Used a specific feature:
- Custom event filter: `feature_used` triggered
- **AND** event data: `featureName` equals `advanced_analytics`
## Use segments in campaigns
### Target a segment
## 3. Use in campaigns
1. Create campaign
2. Audience: Select **Segment**
3. Choose `Premium Users`
## 4. Use in workflows
1. Create workflow
2. Trigger: **Segment entry**
3. Choose your segment
4. Campaign sends only to contacts in that segment
### Preview count
Before sending, see how many contacts match:
- Shows estimated recipient count
- Updates in real-time as you adjust filters
## Use segments in workflows
### Trigger on segment entry
Create workflow that runs when contacts enter a segment:
1. **Workflows** → **Create Workflow**
2. Trigger: Segment `trial_expiring_soon`
3. Trigger condition: Contact **enters** segment
4. Add email: Trial expiration reminder
When a contact enters "trial_expiring_soon" segment, workflow triggers.
### Trigger on segment exit
Run workflow when contacts leave a segment:
1. Trigger: Segment `active_users`
2. Trigger condition: Contact **exits** segment
3. Add workflow: Re-engagement sequence
When user becomes inactive (exits "active_users"), re-engagement starts.
## Track membership changes
Enable to trigger events when contacts enter/exit:
1. Edit segment
2. Toggle **Track Membership**
3. Save
Now when contacts move in/out of the segment:
- Event `segment_entry_[segment_id]` is tracked
- Event `segment_exit_[segment_id]` is tracked
- Can use these events in other workflows
**Performance note:** Only enable for segments you'll use for triggers. Adds processing overhead.
## Segment best practices
**Keep it simple**
- 3-5 filters per segment max
- Avoid deeply nested conditions
- Test with expected contacts
**Use consistent data**
- Store dates as ISO strings
- Use numbers for numeric values
- Consistent field naming
**Name clearly**
- ✅ `Premium Users - Active`
- ❌ `Segment 1`
**Monitor size**
- Check segment count regularly
- Too large = slow processing
- Too small = not enough data
## Update segments
Segments update automatically:
- When contact data changes
- When contacts are added/removed
- Typically updates within minutes
Force refresh:
1. Go to segment
2. Click **Refresh Count**
## Advanced: Multi-level targeting
### Premium users in specific region
- `plan` equals `premium`
- **AND** `country` equals `United States`
- **AND** `lastLoginAt` within `7` days
### Churn risk scoring
- `lastLoginAt` within `30` days is `false`
- **AND** `supportTickets` greater than `3`
- **AND** `npsScore` less than `7`
### Upsell targeting
- `plan` equals `free`
- **AND** `featureUsageCount` greater than `50`
- **AND** `teamSize` greater than `5`
Users on free plan who are power users with teams (good upsell candidates).
## Performance at scale
Segments work efficiently with millions of contacts:
- Indexed queries for fast filtering
- Cursor-based pagination
- Background count computation
**Tips for large segments:**
- Use specific filters (avoid `contains` on large text fields)
- Store commonly queried data as top-level fields
- Use numeric comparisons when possible (faster than text)
## Troubleshooting
**Segment count is 0 but should have contacts**
- Check filter logic (AND vs OR)
- Verify field names match exactly (case-sensitive)
- Ensure contacts have the required fields
- Try simpler filters to debug
**Segment not updating**
- Click **Refresh Count** to force update
- Check that contact data was actually updated
- Segments typically update within 5 minutes
**Workflow not triggering on segment entry**
- Workflow is enabled
- Track Membership is enabled on segment
- Trigger is set to segment entry (not exit)
**Too many contacts in segment**
- Filters too broad
- Add more specific conditions
- Use AND logic instead of OR
## Next steps
- [Send a campaign](/tutorials/newsletter-campaign) to a segment
- [Build a workflow](/tutorials/welcome-series-workflow) triggered by segment changes
- [Track events](/tutorials/event-tracking-integration) to update contact data
4. Enable **Track membership changes** on the segment
@@ -6,31 +6,26 @@ icon: Workflow
## What you'll build
An automated workflow that sends 3 emails when users sign up:
- Day 0: Welcome email (immediate)
- Day 1: Feature tour (24 hours later)
- Day 3: Help offer (48 hours after that)
- Day 1: Feature tour
- Day 3: Help offer
## Create the workflow
## 1. Create templates
1. **Workflows****Create Workflow**
Create 3 templates in **Templates → Create Template**:
- `Welcome` (use `{{name}}` for personalization)
- `Feature Tour`
- `Help Offer`
## 2. Create the workflow
1. Go to **Workflows → Create Workflow**
2. Name: `Welcome Series`
3. Trigger Event: `user_signed_up`
4. Allow Re-entry: `No` (users get this once)
5. Click **Create**
3. Trigger: Event `user_signed_up`
4. Allow Re-entry: No
## Build the flow
## 3. Build the flow
In the visual editor:
1. **Add Send Email** step → Select your welcome template
2. **Add Delay** step → 1 day
3. **Add Send Email** step → Select your feature tour template
4. **Add Delay** step → 2 days
5. **Add Send Email** step → Select your help offer template
6. **Add Exit** step
Your flow:
```
[Trigger: user_signed_up]
@@ -47,110 +42,27 @@ Your flow:
[Exit]
```
7. **Enable** the workflow (toggle switch)
## 4. Enable the workflow
## Track the signup event
Toggle the workflow ON.
Add event tracking to your app when users sign up.
## 5. Track signups
### JavaScript
Add to your app:
```javascript
// After successful signup
await fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY}`,
'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'user_signed_up',
email: user.email,
data: {
name: user.name,
dashboardUrl: 'https://app.yourapp.com/dashboard',
docsUrl: 'https://docs.yourapp.com'
}
data: { name: user.name }
})
});
```
### Python
```python
import requests
requests.post('{{API_URL}}/v1/track',
headers={
'Authorization': f'Bearer {os.environ["PLUNK_PUBLIC_KEY"]}',
'Content-Type': 'application/json'
},
json={
'event': 'user_signed_up',
'email': user.email,
'data': {
'name': user.name,
'dashboardUrl': 'https://app.yourapp.com/dashboard',
'docsUrl': 'https://docs.yourapp.com'
}
}
)
```
**Important:** Use your **Public Key** (starts with `pk_`) for event tracking.
## Test the workflow
### Manual test
1. Go to **Workflows** → Your workflow → **Executions** tab
2. Click **Create Execution**
3. Select a test contact
4. Add test data:
```json
{
"name": "Test User",
"dashboardUrl": "https://app.yourapp.com",
"docsUrl": "https://docs.yourapp.com"
}
```
5. **Start Execution**
Check your email - you should receive the welcome email immediately. The workflow will pause at the delay steps.
### Faster testing
For testing, temporarily change delays to 5 minutes instead of days. Test the flow, then change back.
## Monitor performance
1. **Workflows** → Your workflow
2. Check **Executions** to see who's in the workflow
3. Go to **Activity** to see email opens/clicks
4. Track open rates for each email
Typical good rates:
- Email 1: 60-80% open rate
- Email 2: 40-60% open rate
- Email 3: 30-50% open rate
## Troubleshooting
**Workflow not triggering** — Check:
- Workflow is enabled (toggle ON)
- Event name matches exactly: `user_signed_up`
- Using Public Key for tracking
- Contact exists and is subscribed
**Email not sending** — Check:
- Template exists
- Contact is subscribed
- Variables in event data match template variables
**Duplicate emails** — Ensure `Allow Re-entry` is `No`.
## Next steps
- [Add conditional logic](/automation-patterns/conditional-branching) for different user types
- [Track more events](/tutorials/event-tracking-integration) to trigger workflows
- [Build cart abandonment](/tutorials/cart-abandonment-automation) workflow
Use your **Public Key** (starts with `pk_`).