Update wiki
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,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_`).
|
||||
|
||||
Reference in New Issue
Block a user