Update wiki

This commit is contained in:
Dries Augustyns
2025-12-07 13:36:31 +01:00
parent 0003e44db8
commit 683356c17c
48 changed files with 633 additions and 8620 deletions
-181
View File
@@ -1,181 +0,0 @@
---
title: Analytics
description: Track and analyze email performance
---
## What you can track
Plunk tracks comprehensive email metrics across all campaigns, workflows, and transactional emails:
**Delivery metrics:**
- Sent, delivered, bounced
**Engagement metrics:**
- Opens, clicks, unsubscribes
**Quality metrics:**
- Open rate, click rate, bounce rate
## Campaign analytics
View detailed performance for specific campaigns:
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id/stats \
-H "Authorization: Bearer sk_your_secret_key"
```
Response:
```json
{
"totalRecipients": 5000,
"sentCount": 5000,
"deliveredCount": 4980,
"openedCount": 2100,
"clickedCount": 450,
"bouncedCount": 20,
"unsubscribedCount": 8,
"openRate": 0.422,
"clickRate": 0.090,
"bounceRate": 0.004
}
```
**Metrics update in real-time** as recipients engage.
View detailed analytics in your dashboard to:
- Monitor daily performance trends
- Identify engagement patterns
- Spot deliverability issues
- Compare time periods
## Understanding metrics
### Open rate
**Formula:** (Unique opens / Delivered) × 100
**Industry benchmarks:**
- B2B: 15-25%
- B2C: 20-30%
- E-commerce: 15-20%
**What affects it:**
- Subject line quality
- Sender reputation
- Send timing
- Audience engagement
### Click rate
**Formula:** (Unique clicks / Delivered) × 100
**Industry benchmarks:**
- B2B: 2-5%
- B2C: 3-7%
- E-commerce: 2-4%
**What affects it:**
- Content relevance
- Call-to-action clarity
- Email design
- Link placement
### Bounce rate
**Formula:** (Bounces / Sent) × 100
**Target:** < 2%
**Types:**
- **Hard bounce** — Invalid email, never retry
- **Soft bounce** — Temporary issue, retry later
**High bounce rate causes:**
- Outdated email list
- Invalid addresses
- Domain issues
### Unsubscribe rate
**Formula:** (Unsubscribes / Delivered) × 100
**Target:** < 0.5%
**High unsubscribe causes:**
- Too frequent emails
- Irrelevant content
- Misleading subject lines
- No segmentation
## Improving performance
### Boost open rates
**Write compelling subject lines:**
- Keep under 50 characters
- Create urgency or curiosity
- Personalize with `{{firstName}}`
- Test different approaches
**Optimize send timing:**
- Test different days/times
- Segment by timezone
- Avoid weekends (for B2B)
- Consider user behavior
**Build sender reputation:**
- Use custom domain
- Maintain consistent volume
- Keep bounce rate low
- Avoid spam triggers
### Increase click rates
**Clear call-to-action:**
- One primary CTA
- Use buttons, not just links
- Action-oriented text ("Get Started" not "Click Here")
- Make it prominent
**Relevant content:**
- Segment audience
- Personalize messaging
- Match subject line promise
- Keep it focused
**Mobile-responsive:**
- Test on mobile devices
- Use large tap targets
- Single column layout
- Readable font sizes
### Reduce bounce rate
**Clean your list:**
```javascript
// Remove hard bounces immediately
const bounced = await fetch('{{API_URL}}/events?name=email.bounced&limit=1000');
for (const event of bounced.data.events) {
if (event.data.bounceType === 'hard') {
await fetch(`{{API_URL}}/contacts/${event.contactId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${apiKey}` }
});
}
}
```
**Verify emails:**
- Use email verification service
- Double opt-in for signups
- Remove invalid formats
- Re-engage inactive users before removing
## Next Steps
- [Set up custom domains](/guides/custom-domains) for better deliverability
- [Build segments](/guides/segments) for targeted campaigns
- [Scale your email](/guides/scaling-email) with best practices
@@ -1,115 +0,0 @@
---
title: Billing & Usage Limits
description: Control monthly email usage and costs
---
## What are billing limits
Billing limits let you cap monthly email sends by category to control costs. Set maximum emails per month for transactional, campaigns, and workflows separately.
## Email categories
Plunk tracks usage across three categories:
**Transactional** — Emails sent via `/v1/send` API
- Order confirmations, password resets
- Account notifications
- Any direct API sends
**Campaigns** — One-time broadcast emails
- Newsletters, announcements
- Promotional campaigns
- Marketing blasts
**Workflows** — Automated sequence emails
- Onboarding flows
- Drip campaigns
- Behavior-triggered emails
**Note:** The category is determined by how you send (API, campaign, or workflow), not the template type.
## How limits work
### Monthly reset
Usage resets on the 1st of each month (UTC). Starts fresh at 0.
### Enforcement
When sending emails:
- **Under 80%** — Sends normally
- **80-99%** — Sends with warning flag
- **100%+** — Blocked with 429 error
### Unlimited
Set limit to unlimited for any category (default for all categories).
## Manage limits
You can view and update your billing limits in the dashboard:
1. Go to **Settings** → **Billing**
2. View current usage for each category
3. Update limits as needed (requires Admin or Owner role)
## When limit is reached
### API error
```json
{
"code": 429,
"error": "Too Many Requests",
"message": "Monthly limit exceeded for campaigns (50000/50000). Resets on 2025-12-01."
}
```
### Handle in code
```javascript
try {
const response = await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
if (response.status === 429) {
const error = await response.json();
// Option 1: Notify admin
await notifyAdmin(`Limit reached: ${error.message}`);
// Option 2: Increase limit
await increaseBillingLimit('transactional', 200000);
// Option 3: Queue for next month
await queueForNextMonth(emailData);
}
} catch (error) {
console.error('Send failed:', error);
}
```
## Best practices
**Monitor usage regularly** — Check dashboard weekly to avoid surprises.
**Set alerts** — Configure notifications at 80% usage.
**Plan for growth** — Increase limits before campaigns, not during.
**Use categories wisely** — Critical transactional emails might need higher limits.
**Review monthly** — Adjust limits based on actual usage patterns.
## Next Steps
- [Track usage analytics](/guides/analytics)
- [Scale email delivery](/guides/scaling-email)
- [Troubleshooting limits](/guides/troubleshooting)
-247
View File
@@ -1,247 +0,0 @@
---
title: Campaigns
description: Send one-time email broadcasts
---
## What are campaigns
Campaigns are one-time email broadcasts sent to your audience. Use them for:
- Product announcements
- Newsletter distributions
- Seasonal promotions
- Feature launches
Unlike workflows (automated sequences), campaigns send once to a snapshot of your audience.
## Creating campaigns
### In the dashboard
1. Go to **Campaigns**
2. Click **Create Campaign**
3. Name your campaign
4. Choose your audience
5. Select a template or compose inline
6. Preview and test
7. Send or schedule
### Via API
```bash
curl -X POST {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "March Newsletter",
"subject": "New features this month",
"body": "<h1>What'\''s new</h1><p>Check out our latest updates...</p>",
"from": "[email protected]",
"fromName": "Acme Inc",
"audienceType": "ALL",
"status": "DRAFT"
}'
```
## Choosing your audience
### All contacts
Sends to everyone in your project:
```json
{
"audienceType": "ALL"
}
```
### Specific segment
Sends to contacts in a saved segment:
```json
{
"audienceType": "SEGMENT",
"segmentId": "premium-users"
}
```
### Custom filters
Sends to contacts matching conditions:
```json
{
"audienceType": "FILTERED",
"audienceFilter": {
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "pro" },
{ "field": "data.lastLoginAt", "operator": "greaterThan", "value": "2024-01-01" }
]
}
}
```
## Subscription handling
Campaign delivery respects your **template type**:
**Marketing templates** (default)
- Only sends to subscribed contacts
- Unsubscribed contacts are skipped automatically
- Includes unsubscribe link
**Transactional templates**
- Sends to all contacts, even if unsubscribed
- Use only for critical business emails
- No unsubscribe link
Choose template type based on content, not audience size.
## Campaign states
**DRAFT** — Being created, can edit freely
**SCHEDULED** — Queued for future send, can cancel
**SENDING** — Currently delivering, cannot stop
**SENT** — Completed successfully
**CANCELLED** — Scheduled campaign was cancelled
## Sending campaigns
### Send immediately
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/send \
-H "Authorization: Bearer sk_your_secret_key"
```
Status changes to SENDING, emails deliver within minutes.
### Schedule for later
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"scheduledAt": "2024-03-20T10:00:00Z"
}'
```
Status changes to SCHEDULED. Campaign sends at the specified time.
### Cancel scheduled campaign
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/cancel \
-H "Authorization: Bearer sk_your_secret_key"
```
Only works if status is SCHEDULED.
## Testing campaigns
Send a test email before broadcasting:
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/test \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]"
}'
```
This sends to the test email without affecting campaign status.
## Campaign analytics
View campaign performance:
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id/stats \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns:
```json
{
"totalRecipients": 5000,
"sentCount": 5000,
"deliveredCount": 4980,
"openedCount": 2100,
"clickedCount": 450,
"bouncedCount": 20,
"openRate": 0.42,
"clickRate": 0.09
}
```
Metrics update in real-time as recipients engage.
## Managing campaigns
### List campaigns
```bash
curl -X GET {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key"
```
Filter by status:
```bash
curl -X GET "{{API_URL}}/campaigns?status=SENT" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Get campaign details
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key"
```
### Update draft campaign
```bash
curl -X PATCH {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated name",
"subject": "New subject line"
}'
```
Only works for DRAFT campaigns.
### Duplicate campaign
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/duplicate \
-H "Authorization: Bearer sk_your_secret_key"
```
Creates a new draft campaign with the same content.
### Delete campaign
```bash
curl -X DELETE {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Can only delete DRAFT or CANCELLED campaigns.
## Next Steps
- [Build automated workflows](/guides/workflows)
- [Create dynamic segments](/guides/segments)
- [Track campaign analytics](/guides/analytics)
- [Set up custom domains](/guides/custom-domains)
@@ -1,205 +0,0 @@
---
title: Working with Contact Data
description: Use persistent and temporary contact data for personalized emails
---
## Contact Data Basics
Every contact has:
- **email**: Required, unique identifier
- **subscribed**: Boolean for subscription status
- **data**: JSON object for custom fields
```javascript
{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Jane",
"plan": "professional",
"signupDate": "2024-03-15"
}
}
```
## Persistent vs. Temporary Data
When sending emails, you can pass data that either saves to the contact or is used only for that email.
### Persistent Data (Default)
```javascript
fetch('/v1/send', {
method: 'POST',
body: JSON.stringify({
to: '[email protected]',
subject: 'Welcome',
body: '<p>Hi {'{{firstName}}'}!</p>',
data: {
firstName: 'John' // Saved to contact.data.firstName
}
})
});
```
### Temporary Data (Non-Persistent)
```javascript
fetch('/v1/send', {
method: 'POST',
body: JSON.stringify({
to: '[email protected]',
subject: 'Password Reset',
body: '<p>Your code: {'{{resetCode}}'}</p>',
data: {
resetCode: {
value: 'ABC123',
persistent: false // NOT saved to contact
}
}
})
});
```
**Use temporary data for**:
- Password reset codes
- One-time verification tokens
- Session-specific information
- Temporary discount codes
## Template Variables
Use `{'{{fieldName}}'}` to insert contact data into emails.
### Basic Variables
```html
<p>Hello {'{{firstName}}'}!</p>
<p>Your plan: {'{{plan}}'}</p>
```
### Fallback Values
Provide defaults when data might be missing:
```html
<p>Hello {'{{firstName ?? \'there\'}}'}!</p>
<p>Plan: {'{{plan ?? \'Free\'}}'}</p>
```
### Reserved Fields
Two fields are always available:
```html
<p>Contact ID: {'{{plunk_id}}'}</p>
<p>Email: {'{{plunk_email}}'}</p>
```
## Data Merging
Updates merge with existing data:
```javascript
// Contact has: { firstName: 'John', plan: 'free' }
// Update with:
{ data: { lastName: 'Doe', plan: 'pro' } }
// Result: { firstName: 'John', lastName: 'Doe', plan: 'pro' }
```
## Best Practices
### Keep Data Flat
```javascript
// Good
{
"firstName": "Jane",
"plan": "pro",
"mrr": 99
}
// Avoid nesting (harder to use in templates)
{
"user": {
"profile": {
"name": "Jane"
}
}
}
```
### Use Consistent Naming
Pick a style and stick to it:
```javascript
// camelCase (recommended)
{ "firstName": "Jane", "lastLogin": "2024-03-15" }
// or snake_case
{ "first_name": "Jane", "last_login": "2024-03-15" }
```
### Store Dates as ISO Strings
```javascript
// Good (filterable, sortable)
{ "signupDate": "2024-03-15T10:30:00Z" }
// Avoid
{ "signupDate": "March 15, 2024" }
```
## Discovering Available Fields
Get all fields across your contacts:
```bash
curl -X GET "{{API_URL}}/contacts/fields" \
-H "Authorization: Bearer sk_your_secret_key"
```
Response:
```json
{
"success": true,
"data": {
"fields": [
"email",
"subscribed",
"firstName",
"plan",
"signupDate"
],
"count": 5
}
}
```
Get unique values for a field:
```bash
curl -X GET "{{API_URL}}/contacts/fields/data.plan/values" \
-H "Authorization: Bearer sk_your_secret_key"
```
Response:
```json
{
"success": true,
"data": {
"field": "data.plan",
"values": ["free", "professional", "enterprise"],
"count": 3
}
}
```
## Next Steps
- [Send personalized emails](/guides/templates)
- [Create segments](/guides/segments) based on contact data
- [Track events](/guides/events) to enrich contact profiles
-436
View File
@@ -1,436 +0,0 @@
---
title: Contacts
description: Manage your audience at scale
---
## What are contacts
Contacts are people in your audience. Each contact has an email address, subscription status, and custom data fields you define. Use contacts to personalize emails, build segments, and track engagement.
## Creating contacts
### Add a single contact
```bash
curl -X POST {{API_URL}}/contacts \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Sarah",
"plan": "pro",
"signupDate": "2024-03-15"
}
}'
```
### Automatic upsert
If the email already exists, the contact is updated instead of creating a duplicate:
```javascript
// First call - creates contact
POST /contacts { email: '[email protected]', data: { plan: 'free' } }
// Second call - updates same contact
POST /contacts { email: '[email protected]', data: { plan: 'pro' } }
// Result: One contact with plan: 'pro'
```
This is useful when syncing user data from your application.
## Contact data fields
Store custom data in the `data` field. Use it for:
- User profile (name, company, role)
- Subscription info (plan, MRR, renewal date)
- Behavior tracking (last login, feature usage)
- Preferences (newsletter, notifications)
**Example:**
```json
{
"email": "[email protected]",
"subscribed": true,
"data": {
"firstName": "Sarah",
"lastName": "Chen",
"company": "Acme Inc",
"plan": "premium",
"mrr": 99,
"lastLoginAt": "2024-03-15T10:30:00Z",
"preferences": {
"newsletter": true,
"productUpdates": false
}
}
}
```
### Best practices
**Use consistent naming** — Pick camelCase or snake_case and stick with it.
**Store dates as ISO strings** — `"2024-03-15T10:30:00Z"` enables date range filtering in segments.
**Keep it relatively flat** — Nested objects work, but flat structures are easier to query in segments.
**Use numbers for numeric data** — Store `99` not `"99"` to enable greater than/less than comparisons.
## Using contact data in emails
### Template variables
Access contact data in email templates using `{{variableName}}` syntax:
```html
<h1>Hello {{firstName}}!</h1>
<p>Your {{plan}} plan renews on {{renewalDate}}.</p>
<p>Total: ${{mrr}}</p>
```
When sending, contact data automatically populates variables:
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Renewal reminder",
"body": "<p>Hi {{firstName}}, your {{plan}} plan renews soon.</p>"
}'
```
The `firstName` and `plan` values come from the contact's `data` field.
### Fallback values
Provide defaults when data might be missing:
```html
<p>Hello {{firstName ?? 'there'}}!</p>
<p>Plan: {{plan ?? 'Free'}}</p>
```
If `firstName` is not set, displays "Hello there!" instead of blank.
### Passing additional data
Send extra data for a specific email without saving it to the contact:
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your verification code",
"body": "<p>Your code: {{verificationCode}}</p>",
"data": {
"verificationCode": "ABC123"
}
}'
```
The `verificationCode` is used in the email but not saved to the contact. This is useful for:
- One-time codes (password reset, verification)
- Session-specific data
- Temporary discount codes
- Order-specific details
### Reserved variables
These are always available in templates:
- `{{email}}` — Contact email address
- `{{id}}` — Contact ID
Example:
```html
<p>Your account: {{email}}</p>
<p><a href="https://app.example.com/contacts/{{id}}">Manage preferences</a></p>
```
## Listing contacts
### Get all contacts
```bash
curl -X GET "{{API_URL}}/contacts?limit=50" \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns:
```json
{
"success": true,
"data": {
"items": [...],
"nextCursor": "abc123",
"hasMore": true,
"total": 10000
}
}
```
### Pagination
For large lists, use cursor-based pagination:
```javascript
let allContacts = [];
let cursor = null;
do {
const params = new URLSearchParams({ limit: 100 });
if (cursor) params.append('cursor', cursor);
const response = await fetch(`{{API_URL}}/contacts?${params}`, {
headers: { 'Authorization': `Bearer ${PLUNK_SECRET_KEY}` }
});
const { data } = await response.json();
allContacts.push(...data.items);
cursor = data.nextCursor;
} while (cursor);
```
### Filter by subscription
```bash
# Only subscribed
curl -X GET "{{API_URL}}/contacts?subscribed=true" \
-H "Authorization: Bearer sk_your_secret_key"
# Only unsubscribed
curl -X GET "{{API_URL}}/contacts?subscribed=false" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Search by email
```bash
curl -X GET "{{API_URL}}/contacts?search=sarah" \
-H "Authorization: Bearer sk_your_secret_key"
```
Searches for emails containing "sarah".
## Getting a contact
### By ID
```bash
curl -X GET {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
## Updating contacts
### Update contact data
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"subscribed": true,
"data": {
"plan": "premium",
"mrr": 99
}
}'
```
### Data merging
Updates merge with existing data:
```javascript
// Current contact data
{ "firstName": "Sarah", "company": "Acme" }
// Update with
{ "lastName": "Chen", "plan": "pro" }
// Result
{ "firstName": "Sarah", "company": "Acme", "lastName": "Chen", "plan": "pro" }
```
To remove a field, set it to `null`.
### Change subscription status
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"subscribed": false}'
```
**Marketing templates** only send to subscribed contacts. **Transactional templates** send to everyone, regardless of subscription status.
## Deleting contacts
```bash
curl -X DELETE {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
**Warning:** Deletion is permanent. Consider unsubscribing instead of deleting.
## Bulk operations
### Import from CSV
Prepare a CSV file:
```csv
email,firstName,lastName,plan
[email protected],Sarah,Chen,pro
[email protected],John,Doe,free
```
Upload via dashboard:
1. Go to **Contacts**
2. Click **Import CSV**
3. Upload file
4. Map columns
5. Set default subscription status
6. Import
The import runs in the background. You'll receive a summary when complete.
## Available fields
### Get all custom fields
See what data fields your contacts have:
```bash
curl -X GET {{API_URL}}/contacts/fields \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns unique field names across all contacts:
```json
{
"fields": [
"firstName",
"lastName",
"company",
"plan",
"mrr",
"signupDate"
]
}
```
### Get field values
See all unique values for a specific field:
```bash
curl -X GET {{API_URL}}/contacts/fields/plan/values \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns:
```json
{
"values": ["free", "pro", "premium", "enterprise"]
}
```
Useful for building segment filters and understanding your data.
## Syncing with your app
Keep contacts in sync with your user database:
```javascript
// When user signs up
async function onUserSignup(user) {
await fetch('{{API_URL}}/contacts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: user.email,
subscribed: true,
data: {
firstName: user.firstName,
lastName: user.lastName,
signupDate: new Date().toISOString()
}
})
});
}
// When user updates profile
async function onUserUpdate(user) {
await fetch(`{{API_URL}}/contacts/${user.contactId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
firstName: user.firstName,
lastName: user.lastName,
company: user.company
}
})
});
}
// When user subscribes to plan
async function onSubscriptionChange(user, plan, mrr) {
await fetch(`{{API_URL}}/contacts/${user.contactId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
plan,
mrr,
subscriptionDate: new Date().toISOString()
}
})
});
}
```
## Best practices
**Sync critical data only** — Don't sync every field. Focus on data used in segments, workflows, and personalization.
**Use webhooks for real-time sync** — Update contacts immediately when user data changes.
**Track subscription separately** — Use the `subscribed` field for email preferences, not app subscription status.
**Clean your list regularly** — Remove or unsubscribe bounced and inactive contacts.
**Respect opt-outs** — When users unsubscribe, update immediately. Don't re-subscribe them automatically.
**Test with real emails** — Use your own email addresses to test the contact experience.
## Next Steps
- [Build segments](/guides/segments) to group contacts
- [Send campaigns](/guides/campaigns) to your contacts
- [Track events](/guides/events) to update contact data automatically
@@ -1,209 +0,0 @@
---
title: Custom Domains
description: Send emails from your own domain
---
## Why use custom domains
Sending from your own domain (e.g., `[email protected]`) instead of a shared domain:
- **Better deliverability** — Email providers trust emails from verified domains
- **Brand consistency** — Recipients see your brand, not Plunk
- **Higher trust** — Your domain builds its own sender reputation
- **Professional appearance** — Custom addresses look more legitimate
## Requirements
- **Domain ownership** — You own or control the domain
- **DNS access** — Ability to add DNS records
- **Verification** — Add DKIM records to prove ownership
## Adding a domain
1. Go to **Settings > Domains**
2. Click **Add Domain**
3. Enter your domain (e.g., `yourdomain.com`)
4. Copy the provided DNS records
## DNS configuration
After adding your domain, you'll receive 3 DKIM tokens. Add them as CNAME records to your DNS.
### Common DNS providers
#### Cloudflare
1. Log into Cloudflare
2. Select your domain
3. Go to **DNS > Records**
4. Click **Add record**
5. Select **CNAME** type
6. Paste name and value from Plunk
7. Click **Save**
8. Repeat for all 3 records
#### Namecheap
1. Log into Namecheap
2. Go to **Domain List**
3. Click **Manage** next to your domain
4. Select **Advanced DNS**
5. Click **Add New Record**
6. Choose **CNAME Record**
7. Enter host and value
8. Repeat for all 3 records
#### GoDaddy
1. Log into GoDaddy
2. Go to **My Products**
3. Click **DNS** next to your domain
4. Click **Add** under Records
5. Select **CNAME** type
6. Enter name and value
7. Repeat for all 3 records
#### Route 53 (AWS)
1. Open Route 53 console
2. Select your hosted zone
3. Click **Create record**
4. Enter record name
5. Select **CNAME** type
6. Paste value
7. Create record
8. Repeat for all 3 records
## Verification
### Automatic verification
Plunk checks DNS records every 5 minutes automatically. Verification typically completes within 10-30 minutes after adding DNS records.
**Note:** DNS propagation can take up to 48 hours, though it's usually much faster.
Check verification status in your dashboard at **Settings > Domains**.
## Using your domain
Once verified, specify your domain in the `from` field:
### In transactional emails
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"from": "[email protected]",
"fromName": "Your Company",
"subject": "Order confirmed",
"body": "<p>Your order has been confirmed.</p>"
}'
```
### In templates
Set default from address in template:
```bash
curl -X POST {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Order Confirmation",
"subject": "Order #{{orderNumber}} confirmed",
"body": "...",
"from": "[email protected]",
"fromName": "Your Company",
"type": "TRANSACTIONAL"
}'
```
### In campaigns
Campaigns use the template's from address, or you can override:
```bash
curl -X POST {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Newsletter",
"templateId": "template_id",
"from": "[email protected]",
"audienceType": "ALL"
}'
```
## Managing domains
### Remove domain
Go to **Settings > Domains**, select the domain, and click **Remove**.
**Warning:** Emails using this domain will fail to send after removal.
## Troubleshooting
### Domain won't verify
**1. Check DNS records are correct**
Verify records are added exactly as provided
**2. Wait for propagation**
DNS changes can take up to 48 hours to propagate globally. Check periodically.
**3. Remove conflicting records**
If you previously used another email service, remove their DKIM records to avoid conflicts.
**4. Check for typos**
Ensure record names and values match exactly. Common issues:
- Extra spaces in values
- Missing dots in record names
- Wrong subdomain
### Emails not sending from domain
**1. Verify domain is verified** - Check status in **Settings > Domains**.
**2. Use correct email format**
Must be `[email protected]`, not `@subdomain.yourdomain.com`.
**3. Check sender reputation**
New domains have no reputation. Start with small volumes and gradually increase.
### Emails going to spam
After adding custom domain:
**1. Warm up your domain** — See [Scaling Email](/guides/scaling-email)
**2. Monitor deliverability** — Check [Analytics](/guides/analytics) for bounce/complaint rates
**3. Clean your list** — Remove bounced addresses immediately
## Best practices
**Start small** — Send to engaged users first to build reputation.
**Monitor metrics** — Watch bounce and complaint rates closely.
**Use subdomains** — Consider `mail.yourdomain.com` for email to separate from main domain reputation.
**Keep DNS records** — Don't remove DKIM records even if verification is complete.
**Test thoroughly** — Send test emails to various providers (Gmail, Outlook, Yahoo).
## Next Steps
- [Scale email delivery](/guides/scaling-email) with your custom domain
- [Monitor analytics](/guides/analytics) for domain performance
- [Troubleshoot issues](/guides/troubleshooting) if problems arise
@@ -1,385 +0,0 @@
---
title: Email Attachments
description: Send emails with file attachments via API or SMTP
---
## Overview
Plunk supports sending emails with file attachments through both the HTTP API and SMTP relay. You can attach documents, images, PDFs, and other files to your transactional emails.
## Limits
- **Maximum attachments**: 10 per email
- **Total size limit**: 10MB (combined size of all attachments)
- **Supported formats**: Any file type (PDF, images, documents, etc.)
## API Usage
### Basic Example
Send an email with a single PDF attachment:
```bash
curl -X POST https://api.useplunk.com/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your Invoice",
"body": "<h1>Invoice Attached</h1><p>Please find your invoice attached.</p>",
"attachments": [
{
"filename": "invoice.pdf",
"content": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL...",
"contentType": "application/pdf"
}
]
}'
```
### Multiple Attachments
Send multiple files in a single email:
```json
{
"to": "[email protected]",
"subject": "Monthly Reports",
"body": "<p>Please find this month's reports attached.</p>",
"attachments": [
{
"filename": "sales-report.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
},
{
"filename": "logo.png",
"content": "iVBORw0KGgo...",
"contentType": "image/png"
},
{
"filename": "data.csv",
"content": "TmFtZSxFbWFp...",
"contentType": "text/csv"
}
]
}
```
## Attachment Format
Each attachment object requires three fields:
### filename
- **Type**: String
- **Max length**: 255 characters
- **Description**: The name of the file as it will appear to recipients
- **Example**: `"invoice-2024.pdf"`
### content
- **Type**: String (Base64 encoded)
- **Description**: The file content encoded in Base64 format
- **Example**: `"JVBERi0xLjQKJeLjz9MK..."`
### contentType
- **Type**: String (MIME type)
- **Max length**: 255 characters
- **Description**: The MIME type of the file
- **Examples**:
- PDF: `application/pdf`
- PNG image: `image/png`
- JPEG image: `image/jpeg`
- Word document: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
- Excel: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- CSV: `text/csv`
- ZIP: `application/zip`
## Base64 Encoding
Attachments must be Base64 encoded before sending. Here are examples in different languages:
### JavaScript/Node.js
```javascript
import fs from 'fs';
// Read file and convert to Base64
const fileBuffer = fs.readFileSync('invoice.pdf');
const base64Content = fileBuffer.toString('base64');
// Send email with attachment
await fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer sk_your_secret_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: '[email protected]',
subject: 'Invoice',
body: '<p>Your invoice is attached.</p>',
attachments: [{
filename: 'invoice.pdf',
content: base64Content,
contentType: 'application/pdf'
}]
})
});
```
### Python
```python
import base64
import requests
# Read and encode file
with open('invoice.pdf', 'rb') as file:
base64_content = base64.b64encode(file.read()).decode('utf-8')
# Send email
response = requests.post(
'https://api.useplunk.com/v1/send',
headers={
'Authorization': 'Bearer sk_your_secret_key',
'Content-Type': 'application/json'
},
json={
'to': '[email protected]',
'subject': 'Invoice',
'body': '<p>Your invoice is attached.</p>',
'attachments': [{
'filename': 'invoice.pdf',
'content': base64_content,
'contentType': 'application/pdf'
}]
}
)
```
### PHP
```php
<?php
// Read and encode file
$fileContent = file_get_contents('invoice.pdf');
$base64Content = base64_encode($fileContent);
// Send email
$ch = curl_init('https://api.useplunk.com/v1/send');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer sk_your_secret_key',
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'to' => '[email protected]',
'subject' => 'Invoice',
'body' => '<p>Your invoice is attached.</p>',
'attachments' => [[
'filename' => 'invoice.pdf',
'content' => $base64Content,
'contentType' => 'application/pdf'
]]
]));
$response = curl_exec($ch);
curl_close($ch);
```
## SMTP Usage
When using the SMTP relay, attachments are automatically parsed from the MIME multipart message and forwarded to the API.
### Standard Email Clients
Configure your email client with Plunk SMTP settings and attach files normally:
- **SMTP Server**: `smtp.yourdomain.com`
- **Port**: 587 (STARTTLS) or 465 (SSL/TLS)
- **Username**: `plunk`
- **Password**: Your Plunk API secret key
Attachments added through your email client will be automatically included.
### Programmatic SMTP
Using nodemailer (Node.js):
```javascript
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: 'smtp.yourdomain.com',
port: 587,
secure: false, // Use STARTTLS
auth: {
user: 'plunk',
pass: 'sk_your_secret_key'
}
});
await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Invoice',
html: '<p>Your invoice is attached.</p>',
attachments: [
{
filename: 'invoice.pdf',
path: '/path/to/invoice.pdf'
}
]
});
```
## Common MIME Types
| File Type | MIME Type |
|-----------|-----------|
| PDF | `application/pdf` |
| PNG | `image/png` |
| JPEG | `image/jpeg` |
| GIF | `image/gif` |
| Word (.docx) | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
| Word (.doc) | `application/msword` |
| Excel (.xlsx) | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
| Excel (.xls) | `application/vnd.ms-excel` |
| CSV | `text/csv` |
| Plain text | `text/plain` |
| HTML | `text/html` |
| ZIP | `application/zip` |
| JSON | `application/json` |
| XML | `application/xml` |
## Best Practices
### Size Optimization
- **Compress files**: Use ZIP compression for large files
- **Optimize images**: Reduce image dimensions and quality before attaching
- **Use links for large files**: For files >5MB, consider uploading to cloud storage and sending a download link instead
### Security
- **Scan for malware**: Ensure files are virus-free before sending
- **Avoid executable files**: Don't attach .exe, .bat, .sh files (often blocked by email providers)
- **Use password protection**: For sensitive documents, password-protect files and send password separately
### Deliverability
- **Mind the size**: Smaller emails have better deliverability
- **Avoid spam triggers**: Don't attach executable files or suspicious content
- **Test first**: Send test emails to verify attachments arrive correctly
## Troubleshooting
### Attachment Too Large
**Error**: `Total attachment size must not exceed 10MB`
**Solution**:
- Reduce file sizes
- Compress files
- Split into multiple emails
- Use cloud storage links instead
### Invalid Base64
**Error**: `Invalid attachment content - must be base64 encoded`
**Solution**:
- Ensure file is properly base64 encoded
- Don't include line breaks in base64 string (or use standard base64 encoding)
- Verify encoding matches content (binary files need binary encoding)
### Wrong Content Type
**Issue**: Attachments don't open correctly
**Solution**:
- Use correct MIME type for file format
- Verify file extension matches content type
- Test with common email clients
### Missing Attachment
**Issue**: Email sends but attachment missing
**Solution**:
- Check attachment array is properly formatted
- Verify all required fields (filename, content, contentType)
- Check email provider limits (some block certain types)
- Review AWS SES sending logs
## Examples by Use Case
### Invoice Email
```json
{
"to": "[email protected]",
"subject": "Invoice #12345",
"body": "<h1>Thank you for your purchase!</h1><p>Your invoice is attached.</p>",
"attachments": [{
"filename": "invoice-12345.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
}]
}
```
### Report with Charts
```json
{
"to": "[email protected]",
"subject": "Weekly Analytics Report",
"body": "<h1>Weekly Report</h1><p>See attached for details.</p>",
"attachments": [
{
"filename": "analytics-report.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
},
{
"filename": "sales-chart.png",
"content": "iVBORw0KGgo...",
"contentType": "image/png"
}
]
}
```
### Welcome Kit
```json
{
"to": "[email protected]",
"subject": "Welcome to Our Service!",
"body": "<h1>Welcome!</h1><p>Here's everything you need to get started.</p>",
"attachments": [
{
"filename": "getting-started-guide.pdf",
"content": "JVBERi0xLjQK...",
"contentType": "application/pdf"
},
{
"filename": "sample-data.csv",
"content": "TmFtZSxFbWFp...",
"contentType": "text/csv"
}
]
}
```
## Next Steps
- [Send your first email](/getting-started/quick-start)
- [SMTP relay setup](/self-hosting/introduction)
- [Email templates](/guides/templates)
-223
View File
@@ -1,223 +0,0 @@
---
title: Events
description: Track user actions and behavior
---
## What are events
Events track user actions in your application. Use them to:
- Trigger automated workflows
- Build behavior-based segments
- Analyze user engagement
- Track conversion funnels
Common events: signups, purchases, logins, feature usage, page views.
## Tracking events
Use your **public key** for event tracking:
```javascript
fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_your_public_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: '[email protected]',
event: 'button_clicked',
data: {
button: 'signup',
page: '/pricing'
}
})
});
```
This creates or updates the contact and tracks the event.
### Persistent vs. Non-Persistent Data
Event data can be either **persistent** (saved to contact) or **non-persistent** (available only to workflows):
**Simple values are persistent** — Saved to contact profile:
```javascript
{
email: '[email protected]',
event: 'subscription_created',
data: {
plan: 'premium', // Saved to contact.data.plan
mrr: 99.00 // Saved to contact.data.mrr
}
}
```
**Non-persistent values** — Available only to triggered workflows:
```javascript
{
email: '[email protected]',
event: 'order_placed',
data: {
totalSpent: 299.99, // Persistent - saved to contact
orderId: {value: 'order-12345', persistent: false}, // Non-persistent - workflows only
receiptUrl: {value: 'https://...', persistent: false} // Non-persistent - workflows only
}
}
```
**Why use non-persistent data?**
- Temporary tokens/codes (password reset, verification)
- One-time URLs or session data
- Data that shouldn't pollute contact profiles
- Information needed only for a specific workflow
Non-persistent data is available throughout the entire workflow execution but never stored on the contact record.
## Event structure
Each event stores:
```json
{
"id": "evt_abc123",
"name": "purchase",
"contactId": "contact_xyz",
"data": {
"product": "Premium Plan",
"amount": 99.00
},
"createdAt": "2024-03-15T10:30:00Z"
}
```
## Using events
You can use events to trigger workflows, create segments, and analyze user behavior.
## Common event patterns
### Lifecycle events
```javascript
// User signs up
track({ email, event: 'signed_up', data: { source: 'homepage' } });
// User activates account
track({ email, event: 'account_activated' });
// User completes onboarding
track({ email, event: 'onboarding_completed', data: { steps: 5 } });
```
### Commerce events
```javascript
// Add to cart
track({ email, event: 'cart_added', data: { productId: '123', price: 49 } });
// Purchase
track({ email, event: 'purchase', data: { orderId: '456', total: 99 } });
// Subscription created
track({ email, event: 'subscription_created', data: { plan: 'pro', mrr: 29 } });
```
### Engagement events
```javascript
// Feature used
track({ email, event: 'feature_used', data: { feature: 'export' } });
// Page viewed
track({ email, event: 'page_view', data: { path: '/dashboard' } });
// Login
track({ email, event: 'logged_in' });
```
### Automatic events
Plunk sends these automatically:
**Email events:**
- `email.sent` — Email delivered to inbox
- `email.opened` — Email opened (first time)
- `email.clicked` — Link clicked in email
- `email.bounced` — Email bounced
- `email.complained` — Spam complaint
**Segment events** (if membership tracking enabled):
- `segment.entered` — Contact joined segment
- `segment.exited` — Contact left segment
## Event naming conventions
**Use lowercase with underscores:**
```
✓ user_signed_up
✓ purchase_completed
✗ UserSignedUp
✗ purchaseCompleted
```
**Be specific but concise:**
```
✓ trial_started
✓ subscription_cancelled
✗ user_started_a_trial
✗ sub_cancel
```
**Group related events:**
```
user_signed_up
user_logged_in
user_deleted_account
subscription_created
subscription_renewed
subscription_cancelled
```
## Managing events
### List events
```bash
curl -X GET "{{API_URL}}/events?limit=100" \
-H "Authorization: Bearer sk_your_secret_key"
```
### List unique event names
```bash
curl -X GET {{API_URL}}/events/names \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns all event names tracked in your project.
### Get events for a contact
```bash
curl -X GET {{API_URL}}/events?contactId=contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
## Best practices
**Track meaningful actions** — Focus on events that indicate intent or value (signups, purchases, key features).
**Include context** — Add relevant data to understand the event better (product, amount, source).
**Be consistent** — Use the same event names and data structure across your application.
**Don't over-track** — Tracking every click creates noise. Focus on conversion events and key milestones.
**Test your tracking** — Verify events appear in dashboard before building workflows around them.
## What's next
- [Build workflows](/guides/workflows) triggered by events
- [Create segments](/guides/segments) based on event data
- [Analyze events](/guides/analytics) to understand user behavior
-17
View File
@@ -1,17 +0,0 @@
{
"title": "Guides",
"pages": [
"contacts",
"templates",
"campaigns",
"segments",
"workflows",
"events",
"webhooks",
"analytics",
"custom-domains",
"billing-limits",
"scaling-email",
"troubleshooting"
]
}
@@ -1,307 +0,0 @@
---
title: Request IDs & Debugging
description: How to use request IDs for debugging and tracing API requests
---
## Overview
Every API request to Plunk receives a unique request ID that follows the request through the entire system. Request IDs are essential for debugging, support, and monitoring.
## What are Request IDs?
A request ID is a UUID (e.g., `f47ac10b-58cc-4372-a567-0e02b2c3d479`) that:
- Is generated for every API request
- Appears in all related log entries
- Is included in both success and error responses
- Can be used to trace requests across services
## Where to Find Request IDs
### In API Responses
**Error responses** (in the `error.requestId` field):
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
...
}
}
```
**Response headers** (always present, even on success):
```bash
X-Request-ID: f47ac10b-58cc-4372-a567-0e02b2c3d479
```
### In Your Application
You can capture and log request IDs for correlation:
```javascript
const response = await fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ to, subject, body })
});
// Get request ID from response header
const requestId = response.headers.get('X-Request-ID');
// Log it for correlation
console.log(`[${requestId}] Email send request initiated`);
const data = await response.json();
if (!data.success) {
// Request ID is also in error response
console.error(`[${data.error.requestId}] Error:`, data.error.message);
}
```
## Database Request Logging
In addition to console/file logs, Plunk stores all API requests in the database with their request IDs. This provides:
- **Historical audit trail** - See all API calls made to your project
- **Analytics** - Analyze API usage patterns, error rates, popular endpoints
- **User-facing logs** - Display API request history in your dashboard
- **Long-term debugging** - Investigate issues that occurred days or weeks ago
- **Compliance** - Meet audit requirements for API access logs
### Database Schema
Each request is stored with:
- Request ID (primary key)
- HTTP method and path
- Status code and response time
- Project ID and user ID (if authenticated)
- IP address and user agent
- Error code and message (if failed)
- Request/response sizes
- Timestamp
### Retention Policy
API request logs are retained for **30 days** by default. A background job runs daily at 3 AM to delete older logs. This prevents unbounded table growth while maintaining recent history for debugging.
You can query your request logs via SQL if self-hosting:
```sql
-- Find all failed requests in the last 24 hours
SELECT * FROM api_requests
WHERE "statusCode" >= 400
AND "createdAt" > NOW() - INTERVAL '24 hours'
ORDER BY "createdAt" DESC;
-- Find all requests for a specific project
SELECT * FROM api_requests
WHERE "projectId" = 'prj_abc123'
ORDER BY "createdAt" DESC
LIMIT 100;
-- Analyze error rates by endpoint
SELECT
path,
COUNT(*) as total_requests,
COUNT(*) FILTER (WHERE "statusCode" >= 400) as errors,
ROUND(100.0 * COUNT(*) FILTER (WHERE "statusCode" >= 400) / COUNT(*), 2) as error_rate_pct
FROM api_requests
WHERE "createdAt" > NOW() - INTERVAL '7 days'
GROUP BY path
ORDER BY error_rate_pct DESC;
```
## How Request IDs Help with Debugging
### Example Scenario
You send an email via the API and receive an error. Here's how request IDs help:
**1. Your application receives an error:**
```json
{
"success": false,
"error": {
"code": "TEMPLATE_NOT_FOUND",
"message": "Template with ID \"tpl_abc123\" was not found",
"requestId": "a1b2c3d4-e5f6-7890-gh12-i34567890jkl",
...
}
}
```
**2. You contact support with the request ID**
**3. We search our logs for that request ID and see:**
```
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] POST /v1/send → Request received
└─ authType: apiKey
└─ projectId: prj_xyz789
└─ ip: 192.168.1.1
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] Looking up template: tpl_abc123
└─ projectId: prj_xyz789
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] Template not found
└─ errorCode: TEMPLATE_NOT_FOUND
└─ statusCode: 404
[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] POST /v1/send → 404 (45ms)
```
From this, we can immediately see:
- You're authenticated correctly (authType: apiKey)
- The template ID doesn't exist in your project
- The request took 45ms to process
- No database errors or system issues
**Result:** We can quickly tell you "That template doesn't exist in your project" without back-and-forth debugging.
## Using Request IDs in Self-Hosted Deployments
If you're self-hosting Plunk, you can use request IDs to debug issues in your own logs.
### Searching Logs
**With Docker logs:**
```bash
# Find all logs for a specific request
docker logs plunk-api 2>&1 | grep "a1b2c3d4-e5f6-7890-gh12-i34567890jkl"
```
**With standard logs:**
```bash
# Search application logs
grep "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" /var/log/plunk/api.log
# Search with context (10 lines before and after)
grep -C 10 "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" /var/log/plunk/api.log
```
### Log Structure
Every log entry includes the request ID in brackets:
```
[f47ac10b-58cc-4372-a567-0e02b2c3d479] POST /v1/track → Request received
[f47ac10b-58cc-4372-a567-0e02b2c3d479] Contact created: cnt_abc123
[f47ac10b-58cc-4372-a567-0e02b2c3d479] Event tracked: evt_xyz789
[f47ac10b-58cc-4372-a567-0e02b2c3d479] POST /v1/track → 200 (127ms)
```
This makes it easy to trace a single request from start to finish.
## Providing Request IDs with Load Balancers
If you use a load balancer or API gateway, you can pass your own request IDs:
```bash
curl -X POST https://api.useplunk.com/v1/send \
-H "X-Request-ID: your-custom-request-id" \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '...'
```
Plunk will use your provided request ID instead of generating a new one. This allows you to:
- Correlate requests across your entire system
- Trace requests from your frontend → your backend → Plunk → email delivery
- Maintain consistent request IDs in your monitoring tools
## Best Practices
### 1. Always Log Request IDs
```javascript
// ✅ Good: Log request ID for correlation
const response = await plunk.send(email);
const requestId = response.headers.get('X-Request-ID');
logger.info(`Email sent to ${email.to}`, { requestId });
```
```javascript
// ❌ Bad: Discard request ID
await plunk.send(email);
// No way to correlate this with Plunk's logs
```
### 2. Include in Error Reporting
```javascript
// ✅ Good: Include request ID in error reports
try {
await plunk.send(email);
} catch (error) {
Sentry.captureException(error, {
extra: {
requestId: error.requestId,
emailTo: email.to
}
});
}
```
### 3. Store for Audit Trails
```javascript
// ✅ Good: Store request ID in your database
await db.emailLog.create({
to: email.to,
subject: email.subject,
plunkRequestId: requestId,
sentAt: new Date()
});
```
### 4. Return to End Users (Optional)
For customer-facing applications, you can show request IDs to users:
```
❌ Error sending email. Please try again.
```
```
❌ Error sending email. Please contact support and provide this reference: a1b2c3d4-e5f6
```
## Monitoring and Observability
Request IDs are essential for:
- **Distributed tracing** - Follow requests across services
- **Error correlation** - Link errors to specific API calls
- **Performance monitoring** - Identify slow requests
- **Debugging production** - Reproduce issues without PII
- **Rate limit tracking** - Monitor usage patterns per project
## FAQ
### Do request IDs expire?
No, request IDs are logged indefinitely (subject to your log retention policy).
### Can I reuse request IDs?
No, each request should have a unique ID. If you send the same request ID twice, logs will be mixed.
### Are request IDs sequential?
No, they are random UUIDs. This prevents information leakage about request volume.
### Can I search by request ID in the dashboard?
This feature is planned but not yet available. For now, contact support with the request ID.
## Related Documentation
- [Error Codes](/api-reference/errors) - Understanding API errors
- [API Reference](/api-reference/overview) - Complete API documentation
- [Troubleshooting](/guides/troubleshooting) - Common issues and solutions
@@ -1,277 +0,0 @@
---
title: Scaling Email Delivery
description: Best practices for high-volume email sending
---
## Email delivery at scale
Plunk is built on AWS SES and handles millions of emails. Follow these practices to maintain high deliverability and performance at scale.
## Deliverability best practices
### Use custom domains
Emails from custom domains have higher trust and better deliverability than shared domains.
**Setup:**
1. Go to **Settings > Domains**
2. Add your domain
3. Configure DNS records (DKIM, SPF)
4. Wait for verification
[Learn more about custom domains →](/guides/custom-domains)
### Warm up new domains
Start small and gradually increase volume.
This builds sender reputation with email providers.
### Clean your list regularly
Remove bounced and inactive contacts:
```javascript
// Get bounced contacts
const bounced = await fetch('{{API_URL}}/events?name=email.bounced&limit=1000', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
// Unsubscribe them
for (const event of bounced.data.events) {
await fetch(`{{API_URL}}/contacts/${event.contactId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscribed: false })
});
}
```
**When to clean:**
- Hard bounces: Immediately unsubscribe
- Soft bounces: After 3 attempts
- No engagement: After 6-12 months
### Segment your audience
Send relevant content to engaged users:
```javascript
// Create engaged users segment
{
"name": "Engaged Users",
"filters": {
"operator": "OR",
"conditions": [
{ "field": "data.lastOpenedAt", "operator": "greaterThan", "value": "{{90 days ago}}" },
{ "field": "data.lastClickedAt", "operator": "greaterThan", "value": "{{90 days ago}}" }
]
}
}
```
Send campaigns to engaged segments for better rates.
## Tracking control
You can disable tracking in your project settings for privacy-focused audiences or to reduce email size.
**When tracking is disabled:**
- No tracking pixel (no open tracking)
- Links not rewritten (no click tracking)
- Smaller email size
- May improve deliverability for privacy-conscious audiences
**When to disable:**
- Regulated industries (healthcare, finance)
- Privacy-focused users
- Transactional emails where tracking isn't needed
- High-volume sends where analytics aren't critical
## Rate limits
### AWS SES limits
Plunk automatically queues emails to stay within limits. Large sends process in background.
### Increase limits
For higher throughput:
1. Maintain good sender reputation
2. Consistent sending volume
3. Low bounce/complaint rates
4. Request limit increase from AWS
### Batch operations
For bulk operations, use appropriate endpoints:
```javascript
// ✓ Good: Single request for multiple recipients
fetch('{{API_URL}}/v1/send', {
method: 'POST',
body: JSON.stringify({
to: ['[email protected]', '[email protected]', '[email protected]'],
subject: 'Update',
body: 'Message'
})
});
// ✗ Avoid: Multiple requests
for (const email of emails) {
await fetch('{{API_URL}}/v1/send', {...}); // Sequential, slow
}
```
## Campaign targeting
### Dynamic filtering
Target audiences without creating segments:
```javascript
{
"name": "Premium Launch",
"audienceType": "FILTERED",
"audienceFilter": {
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "premium" },
{ "field": "data.signupDate", "operator": "greaterThan", "value": "2025-01-01" },
{ "field": "subscribed", "operator": "equals", "value": true }
]
},
"templateId": "template_id"
}
```
**Use FILTERED for:**
- One-time sends
- Testing targeting
- Very specific criteria
**Use SEGMENT for:**
- Repeated targeting
- Workflow triggers
- Segment analytics
## Segment membership tracking
For segments used in workflows, enable membership tracking:
```bash
curl -X POST {{API_URL}}/segments \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Active Premium Users",
"filters": {...},
"trackMembership": true
}'
```
**Enable for:**
- Workflow trigger segments
- Lifecycle stage tracking
- Cohort analysis
**Disable for:**
- Large segments (100k+ contacts)
- Campaign-only segments
- Frequently changing segments
Membership updates run every 5 minutes in background.
## Performance optimization
### Cache contact data
For high-volume API sends, cache contact lookups:
```javascript
// Cache contact IDs locally
const contactCache = new Map();
async function getContactId(email) {
if (contactCache.has(email)) {
return contactCache.get(email);
}
const contact = await fetch(`{{API_URL}}/contacts?search=${email}`);
contactCache.set(email, contact.id);
return contact.id;
}
```
### Use webhooks for async processing
Instead of waiting for email sends:
```javascript
// Workflow with webhook for confirmation
{
"steps": [
{ "type": "SEND_EMAIL", "config": {...} },
{
"type": "WEBHOOK",
"config": {
"url": "https://your-api.com/email-sent",
"method": "POST",
"body": {
"contactId": "{{id}}",
"emailId": "{{emailId}}"
}
}
}
]
}
```
### Batch workflow triggers
Trigger workflows in batches instead of one at a time:
```javascript
// Batch event tracking
const events = users.map(user => ({
email: user.email,
event: 'welcome_campaign',
data: { userId: user.id }
}));
// Send in parallel (respecting rate limits)
await Promise.all(
events.map(event =>
fetch('{{API_URL}}/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(event)
})
)
);
```
## Monitoring
### Track key metrics
Monitor these regularly:
- **Bounce rate** — Should be < 2%
- **Complaint rate** — Should be < 0.1%
- **Open rate** — Industry average 15-25%
- **Click rate** — Industry average 2-5%
Monitor these metrics in your dashboard analytics page to track deliverability and engagement over time.
## Next Steps
- [Set billing limits](/guides/billing-limits) to control costs
- [Monitor analytics](/guides/analytics) for deliverability
- [Troubleshoot issues](/guides/troubleshooting) if problems arise
-342
View File
@@ -1,342 +0,0 @@
---
title: Segments
description: Create dynamic audience groups with filters
---
## What are segments
Segments are dynamic groups of contacts based on filter conditions. Unlike static lists, segments automatically update as contact data changes.
Use segments to:
- Target specific audiences in campaigns
- Trigger workflows when contacts enter/exit
- Analyze cohorts and user behavior
- Personalize communications
## Creating segments
### In the dashboard
1. Go to **Segments**
2. Click **Create Segment**
3. Name your segment
4. Add filter conditions
5. Enable membership tracking (optional)
6. Save
### Via API
```bash
curl -X POST {{API_URL}}/segments \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Active Premium Users",
"filters": {
"operator": "AND",
"conditions": [
{
"field": "data.plan",
"operator": "equals",
"value": "premium"
},
{
"field": "subscribed",
"operator": "equals",
"value": true
}
]
}
}'
```
## Filter conditions
Combine conditions with `AND` or `OR` operators to build precise segments.
### Available operators
**Equality**
- `equals` — Exact match
- `notEquals` — Does not match
**Text**
- `contains` — String includes value
- `notContains` — String excludes value
**Numeric/Date**
- `greaterThan` — Larger than value
- `lessThan` — Smaller than value
- `greaterThanOrEquals` — At least value
- `lessThanOrEquals` — At most value
**Arrays**
- `in` — Value in array
- `notIn` — Value not in array
**Existence**
- `exists` — Field has a value
- `notExists` — Field is missing or null
### Field paths
Access contact fields with dot notation:
- `email` — Contact email address
- `subscribed` — Subscription status
- `createdAt` — When contact was created
- `data.firstName` — Custom field
- `data.preferences.newsletter` — Nested field
- `data.lastPurchaseDate` — Custom date field
## Example segments
### Premium subscribers
All contacts on premium plan who are subscribed:
```json
{
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "premium" },
{ "field": "subscribed", "operator": "equals", "value": true }
]
}
```
### Recent signups
Contacts who joined in the last 7 days:
```json
{
"operator": "AND",
"conditions": [
{
"field": "createdAt",
"operator": "greaterThan",
"value": "{{now - 7 days}}"
}
]
}
```
### Inactive users
Users who haven't logged in for 30+ days:
```json
{
"operator": "AND",
"conditions": [
{
"field": "data.lastLoginAt",
"operator": "lessThan",
"value": "{{now - 30 days}}"
},
{
"field": "data.lastLoginAt",
"operator": "exists",
"value": true
}
]
}
```
### High-value customers
Total spending over $1000:
```json
{
"operator": "AND",
"conditions": [
{
"field": "data.totalSpent",
"operator": "greaterThanOrEquals",
"value": 1000
},
{
"field": "subscribed",
"operator": "equals",
"value": true
}
]
}
```
### Free tier churned users
Users who downgraded from paid to free:
```json
{
"operator": "AND",
"conditions": [
{ "field": "data.plan", "operator": "equals", "value": "free" },
{ "field": "data.previousPlan", "operator": "in", "value": ["pro", "premium"] },
{ "field": "data.downgradedAt", "operator": "exists", "value": true }
]
}
```
## Membership tracking
When you enable `trackMembership`:
**What happens:**
- Plunk computes and stores segment membership
- When contacts enter, sends `segment.entered` event
- When contacts exit, sends `segment.exited` event
- Provides historical membership data
**Use when:**
- You want to trigger workflows on entry/exit
- You need to track cohort changes over time
- Segment is relatively stable (not millions of changes per day)
**Disable when:**
- Segment changes very frequently
- You only need current membership (not history)
- You have millions of contacts and want to save storage
### Using entry/exit events
With membership tracking enabled, use segment events to trigger workflows:
```json
{
"triggerType": "SEGMENT_ENTRY",
"triggerConfig": {
"segmentId": "high-value-customers"
}
}
```
Or use the generic event trigger:
```json
{
"triggerType": "EVENT",
"triggerConfig": {
"eventName": "segment.entered"
}
}
```
Event data includes:
```json
{
"segmentId": "seg_abc123",
"segmentName": "High Value Customers"
}
```
## Using segments
### In campaigns
Send a campaign to a segment:
```bash
curl -X POST {{API_URL}}/campaigns \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Feature Announcement",
"audienceType": "SEGMENT",
"segmentId": "premium-users",
"templateId": "feature-announcement"
}'
```
### In workflows
Trigger workflows when contacts enter a segment:
```bash
curl -X POST {{API_URL}}/workflows \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "VIP Welcome",
"triggerType": "SEGMENT_ENTRY",
"triggerConfig": {
"segmentId": "high-value-customers"
}
}'
```
## Managing segments
### Get segment with count
```bash
curl -X GET {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns member count:
```json
{
"id": "segment_id",
"name": "Premium Users",
"memberCount": 1542,
"filters": {...}
}
```
### List segment members
```bash
curl -X GET "{{API_URL}}/segments/segment_id/contacts?limit=50" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Update segment
```bash
curl -X PATCH {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated name",
"filters": {
"operator": "AND",
"conditions": [...]
}
}'
```
When you update filters, membership is recomputed automatically.
### Delete segment
```bash
curl -X DELETE {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Active campaigns and workflows using this segment will stop working.
## Best practices
**Start broad, then narrow** — Create general segments first, then add specific conditions.
**Test your filters** — Preview segment size before using in campaigns to avoid sending to wrong audience.
**Name clearly** — Use descriptive names: "Q4 2024 Premium Signups" not "Segment 3".
**Avoid overlapping segments** — If using in workflows, ensure segments don't overlap to prevent duplicate emails.
**Use for analysis** — Create segments to understand user cohorts, even if not used in campaigns.
**Combine with events** — Track events and use them in segment conditions for behavior-based targeting.
## What's next
- [Build campaigns](/guides/campaigns) to send to segments
- [Create workflows](/guides/workflows) triggered by segment entry
- [Track events](/guides/events) to use in segment filters
-287
View File
@@ -1,287 +0,0 @@
---
title: Templates
description: Create reusable email templates
---
## Why use templates
Templates let you design emails once and reuse them across:
- Transactional API sends (`/v1/send`)
- Automated workflows
Benefits:
- Update design in one place, applies everywhere
- Maintain consistent branding
- Separate content from code
## Template types
### Marketing templates
**Use for:** Newsletters, promotions, announcements
- Only sends to subscribed contacts
- Automatically includes unsubscribe link
- Respects subscription preferences
### Transactional templates
**Use for:** Order confirmations, password resets, receipts
- Sends to all contacts, even if unsubscribed
- No unsubscribe link required
- Critical business communications
Choose the right type based on content, not delivery method. You can use both types in campaigns, workflows, and API calls. The type determines subscription enforcement.
## Creating templates
### In the dashboard
1. Go to **Templates**
2. Click **Create Template**
3. Choose type (Marketing or Transactional)
4. Set subject, from address, and body
5. Add variables using `{{variableName}}`
6. Save
### Via API
```bash
curl -X POST {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "order-confirmation",
"subject": "Order #{{orderNumber}} confirmed",
"body": "<h1>Thanks for your order!</h1><p>Order #{{orderNumber}} will arrive by {{deliveryDate}}.</p>",
"from": "[email protected]",
"fromName": "Acme Store",
"type": "TRANSACTIONAL"
}'
```
## Using variables
Variables let you personalize each email. Use `{{variableName}}` syntax:
```html
<h1>Hello {{firstName}}!</h1>
<p>Your {{plan}} subscription renews on {{renewalDate}}.</p>
<p>Total: ${{amount}}</p>
```
When sending, provide values in the `data` object:
```json
{
"template": "subscription-renewal",
"data": {
"firstName": "Sarah",
"plan": "Pro",
"renewalDate": "April 15, 2024",
"amount": "29.00"
}
}
```
## Persistent vs. Temporary Data
Template variables can come from **persistent contact data** or **temporary non-persistent data**:
**Persistent data** — Saved to contact profile:
```json
{
"to": "[email protected]",
"subject": "Welcome {{firstName}}!",
"body": "<p>Your {{plan}} subscription is active.</p>",
"data": {
"firstName": "John", // Saved to contact
"plan": "Pro" // Saved to contact
}
}
```
**Non-persistent data** — Used only for this email:
```json
{
"to": "[email protected]",
"subject": "Password Reset",
"body": "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
"data": {
"firstName": "John", // Saved to contact
"resetCode": {value: "ABC123", persistent: false} // NOT saved to contact
}
}
```
**When to use non-persistent data:**
- One-time verification codes or tokens
- Temporary URLs (password reset, magic links)
- Session-specific information
- Data that shouldn't pollute contact profiles
**In workflows:**
Non-persistent data from events is available throughout the entire workflow execution via the execution context, allowing you to use tokens/URLs across multiple workflow steps.
### Fallback values
Provide defaults for missing data:
```html
<h1>Hello {{firstName ?? 'there'}}!</h1>
```
If `firstName` isn't provided, displays "Hello there!" instead.
### Nested data
Access nested objects with dot notation:
```html
<p>{{user.email}}</p>
<p>{{order.items.0.name}}</p>
<p>{{preferences.newsletter}}</p>
```
## Sending with templates
### Transactional emails
Use the `template` field with the **template ID** (not the template name):
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"template": "clx123abc456",
"data": {
"orderNumber": "12345",
"deliveryDate": "March 20"
}
}'
```
**Finding your template ID:**
- In the dashboard: Go to Templates → Click on your template → Copy the ID from the URL or template details
- Via API: Use `GET /templates` to list all templates with their IDs
When using a template:
- **Subject, body, from, and reply-to** are automatically taken from the template
- **Template variables** (e.g., `{{orderNumber}}`) are populated from the `data` field
- You can **override** any template value by explicitly providing it in the request (see example below)
### Overriding template values
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"template": "clx123abc456",
"subject": "Custom Subject (overrides template)",
"from": {
"name": "Custom Sender",
"email": "[email protected]"
},
"data": {
"orderNumber": "12345"
}
}'
```
### In workflows
When creating a **Send Email** workflow step, select the template from the dropdown. Variables are automatically filled from contact data and workflow context.
### In campaigns
When creating a campaign, choose a template instead of composing inline. All campaign recipients get the same template with personalized variables.
## When to use inline vs templates
**Use templates when:**
- Sending the same email repeatedly
- Multiple workflows/campaigns use same design
- Design may change over time
- Want to centralize branding
**Use inline content when:**
- One-off transactional emails
- Unique per-user content
- Testing or prototyping
- Content is generated dynamically
Example inline send:
```bash
curl -X POST {{API_URL}}/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your verification code",
"body": "<p>Your code: {{code}}</p>",
"data": {"code": "ABC123"}
}'
```
## Managing templates
### List templates
```bash
curl -X GET {{API_URL}}/templates \
-H "Authorization: Bearer sk_your_secret_key"
```
Filter by type:
```bash
curl -X GET "{{API_URL}}/templates?type=MARKETING" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Update template
```bash
curl -X PATCH {{API_URL}}/templates/template_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"subject": "New subject line",
"body": "<p>Updated content</p>"
}'
```
Changes apply to all future sends using this template.
### Delete template
```bash
curl -X DELETE {{API_URL}}/templates/template_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Templates used in active workflows are not deleted—you'll need to update workflows first.
## Best practices
**Keep templates simple** — Focus on content, avoid complex layouts that break in email clients.
**Test across clients** — Email rendering varies. Preview in Gmail, Outlook, Apple Mail, and mobile devices.
**Use semantic HTML** — Use `<h1>`, `<p>`, `<strong>` instead of styled `<div>` elements.
**Provide all variables** — Missing variables display as empty. Use fallbacks: `{{name ?? 'Customer'}}`.
**Version your templates** — For critical transactional emails, create new templates rather than editing existing ones.
## Next Steps
- [Create campaigns](/guides/campaigns) with your templates
- [Build workflows](/guides/workflows) with automated emails
- [Track performance](/guides/analytics) of your templates
@@ -1,427 +0,0 @@
---
title: Troubleshooting
description: Common issues and solutions
---
## Authentication issues
### Invalid API key error
**Error:**
```json
{
"code": 401,
"error": "Unauthorized",
"message": "Invalid API key"
}
```
**Solutions:**
1. **Verify key format** — Secret keys start with `sk_`, public keys start with `pk_`
2. **Check Authorization header** — Must use `Bearer` format: `Authorization: Bearer sk_your_secret_key`
3. **Ensure key hasn't been regenerated** — If you regenerated keys in dashboard, update your application
4. **Verify project access** — Key must belong to the project you're accessing
**Test your key:**
```bash
curl -X GET {{API_URL}}/contacts?limit=1 \
-H "Authorization: Bearer sk_your_secret_key"
```
### Wrong key type for endpoint
**Error:**
```json
{
"code": 401,
"error": "Unauthorized",
"message": "This endpoint requires a secret key (sk_*)"
}
```
**Solution:**
You're using a public key (`pk_*`) for an endpoint that requires a secret key.
- **Public keys** — Only work with `/v1/track` endpoint
- **Secret keys** — Required for all other endpoints
Use your secret key from **Settings > API Keys** in the dashboard.
## Email sending issues
### Emails not sending
**Check these common causes:**
1. **Billing limit reached** - Check your billing limits in Settings → Billing. If you hit your monthly limit, increase it or wait for monthly reset.
2. **Template not found**
```json
{
"code": 404,
"error": "Not Found",
"message": "Template not found"
}
```
Verify template ID exists and belongs to your project.
3. **Contact unsubscribed**
Marketing templates skip unsubscribed contacts. Check contact subscription status:
```bash
curl -X GET {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
If `subscribed: false`, use a transactional template for critical emails.
4. **Project disabled**
If your project is disabled, all email sends will fail. Contact support.
### Emails going to spam
**Common causes:**
1. **No custom domain** — Emails from default domain may have lower trust
2. **Low engagement** — Recipients not opening/clicking emails
3. **High bounce rate** — Too many invalid email addresses
4. **Spam complaints** — Recipients marking as spam
**Solutions:**
1. **Set up custom domain** — [Add your domain](/guides/custom-domains) for better deliverability
2. **Clean your list** — Remove bounced and inactive contacts
3. **Improve content** — Avoid spam trigger words, include clear unsubscribe link
4. **Warm up your domain** — Start with small volumes, gradually increase
5. **Segment your audience** — Only send relevant content to engaged users
### Tracking not working
**Open tracking:**
Requires:
- `trackingEnabled: true` on project
- Recipient email client loads images
- HTML email (not plain text)
Some email clients block tracking pixels. Open rates are estimates, not exact.
**Click tracking:**
Requires:
- Tracking enabled on project (check your project settings in dashboard)
- Links in email body (not subject)
- HTML email format
## Campaign issues
### Campaign won't send
**Error:**
```json
{
"code": 400,
"error": "Bad Request",
"message": "Campaign must be in DRAFT or SCHEDULED status"
}
```
**Solution:**
Can only send campaigns with status `DRAFT`. If campaign is `SENT` or `CANCELLED`, duplicate it:
```bash
curl -X POST {{API_URL}}/campaigns/campaign_id/duplicate \
-H "Authorization: Bearer sk_your_secret_key"
```
### No recipients for campaign
**Cause:**
Campaign targets a segment or filter with zero matching contacts.
**Solutions:**
1. **Check segment size**
```bash
curl -X GET {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Look at `memberCount` field.
2. **Verify filters** — Test filters on segments page to see matches
3. **Check subscription status** — Marketing templates only send to subscribed contacts
### Scheduled campaign didn't send
**Check:**
1. **Verify schedule time** — Must be in future when scheduled
2. **Campaign status** — Should be `SCHEDULED`, not `CANCELLED`
3. **Project limits** — Billing limits may block sends
View campaign details:
```bash
curl -X GET {{API_URL}}/campaigns/campaign_id \
-H "Authorization: Bearer sk_your_secret_key"
```
## Workflow issues
### Workflow not triggering
**Common causes:**
1. **Workflow not enabled**
Check `enabled: true`:
```bash
curl -X GET {{API_URL}}/workflows/workflow_id \
-H "Authorization: Bearer sk_your_secret_key"
```
2. **Wrong event name**
Event names are case-sensitive. `user_signed_up` ≠ `User_Signed_Up`
Verify event name exactly matches workflow trigger.
3. **Contact already entered (allowReentry: false)**
If `allowReentry: false`, contact can only enter once. Check execution history:
```bash
curl -X GET {{API_URL}}/workflows/workflow_id/executions?contactId=contact_id \
-H "Authorization: Bearer sk_your_secret_key"
```
4. **Segment membership not tracked**
For `SEGMENT_ENTRY` triggers, segment must have `trackMembership: true`.
### Workflow emails not sending
**Check each email step execution:**
```bash
curl -X GET {{API_URL}}/workflows/workflow_id/executions/execution_id \
-H "Authorization: Bearer sk_your_secret_key"
```
Look for step failures in response.
**Common causes:**
1. **Template not found** — Template was deleted
2. **Contact unsubscribed** — Marketing templates skip unsubscribed contacts
3. **Billing limit reached** — Monthly workflow email limit exceeded
4. **Workflow execution stopped** — Contact was deleted or execution was cancelled
### Workflow stuck on delay step
Delays are processed by background jobs. Check:
1. **scheduledFor** time — When should it execute?
2. **Current time** — Has the scheduled time passed?
Delays process every minute. Wait a few minutes and check again.
## Contact issues
### Contact not found
**Error:**
```json
{
"code": 404,
"error": "Not Found",
"message": "Contact not found"
}
```
**Solutions:**
1. **Verify contact ID** — Check for typos in the ID
2. **Check project** — Contact may belong to different project
3. **Contact deleted** — Contact may have been deleted
Search by email instead:
```bash
curl -X GET "{{API_URL}}/[email protected]" \
-H "Authorization: Bearer sk_your_secret_key"
```
### Duplicate contacts
Plunk automatically prevents duplicates. Creating a contact with existing email updates that contact instead of creating a new one.
If you see duplicates:
1. Check email addresses carefully (they may differ slightly)
2. Verify you're viewing the same project
### Contact data not updating
**Verify data format:**
```bash
curl -X PATCH {{API_URL}}/contacts/contact_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"data": {
"plan": "premium"
}
}'
```
**Common issues:**
1. **Missing `data` wrapper** — Custom fields must be inside `data` object
2. **Wrong data types** — Numbers should be `99`, not `"99"`
3. **Nested too deeply** — Keep data relatively flat
## Segment issues
### Segment shows wrong count
Segment counts update every 5 minutes. Wait a few minutes and refresh.
For real-time count, query members directly:
```bash
curl -X GET "{{API_URL}}/segments/segment_id/contacts?limit=1" \
-H "Authorization: Bearer sk_your_secret_key"
```
Check `total` field in response.
### Segment filter not working
**Test your filter:**
1. **Check field names** — Use `data.fieldName` for custom fields
2. **Verify operators** — `equals` for strings, `greaterThan` for numbers
3. **Match data types** — Don't compare string `"99"` with number `99`
**Example filters:**
```javascript
// ❌ Wrong
{ "field": "plan", "operator": "equals", "value": "pro" }
// ✓ Correct
{ "field": "data.plan", "operator": "equals", "value": "pro" }
```
### Segment entry/exit events not firing
Requires `trackMembership: true` on segment:
```bash
curl -X PATCH {{API_URL}}/segments/segment_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"trackMembership": true}'
```
Membership is computed every 5 minutes. Events fire on the next computation cycle after a contact's segment membership changes.
## Domain verification issues
### Domain won't verify
**Common causes:**
1. **DNS not propagated** — Can take up to 48 hours
2. **Wrong DNS records** — Double-check DKIM tokens
3. **Conflicting records** — Remove old DKIM records from other email services
**Check DNS propagation:**
```bash
dig TXT _domainkey.yourdomain.com
```
Records should match the DKIM tokens provided by Plunk.
**Force verification check:**
```bash
curl -X POST {{API_URL}}/domains/domain_id/verify \
-H "Authorization: Bearer sk_your_secret_key"
```
### Emails not sending from custom domain
1. **Verify domain is verified** — Check `verified: true` in domain settings
2. **Use correct `from` address** — Must be `@yourdomain.com`
3. **Check SPF and DKIM** — Ensure DNS records are correct
## Rate limiting
### 429 Too Many Requests
**Error:**
```json
{
"code": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded"
}
```
**Solutions:**
1. **Implement exponential backoff** — Wait and retry with increasing delays
2. **Batch operations** — Group multiple operations when possible
3. **Spread requests** — Distribute load over time instead of bursts
**Example retry logic:**
```javascript
async function sendWithRetry(data, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch('{{API_URL}}/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
const waitTime = Math.pow(2, i) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return response;
} catch (error) {
if (i === maxRetries - 1) throw error;
}
}
}
```
## Getting help
If you're still experiencing issues:
1. **Check API status** — Is there a known outage?
2. **Review error message** — Error messages usually indicate the problem
3. **Search documentation** — Look for specific error codes or messages
4. **Check GitHub issues** — Similar issues may already be reported
5. **Join Discord community** — Ask for help from other users
6. **Contact support** — Provide error details, request IDs, and steps to reproduce
**Include in support requests:**
- Error message and status code
- API endpoint and method
- Request body (remove sensitive data)
- Timestamp of the issue
- Project ID (if applicable)
-262
View File
@@ -1,262 +0,0 @@
---
title: Webhooks
description: Send real-time notifications to external services
---
## What are webhooks
Webhooks let you send HTTP requests to external services from within workflows. Use them to:
- Notify your CRM when workflows complete
- Update external databases with contact actions
- Trigger third-party automations
- Sync data across systems
- Track workflow progress in analytics tools
## Using webhooks in workflows
Add a **Webhook** step to any workflow:
1. Go to **Workflows**
2. Create or edit a workflow
3. Add a **Webhook** step
4. Configure the HTTP request
5. Connect to other steps
### Basic webhook configuration
```json
{
"type": "WEBHOOK",
"config": {
"url": "https://your-api.com/webhook",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer your_api_token"
},
"body": {
"email": "{{email}}",
"event": "workflow_completed",
"contactId": "{{id}}"
}
}
}
```
### Supported HTTP methods
- **POST** — Most common, sends data to endpoint
- **PUT** — Update existing resource
- **PATCH** — Partial update
- **GET** — Retrieve data (rarely used in workflows)
- **DELETE** — Remove resource
## Using contact variables
Access contact data in your webhook using template variables:
```json
{
"url": "https://crm.example.com/contacts",
"method": "POST",
"body": {
"email": "{{email}}",
"firstName": "{{data.firstName}}",
"lastName": "{{data.lastName}}",
"plan": "{{data.plan}}",
"workflowName": "{{workflowName}}",
"completedAt": "{{now}}"
}
}
```
Available variables:
- `{{email}}` — Contact email
- `{{id}}` — Contact ID
- `{{data.fieldName}}` — Any custom data field
- `{{workflowName}}` — Current workflow name
- `{{now}}` — Current timestamp
## Common use cases
### Notify Slack
```json
{
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"method": "POST",
"body": {
"text": "New user completed onboarding: {{email}}"
}
}
```
### Update CRM
```json
{
"url": "https://api.crm.com/contacts/{{data.crmId}}",
"method": "PATCH",
"headers": {
"Authorization": "Bearer crm_api_token"
},
"body": {
"onboardingCompleted": true,
"lastEngaged": "{{now}}"
}
}
```
### Track analytics
```json
{
"url": "https://analytics.example.com/events",
"method": "POST",
"headers": {
"X-API-Key": "analytics_key"
},
"body": {
"event": "workflow_milestone",
"userId": "{{email}}",
"properties": {
"workflow": "{{workflowName}}",
"step": "purchase_completed"
}
}
}
```
### Trigger Zapier
```json
{
"url": "https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/",
"method": "POST",
"body": {
"email": "{{email}}",
"firstName": "{{data.firstName}}",
"event": "trial_ended"
}
}
```
## Error handling
### Webhook failures
If a webhook request fails:
- Workflow continues to next step
- Error is logged in workflow execution
- Contact is not blocked
Check workflow execution logs to see webhook errors:
```bash
curl -X GET {{API_URL}}/workflows/workflow_id/executions \
-H "Authorization: Bearer sk_your_secret_key"
```
### Timeouts
Webhooks timeout after 30 seconds. If your endpoint takes longer:
- Use async processing on your end
- Return 202 Accepted immediately
- Process in background job
### Retry logic
Webhooks are **not automatically retried**. If you need guaranteed delivery:
- Implement retry logic in your endpoint
- Use a message queue (SQS, RabbitMQ)
- Track webhook status in your database
## Receiving email event webhooks
Plunk tracks email events automatically (opens, clicks, bounces). Access them via:
### Events API
```bash
curl -X GET "{{API_URL}}/events?contactId=contact_id" \
-H "Authorization: Bearer sk_your_secret_key"
```
Returns email events:
```json
{
"events": [
{
"name": "email.opened",
"data": { "emailId": "...", "timestamp": "..." }
},
{
"name": "email.clicked",
"data": { "emailId": "...", "url": "...", "timestamp": "..." }
}
]
}
```
### Trigger workflows on email events
Create a workflow triggered by email events:
```json
{
"triggerType": "EVENT",
"triggerConfig": {
"eventName": "email.clicked"
}
}
```
Then use a webhook step to forward to your system.
## Security best practices
**Use HTTPS only** — Never send sensitive data over HTTP.
**Authenticate requests** — Include API tokens in headers:
```json
{
"headers": {
"Authorization": "Bearer your_secret_token"
}
}
```
**Validate on receiving end** — Don't trust webhook data blindly. Verify it matches your expectations.
**Don't expose secrets** — Store API tokens as environment variables, not in workflow config.
**Rate limit your endpoint** — Protect against webhook floods.
## Testing webhooks
### Use webhook.site
For testing, use [webhook.site](https://webhook.site):
1. Go to webhook.site
2. Copy your unique URL
3. Use it in your workflow webhook step
4. Trigger the workflow
5. See the request in webhook.site
### Test with ngrok
For local development:
```bash
ngrok http 3000
```
Use the ngrok URL in your webhook configuration. Requests will tunnel to your local server.
## What's next
- [Build workflows](/guides/workflows) with webhook steps
- [Track events](/guides/events) to trigger webhooks
- [Monitor analytics](/guides/analytics) for webhook success rates
@@ -1,211 +0,0 @@
---
title: Advanced Workflow Automation
description: Control workflow re-entry and pass execution context data
---
## Workflow Re-Entry Control
Control whether contacts can enter the same workflow multiple times.
### Allow Re-Entry
Use when the workflow represents a repeating process:
```javascript
{
"name": "Weekly Newsletter",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "newsletter.send" },
"allowReentry": true // Contacts can re-enter
}
```
**When to use**:
- Recurring campaigns (weekly newsletters)
- Event-based sequences (cart abandoned)
- Behavior triggers that can happen multiple times
**What happens**: Contact can have multiple active executions of the same workflow.
### Prevent Re-Entry (Default)
Use for one-time journeys:
```javascript
{
"name": "Onboarding Series",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "user.signup" },
"allowReentry": false // One-time only (default)
}
```
**When to use**:
- User onboarding
- Trial expiration
- Welcome sequences
**What happens**: Contact can only enter once, even if the trigger fires again.
## Execution Context
Pass event-specific data when starting a workflow execution.
### Basic Example
```javascript
// Start workflow with order-specific context
fetch('/workflows/workflow_id/executions', {
method: 'POST',
body: JSON.stringify({
contactId: 'contact_id',
context: {
orderNumber: 'ORD-12345',
orderTotal: 299.99,
deliveryDate: '2024-03-30'
}
})
});
```
In your workflow email templates:
```html
<p>Hi {'{{firstName}}'}!</p>
<p>Order #{'{{orderNumber}}'}: ${'{{orderTotal}}'}</p>
<p>Delivery: {'{{deliveryDate}}'}</p>
```
The template accesses both contact data (`firstName`) and context data (`orderNumber`, `orderTotal`, `deliveryDate`).
### When to Use Context
**Use execution context for**:
- Order-specific details
- Event registration info
- Session data
- Campaign-specific values
**Update contact data for**:
- Persistent user attributes
- Cumulative metrics (total orders, lifetime value)
- Segment-able fields
### Example: Order Confirmation Workflow
```javascript
// Trigger workflow on purchase
{
"name": "Order Confirmation",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "purchase.completed" },
"allowReentry": true // Can purchase multiple times
}
// Start execution with order context
fetch('/workflows/order_workflow_id/executions', {
method: 'POST',
body: JSON.stringify({
contactId: 'contact_id',
context: {
orderNumber: 'ORD-456',
total: 199.99,
trackingUrl: 'https://track.example.com/456'
}
})
});
```
Workflow sends emails with order-specific details while tracking cumulative purchase data on the contact.
## Real-World Patterns
### Pattern 1: Trial Workflow
```javascript
{
"name": "14-Day Trial",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "trial.started" },
"allowReentry": false // Only trial once
}
// Workflow steps:
// Day 0: Welcome email
// Day 7: Mid-trial check-in
// Day 13: Upgrade reminder
```
### Pattern 2: Cart Abandonment
```javascript
{
"name": "Cart Abandoned",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "cart.abandoned" },
"allowReentry": true // Can abandon multiple times
}
// Pass cart data as context
context: {
cartTotal: 99.99,
cartUrl: 'https://app.example.com/cart/abc'
}
```
### Pattern 3: Event Reminders
```javascript
{
"name": "Webinar Reminders",
"triggerType": "EVENT",
"triggerConfig": { "eventName": "webinar.registered" },
"allowReentry": true // Can register for multiple webinars
}
// Pass webinar details as context
context: {
webinarTitle: 'Email Automation Masterclass',
webinarDate: '2024-04-10',
webinarLink: 'https://zoom.us/j/12345'
}
```
## Monitoring Executions
Check workflow execution status:
```bash
curl -X GET "/workflows/workflow_id/executions/execution_id" \
-H "Authorization: Bearer sk_your_secret_key"
```
Response shows step progress:
```json
{
"success": true,
"data": {
"id": "execution_id",
"status": "active",
"steps": [
{
"stepId": "step_1",
"status": "completed",
"completedAt": "2024-03-15T10:00:00Z"
},
{
"stepId": "step_2",
"status": "waiting",
"waitingUntil": "2024-03-16T10:00:00Z"
}
]
}
}
```
## Next Steps
- [Create workflows](/guides/workflows) to automate sequences
- [Track events](/guides/events) to trigger workflows
- [Set up segments](/guides/segments) for segment-based triggers
-445
View File
@@ -1,445 +0,0 @@
---
title: Workflows
description: Automate email sequences with triggers and conditions
---
## What are workflows
Workflows automate email sequences based on user behavior. Build onboarding drips, re-engagement campaigns, and event-triggered emails—all without writing code.
A workflow consists of:
- **Trigger** — What starts the workflow (event, segment entry, schedule)
- **Steps** — Actions like sending emails, waiting, or checking conditions
- **Transitions** — Connections between steps that define the flow
## Common use cases
### Welcome series
Send a 3-email onboarding sequence when users sign up:
1. User triggers `signed_up` event
2. Send welcome email immediately
3. Wait 1 day
4. Send getting started guide
5. Wait 2 days
6. Send feature tips
### Abandoned cart recovery
Re-engage users who add items but don't purchase:
1. User triggers `cart_abandoned` event
2. Wait 1 hour
3. Send reminder email with cart contents
4. Wait for `purchase` event (timeout: 24 hours)
5. If purchased → Exit workflow
6. If timeout → Send discount offer
### Trial expiration
Notify users before trial ends and encourage upgrade:
1. Trigger daily at 9am
2. Check if trial expires in 3 days
3. If yes → Send upgrade reminder
4. Wait for `subscription_created` event (timeout: 3 days)
5. If subscribed → Send thank you email
6. If timeout → Send last chance offer
### Re-engagement campaign
Win back inactive users:
1. Contact enters "Inactive Users" segment
2. Send "We miss you" email
3. Wait for `login` event (timeout: 7 days)
4. If logged in → Exit workflow
5. If timeout → Send special offer
## Creating workflows
### In the dashboard
1. Go to **Workflows**
2. Click **Create Workflow**
3. Name your workflow
4. Choose trigger type
5. Add steps using visual builder
6. Connect steps with transitions
7. Activate workflow
### Via API
```bash
curl -X POST {{API_URL}}/workflows \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome Series",
"eventName": "signed_up",
"enabled": true
}'
```
Then add steps and transitions through the dashboard or API.
## Trigger types
### Event trigger
Starts when a specific event is tracked:
```json
{
"triggerType": "EVENT",
"triggerConfig": {
"eventName": "signed_up"
}
}
```
Track the event with `/v1/track` to start the workflow.
### Segment entry
Starts when contact joins a segment:
```json
{
"triggerType": "SEGMENT_ENTRY",
"triggerConfig": {
"segmentId": "premium-users"
}
}
```
Requires segment to have `trackMembership: true`.
### Segment exit
Starts when contact leaves a segment:
```json
{
"triggerType": "SEGMENT_EXIT",
"triggerConfig": {
"segmentId": "trial-users"
}
}
```
### Schedule
Runs on a cron schedule:
```json
{
"triggerType": "SCHEDULE",
"triggerConfig": {
"schedule": "0 9 * * *"
}
}
```
Evaluates all contacts—use conditions to filter who continues.
## Workflow steps
### Send Email
Send a template to the contact:
```json
{
"type": "SEND_EMAIL",
"config": {
"templateId": "welcome-template"
}
}
```
### Delay
Wait before continuing:
```json
{
"type": "DELAY",
"config": {
"duration": 86400,
"unit": "seconds"
}
}
```
Common durations:
- 1 hour: `3600`
- 1 day: `86400`
- 1 week: `604800`
### Wait for Event
Pause until an event occurs or timeout:
```json
{
"type": "WAIT_FOR_EVENT",
"config": {
"eventName": "purchase",
"timeout": 604800
}
}
```
Create two transitions: one for "success" (event occurred) and one for "timeout".
### Condition
Branch based on contact data:
```json
{
"type": "CONDITION",
"config": {
"field": "data.plan",
"operator": "equals",
"value": "premium"
}
}
```
Create two transitions: "true" and "false".
### Update Contact
Modify contact fields:
```json
{
"type": "UPDATE_CONTACT",
"config": {
"data": {
"onboardingCompleted": true,
"completedAt": "{{now}}"
}
}
}
```
### Webhook
Call an external API:
```json
{
"type": "WEBHOOK",
"config": {
"url": "https://api.example.com/webhook",
"method": "POST",
"body": {
"email": "{{email}}",
"event": "workflow_completed"
}
}
}
```
### Exit
End the workflow:
```json
{
"type": "EXIT"
}
```
## Re-entry behavior
Control whether contacts can enter a workflow multiple times:
### Prevent re-entry (default)
**allowReentry: false**
- Contact can only enter once, ever
- Subsequent triggers are ignored
- Use for one-time journeys
**Best for:**
- User onboarding sequences
- Welcome series
- Trial expiration flows
- One-time educational content
**Example:**
```json
{
"name": "Onboarding Series",
"eventName": "user_signed_up",
"allowReentry": false,
"enabled": true
}
```
Even if the `user_signed_up` event fires multiple times for the same contact, they'll only enter once.
### Allow re-entry
**allowReentry: true**
- Contact can enter multiple times
- Each trigger starts a new execution
- Multiple executions can run simultaneously
**Best for:**
- Recurring events (weekly newsletters, monthly reports)
- Behavior-triggered campaigns (cart abandonment, content engagement)
- Event-specific sequences (order confirmations, webinar reminders)
**Example:**
```json
{
"name": "Cart Abandoned Reminder",
"eventName": "cart_abandoned",
"allowReentry": true,
"enabled": true
}
```
User abandons cart multiple times → Each triggers a new workflow execution.
## Execution context
Pass event-specific data when workflows are triggered automatically.
### What is context?
Context is temporary data passed with a workflow execution that's available in templates but not saved to the contact record.
**Contact data vs Context:**
- **Contact data** — Persistent, saved to contact, used for segmentation
- **Execution context** — Temporary, specific to this workflow run, not saved
### Using context in templates
Context data is available in workflow email templates alongside contact data:
```html
<h1>Hi {{firstName}}!</h1>
<p>Order #{{orderNumber}} confirmed!</p>
<p>Total: ${{orderTotal}}</p>
<p>Tracking: <a href="{{trackingUrl}}">View shipment</a></p>
```
Where:
- `{{firstName}}` comes from contact.data
- `{{orderNumber}}`, `{{orderTotal}}`, `{{trackingUrl}}` come from execution context
### Common patterns
**Order confirmations:**
```javascript
// Event includes order details
{
event: 'purchase_completed',
email: '[email protected]',
data: {
// Saved to contact
totalPurchases: 5,
lifetimeValue: 599
}
}
// Workflow execution receives context (not saved)
context: {
orderNumber: 'ORD-12345',
orderTotal: 99.99,
trackingUrl: 'https://track.example.com/12345',
deliveryDate: '2025-12-05'
}
```
**Event registrations:**
```javascript
context: {
eventTitle: 'Email Marketing Workshop',
eventDate: '2025-12-10T14:00:00Z',
eventUrl: 'https://zoom.us/j/123456',
speakerName: 'Jane Doe'
}
```
**Cart abandonment:**
```javascript
context: {
cartTotal: 149.99,
cartUrl: 'https://app.example.com/cart/abc123',
itemCount: 3,
expiresAt: '2025-12-01T10:00:00Z'
}
```
## Workflow execution
When a workflow triggers:
1. Contact enters at the trigger step
2. Executes each step in sequence
3. Follows transitions between steps
4. Continues until reaching Exit step
5. Status changes from RUNNING to COMPLETED
If contact unsubscribes or is deleted, workflow execution stops immediately.
## Managing workflows
### List workflows
```bash
curl -X GET {{API_URL}}/workflows \
-H "Authorization: Bearer sk_your_secret_key"
```
### Get workflow details
```bash
curl -X GET {{API_URL}}/workflows/workflow_id \
-H "Authorization: Bearer sk_your_secret_key"
```
### Activate/deactivate
```bash
curl -X PATCH {{API_URL}}/workflows/workflow_id \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
```
### View executions
```bash
curl -X GET "{{API_URL}}/workflows/workflow_id/executions" \
-H "Authorization: Bearer sk_your_secret_key"
```
## Best practices
**Start simple** — Begin with 2-3 emails before adding complex conditions.
**Test with yourself** — Create a test contact and trigger the workflow to verify timing and content.
**Monitor execution stats** — Check completion rates to identify where contacts drop off.
**Use meaningful names** — Name steps clearly: "Send Welcome Email" not "Step 1".
**Set appropriate timeouts** — For "Wait for Event" steps, choose realistic timeouts based on user behavior.
**Handle both paths** — Every condition and wait should have both success and failure paths defined.
## Next Steps
- [Track events](/guides/events) to trigger workflows
- [Create segments](/guides/segments) for segment-based triggers
- [Build templates](/guides/templates) to use in workflow emails
- [Set up webhooks](/guides/webhooks) for external integrations