diff --git a/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx b/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx
new file mode 100644
index 0000000..8b413a8
--- /dev/null
+++ b/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx
@@ -0,0 +1,135 @@
+---
+title: Conditional Branching
+description: If/then logic in workflows
+icon: GitBranch
+---
+
+## Overview
+
+Conditions split workflows into two paths based on contact data.
+
+```
+[Condition: plan equals "premium"]
+ ├─ True → [Send: Premium features]
+ └─ False → [Send: Upgrade offer]
+```
+
+Both paths required.
+
+## Operators
+
+| Operator | Use | Example |
+|----------|-----|---------|
+| `equals` | Exact match | `plan equals "pro"` |
+| `notEquals` | Not matching | `plan notEquals "free"` |
+| `contains` | Substring | `company contains "tech"` |
+| `greaterThan` | Greater than | `mrr greaterThan 100` |
+| `lessThan` | Less than | `loginCount lessThan 5` |
+| `greaterThanOrEqual` | Greater than or equal | `age greaterThanOrEqual 18` |
+| `lessThanOrEqual` | Less than or equal | `daysInactive lessThanOrEqual 30` |
+| `exists` | Has value | `company exists` |
+| `notExists` | Missing/null | `lastName notExists` |
+| `startsWith` | Prefix | `coupon startsWith "SAVE"` |
+| `endsWith` | Suffix | `email endsWith "@company.com"` |
+
+**Important:** Numeric operators require field stored as number, not string.
+
+## Common patterns
+
+### Filter by plan
+
+```
+[Condition: plan equals "enterprise"]
+ ├─ True → [Send: Enterprise onboarding]
+ └─ False → [Send: Standard onboarding]
+```
+
+### Activity check
+
+```
+[Condition: loginCount greaterThan 10]
+ ├─ True → [Send: Power user tips]
+ └─ False → [Send: Getting started]
+```
+
+### Nested conditions
+
+Chain for multi-tier logic:
+
+```
+[Condition: plan equals "enterprise"]
+ ├─ True → [Send: Enterprise email]
+ └─ False ↓
+ [Condition: plan equals "pro"]
+ ├─ True → [Send: Pro email]
+ └─ False → [Send: Free email]
+```
+
+### Multiple field checks (AND)
+
+Nest conditions:
+
+```
+[Condition: plan equals "pro"]
+ ├─ True ↓
+ │ [Condition: trialDaysLeft lessThan 3]
+ │ ├─ True → [Send: Trial ending]
+ │ └─ False → [Exit]
+ └─ False → [Exit]
+```
+
+Matches: `plan = "pro"` AND `trialDaysLeft < 3`
+
+## Nested data
+
+Access with dot notation:
+
+```json
+{
+ "field": "preferences.newsletter",
+ "operator": "equals",
+ "value": true
+}
+```
+
+## Best practices
+
+**Store correct types** — `99` (number) not `"99"` (string) for numeric comparisons.
+
+**Check existence first** — If field might not exist:
+
+```
+[Condition: mrr exists]
+ ├─ True → [Condition: mrr greaterThan 100]
+ └─ False → [Exit]
+```
+
+**Limit nesting** — More than 3 levels gets hard to maintain. Use separate workflows.
+
+**Test both paths** — Verify true and false outcomes work.
+
+**Use segments when filtering many** — Segment-based triggers more efficient than in-workflow conditions for large audiences.
+
+## Common mistakes
+
+**Case sensitivity** — `equals "Pro"` doesn't match `"pro"`
+
+**Type mismatch** — `"100" greaterThan 50` fails (string vs number)
+
+**Missing both paths** — Every condition needs true AND false connections
+
+## Debugging
+
+Check contact data first:
+
+```bash
+curl -X GET {{API_URL}}/contacts/contact_id \
+ -H "Authorization: Bearer sk_your_secret_key"
+```
+
+Verify field names and types match your condition.
+
+## Next steps
+
+- [See workflow patterns](/automation-patterns/workflow-patterns)
+- [Build segments](/guides/segments) for trigger filtering
diff --git a/apps/wiki/content/docs/automation-patterns/index.mdx b/apps/wiki/content/docs/automation-patterns/index.mdx
new file mode 100644
index 0000000..8859ada
--- /dev/null
+++ b/apps/wiki/content/docs/automation-patterns/index.mdx
@@ -0,0 +1,53 @@
+---
+title: Automation Patterns
+description: Workflow patterns and examples
+icon: Zap
+---
+
+## Learn workflows
+
+
Your {{plan}} plan renews on {{renewalDate}}.
+``` + +### In segments + +Filter by data fields: +- `plan equals "premium"` +- `mrr greaterThan 100` +- `loginCount lessThan 5` + +### In workflows + +``` +[Condition: plan equals "enterprise"] + ├─ True → [Send: Enterprise content] + └─ False → [Send: Standard content] +``` + +## Best practices + +**Consistent naming** — Use camelCase or snake_case, not both. + +**Correct types** — Use `99` not `"99"` for numbers. + +**ISO dates** — `"2024-03-15T10:30:00Z"` for date fields. + +**Sync critical fields only** — Don't mirror entire database. Only fields used in emails, segments, or workflows. + +**Update in real-time** — When user data changes, update contact immediately. + +**Respect unsubscribes** — Never re-subscribe automatically. + +## Deleting contacts + +```bash +curl -X DELETE {{API_URL}}/contacts/contact_id \ + -H "Authorization: Bearer sk_your_secret_key" +``` + +Permanent deletion. Consider unsubscribing instead to preserve history. + +## Next steps + +- [Create segments](/concepts/segments-and-filters) +- [Track events](/concepts/events-and-triggers) +- [Use templates](/concepts/templates-types) diff --git a/apps/wiki/content/docs/concepts/events-and-triggers.mdx b/apps/wiki/content/docs/concepts/events-and-triggers.mdx new file mode 100644 index 0000000..4076b69 --- /dev/null +++ b/apps/wiki/content/docs/concepts/events-and-triggers.mdx @@ -0,0 +1,180 @@ +--- +title: Events and Triggers +description: Track behavior and trigger workflows +icon: Activity +--- + +## What are events + +Events track user actions from your application. Use them to update contact data, trigger workflows, and build segments. + +## Tracking events + +### Basic event + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'signed_up', + email: user.email + }) +}); +``` + +Creates or updates contact. Event is recorded. + +### Event with data + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'purchase_completed', + email: user.email, + data: { + plan: 'premium', + mrr: 99 + } + }) +}); +``` + +Updates contact data fields. Data persists on contact record. + +## Public vs Secret keys + +### Public Key (pk_*) + +- Safe for client-side code +- Only works with `/v1/track` +- Use in browser/mobile apps + +### Secret Key (sk_*) + +- Server-side only +- Works with all endpoints +- Full API access + +## Event naming + +Use clear, past-tense verbs with underscores: + +✅ `signed_up`, `purchase_completed`, `feature_activated` +❌ `signup`, `buy`, `feature` + +## Workflow triggers + +### Event trigger + +Workflow starts when event is tracked. + +``` +Trigger: event = "signed_up" +``` + +Track event: +```javascript +track('signed_up', 'user@example.com'); +``` + +Workflow starts for that contact. + +### Wait for event + +Workflow pauses until event occurs. + +``` +[Wait for Event: purchase_completed, timeout: 7 days] + ├─ Event occurred → [Send: Thank you] + └─ Timeout → [Send: Discount offer] +``` + +## Common patterns + +### User lifecycle + +```javascript +// Signup +track('signed_up', email, { source: 'landing' }); + +// First login +track('first_login', email, { lastLoginAt: new Date().toISOString() }); + +// Subscription +track('subscription_created', email, { plan: 'premium', mrr: 99 }); +``` + +### E-commerce + +```javascript +// Cart abandonment +track('cart_abandoned', email, { + cartTotal: 149.99, + cartUrl: `https://store.com/cart/${cartId}` +}); + +// Purchase +track('purchase_completed', email, { + orderId: order.id, + orderTotal: order.total +}); +``` + +## Event-based segments + +Track events to update contact data, then segment on that data. + +1. Track login: +```javascript +track('login', email, { + lastLoginAt: new Date().toISOString() +}); +``` + +2. Create segment: +``` +Field: lastLoginAt +Operator: greaterThan +Value: {{7_days_ago}} +``` + +Contacts auto-join segment when they log in. + +## Testing events + +**Dashboard:** Contacts → Search email → Events tab + +Shows all events for that contact. + +**Workflows:** Check executions after tracking event to verify workflow triggered. + +## Rate limits + +- Public key: 100 requests/minute +- Secret key: 1000 requests/minute + +## Common issues + +**Workflow not triggering:** +- Workflow is enabled +- Event name matches exactly (case-sensitive) +- Contact email is correct + +**Contact data not updating:** +- Field names are case-sensitive +- Values are correct type (number vs string) + +## Next steps + +- [Build event-triggered workflows](/tutorials/welcome-series-workflow) +- [Create event-based segments](/concepts/segments-and-filters) +- [Track events from your app](/tutorials/event-tracking-integration) diff --git a/apps/wiki/content/docs/concepts/index.mdx b/apps/wiki/content/docs/concepts/index.mdx new file mode 100644 index 0000000..068c32d --- /dev/null +++ b/apps/wiki/content/docs/concepts/index.mdx @@ -0,0 +1,49 @@ +--- +title: Core Concepts +description: Understand how Plunk works +icon: Lightbulb +--- + +## Email methods + +Click: {{resetLink}}
" + }' +``` + +Type: `MARKETING` or `TRANSACTIONAL` + +## Changing type + +Update anytime: + +```bash +curl -X PATCH {{API_URL}}/templates/template_id \ + -H "Authorization: Bearer sk_your_secret_key" \ + -H "Content-Type: application/json" \ + -d '{"type": "MARKETING"}' +``` + +**Warning:** Changing transactional → marketing stops sending to unsubscribed contacts. + +## Unsubscribe links + +Marketing templates auto-include unsubscribe link in footer. + +Custom placement: +```html +Unsubscribe +``` + +Transactional templates don't need unsubscribe links. + +## Choosing the right type + +Ask: "Would user be frustrated if they didn't receive this after unsubscribing?" + +**If yes → Transactional** +- Password resets +- Order confirmations +- Account alerts + +**If no → Marketing** +- Newsletters +- Product announcements +- Promotions + +## Best practices + +**Default to marketing** — Only use transactional for truly necessary emails. + +**Don't abuse transactional** — Sending marketing content via transactional templates violates regulations and damages reputation. + +**Test both states** — Verify behavior with subscribed and unsubscribed contacts. + +## Next steps + +- [Create templates](/guides/templates) +- [Manage subscriptions](/concepts/contacts-and-data) +- [Send emails](/tutorials/first-transactional-email) diff --git a/apps/wiki/content/docs/guides/templates.mdx b/apps/wiki/content/docs/guides/templates.mdx index 237ccce..39aa5e5 100644 --- a/apps/wiki/content/docs/guides/templates.mdx +++ b/apps/wiki/content/docs/guides/templates.mdx @@ -148,13 +148,15 @@ Access nested objects with dot notation: ### 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": "user@example.com", - "template": "order-confirmation", + "template": "clx123abc456", "data": { "orderNumber": "12345", "deliveryDate": "March 20" @@ -162,6 +164,35 @@ curl -X POST {{API_URL}}/v1/send \ }' ``` +**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": "user@example.com", + "template": "clx123abc456", + "subject": "Custom Subject (overrides template)", + "from": { + "name": "Custom Sender", + "email": "custom@example.com" + }, + "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. diff --git a/apps/wiki/content/docs/meta.json b/apps/wiki/content/docs/meta.json index b32dd27..c596140 100644 --- a/apps/wiki/content/docs/meta.json +++ b/apps/wiki/content/docs/meta.json @@ -3,8 +3,14 @@ "index", "---Getting Started---", "getting-started", + "---Tutorials---", + "tutorials", + "---Core Concepts---", + "concepts", "---Guides---", "guides", + "---Automation Patterns---", + "automation-patterns", "---API Reference---", "api-reference", "---Self-Hosting---", diff --git a/apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx b/apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx new file mode 100644 index 0000000..1af9db8 --- /dev/null +++ b/apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx @@ -0,0 +1,227 @@ +--- +title: Cart Abandonment Recovery +description: Recover abandoned carts with automated emails +icon: ShoppingCart +--- + +## Overview + +Send automated recovery emails when users add items to cart but don't complete purchase. Uses a two-email sequence with a discount incentive. + +## Prerequisites + +- Track `cart_abandoned` and `purchase_completed` events from your app +- Create two email templates in dashboard + +## Track cart events + +### Cart abandoned + +When user adds items but leaves without purchasing: + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'cart_abandoned', + email: user.email, + data: { + cartTotal: cart.total, + cartUrl: `https://yourstore.com/cart/${cart.id}`, + itemCount: cart.items.length, + items: cart.items.map(i => ({ + name: i.product.name, + price: i.price, + quantity: i.quantity, + imageUrl: i.product.imageUrl + })) + } + }) +}); +``` + +### Purchase completed + +When user completes checkout: + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'purchase_completed', + email: user.email, + data: { + orderId: order.id, + total: order.total + } + }) +}); +``` + +## Build the workflow + +### 1. Create workflow + +Go to **Workflows** → **Create Workflow** + +- **Name:** Cart Abandonment Recovery +- **Trigger:** Event - `cart_abandoned` +- **Allow re-entry:** Yes (users can abandon multiple times) + +### 2. Add workflow steps + +``` +[Trigger: cart_abandoned] + ↓ +[Delay: 1 hour] + ↓ +[Send Email: Cart Reminder] + ↓ +[Wait for Event: purchase_completed, timeout: 23 hours] + ├─ Purchased → [Exit] + └─ Timeout → [Send Email: Cart Discount] → [Exit] +``` + +**Step-by-step:** + +1. **Delay** (1 hour) + - Duration: 1 + - Unit: Hours + +2. **Send Email** (Cart Reminder) + - Template: Cart Reminder + - Variables: (auto-populated from event data) + +3. **Wait for Event** + - Event: `purchase_completed` + - Timeout: 23 hours + - Connect two paths: + - **Event triggered** → Exit + - **Timeout** → Continue to discount + +4. **Send Email** (Cart Discount) + - Template: Cart Discount + - Variables: + - All cart data from event + - `discountedTotal`: Calculate in template or pass from backend + +5. **Exit** + +### 3. Enable workflow + +Toggle workflow to **ON** + +## Test the workflow + +### Manual test + +1. Go to **Workflows** → Cart Abandonment Recovery → **Executions** +2. Click **Create Execution** +3. Select test contact +4. Provide test data: + +```json +{ + "cartTotal": 149.99, + "cartUrl": "https://yourstore.com/cart/test123", + "itemCount": 2, + "items": [ + { + "name": "Product A", + "price": 79.99, + "quantity": 1, + "imageUrl": "https://cdn.example.com/product-a.jpg" + }, + { + "name": "Product B", + "price": 69.99, + "quantity": 1, + "imageUrl": "https://cdn.example.com/product-b.jpg" + } + ], + "discountedTotal": 134.99 +} +``` + +5. **Start Execution** + +Watch the execution run. For faster testing, temporarily set delay to 1 minute instead of 1 hour. + +### Live test + +1. Trigger `cart_abandoned` event with your email +2. Wait 1 hour (or 1 minute if testing with shorter delay) +3. Check for first email +4. Either: + - Complete purchase → workflow ends + - Wait 23 hours → receive discount email + +## Improve conversion + +### Add cart item images + +Pass product images in event data and display in email. Visual reminders increase clicks. + +### Personalize timing + +Test different delays: +- First email: 30min, 1hr, 2hr +- Second email: 12hr, 24hr, 48hr + +Monitor which timing drives best conversion. + +### Increase discount incrementally + +Second email could offer 10%, third email (if you add one) could offer 15%. + +### Segment by cart value + +Create separate workflows for high-value carts (e.g., >$200) with different messaging or larger discounts. + +### Track discount usage + +When user applies discount code, track event: + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'discount_applied', + email: user.email, + data: { + code: 'SAVE10', + source: 'cart_abandonment_email' + } + }) +}); +``` + +This lets you measure email-driven conversions. + +## Common issues + +**Email sends but cart is already purchased** — Add a condition before sending emails to check if purchase event already occurred. + +**Cart URL expired** — Ensure cart sessions last at least 48 hours, or regenerate cart from saved items. + +**Discount code doesn't work** — Verify code exists in your system before sending email. Auto-generate unique codes per user for better tracking. + +**Too many emails** — Users abandoning multiple carts quickly will enter workflow multiple times. Consider adding a delay condition or rate limiting. + +## Next steps + +- [Track more events](/tutorials/event-tracking-integration) for behavior-based workflows +- [Build segments](/tutorials/segment-based-targeting) for high-value cart abandoners +- [Use conditions](/automation-patterns/conditional-branching) for cart value-based logic diff --git a/apps/wiki/content/docs/tutorials/event-tracking-integration.mdx b/apps/wiki/content/docs/tutorials/event-tracking-integration.mdx new file mode 100644 index 0000000..c3e403a --- /dev/null +++ b/apps/wiki/content/docs/tutorials/event-tracking-integration.mdx @@ -0,0 +1,475 @@ +--- +title: Event Tracking Integration +description: Track user behavior to trigger workflows +icon: Activity +--- + +## Overview + +Track events from your application to trigger workflows and update contact data. Events like `user_signed_up`, `purchase_completed`, `feature_used` can start automated email sequences. + +## Get your public key + +1. Go to [Settings → General]({{DASHBOARD_URL}}/settings) +2. Copy your **Public Key** (starts with `pk_`) + +Public keys are safe to use in client-side code. + +## Basic event tracking + +### JavaScript (client-side) + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'button_clicked', + email: user.email, + data: { + buttonName: 'Get Started', + page: '/pricing' + } + }) +}); +``` + +### Node.js (server-side) + +```javascript +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'user_signed_up', + email: user.email, + data: { + name: user.name, + plan: 'free', + signupDate: new Date().toISOString() + } + }) +}); +``` + +### Python + +```python +import requests +import os + +requests.post('{{API_URL}}/v1/track', + headers={ + 'Authorization': f'Bearer {os.environ["PLUNK_PUBLIC_KEY"]}', + 'Content-Type': 'application/json' + }, + json={ + 'event': 'purchase_completed', + 'email': user.email, + 'data': { + 'orderId': order.id, + 'total': order.total, + 'items': order.items + } + } +) +``` + +## Common events to track + +### User lifecycle + +```javascript +// Signup +await trackEvent('user_signed_up', user.email, { + name: user.name, + source: 'google', + plan: 'free' +}); + +// Activation +await trackEvent('first_value_achieved', user.email, { + action: 'created_first_project', + timestamp: new Date().toISOString() +}); + +// Upgrade +await trackEvent('subscription_upgraded', user.email, { + fromPlan: 'free', + toPlan: 'premium', + mrr: 99 +}); + +// Churn +await trackEvent('subscription_cancelled', user.email, { + reason: user.cancellationReason, + cancelledAt: new Date().toISOString() +}); +``` + +### Product engagement + +```javascript +// Feature usage +await trackEvent('feature_used', user.email, { + featureName: 'data_export', + timestamp: new Date().toISOString() +}); + +// Content interaction +await trackEvent('video_watched', user.email, { + videoId: 'intro-101', + duration: 300, + completed: true +}); + +// Settings changes +await trackEvent('settings_updated', user.email, { + setting: 'notifications', + value: 'enabled' +}); +``` + +### E-commerce + +```javascript +// Cart +await trackEvent('cart_abandoned', user.email, { + cartId: cart.id, + cartTotal: cart.total, + items: cart.items.map(i => i.name) +}); + +// Purchase +await trackEvent('purchase_completed', user.email, { + orderId: order.id, + total: order.total, + paymentMethod: 'credit_card' +}); + +// Review +await trackEvent('review_submitted', user.email, { + productId: product.id, + rating: 5 +}); +``` + +## Event naming conventions + +**Use lowercase with underscores:** +- ✅ `user_signed_up` +- ✅ `purchase_completed` +- ❌ `UserSignedUp` +- ❌ `purchase-completed` + +**Be specific:** +- ✅ `trial_started` +- ❌ `event` + +**Use past tense:** +- ✅ `email_opened` +- ❌ `email_open` + +## Event data best practices + +**Keep data flat when possible:** + +```javascript +// Good +{ + name: 'John', + plan: 'premium', + mrr: 99 +} + +// Works but harder to use +{ + user: { + profile: { + name: 'John' + } + } +} +``` + +**Use consistent types:** + +```javascript +// Good - number for numeric values +{ total: 99.99 } + +// Bad - string for numeric values +{ total: "99.99" } +``` + +**Use ISO dates:** + +```javascript +// Good +{ signupDate: new Date().toISOString() } + +// Okay but less flexible +{ signupDate: '2024-03-15' } +``` + +## Integrate with React + +### Context provider + +```javascript +// EventTrackingContext.js +import { createContext, useContext } from 'react'; + +const EventTrackingContext = createContext(); + +export function EventTrackingProvider({ children }) { + const trackEvent = async (event, data = {}) => { + const user = getCurrentUser(); // Your auth logic + + if (!user?.email) return; + + await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event, + email: user.email, + data: { + name: user.name, + ...data + } + }) + }); + }; + + return ( +Click here to reset: Reset Password
", + "subscribed": true + }' +``` + +Replace `sk_your_secret_key` and `user@example.com` with your values. + +### With JavaScript + +```javascript +await fetch('{{API_URL}}/v1/send', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + to: user.email, + subject: 'Reset your password', + body: `Click here: Reset Password
`, + subscribed: true + }) +}); +``` + +### With Python + +```python +import requests +import os + +requests.post('{{API_URL}}/v1/send', + headers={ + 'Authorization': f'Bearer {os.environ["PLUNK_SECRET_KEY"]}', + 'Content-Type': 'application/json' + }, + json={ + 'to': user.email, + 'subject': 'Reset your password', + 'body': f'Click here: Reset Password
', + 'subscribed': True + } +) +``` + +## Use variables + +Make emails dynamic with variables: + +```javascript +await fetch('{{API_URL}}/v1/send', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + to: user.email, + subject: 'Reset your password', + body: 'Hi {{name}}, click here: Reset
', + name: user.name, + resetLink: `https://app.com/reset/${token}`, + subscribed: true + }) +}); +``` + +Variables in the body (`{{name}}`, `{{resetLink}}`) are replaced with the values you provide. + +## Use templates + +Instead of passing HTML every time, create reusable templates. + +### Create a template + +1. Go to **Templates** → **Create Template** +2. Name: `Password Reset` +3. Type: `Transactional` +4. Subject: `Reset your password` +5. Body: +```html +Hi {{name}},
+Click here to reset your password:
+ +This link expires in 1 hour.
+``` + +### Send with template + +```javascript +await fetch('{{API_URL}}/v1/send', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + to: user.email, + template: 'password-reset', // Use template slug + name: user.name, + resetLink: `https://app.com/reset/${token}`, + subscribed: true + }) +}); +``` + +No need to pass `subject` or `body` - they come from the template. + +## Integration example + +```javascript +// Express.js password reset endpoint +app.post('/forgot-password', async (req, res) => { + const { email } = req.body; + + const user = await db.users.findOne({ email }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + await db.resetTokens.create({ userId: user.id, token, expiresAt: Date.now() + 3600000 }); + + // Send email via Plunk + await fetch('{{API_URL}}/v1/send', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + to: user.email, + subject: 'Reset your password', + body: `Click here: Reset Password
`, + subscribed: true + }) + }); + + res.json({ success: true }); +}); +``` + +## Troubleshooting + +**"Unauthorized" error** — Check your API key. Must be Secret Key (starts with `sk_`). + +**Email not received** — Check spam folder. If using custom domain, verify it in Settings → Domains. + +**Variables not replaced** — Ensure variable names match exactly (case-sensitive). + +## Next steps + +- [Build a workflow](/tutorials/welcome-series-workflow) for automated sequences +- [Set up custom domain](/guides/custom-domains) for better deliverability +- [Track events](/tutorials/event-tracking-integration) to trigger workflows diff --git a/apps/wiki/content/docs/tutorials/index.mdx b/apps/wiki/content/docs/tutorials/index.mdx new file mode 100644 index 0000000..8db94fc --- /dev/null +++ b/apps/wiki/content/docs/tutorials/index.mdx @@ -0,0 +1,37 @@ +--- +title: Tutorials +description: Step-by-step guides +icon: BookOpen +--- + +## Getting started + +Hi {{firstName ?? 'there'}},
+ +We've been busy this month. Here's what's new:
+ +Invite team members and collaborate in real-time.
+ +Everything is now 2x faster.
+ +Better insights into your data.
+ + + +Thanks,
The Team
+ You're receiving this because you subscribed to updates. + Unsubscribe +
+``` + +### Use variables + +Available variables: +- `{{firstName}}` - Contact's first name +- `{{email}}` - Contact's email +- `{{id}}` - Contact ID +- `{{unsubscribeUrl}}` - Auto-generated unsubscribe link +- Any custom contact data fields + +**Fallback values:** +```html +Hi {{firstName ?? 'there'}},
+ +``` + +## Select your audience + +### All contacts + +Sends to everyone subscribed in your account. + +### Specific segment + +1. Select **Segment** audience type +2. Choose a segment (e.g., "Premium Users") +3. Campaign sends to all contacts in that segment + +### Filtered audience + +Create custom filters for this campaign only: + +**Example filters:** +- `plan` equals `premium` +- `lastLoginAt` within `30` days +- `country` equals `United States` + +Combine with AND/OR logic. + +## Preview and test + +### Send test email + +1. Click **Send Test** +2. Enter your email address +3. Check your inbox + +Verify: +- Subject line +- Email content +- Variables are replaced +- Links work +- Unsubscribe link present + +## Send or schedule + +### Send now + +1. Click **Send Now** +2. Confirm +3. Campaign starts sending immediately + +### Schedule for later + +1. Click **Schedule** +2. Select date and time +3. Confirm + +Campaign will send automatically at scheduled time. + +## Monitor performance + +### Real-time stats + +Go to **Campaigns** → Your campaign to see: + +- **Recipients**: Total contacts targeted +- **Sent**: How many emails sent +- **Delivered**: Successfully delivered +- **Opened**: Unique opens +- **Clicked**: Unique clicks +- **Bounced**: Failed deliveries + +### Open and click rates + +- **Open rate** = Opened / Delivered × 100% +- **Click rate** = Clicked / Delivered × 100% + +**Good benchmarks:** +- Open rate: 15-25% +- Click rate: 2-5% + +### View in activity + +Go to **Activity** to see: +- Individual email opens +- Link clicks +- Delivery timeline + +## Campaign best practices + +**Subject lines:** +- Keep under 50 characters +- Avoid spam words (FREE, $$, URGENT) +- Personalize: `{{name}}, check out our new feature` +- A/B test different subject lines + +**Send timing:** +- Tuesday-Thursday perform best +- 10am-2pm in recipient's timezone +- Avoid Mondays and Fridays +- Test what works for your audience + +**Content:** +- One clear call-to-action +- Mobile-friendly design +- Keep under 500 words +- Use images sparingly (slow loading) +- Always include unsubscribe link + +**Frequency:** +- Weekly: Maximum for engaged audiences +- Monthly: Safe default +- Quarterly: Minimum to stay top-of-mind +- Don't email too often - causes unsubscribes + +## Advanced: Segment-based campaigns + +### Example: Product announcement to paying customers + +1. Create segment "Paying Customers": + - Filter: `plan` is not `free` + - Filter: `subscribed` equals `true` + +2. Create campaign: + - Subject: `New premium features just for you` + - Audience: Segment "Paying Customers" + +3. Send campaign - only goes to paying customers + +### Example: Re-engagement campaign + +1. Create segment "Inactive Users": + - Filter: `lastLoginAt` within `90` days is `false` + - Filter: `subscribed` equals `true` + +2. Create campaign: + - Subject: `We miss you! Here's what's new` + - Content: Highlight recent updates + - Special offer: 20% off upgrade + +3. Send or schedule + +## Campaign vs workflow + +**Use Campaign when:** +- One-time send (newsletter, announcement) +- Manual timing +- Same message to everyone +- No automation needed + +**Use Workflow when:** +- Multi-email sequence needed +- Trigger on user action +- Delays between emails +- Personalized paths (if/then logic) + +See [Campaigns vs Workflows](/concepts/campaigns-vs-workflows) for full comparison. + +## Duplicate and reuse + +### Duplicate a campaign + +1. Go to campaign +2. Click **Duplicate** +3. Edit content +4. Send to same or different audience + +Useful for monthly newsletters - duplicate last month's, update content. + +### Save as template + +If you'll reuse the design: + +1. **Templates** → **Create Template** +2. Paste your campaign HTML +3. Save + +Now you can create campaigns faster using the template. + +## Cancel a campaign + +### Before sending + +1. Go to campaign (status: Draft or Scheduled) +2. Click **Delete** + +### While sending + +1. Go to campaign (status: Sending) +2. Click **Cancel** +3. Stops queuing new emails (already sent emails can't be recalled) + +### After sending + +Cannot cancel or recall. Sent emails are delivered. + +## Troubleshooting + +**Campaign not sending** +- Check campaign status (Draft needs to be sent) +- Verify audience has contacts +- Ensure contacts are subscribed +- Custom domain must be verified + +**Low open rate** +- Improve subject line +- Check spam folder placement +- Verify sender email/domain +- Review send time + +**High unsubscribe rate** +- Sending too frequently +- Content not relevant +- Set better expectations at signup +- Review targeting + +**Emails going to spam** +- Verify custom domain +- Avoid spam trigger words +- Don't use all caps or excessive punctuation +- Warm up new sending domain + +## Next steps + +- [Build a workflow](/tutorials/welcome-series-workflow) for automated sequences +- [Create segments](/tutorials/segment-based-targeting) for better targeting +- [Set up custom domain](/guides/custom-domains) for better deliverability diff --git a/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx b/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx new file mode 100644 index 0000000..5afdd5c --- /dev/null +++ b/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx @@ -0,0 +1,298 @@ +--- +title: Segment-Based Targeting +description: Target specific audiences with filters +icon: Users +--- + +## Overview + +Segments are dynamic groups of contacts based on filters. Use them to send targeted campaigns or trigger workflows when contacts enter/exit segments. + +## Create a segment + +1. Go to **Segments** → **Create Segment** +2. Name: `Premium Users` +3. Description: `Users on premium or enterprise plan` + +## Add filters + +### Simple filter + +Filter by a single field: + +- Field: `plan` +- Operator: `equals` +- Value: `premium` + +All contacts where `plan` equals `premium` are in this segment. + +### Multiple filters (AND logic) + +All conditions must be true: + +- `plan` equals `premium` +- **AND** `subscribed` equals `true` +- **AND** `lastLoginAt` within `30` days + +Only premium users who are subscribed AND logged in recently. + +### Multiple filters (OR logic) + +Any condition can be true: + +- `plan` equals `premium` +- **OR** `plan` equals `enterprise` + +Users on either premium or enterprise plan. + +### Complex filters (AND + OR) + +Combine both: + +- `subscribed` equals `true` +- **AND** (`plan` equals `premium` **OR** `plan` equals `enterprise`) + +Subscribed users on premium OR enterprise plans. + +## Filter operators + +### Equals + +Exact match: +- `plan` equals `premium` +- `country` equals `United States` + +### Not equals + +Everything except: +- `plan` not equals `free` +- `status` not equals `cancelled` + +### Contains + +Partial text match: +- `email` contains `@gmail.com` +- `companyName` contains `Inc` + +### Greater than / Less than + +Numeric comparisons: +- `mrr` greater than `100` +- `age` less than `30` +- `loginCount` greater than `10` + +### Exists / Does not exist + +Field has any value: +- `phoneNumber` exists +- `referralCode` does not exist + +### Within + +Time-based (requires ISO date): +- `signupDate` within `7` days +- `lastLoginAt` within `30` days +- `trialExpiresAt` within `3` days + +## Example segments + +### Active users + +Users who logged in recently: + +- `lastLoginAt` within `7` days +- **AND** `subscribed` equals `true` + +### High-value customers + +Users spending over $100/month: + +- `mrr` greater than `100` +- **AND** `plan` is not `free` + +### Trial expiring soon + +Users whose trial ends in 3 days: + +- `trialExpiresAt` within `3` days +- **AND** `plan` equals `trial` + +### Inactive users + +Haven't logged in for 30+ days: + +- `lastLoginAt` within `30` days is `false` +- **AND** `subscribed` equals `true` +- **AND** `status` equals `active` + +**Note:** To check "NOT within", set the within filter and toggle the NOT operator. + +### Power users + +High engagement score: + +- `loginCount` greater than `50` +- **AND** `featureUsageCount` greater than `100` + +### Geographic targeting + +Specific country or region: + +- `country` equals `United States` +- **AND** `state` equals `California` + +### Feature adopters + +Used a specific feature: + +- Custom event filter: `feature_used` triggered +- **AND** event data: `featureName` equals `advanced_analytics` + +## Use segments in campaigns + +### Target a segment + +1. Create campaign +2. Audience: Select **Segment** +3. Choose your segment +4. Campaign sends only to contacts in that segment + +### Preview count + +Before sending, see how many contacts match: +- Shows estimated recipient count +- Updates in real-time as you adjust filters + +## Use segments in workflows + +### Trigger on segment entry + +Create workflow that runs when contacts enter a segment: + +1. **Workflows** → **Create Workflow** +2. Trigger: Segment `trial_expiring_soon` +3. Trigger condition: Contact **enters** segment +4. Add email: Trial expiration reminder + +When a contact enters "trial_expiring_soon" segment, workflow triggers. + +### Trigger on segment exit + +Run workflow when contacts leave a segment: + +1. Trigger: Segment `active_users` +2. Trigger condition: Contact **exits** segment +3. Add workflow: Re-engagement sequence + +When user becomes inactive (exits "active_users"), re-engagement starts. + +## Track membership changes + +Enable to trigger events when contacts enter/exit: + +1. Edit segment +2. Toggle **Track Membership** +3. Save + +Now when contacts move in/out of the segment: +- Event `segment_entry_[segment_id]` is tracked +- Event `segment_exit_[segment_id]` is tracked +- Can use these events in other workflows + +**Performance note:** Only enable for segments you'll use for triggers. Adds processing overhead. + +## Segment best practices + +**Keep it simple** +- 3-5 filters per segment max +- Avoid deeply nested conditions +- Test with expected contacts + +**Use consistent data** +- Store dates as ISO strings +- Use numbers for numeric values +- Consistent field naming + +**Name clearly** +- ✅ `Premium Users - Active` +- ❌ `Segment 1` + +**Monitor size** +- Check segment count regularly +- Too large = slow processing +- Too small = not enough data + +## Update segments + +Segments update automatically: +- When contact data changes +- When contacts are added/removed +- Typically updates within minutes + +Force refresh: +1. Go to segment +2. Click **Refresh Count** + +## Advanced: Multi-level targeting + +### Premium users in specific region + +- `plan` equals `premium` +- **AND** `country` equals `United States` +- **AND** `lastLoginAt` within `7` days + +### Churn risk scoring + +- `lastLoginAt` within `30` days is `false` +- **AND** `supportTickets` greater than `3` +- **AND** `npsScore` less than `7` + +### Upsell targeting + +- `plan` equals `free` +- **AND** `featureUsageCount` greater than `50` +- **AND** `teamSize` greater than `5` + +Users on free plan who are power users with teams (good upsell candidates). + +## Performance at scale + +Segments work efficiently with millions of contacts: + +- Indexed queries for fast filtering +- Cursor-based pagination +- Background count computation + +**Tips for large segments:** +- Use specific filters (avoid `contains` on large text fields) +- Store commonly queried data as top-level fields +- Use numeric comparisons when possible (faster than text) + +## Troubleshooting + +**Segment count is 0 but should have contacts** +- Check filter logic (AND vs OR) +- Verify field names match exactly (case-sensitive) +- Ensure contacts have the required fields +- Try simpler filters to debug + +**Segment not updating** +- Click **Refresh Count** to force update +- Check that contact data was actually updated +- Segments typically update within 5 minutes + +**Workflow not triggering on segment entry** +- Workflow is enabled +- Track Membership is enabled on segment +- Trigger is set to segment entry (not exit) + +**Too many contacts in segment** +- Filters too broad +- Add more specific conditions +- Use AND logic instead of OR + +## Next steps + +- [Send a campaign](/tutorials/newsletter-campaign) to a segment +- [Build a workflow](/tutorials/welcome-series-workflow) triggered by segment changes +- [Track events](/tutorials/event-tracking-integration) to update contact data diff --git a/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx b/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx new file mode 100644 index 0000000..b550070 --- /dev/null +++ b/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx @@ -0,0 +1,156 @@ +--- +title: Build a Welcome Series +description: Create a 3-email onboarding workflow +icon: Workflow +--- + +## What you'll build + +An automated workflow that sends 3 emails when users sign up: +- Day 0: Welcome email (immediate) +- Day 1: Feature tour (24 hours later) +- Day 3: Help offer (48 hours after that) + +## Create the workflow + +1. **Workflows** → **Create Workflow** +2. Name: `Welcome Series` +3. Trigger Event: `user_signed_up` +4. Allow Re-entry: `No` (users get this once) +5. Click **Create** + +## Build the flow + +In the visual editor: + +1. **Add Send Email** step → Select your welcome template +2. **Add Delay** step → 1 day +3. **Add Send Email** step → Select your feature tour template +4. **Add Delay** step → 2 days +5. **Add Send Email** step → Select your help offer template +6. **Add Exit** step + +Your flow: +``` +[Trigger: user_signed_up] + ↓ +[Send: Welcome] + ↓ +[Delay: 1 day] + ↓ +[Send: Feature Tour] + ↓ +[Delay: 2 days] + ↓ +[Send: Help Offer] + ↓ +[Exit] +``` + +7. **Enable** the workflow (toggle switch) + +## Track the signup event + +Add event tracking to your app when users sign up. + +### JavaScript + +```javascript +// After successful signup +await fetch('{{API_URL}}/v1/track', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: 'user_signed_up', + email: user.email, + data: { + name: user.name, + dashboardUrl: 'https://app.yourapp.com/dashboard', + docsUrl: 'https://docs.yourapp.com' + } + }) +}); +``` + +### Python + +```python +import requests + +requests.post('{{API_URL}}/v1/track', + headers={ + 'Authorization': f'Bearer {os.environ["PLUNK_PUBLIC_KEY"]}', + 'Content-Type': 'application/json' + }, + json={ + 'event': 'user_signed_up', + 'email': user.email, + 'data': { + 'name': user.name, + 'dashboardUrl': 'https://app.yourapp.com/dashboard', + 'docsUrl': 'https://docs.yourapp.com' + } + } +) +``` + +**Important:** Use your **Public Key** (starts with `pk_`) for event tracking. + +## Test the workflow + +### Manual test + +1. Go to **Workflows** → Your workflow → **Executions** tab +2. Click **Create Execution** +3. Select a test contact +4. Add test data: +```json +{ + "name": "Test User", + "dashboardUrl": "https://app.yourapp.com", + "docsUrl": "https://docs.yourapp.com" +} +``` +5. **Start Execution** + +Check your email - you should receive the welcome email immediately. The workflow will pause at the delay steps. + +### Faster testing + +For testing, temporarily change delays to 5 minutes instead of days. Test the flow, then change back. + +## Monitor performance + +1. **Workflows** → Your workflow +2. Check **Executions** to see who's in the workflow +3. Go to **Activity** to see email opens/clicks +4. Track open rates for each email + +Typical good rates: +- Email 1: 60-80% open rate +- Email 2: 40-60% open rate +- Email 3: 30-50% open rate + +## Troubleshooting + +**Workflow not triggering** — Check: +- Workflow is enabled (toggle ON) +- Event name matches exactly: `user_signed_up` +- Using Public Key for tracking +- Contact exists and is subscribed + +**Email not sending** — Check: +- Template exists +- Contact is subscribed +- Variables in event data match template variables + +**Duplicate emails** — Ensure `Allow Re-entry` is `No`. + +## Next steps + +- [Add conditional logic](/automation-patterns/conditional-branching) for different user types +- [Track more events](/tutorials/event-tracking-integration) to trigger workflows +- [Build cart abandonment](/tutorials/cart-abandonment-automation) workflow diff --git a/apps/wiki/openapi.json b/apps/wiki/openapi.json index 82ee4c9..e35ba59 100644 --- a/apps/wiki/openapi.json +++ b/apps/wiki/openapi.json @@ -251,7 +251,7 @@ }, "template": { "type": "string", - "description": "Template identifier to use" + "description": "Template ID to use for this email. When provided, uses the template's subject, body, from, and reply-to settings. You can override these by explicitly providing subject, body, from, or reply fields in the request. Template variables are populated from the data field." }, "from": { "oneOf": [ @@ -384,13 +384,30 @@ } }, "withTemplate": { - "summary": "Using template", + "summary": "Using a template", + "description": "Send email using a template. Provide the template ID and any data for template variables. The template's subject, body, from address, and reply-to will be used automatically.", "value": { "to": "user@example.com", - "template": "welcome-email", + "template": "clx123abc456", "data": { "firstName": "John", - "lastName": "Doe" + "lastName": "Doe", + "resetCode": { + "value": "ABC123", + "persistent": false + } + } + } + }, + "withTemplateOverride": { + "summary": "Using template with overrides", + "description": "You can override template values by providing subject, body, from, or reply fields. This example overrides the template's subject line.", + "value": { + "to": "user@example.com", + "template": "clx123abc456", + "subject": "Custom Subject Override", + "data": { + "firstName": "Jane" } } },