Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
---
|
||||
title: Error Codes
|
||||
description: API error codes and troubleshooting
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Plunk API uses standardized error responses to help you quickly identify and resolve issues. All errors include:
|
||||
|
||||
- **Machine-readable error codes** for programmatic handling
|
||||
- **Human-readable messages** explaining what went wrong
|
||||
- **Helpful suggestions** to guide you toward a solution
|
||||
- **Request IDs** for debugging and support requests
|
||||
- **Field-level validation details** when applicable
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
### 200 OK
|
||||
Request successful.
|
||||
|
||||
### 201 Created
|
||||
Resource created successfully.
|
||||
|
||||
### 400 Bad Request
|
||||
Invalid request format or parameters. Check the error details for specific issues.
|
||||
|
||||
### 401 Unauthorized
|
||||
Authentication failed or missing. Verify your API key.
|
||||
|
||||
### 403 Forbidden
|
||||
Not authorized to access this resource. Check permissions or project status.
|
||||
|
||||
### 404 Not Found
|
||||
The requested resource does not exist. Verify the resource ID.
|
||||
|
||||
### 422 Unprocessable Entity
|
||||
Request validation failed. Check the `errors` array for field-level details.
|
||||
|
||||
### 429 Too Many Requests
|
||||
Rate limit exceeded. Wait before retrying or upgrade your plan.
|
||||
|
||||
### 500 Internal Server Error
|
||||
An unexpected server error occurred. Contact support with the request ID.
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All errors follow this standardized format:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Request validation failed",
|
||||
"statusCode": 422,
|
||||
"requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
"errors": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Invalid email",
|
||||
"code": "invalid_string"
|
||||
}
|
||||
],
|
||||
"suggestion": "One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `success` | boolean | Always `false` for errors |
|
||||
| `error.code` | string | Machine-readable error code (see below) |
|
||||
| `error.message` | string | Human-readable error description |
|
||||
| `error.statusCode` | number | HTTP status code |
|
||||
| `error.requestId` | string | Unique request identifier for debugging |
|
||||
| `error.errors` | array | Field-level validation errors (validation errors only) |
|
||||
| `error.details` | object | Additional context about the error (optional) |
|
||||
| `error.suggestion` | string | Helpful guidance for fixing the error (optional) |
|
||||
| `timestamp` | string | ISO 8601 timestamp when the error occurred |
|
||||
|
||||
## Error Codes Reference
|
||||
|
||||
All errors include a machine-readable `code` field for programmatic handling. Here are all possible error codes:
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
| Code | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `UNAUTHORIZED` | 401 | General authentication failure |
|
||||
| `INVALID_CREDENTIALS` | 401 | Login credentials are incorrect |
|
||||
| `MISSING_AUTH` | 401 | Authorization header is missing or malformed |
|
||||
| `INVALID_API_KEY` | 401 | API key is invalid or not found |
|
||||
| `FORBIDDEN` | 403 | Not allowed to perform this action |
|
||||
| `PROJECT_ACCESS_DENIED` | 403 | No access to this project |
|
||||
| `PROJECT_DISABLED` | 403 | Project has been disabled |
|
||||
|
||||
### Validation & Input Errors
|
||||
|
||||
| Code | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `BAD_REQUEST` | 400 | General request error |
|
||||
| `VALIDATION_ERROR` | 422 | Request validation failed (includes field errors) |
|
||||
| `INVALID_EMAIL` | 422 | Email format is invalid |
|
||||
| `INVALID_REQUEST_BODY` | 400 | Request body is malformed |
|
||||
| `MISSING_REQUIRED_FIELD` | 422 | Required field is missing |
|
||||
|
||||
### Resource Errors
|
||||
|
||||
| Code | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `RESOURCE_NOT_FOUND` | 404 | Generic resource not found |
|
||||
| `CONTACT_NOT_FOUND` | 404 | Contact does not exist |
|
||||
| `TEMPLATE_NOT_FOUND` | 404 | Template does not exist |
|
||||
| `CAMPAIGN_NOT_FOUND` | 404 | Campaign does not exist |
|
||||
| `WORKFLOW_NOT_FOUND` | 404 | Workflow does not exist |
|
||||
| `CONFLICT` | 409 | Resource conflict (e.g., duplicate) |
|
||||
|
||||
### Rate Limiting & Billing
|
||||
|
||||
| Code | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests |
|
||||
| `BILLING_LIMIT_EXCEEDED` | 402 | Billing limit reached |
|
||||
| `UPGRADE_REQUIRED` | 402 | Feature requires plan upgrade |
|
||||
|
||||
### Server Errors
|
||||
|
||||
| Code | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `INTERNAL_SERVER_ERROR` | 500 | Unexpected server error |
|
||||
| `DATABASE_ERROR` | 500 | Database operation failed |
|
||||
| `EXTERNAL_SERVICE_ERROR` | 500 | External service unavailable |
|
||||
|
||||
## Common Error Examples
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
#### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "INVALID_API_KEY",
|
||||
"message": "Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.",
|
||||
"statusCode": 401,
|
||||
"requestId": "abc-123",
|
||||
"suggestion": "Verify your API key is correct and starts with \"sk_\" for secret keys or \"pk_\" for public keys."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Check your API key is correct. Secret endpoints require keys starting with `sk_`, while tracking endpoints use `pk_` keys.
|
||||
|
||||
#### Missing Authorization Header
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "MISSING_AUTH",
|
||||
"message": "Authorization header is required",
|
||||
"statusCode": 401,
|
||||
"requestId": "abc-123",
|
||||
"suggestion": "Include an Authorization header with format: \"Authorization: Bearer YOUR_API_KEY\""
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Add the `Authorization` header with your API key in Bearer token format.
|
||||
|
||||
### Validation Errors
|
||||
|
||||
#### Invalid Email Format
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Request validation failed",
|
||||
"statusCode": 422,
|
||||
"requestId": "abc-123",
|
||||
"errors": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Invalid email",
|
||||
"code": "invalid_string"
|
||||
}
|
||||
],
|
||||
"suggestion": "One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Provide a valid email address. The `errors` array shows which fields failed validation.
|
||||
|
||||
#### Missing Required Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Request validation failed",
|
||||
"statusCode": 422,
|
||||
"requestId": "abc-123",
|
||||
"errors": [
|
||||
{
|
||||
"field": "event",
|
||||
"message": "Required",
|
||||
"code": "invalid_type"
|
||||
},
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Required",
|
||||
"code": "invalid_type"
|
||||
}
|
||||
],
|
||||
"suggestion": "Required fields are missing. Ensure all required fields are included in your request."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Include all required fields in your request body.
|
||||
|
||||
### Resource Errors
|
||||
|
||||
#### Template Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "TEMPLATE_NOT_FOUND",
|
||||
"message": "Template with ID \"tpl_abc123\" was not found",
|
||||
"statusCode": 404,
|
||||
"requestId": "abc-123",
|
||||
"details": {
|
||||
"resource": "Template",
|
||||
"id": "tpl_abc123"
|
||||
},
|
||||
"suggestion": "Ensure the template ID is correct and belongs to your project. You can list available templates via the API."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Verify the template ID exists and belongs to your project.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
#### Rate Limit Exceeded
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"message": "Rate limit exceeded. Please try again later.",
|
||||
"statusCode": 429,
|
||||
"requestId": "abc-123",
|
||||
"suggestion": "You have exceeded the rate limit. Wait a moment before retrying, or upgrade your plan."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Implement exponential backoff and retry logic. Consider upgrading your plan for higher limits.
|
||||
|
||||
## Success Response Format
|
||||
|
||||
Successful API requests return a standardized format with `success: true` and a `data` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"contact": "cnt_abc123",
|
||||
"event": "evt_xyz789",
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `success` | boolean | Always `true` for successful requests |
|
||||
| `data` | object | Response data specific to the endpoint |
|
||||
|
||||
## Handling Errors in Your Code
|
||||
|
||||
### JavaScript/TypeScript Example
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const response = await fetch('https://api.useplunk.com/v1/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${publicKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event: 'purchase',
|
||||
email: '[email protected]'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
// Handle error
|
||||
console.error(`Error [${data.error.code}]:`, data.error.message);
|
||||
|
||||
// Show suggestion to user
|
||||
if (data.error.suggestion) {
|
||||
console.log('Suggestion:', data.error.suggestion);
|
||||
}
|
||||
|
||||
// Log request ID for support
|
||||
console.log('Request ID:', data.error.requestId);
|
||||
|
||||
// Handle specific error types
|
||||
switch (data.error.code) {
|
||||
case 'VALIDATION_ERROR':
|
||||
// Show field-level errors
|
||||
data.error.errors?.forEach(err => {
|
||||
console.log(`${err.field}: ${err.message}`);
|
||||
});
|
||||
break;
|
||||
case 'INVALID_API_KEY':
|
||||
// Prompt user to check their API key
|
||||
break;
|
||||
case 'RATE_LIMIT_EXCEEDED':
|
||||
// Implement retry with backoff
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle success
|
||||
console.log('Event tracked:', data.data);
|
||||
} catch (error) {
|
||||
console.error('Network error:', error);
|
||||
}
|
||||
```
|
||||
|
||||
### Python Example
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
'https://api.useplunk.com/v1/track',
|
||||
headers={
|
||||
'Authorization': f'Bearer {public_key}',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
json={
|
||||
'event': 'purchase',
|
||||
'email': '[email protected]'
|
||||
}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success'):
|
||||
error = data['error']
|
||||
print(f"Error [{error['code']}]: {error['message']}")
|
||||
|
||||
# Show suggestion
|
||||
if 'suggestion' in error:
|
||||
print(f"Suggestion: {error['suggestion']}")
|
||||
|
||||
# Log request ID
|
||||
print(f"Request ID: {error['requestId']}")
|
||||
|
||||
# Handle validation errors
|
||||
if error['code'] == 'VALIDATION_ERROR':
|
||||
for err in error.get('errors', []):
|
||||
print(f"{err['field']}: {err['message']}")
|
||||
else:
|
||||
print(f"Event tracked: {data['data']}")
|
||||
```
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Using Request IDs
|
||||
|
||||
Every error includes a unique `requestId` that traces the request through our entire system. Request IDs enable:
|
||||
|
||||
**For Developers:**
|
||||
- Every log entry includes the request ID
|
||||
- You can grep logs to find all entries for a specific request
|
||||
- Trace a request from API → Database → Queue → Worker
|
||||
|
||||
**For Support:**
|
||||
1. Include the request ID in your message
|
||||
2. Describe what you were trying to do
|
||||
3. Share the full error response if possible
|
||||
|
||||
**Example:** If you receive request ID `f47ac10b-58cc-4372-a567-0e02b2c3d479`, we can search our logs for that ID and see:
|
||||
- The exact request body you sent
|
||||
- Which database queries were executed
|
||||
- Any background jobs that were triggered
|
||||
- The full error stack trace (if applicable)
|
||||
|
||||
This helps us quickly locate and diagnose the issue without asking you for additional information.
|
||||
|
||||
#### How to Find Request IDs
|
||||
|
||||
Request IDs are included in:
|
||||
- **Error responses**: `error.requestId` field
|
||||
- **Response headers**: `X-Request-ID` header (also included in successful responses)
|
||||
- **Your application logs**: Include the header in your logs for correlation
|
||||
|
||||
```javascript
|
||||
// Example: Logging request ID in your application
|
||||
const response = await fetch('https://api.useplunk.com/v1/send', {
|
||||
// ... your request
|
||||
});
|
||||
|
||||
const requestId = response.headers.get('X-Request-ID');
|
||||
console.log('Request ID:', requestId); // Log for correlation
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Error:', data.error.message);
|
||||
console.error('Request ID:', data.error.requestId); // Same as header
|
||||
}
|
||||
```
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### Authentication Issues (401)
|
||||
|
||||
**Problem**: `INVALID_API_KEY` or `MISSING_AUTH`
|
||||
|
||||
**Solutions**:
|
||||
- Verify your API key is copied correctly (no extra spaces)
|
||||
- Check you're using the right key type (`sk_` for secret, `pk_` for public)
|
||||
- Ensure the `Authorization` header uses Bearer token format
|
||||
- Verify the key hasn't been revoked or regenerated
|
||||
|
||||
#### Validation Issues (422)
|
||||
|
||||
**Problem**: `VALIDATION_ERROR` with field errors
|
||||
|
||||
**Solutions**:
|
||||
- Check the `errors` array for specific field issues
|
||||
- Verify all required fields are included
|
||||
- Ensure field types match (strings quoted, numbers unquoted)
|
||||
- Review the API reference for correct request format
|
||||
|
||||
#### Not Found Issues (404)
|
||||
|
||||
**Problem**: `TEMPLATE_NOT_FOUND`, `CONTACT_NOT_FOUND`, etc.
|
||||
|
||||
**Solutions**:
|
||||
- Verify the resource ID is correct
|
||||
- Check the resource belongs to your project
|
||||
- Ensure the resource hasn't been deleted
|
||||
- List available resources via the API to confirm IDs
|
||||
|
||||
#### Rate Limit Issues (429)
|
||||
|
||||
**Problem**: `RATE_LIMIT_EXCEEDED`
|
||||
|
||||
**Solutions**:
|
||||
- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries)
|
||||
- Reduce request frequency
|
||||
- Consider upgrading your plan for higher limits
|
||||
- Batch operations when possible
|
||||
|
||||
#### Server Errors (500)
|
||||
|
||||
**Problem**: `INTERNAL_SERVER_ERROR`
|
||||
|
||||
**Solutions**:
|
||||
- Note the request ID from the error response
|
||||
- Wait a moment and retry the request
|
||||
- Check [status.useplunk.com](https://status.useplunk.com) for incidents
|
||||
- Contact support with the request ID if the issue persists
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Always check the `success` field** before processing responses
|
||||
2. **Log request IDs** for debugging and support requests
|
||||
3. **Handle errors gracefully** with user-friendly messages
|
||||
4. **Implement retry logic** with exponential backoff for transient errors
|
||||
5. **Monitor error rates** to detect issues early
|
||||
6. **Use error codes** for programmatic error handling, not just status codes
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you continue experiencing issues:
|
||||
|
||||
1. Review the error `suggestion` field for guidance
|
||||
2. Check the [API Reference](/api-reference/overview) for correct usage
|
||||
3. Search our documentation for your specific error code
|
||||
4. Contact support with your request ID
|
||||
5. Join our community for help from other developers
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"title": "API Reference",
|
||||
"pages": [
|
||||
"overview",
|
||||
"---Public API---",
|
||||
"public-api/sendEmail",
|
||||
"public-api/trackEvent",
|
||||
"---Resources---",
|
||||
"contacts",
|
||||
"templates",
|
||||
"campaigns",
|
||||
"segments",
|
||||
"---Reference---",
|
||||
"errors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: API Reference
|
||||
description: Complete Plunk API documentation
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
{{API_URL}}
|
||||
```
|
||||
|
||||
All API requests use this base URL.
|
||||
|
||||
## Authentication
|
||||
|
||||
Include your API key in the `Authorization` header:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
- **Secret Key (sk_*)** — Required for all endpoints except `/v1/track`
|
||||
- **Public Key (pk_*)** — Only works with `/v1/track` for client-side event tracking
|
||||
|
||||
## Making requests
|
||||
|
||||
### Send transactional email
|
||||
|
||||
```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": "Hello",
|
||||
"body": "<p>Your message here</p>"
|
||||
}'
|
||||
```
|
||||
|
||||
### Track event
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/v1/track \
|
||||
-H "Authorization: Bearer pk_your_public_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "[email protected]",
|
||||
"event": "signed_up"
|
||||
}'
|
||||
```
|
||||
|
||||
### Create 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": "John",
|
||||
"plan": "pro"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Response format
|
||||
|
||||
All API responses follow a standardized format for easy parsing and error handling.
|
||||
|
||||
### Success response
|
||||
|
||||
Public API endpoints (`/v1/send`, `/v1/track`):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"contact": "cnt_abc123",
|
||||
"event": "evt_xyz789",
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dashboard API endpoints (contacts, templates, campaigns):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "cnt_abc123",
|
||||
"email": "[email protected]",
|
||||
"createdAt": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
List endpoints with pagination:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [...],
|
||||
"nextCursor": "abc123",
|
||||
"hasMore": true,
|
||||
"total": 1000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error response
|
||||
|
||||
All errors include detailed information to help you debug issues:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Request validation failed",
|
||||
"statusCode": 422,
|
||||
"requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
"errors": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Invalid email",
|
||||
"code": "invalid_string"
|
||||
}
|
||||
],
|
||||
"suggestion": "One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false."
|
||||
},
|
||||
"timestamp": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error fields:**
|
||||
- `code` — Machine-readable error code for programmatic handling
|
||||
- `message` — Human-readable description
|
||||
- `statusCode` — HTTP status code
|
||||
- `requestId` — Unique ID for debugging (include when contacting support)
|
||||
- `errors` — Field-level validation details (when applicable)
|
||||
- `suggestion` — Helpful guidance for fixing the error
|
||||
|
||||
See the [Error Codes documentation](/api-reference/errors) for complete details and examples.
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints support cursor-based pagination:
|
||||
|
||||
```bash
|
||||
GET /contacts?limit=100&cursor=abc123
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `limit` — Number of items per page (default: 20, max: 100)
|
||||
- `cursor` — Pagination cursor from previous response
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"nextCursor": "def456",
|
||||
"hasMore": true,
|
||||
"total": 10000
|
||||
}
|
||||
```
|
||||
|
||||
Use `nextCursor` for the next page. When `hasMore` is false, you've reached the end.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Plunk enforces reasonable rate limits to ensure service quality:
|
||||
|
||||
- **Email sending** — 14 emails/second (AWS SES default)
|
||||
- **API requests** — 1000 requests/minute per project
|
||||
- **Bulk operations** — Automatically queued for processing
|
||||
|
||||
If you exceed limits, you'll receive a `429 Too Many Requests` response.
|
||||
|
||||
## Error codes
|
||||
|
||||
The API uses standard HTTP status codes along with machine-readable error codes:
|
||||
|
||||
**400 Bad Request** — Invalid request parameters or malformed request body
|
||||
|
||||
**401 Unauthorized** — Missing or invalid API key
|
||||
|
||||
**403 Forbidden** — Not authorized to access this resource or project disabled
|
||||
|
||||
**404 Not Found** — Resource doesn't exist
|
||||
|
||||
**422 Unprocessable Entity** — Request validation failed (see `errors` array for details)
|
||||
|
||||
**429 Too Many Requests** — Rate limit exceeded
|
||||
|
||||
**500 Internal Server Error** — An unexpected error occurred (contact support with request ID)
|
||||
|
||||
For a complete list of error codes and troubleshooting guidance, see the [Error Codes documentation](/api-reference/errors).
|
||||
|
||||
## API endpoints
|
||||
|
||||
### Public API (transactional)
|
||||
|
||||
**POST /v1/send** — Send transactional email(s)
|
||||
- Accepts single or multiple recipients
|
||||
- Template or inline content
|
||||
- Variable substitution
|
||||
|
||||
**POST /v1/track** — Track event for contact
|
||||
- Creates/updates contact
|
||||
- Tracks custom event
|
||||
- Can use public key
|
||||
|
||||
### Contacts
|
||||
|
||||
**GET /contacts** — List all contacts
|
||||
**POST /contacts** — Create new contact
|
||||
**GET /contacts/:id** — Get contact details
|
||||
**PATCH /contacts/:id** — Update contact
|
||||
**DELETE /contacts/:id** — Delete contact
|
||||
|
||||
### Templates
|
||||
|
||||
**GET /templates** — List all templates
|
||||
**POST /templates** — Create new template
|
||||
**GET /templates/:id** — Get template details
|
||||
**PATCH /templates/:id** — Update template
|
||||
**DELETE /templates/:id** — Delete template
|
||||
|
||||
### Campaigns
|
||||
|
||||
**GET /campaigns** — List all campaigns
|
||||
**POST /campaigns** — Create new campaign
|
||||
**GET /campaigns/:id** — Get campaign details
|
||||
**PATCH /campaigns/:id** — Update campaign
|
||||
**POST /campaigns/:id/send** — Send or schedule campaign
|
||||
**POST /campaigns/:id/cancel** — Cancel scheduled campaign
|
||||
**POST /campaigns/:id/test** — Send test email
|
||||
**GET /campaigns/:id/stats** — Get campaign analytics
|
||||
|
||||
### Segments
|
||||
|
||||
**GET /segments** — List all segments
|
||||
**POST /segments** — Create new segment
|
||||
**GET /segments/:id** — Get segment details
|
||||
**PATCH /segments/:id** — Update segment
|
||||
**DELETE /segments/:id** — Delete segment
|
||||
**GET /segments/:id/contacts** — List segment members
|
||||
|
||||
### Workflows
|
||||
|
||||
**GET /workflows** — List all workflows
|
||||
**POST /workflows** — Create new workflow
|
||||
**GET /workflows/:id** — Get workflow details
|
||||
**PATCH /workflows/:id** — Update workflow
|
||||
**DELETE /workflows/:id** — Delete workflow
|
||||
**GET /workflows/:id/executions** — List workflow executions
|
||||
|
||||
### Events
|
||||
|
||||
**GET /events** — List all events
|
||||
**GET /events/names** — List unique event names
|
||||
|
||||
### Domains
|
||||
|
||||
**GET /domains** — List verified domains
|
||||
**POST /domains** — Add domain for verification
|
||||
**DELETE /domains/:id** — Remove domain
|
||||
|
||||
## Client libraries
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
const PLUNK_SECRET_KEY = process.env.PLUNK_SECRET_KEY;
|
||||
|
||||
async function sendEmail(to, subject, body) {
|
||||
const response = await fetch('{{API_URL}}/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ to, subject, body })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(`[${data.error.code}] ${data.error.message}`);
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
PLUNK_SECRET_KEY = os.environ['PLUNK_SECRET_KEY']
|
||||
|
||||
def send_email(to, subject, body):
|
||||
response = requests.post(
|
||||
'{{API_URL}}/v1/send',
|
||||
headers={
|
||||
'Authorization': f'Bearer {PLUNK_SECRET_KEY}',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
json={'to': to, 'subject': subject, 'body': body}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success'):
|
||||
error = data['error']
|
||||
raise Exception(f"[{error['code']}] {error['message']}")
|
||||
|
||||
return data['data']
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/v1/send \
|
||||
-H "Authorization: Bearer $PLUNK_SECRET_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"to": "[email protected]", "subject": "Hello", "body": "Message"}'
|
||||
```
|
||||
|
||||
## What's next
|
||||
|
||||
- [Send your first email](/getting-started/quick-start)
|
||||
- [View detailed endpoint docs](/api-reference/public-api)
|
||||
- [Check error codes](/api-reference/errors)
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Understanding API keys and authentication
|
||||
---
|
||||
|
||||
## Two types of API keys
|
||||
|
||||
Each project has two API keys for different purposes:
|
||||
|
||||
### Secret Key (sk_*)
|
||||
|
||||
**Use for:** All server-side API calls
|
||||
|
||||
- Required for `/v1/send` (sending emails)
|
||||
- Required for all dashboard API endpoints (contacts, campaigns, templates, etc.)
|
||||
- Can access and modify all project data
|
||||
- **Never expose in client-side code**
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Server-side only
|
||||
fetch('{{API_URL}}/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer sk_your_secret_key',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: '[email protected]',
|
||||
subject: 'Hello',
|
||||
body: '<p>Your order is ready!</p>'
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Public Key (pk_*)
|
||||
|
||||
**Use for:** Client-side event tracking only
|
||||
|
||||
- Works **only** with `/v1/track` endpoint
|
||||
- Cannot send emails or access any other endpoints
|
||||
- Safe to include in frontend JavaScript
|
||||
- Use for tracking user behavior from web browsers or mobile apps
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Client-side safe
|
||||
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' }
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
## Finding your API keys
|
||||
|
||||
1. Log into [Plunk dashboard]({{DASHBOARD_URL}})
|
||||
2. Select your project
|
||||
3. Go to **Settings > API Keys**
|
||||
4. Copy the key you need
|
||||
|
||||
## Using API keys
|
||||
|
||||
All authenticated requests use the `Authorization` header with Bearer token format:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer sk_your_secret_key
|
||||
```
|
||||
|
||||
### cURL example
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/v1/send \
|
||||
-H "Authorization: Bearer sk_your_secret_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"to": "[email protected]", "subject": "Test", "body": "Hello"}'
|
||||
```
|
||||
|
||||
### Node.js example
|
||||
|
||||
```javascript
|
||||
const PLUNK_SECRET_KEY = process.env.PLUNK_SECRET_KEY;
|
||||
|
||||
const response = await fetch('{{API_URL}}/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Hello'
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Python example
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
PLUNK_SECRET_KEY = os.environ.get('PLUNK_SECRET_KEY')
|
||||
|
||||
response = requests.post(
|
||||
'{{API_URL}}/v1/send',
|
||||
headers={
|
||||
'Authorization': f'Bearer {PLUNK_SECRET_KEY}',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
json={
|
||||
'to': '[email protected]',
|
||||
'subject': 'Test',
|
||||
'body': 'Hello'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Security best practices
|
||||
|
||||
### Store secret keys securely
|
||||
|
||||
Never commit secret keys to version control. Use environment variables:
|
||||
|
||||
```bash
|
||||
# .env file (add to .gitignore)
|
||||
PLUNK_SECRET_KEY=sk_your_secret_key
|
||||
```
|
||||
|
||||
### Rotate compromised keys
|
||||
|
||||
If a secret key is exposed:
|
||||
|
||||
1. Go to **Settings > API Keys**
|
||||
2. Click **Regenerate Secret Key**
|
||||
3. Update your application with the new key
|
||||
4. The old key stops working immediately
|
||||
|
||||
### Use the right key for the job
|
||||
|
||||
- **Sending emails from your backend?** → Use secret key
|
||||
- **Tracking events from frontend?** → Use public key
|
||||
- **Managing contacts via API?** → Use secret key
|
||||
- **Building a workflow dashboard?** → Use secret key
|
||||
|
||||
When in doubt, if it's not `/v1/track`, you need the secret key.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you're authenticated, you can:
|
||||
|
||||
- [Send your first email](/getting-started/quick-start)
|
||||
- [Explore the API](/api-reference/overview)
|
||||
- [Manage contacts](/guides/contacts)
|
||||
- [Create campaigns](/guides/campaigns)
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: Get started with Plunk
|
||||
---
|
||||
|
||||
## What is Plunk?
|
||||
|
||||
Plunk is an open-source email platform built on AWS SES. It provides a complete solution for transactional emails, marketing campaigns, and automated workflows—everything you need to manage email communications at scale.
|
||||
|
||||
## What you can build
|
||||
|
||||
### Send transactional emails
|
||||
Send password resets, order confirmations, and notifications via API with template support and variable substitution.
|
||||
|
||||
```javascript
|
||||
await fetch('{{API_URL}}/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer sk_your_secret_key',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: '[email protected]',
|
||||
subject: 'Welcome {{name}}!',
|
||||
body: '<h1>Hello {{name}}</h1><p>Thanks for signing up.</p>',
|
||||
data: { name: 'John' }
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Run marketing campaigns
|
||||
Send newsletters and product updates to segments of your audience. Schedule sends or deliver immediately.
|
||||
|
||||
### Automate email sequences
|
||||
Build drip campaigns, onboarding flows, and behavior-triggered emails with the visual workflow builder.
|
||||
|
||||
## Core concepts
|
||||
|
||||
**Contacts** — People in your audience. Each has an email, subscription status, and custom data fields.
|
||||
|
||||
**Templates** — Reusable email designs. Marketing templates respect subscription status; transactional templates always send.
|
||||
|
||||
**Segments** — Dynamic groups based on contact data. Update automatically as data changes.
|
||||
|
||||
**Campaigns** — One-time email broadcasts to all contacts, a segment, or filtered audience.
|
||||
|
||||
**Workflows** — Automated sequences with delays, conditions, and event triggers.
|
||||
|
||||
**Events** — Track user actions (signups, purchases) to trigger workflows and build segments.
|
||||
|
||||
## Authentication
|
||||
|
||||
Each project has two API keys:
|
||||
|
||||
- **Secret Key (sk_*)** — Server-side only. Required for all endpoints except event tracking.
|
||||
- **Public Key (pk_*)** — Client-side safe. Only works with `/v1/track` for event tracking.
|
||||
|
||||
Never expose secret keys in frontend code.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Quick Start](/getting-started/quick-start) — Send your first email in 5 minutes
|
||||
- [Authentication](/getting-started/authentication) — Understand API keys
|
||||
- [API Reference](/api-reference/overview) — Explore all endpoints
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Getting Started",
|
||||
"pages": ["introduction", "authentication", "quick-start"]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Send your first email in 5 minutes
|
||||
---
|
||||
|
||||
## Send your first email
|
||||
|
||||
### 1. Get your API key
|
||||
|
||||
1. Sign up at [Plunk dashboard]({{DASHBOARD_URL}})
|
||||
2. Create or select a project
|
||||
3. Go to **Settings > API Keys**
|
||||
4. Copy your **Secret Key** (starts with `sk_`)
|
||||
|
||||
### 2. Send an email
|
||||
|
||||
```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": "Hello from Plunk",
|
||||
"body": "<h1>It works!</h1><p>Your first email via Plunk.</p>"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"emails": [{
|
||||
"contact": {
|
||||
"id": "contact_abc123",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
"email": "email_xyz789"
|
||||
}],
|
||||
"timestamp": "2024-03-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The email is queued and delivers within seconds.
|
||||
|
||||
## Use variables
|
||||
|
||||
Make emails dynamic with template 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": "Welcome {{firstName}}!",
|
||||
"body": "<h1>Hello {{firstName}}</h1><p>Welcome to {{companyName}}.</p>",
|
||||
"data": {
|
||||
"firstName": "Sarah",
|
||||
"companyName": "Acme Inc"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Variables use `{{variableName}}` syntax and pull from the `data` object.
|
||||
|
||||
## Send to multiple recipients
|
||||
|
||||
Pass an array to send the same email to multiple people:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"to": ["[email protected]", "[email protected]", "[email protected]"],
|
||||
"subject": "Team update",
|
||||
"body": "<p>Check out our new features!</p>"
|
||||
}
|
||||
```
|
||||
|
||||
Each recipient gets their own email with personalized data if provided.
|
||||
|
||||
## Use saved templates
|
||||
|
||||
Create reusable templates in the dashboard, then reference them by ID:
|
||||
|
||||
```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": "welcome-email-template-id",
|
||||
"data": {
|
||||
"firstName": "Sarah",
|
||||
"verificationUrl": "https://example.com/verify/abc123"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
The template's subject, body, and sender settings are used automatically. Your `data` fills in the template variables.
|
||||
|
||||
## Track events
|
||||
|
||||
Track user actions to trigger workflows and build segments:
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/v1/track \
|
||||
-H "Authorization: Bearer pk_your_public_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "[email protected]",
|
||||
"event": "signed_up",
|
||||
"data": {
|
||||
"plan": "pro",
|
||||
"source": "landing_page"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
This creates or updates the contact and tracks the event. Use events to trigger automated workflows.
|
||||
|
||||
## What's next
|
||||
|
||||
**Set up workflows** — Build automated email sequences with [workflows](/guides/workflows)
|
||||
|
||||
**Manage contacts** — Import and segment your audience with [contacts](/guides/contacts)
|
||||
|
||||
**Send campaigns** — Broadcast to your entire list with [campaigns](/guides/campaigns)
|
||||
|
||||
**Track engagement** — Monitor opens and clicks with [analytics](/guides/analytics)
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
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}}`
|
||||
- A/B 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
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,436 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,385 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Guides",
|
||||
"pages": [
|
||||
"contacts",
|
||||
"templates",
|
||||
"campaigns",
|
||||
"segments",
|
||||
"workflows",
|
||||
"events",
|
||||
"webhooks",
|
||||
"analytics",
|
||||
"custom-domains",
|
||||
"billing-limits",
|
||||
"scaling-email",
|
||||
"troubleshooting"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,342 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
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
|
||||
|
||||
```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": "order-confirmation",
|
||||
"data": {
|
||||
"orderNumber": "12345",
|
||||
"deliveryDate": "March 20"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,427 @@
|
||||
---
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,445 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Welcome to Plunk
|
||||
description: Open-source email platform for developers
|
||||
icon: House
|
||||
---
|
||||
|
||||
## What is Plunk?
|
||||
|
||||
Plunk is an open-source email platform designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built with modern technologies and designed to handle millions of contacts, Plunk provides everything you need to manage your email communications.
|
||||
|
||||
## Key Features
|
||||
|
||||
<Cards>
|
||||
<Card title="Transactional Emails" href="/api-reference/public-api">
|
||||
Send transactional emails via API with template support and variable substitution
|
||||
</Card>
|
||||
<Card title="Email Campaigns" href="/guides/campaigns">
|
||||
Create and send one-time email broadcasts to your contacts with advanced scheduling
|
||||
</Card>
|
||||
<Card title="Marketing Automation" href="/guides/workflows">
|
||||
Build automated email sequences with visual workflow builder and conditional logic
|
||||
</Card>
|
||||
<Card title="Contact Management" href="/guides/contacts">
|
||||
Manage millions of contacts with custom fields and CSV import capabilities
|
||||
</Card>
|
||||
<Card title="Dynamic Segmentation" href="/guides/segments">
|
||||
Create dynamic audience segments based on contact data and behavior
|
||||
</Card>
|
||||
<Card title="Event Tracking" href="/guides/events">
|
||||
Track events and use them to trigger workflows and segment audiences
|
||||
</Card>
|
||||
<Card title="Email Templates" href="/guides/templates">
|
||||
Create reusable email templates for transactional and marketing emails
|
||||
</Card>
|
||||
<Card title="Analytics" href="/guides/analytics">
|
||||
Track email performance with detailed analytics on opens, clicks, and more
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Quick Links
|
||||
|
||||
<Cards>
|
||||
<Card title="Get Started" href="/getting-started/introduction">
|
||||
Learn how to set up Plunk and send your first email
|
||||
</Card>
|
||||
<Card title="API Reference" href="/api-reference/overview">
|
||||
Explore the complete API documentation
|
||||
</Card>
|
||||
<Card title="Self-Hosting" href="/self-hosting/introduction">
|
||||
Deploy Plunk on your own infrastructure
|
||||
</Card>
|
||||
<Card title="Guides" href="/guides/contacts">
|
||||
Step-by-step guides for common use cases
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Open Source
|
||||
|
||||
Plunk is fully open-source and available on GitHub. Contributions are welcome!
|
||||
|
||||
## Support
|
||||
|
||||
If you need help or have questions:
|
||||
|
||||
- Read the documentation
|
||||
- Check the GitHub repository
|
||||
- Join our Discord community
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"pages": [
|
||||
"index",
|
||||
"---Getting Started---",
|
||||
"getting-started",
|
||||
"---Guides---",
|
||||
"guides",
|
||||
"---API Reference---",
|
||||
"api-reference",
|
||||
"---Self-Hosting---",
|
||||
"self-hosting"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: Database Setup
|
||||
description: Configure and manage your PostgreSQL database
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- PostgreSQL 14 or higher
|
||||
- Database created
|
||||
- Database user with permissions
|
||||
|
||||
## Installation
|
||||
|
||||
### Using Docker
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name plunk-postgres \
|
||||
-e POSTGRES_PASSWORD=your-password \
|
||||
-e POSTGRES_DB=plunk \
|
||||
-p 5432:5432 \
|
||||
-v postgres_data:/var/lib/postgresql/data \
|
||||
postgres:14
|
||||
```
|
||||
|
||||
### Using Package Manager
|
||||
|
||||
#### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
brew install postgresql@14
|
||||
brew services start postgresql@14
|
||||
```
|
||||
|
||||
## Database Creation
|
||||
|
||||
```sql
|
||||
CREATE DATABASE plunk;
|
||||
CREATE USER plunk WITH ENCRYPTED PASSWORD 'your-password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE plunk TO plunk;
|
||||
```
|
||||
|
||||
## Connection String
|
||||
|
||||
```bash
|
||||
DATABASE_URL="postgresql://plunk:your-password@localhost:5432/plunk"
|
||||
DIRECT_DATABASE_URL="postgresql://plunk:your-password@localhost:5432/plunk"
|
||||
```
|
||||
|
||||
## Running Migrations
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
yarn workspace @plunk/db migrate:dev
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
yarn workspace @plunk/db migrate:prod
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
Plunk uses Prisma ORM. The schema is located at:
|
||||
```
|
||||
packages/db/prisma/schema.prisma
|
||||
```
|
||||
|
||||
### Core Tables
|
||||
|
||||
- **User**: User accounts
|
||||
- **Project**: Workspaces/tenants
|
||||
- **Membership**: User-project relationships
|
||||
- **Contact**: Email contacts
|
||||
- **Template**: Email templates
|
||||
- **Campaign**: Email campaigns
|
||||
- **Workflow**: Automated sequences
|
||||
- **Email**: Email tracking
|
||||
- **Event**: Custom events
|
||||
|
||||
## Indexes
|
||||
|
||||
Important indexes for performance:
|
||||
|
||||
```sql
|
||||
-- Contact email index (unique per project)
|
||||
CREATE INDEX idx_contact_email ON "Contact" (email, "projectId");
|
||||
|
||||
-- Contact subscription index
|
||||
CREATE INDEX idx_contact_subscribed ON "Contact" (subscribed);
|
||||
|
||||
-- Email tracking indexes
|
||||
CREATE INDEX idx_email_contact ON "Email" ("contactId");
|
||||
CREATE INDEX idx_email_created ON "Email" ("createdAt");
|
||||
|
||||
-- Event indexes
|
||||
CREATE INDEX idx_event_contact ON "Event" ("contactId");
|
||||
CREATE INDEX idx_event_name ON "Event" (event);
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### For Large Datasets (1M+ contacts)
|
||||
|
||||
```sql
|
||||
-- Increase shared buffers (25% of RAM)
|
||||
ALTER SYSTEM SET shared_buffers = '2GB';
|
||||
|
||||
-- Increase work memory
|
||||
ALTER SYSTEM SET work_mem = '50MB';
|
||||
|
||||
-- Increase maintenance work memory
|
||||
ALTER SYSTEM SET maintenance_work_mem = '512MB';
|
||||
|
||||
-- Enable parallel queries
|
||||
ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
|
||||
|
||||
-- Reload configuration
|
||||
SELECT pg_reload_conf();
|
||||
```
|
||||
|
||||
### Vacuum and Analyze
|
||||
|
||||
Run regularly for optimal performance:
|
||||
|
||||
```bash
|
||||
# Manual vacuum
|
||||
vacuumdb --analyze --verbose plunk
|
||||
|
||||
# Auto-vacuum (enabled by default)
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
pg_dump -U plunk -d plunk > backup-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
psql -U plunk -d plunk < backup.sql
|
||||
```
|
||||
|
||||
### Automated Backups
|
||||
|
||||
Set up cron job:
|
||||
|
||||
```bash
|
||||
0 2 * * * pg_dump -U plunk plunk > /backups/plunk-$(date +\%Y\%m\%d).sql
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Connection Count
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM pg_stat_activity;
|
||||
```
|
||||
|
||||
### Database Size
|
||||
|
||||
```sql
|
||||
SELECT pg_size_pretty(pg_database_size('plunk'));
|
||||
```
|
||||
|
||||
### Table Sizes
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS size
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;
|
||||
```
|
||||
|
||||
### Slow Queries
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
query,
|
||||
calls,
|
||||
total_time,
|
||||
mean_time
|
||||
FROM pg_stat_statements
|
||||
ORDER BY mean_time DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection refused
|
||||
|
||||
Check PostgreSQL is running:
|
||||
```bash
|
||||
sudo systemctl status postgresql
|
||||
```
|
||||
|
||||
### Authentication failed
|
||||
|
||||
Verify credentials in connection string.
|
||||
|
||||
### Too many connections
|
||||
|
||||
Increase max_connections:
|
||||
```sql
|
||||
ALTER SYSTEM SET max_connections = 200;
|
||||
SELECT pg_reload_conf();
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configure email delivery](/self-hosting/email-setup)
|
||||
- [Deploy with Docker](/self-hosting/docker)
|
||||
- [Environment variables](/self-hosting/environment-variables)
|
||||
@@ -0,0 +1,394 @@
|
||||
---
|
||||
title: Docker Deployment
|
||||
description: Deploy Plunk with Docker Compose
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed
|
||||
- Docker Compose installed
|
||||
- Git installed
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/useplunk/plunk.git
|
||||
cd plunk
|
||||
```
|
||||
|
||||
### 2. Copy Environment File
|
||||
|
||||
```bash
|
||||
cp .env.self-host.example .env
|
||||
```
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Edit `.env` and configure required variables:
|
||||
|
||||
```bash
|
||||
# Database Password (PostgreSQL)
|
||||
DB_PASSWORD="changeme123"
|
||||
|
||||
# JWT Secret (generate with: openssl rand -base64 32)
|
||||
JWT_SECRET="your-secret-here"
|
||||
|
||||
# Domains (for subdomain-based routing)
|
||||
API_DOMAIN="api.localhost"
|
||||
DASHBOARD_DOMAIN="app.localhost"
|
||||
LANDING_DOMAIN="www.localhost"
|
||||
WIKI_DOMAIN="docs.localhost"
|
||||
|
||||
# Set to 'true' for HTTPS in production
|
||||
USE_HTTPS="false"
|
||||
|
||||
# AWS SES (for email sending)
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="your-access-key"
|
||||
AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
|
||||
SES_CONFIGURATION_SET="plunk-configuration-set"
|
||||
|
||||
# S3-compatible storage (Minio is included by default)
|
||||
# Leave defaults unless using external S3
|
||||
S3_ENDPOINT="http://minio:9000"
|
||||
S3_ACCESS_KEY_ID="plunk"
|
||||
S3_ACCESS_KEY_SECRET="plunkminiopass"
|
||||
S3_BUCKET="uploads"
|
||||
S3_PUBLIC_URL="http://localhost:9000/uploads"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
```
|
||||
|
||||
### 4. Start Services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- PostgreSQL database
|
||||
- Redis
|
||||
- Minio (S3-compatible storage)
|
||||
- Plunk application (all services in one container with nginx)
|
||||
- API server
|
||||
- Worker process
|
||||
- Web dashboard
|
||||
- Landing page
|
||||
- Documentation
|
||||
|
||||
### 5. Access Services
|
||||
|
||||
The services are available at the configured domains:
|
||||
- **Dashboard**: `http://app.localhost` (or your configured domain)
|
||||
- **API**: `http://api.localhost`
|
||||
- **Landing**: `http://www.localhost`
|
||||
- **Docs**: `http://docs.localhost`
|
||||
- **Minio Console**: `http://localhost:9001`
|
||||
|
||||
Create your first account and start sending emails!
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
The `docker-compose.yml` file uses the pre-built Plunk image from GitHub Container Registry:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: plunk-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: plunk
|
||||
POSTGRES_USER: plunk
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- plunk
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: plunk-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- plunk
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: plunk-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-plunk}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-plunkminiopass}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console
|
||||
networks:
|
||||
- plunk
|
||||
|
||||
plunk:
|
||||
image: ghcr.io/useplunk/plunk:latest
|
||||
container_name: plunk
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SERVICE: all # Runs all services (API, Worker, Web, Landing, Wiki)
|
||||
DATABASE_URL: postgresql://plunk:${DB_PASSWORD}@postgres:5432/plunk
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
# Domain configuration for subdomain routing
|
||||
API_DOMAIN: ${API_DOMAIN:-api.localhost}
|
||||
DASHBOARD_DOMAIN: ${DASHBOARD_DOMAIN:-app.localhost}
|
||||
LANDING_DOMAIN: ${LANDING_DOMAIN:-www.localhost}
|
||||
WIKI_DOMAIN: ${WIKI_DOMAIN:-docs.localhost}
|
||||
USE_HTTPS: ${USE_HTTPS:-false}
|
||||
# AWS SES
|
||||
AWS_SES_REGION: ${AWS_SES_REGION}
|
||||
AWS_SES_ACCESS_KEY_ID: ${AWS_SES_ACCESS_KEY_ID}
|
||||
AWS_SES_SECRET_ACCESS_KEY: ${AWS_SES_SECRET_ACCESS_KEY}
|
||||
# S3/Minio storage
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-plunk}
|
||||
S3_ACCESS_KEY_SECRET: ${S3_ACCESS_KEY_SECRET:-plunkminiopass}
|
||||
ports:
|
||||
- "465:465" # SMTP (implicit TLS)
|
||||
- "587:587" # SMTP (STARTTLS)
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- plunk
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
plunk_data:
|
||||
|
||||
networks:
|
||||
plunk:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
**Note**: The Plunk image contains all applications (API, Worker, Web, Landing, Wiki) and uses nginx for subdomain-based routing.
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Using Pre-built Image
|
||||
|
||||
The easiest way to deploy is using the pre-built image from GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/useplunk/plunk:latest
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Building Your Own Image
|
||||
|
||||
If you want to build from source:
|
||||
|
||||
```bash
|
||||
docker build -t plunk:custom .
|
||||
```
|
||||
|
||||
Then update `docker-compose.yml` to use your custom image:
|
||||
```yaml
|
||||
plunk:
|
||||
image: plunk:custom
|
||||
# ... rest of configuration
|
||||
```
|
||||
|
||||
### Security Hardening
|
||||
|
||||
1. **Use strong passwords**
|
||||
```bash
|
||||
DB_PASSWORD=$(openssl rand -base64 32)
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
2. **Use HTTPS**
|
||||
- Set `USE_HTTPS=true` in your `.env`
|
||||
- Set up reverse proxy (Traefik, Caddy, or nginx)
|
||||
- Configure SSL certificates (Let's Encrypt)
|
||||
|
||||
3. **Configure domains**
|
||||
```bash
|
||||
API_DOMAIN=api.yourdomain.com
|
||||
DASHBOARD_DOMAIN=app.yourdomain.com
|
||||
LANDING_DOMAIN=www.yourdomain.com
|
||||
WIKI_DOMAIN=docs.yourdomain.com
|
||||
USE_HTTPS=true
|
||||
```
|
||||
|
||||
4. **Restrict network access**
|
||||
- Don't expose database and Redis ports publicly
|
||||
- Use internal Docker networks
|
||||
- Only expose port 80/443 (via reverse proxy) and SMTP ports
|
||||
|
||||
5. **Regular backups**
|
||||
- Database backups (automated via cron)
|
||||
- Minio data backups
|
||||
|
||||
### Scaling
|
||||
|
||||
The Plunk image runs all services in a single container by default. For higher scale:
|
||||
|
||||
#### Separate Services
|
||||
|
||||
You can run services separately by setting the `SERVICE` environment variable:
|
||||
|
||||
```yaml
|
||||
# API only
|
||||
plunk-api:
|
||||
image: ghcr.io/useplunk/plunk:latest
|
||||
environment:
|
||||
SERVICE: api
|
||||
# ... configuration
|
||||
|
||||
# Worker only
|
||||
plunk-worker:
|
||||
image: ghcr.io/useplunk/plunk:latest
|
||||
environment:
|
||||
SERVICE: worker
|
||||
# ... configuration
|
||||
|
||||
# Web only
|
||||
plunk-web:
|
||||
image: ghcr.io/useplunk/plunk:latest
|
||||
environment:
|
||||
SERVICE: web
|
||||
# ... configuration
|
||||
```
|
||||
|
||||
#### Scale Workers
|
||||
|
||||
For higher email throughput, run multiple worker containers:
|
||||
|
||||
```bash
|
||||
docker compose up -d --scale plunk-worker=3
|
||||
```
|
||||
|
||||
#### External Services
|
||||
|
||||
For production at scale, use managed services:
|
||||
- Managed PostgreSQL (AWS RDS, DigitalOcean, Supabase)
|
||||
- Managed Redis (AWS ElastiCache, Redis Cloud, Upstash)
|
||||
- AWS S3 (instead of Minio)
|
||||
|
||||
## Maintenance
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f plunk
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
docker compose restart plunk
|
||||
```
|
||||
|
||||
### Update Plunk
|
||||
|
||||
Pull the latest image and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull plunk
|
||||
docker compose up -d plunk
|
||||
```
|
||||
|
||||
If you built from source:
|
||||
```bash
|
||||
git pull
|
||||
docker build -t plunk:custom .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Backup Database
|
||||
|
||||
```bash
|
||||
docker compose exec postgres pg_dump -U plunk plunk > backup-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres psql -U plunk plunk < backup.sql
|
||||
```
|
||||
|
||||
### Backup Minio Data
|
||||
|
||||
```bash
|
||||
docker compose exec minio mc alias set local http://localhost:9000 plunk plunkminiopass
|
||||
docker compose exec minio mc mirror local/uploads /backups/minio
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Services won't start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker compose logs plunk
|
||||
```
|
||||
|
||||
### Database connection errors
|
||||
|
||||
Ensure DATABASE_URL is correct and database is running:
|
||||
```bash
|
||||
docker compose ps postgres
|
||||
docker compose exec postgres psql -U plunk -d plunk -c "SELECT 1;"
|
||||
```
|
||||
|
||||
### Worker not processing jobs
|
||||
|
||||
Check Plunk container logs for worker output:
|
||||
```bash
|
||||
docker compose logs plunk | grep worker
|
||||
```
|
||||
|
||||
Verify Redis connection:
|
||||
```bash
|
||||
docker compose exec redis redis-cli PING
|
||||
```
|
||||
|
||||
### Cannot access services
|
||||
|
||||
Check that your domains resolve correctly:
|
||||
```bash
|
||||
# For local development with *.localhost domains, these should work automatically
|
||||
# For production domains, ensure DNS is configured correctly
|
||||
curl http://api.localhost
|
||||
curl http://app.localhost
|
||||
```
|
||||
|
||||
### Minio not accessible
|
||||
|
||||
Check Minio is running:
|
||||
```bash
|
||||
docker compose ps minio
|
||||
docker compose logs minio
|
||||
```
|
||||
|
||||
Access Minio console at `http://localhost:9001` with credentials from `.env`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configure environment variables](/self-hosting/environment-variables)
|
||||
- [Set up email delivery](/self-hosting/email-setup)
|
||||
- [Database setup and migrations](/self-hosting/database-setup)
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: Email Setup (AWS SES)
|
||||
description: Configure AWS SES for email delivery
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AWS account
|
||||
- AWS SES access
|
||||
- Domain ownership (for custom domains)
|
||||
|
||||
## AWS SES Setup
|
||||
|
||||
### 1. Create AWS Account
|
||||
|
||||
Sign up at [aws.amazon.com](https://aws.amazon.com)
|
||||
|
||||
### 2. Request Production Access
|
||||
|
||||
By default, SES is in sandbox mode (limited to verified addresses).
|
||||
|
||||
1. Go to AWS SES Console
|
||||
2. Click "Request production access"
|
||||
3. Fill out the form
|
||||
4. Wait for approval (usually 24-48 hours)
|
||||
|
||||
### 3. Create IAM User
|
||||
|
||||
Create dedicated IAM user for Plunk:
|
||||
|
||||
1. Go to IAM Console
|
||||
2. Create new user: "plunk-ses"
|
||||
3. Attach policy: `AmazonSESFullAccess`
|
||||
4. Create access keys
|
||||
5. Save access key ID and secret key
|
||||
|
||||
### 4. Configure Environment Variables
|
||||
|
||||
```bash
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="your-access-key-id"
|
||||
AWS_SES_SECRET_ACCESS_KEY="your-secret-access-key"
|
||||
```
|
||||
|
||||
## Verify Email Addresses
|
||||
|
||||
### Single Email
|
||||
|
||||
```bash
|
||||
aws ses verify-email-identity --email-address [email protected]
|
||||
```
|
||||
|
||||
Check your inbox and click verification link.
|
||||
|
||||
### Domain Verification
|
||||
|
||||
1. Go to SES Console → Verified Identities
|
||||
2. Click "Create identity"
|
||||
3. Choose "Domain"
|
||||
4. Enter your domain: `yourdomain.com`
|
||||
5. Add DNS records provided by AWS
|
||||
|
||||
DNS records (example):
|
||||
```
|
||||
Type: TXT
|
||||
Name: _amazonses.yourdomain.com
|
||||
Value: provided-by-aws
|
||||
```
|
||||
|
||||
Wait for verification (up to 72 hours).
|
||||
|
||||
## Configuration Sets
|
||||
|
||||
Create configuration sets for tracking:
|
||||
|
||||
### 1. Tracking Configuration Set
|
||||
|
||||
```bash
|
||||
aws ses create-configuration-set \
|
||||
--configuration-set-name plunk-tracking
|
||||
```
|
||||
|
||||
### 2. No-Tracking Configuration Set
|
||||
|
||||
```bash
|
||||
aws ses create-configuration-set \
|
||||
--configuration-set-name plunk-no-tracking
|
||||
```
|
||||
|
||||
### 3. Update Environment
|
||||
|
||||
```bash
|
||||
SES_CONFIGURATION_SET="plunk-tracking"
|
||||
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
|
||||
```
|
||||
|
||||
## SNS for Email Events
|
||||
|
||||
Set up SNS to receive email events (opens, clicks, bounces):
|
||||
|
||||
### 1. Create SNS Topic
|
||||
|
||||
```bash
|
||||
aws sns create-topic --name plunk-email-events
|
||||
```
|
||||
|
||||
### 2. Subscribe Plunk Webhook
|
||||
|
||||
```bash
|
||||
aws sns subscribe \
|
||||
--topic-arn arn:aws:sns:us-east-1:123456789:plunk-email-events \
|
||||
--protocol https \
|
||||
--notification-endpoint https://api.yourdomain.com/webhooks/sns
|
||||
```
|
||||
|
||||
### 3. Configure SES Event Publishing
|
||||
|
||||
1. Go to SES Console → Configuration Sets
|
||||
2. Select `plunk-tracking`
|
||||
3. Add destination → SNS
|
||||
4. Select your SNS topic
|
||||
5. Enable events: Delivery, Bounce, Complaint, Open, Click
|
||||
|
||||
## DKIM Setup
|
||||
|
||||
Enable DKIM signing for better deliverability:
|
||||
|
||||
1. Go to SES Console → Verified Identities
|
||||
2. Select your domain
|
||||
3. Enable "Easy DKIM"
|
||||
4. Add CNAME records to your DNS
|
||||
|
||||
```
|
||||
Type: CNAME
|
||||
Name: xxx._domainkey.yourdomain.com
|
||||
Value: xxx.dkim.amazonses.com
|
||||
```
|
||||
|
||||
Repeat for all 3 CNAME records provided.
|
||||
|
||||
## Testing Email Delivery
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/v1/send \
|
||||
-H "Authorization: Bearer sk_your_secret_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"to": "[email protected]",
|
||||
"subject": "Test Email",
|
||||
"body": "Hello from Plunk!",
|
||||
"subscribed": true
|
||||
}'
|
||||
```
|
||||
|
||||
Check AWS SES Console → Email sending → Sending statistics.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### SES Dashboard
|
||||
|
||||
View in AWS Console:
|
||||
- Sends
|
||||
- Bounces
|
||||
- Complaints
|
||||
- Reputation
|
||||
|
||||
### CloudWatch Metrics
|
||||
|
||||
Set up alarms for:
|
||||
- Bounce rate > 5%
|
||||
- Complaint rate > 0.1%
|
||||
- Send quota utilization > 80%
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Emails in sandbox mode only
|
||||
|
||||
Request production access via SES Console.
|
||||
|
||||
### Domain not verified
|
||||
|
||||
Check DNS records and wait for propagation (up to 72 hours).
|
||||
|
||||
### High bounce rate
|
||||
|
||||
- Clean your contact list
|
||||
- Use double opt-in
|
||||
- Remove hard bounces immediately
|
||||
|
||||
### Low reputation score
|
||||
|
||||
- Reduce bounce and complaint rates
|
||||
- Send only to engaged users
|
||||
- Implement feedback loops
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Warm up gradually**: Start with low volume, increase slowly
|
||||
2. **Monitor metrics**: Watch bounces and complaints closely
|
||||
3. **Clean lists**: Remove inactive and bounced addresses
|
||||
4. **Use DKIM**: Enable for better deliverability
|
||||
5. **Segment sends**: Don't send same content to everyone
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
- First 62,000 emails/month: **FREE** (from EC2)
|
||||
- Additional: **$0.10 per 1,000 emails**
|
||||
- Data transfer: **$0.12 per GB**
|
||||
|
||||
Example:
|
||||
- 100,000 emails/month: ~$3.80/month
|
||||
- 1,000,000 emails/month: ~$94/month
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Complete environment setup](/self-hosting/environment-variables)
|
||||
- [Deploy with Docker](/self-hosting/docker)
|
||||
- [Send your first email](/getting-started/quick-start)
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
title: Environment Variables
|
||||
description: Complete environment variable reference
|
||||
---
|
||||
|
||||
## Required Variables
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
# PostgreSQL connection string
|
||||
DATABASE_URL="postgresql://user:password@host:5432/plunk"
|
||||
|
||||
# Direct connection (for Prisma migrations)
|
||||
DIRECT_DATABASE_URL="postgresql://user:password@host:5432/plunk"
|
||||
```
|
||||
|
||||
### Redis
|
||||
|
||||
```bash
|
||||
# Redis connection URL
|
||||
REDIS_URL="redis://host:6379"
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
```bash
|
||||
# JWT signing secret (generate with: openssl rand -base64 32)
|
||||
JWT_SECRET="your-secret-here"
|
||||
```
|
||||
|
||||
### AWS SES (Email Delivery)
|
||||
|
||||
```bash
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="your-access-key"
|
||||
AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
|
||||
|
||||
# SES Configuration Sets
|
||||
SES_CONFIGURATION_SET="plunk-tracking"
|
||||
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
|
||||
```
|
||||
|
||||
### Application URLs
|
||||
|
||||
```bash
|
||||
# Protocol configuration (auto-generates URIs with http:// or https://)
|
||||
USE_HTTPS="false" # Set to "true" for HTTPS in production
|
||||
|
||||
# Application URIs (auto-generated from domains if not set)
|
||||
API_URI="https://api.yourdomain.com"
|
||||
DASHBOARD_URI="https://app.yourdomain.com"
|
||||
LANDING_URI="https://www.yourdomain.com"
|
||||
|
||||
# For Next.js (build time)
|
||||
NEXT_PUBLIC_API_URI="https://api.yourdomain.com"
|
||||
NEXT_PUBLIC_DASHBOARD_URI="https://app.yourdomain.com"
|
||||
NEXT_PUBLIC_LANDING_URI="https://www.yourdomain.com"
|
||||
```
|
||||
|
||||
**Note**: When using domain-based configuration (e.g., `API_DOMAIN=api.yourdomain.com`), the application URIs are automatically generated. Set `USE_HTTPS=true` to use HTTPS protocol, otherwise HTTP will be used by default. You can also manually set the full URIs to override the auto-generation.
|
||||
|
||||
## Optional Variables
|
||||
|
||||
### Plunk API
|
||||
|
||||
If you're using the email package's `sendEmail` function to send emails via the Plunk API:
|
||||
|
||||
```bash
|
||||
# Plunk API Key (obtained from dashboard)
|
||||
PLUNK_API_KEY="sk_your_secret_key"
|
||||
```
|
||||
|
||||
### S3-Compatible Storage (Minio)
|
||||
|
||||
**Note**: When using Docker Compose, Minio is included and these variables are automatically configured with defaults. You typically don't need to set these unless you want to use external S3 storage.
|
||||
|
||||
```bash
|
||||
# Only configure if NOT using the bundled Minio
|
||||
S3_ENDPOINT="http://minio:9000" # Default: uses bundled Minio
|
||||
S3_ACCESS_KEY_ID="plunk" # Default: plunk
|
||||
S3_ACCESS_KEY_SECRET="plunkminiopass" # Default: plunkminiopass
|
||||
S3_BUCKET="uploads" # Default: uploads
|
||||
S3_PUBLIC_URL="http://localhost:9000/uploads" # Default: Minio URL
|
||||
S3_FORCE_PATH_STYLE="true" # Required for Minio
|
||||
```
|
||||
|
||||
### OAuth Providers
|
||||
|
||||
```bash
|
||||
# GitHub OAuth
|
||||
GITHUB_OAUTH_CLIENT="your-client-id"
|
||||
GITHUB_OAUTH_SECRET="your-client-secret"
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_OAUTH_CLIENT="your-client-id"
|
||||
GOOGLE_OAUTH_SECRET="your-client-secret"
|
||||
```
|
||||
|
||||
### Stripe Billing
|
||||
|
||||
```bash
|
||||
STRIPE_SK="sk_test_..."
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
|
||||
# Stripe Products
|
||||
STRIPE_PRICE_ONBOARDING="price_..."
|
||||
STRIPE_PRICE_EMAIL_USAGE="price_..."
|
||||
|
||||
# Stripe Metering
|
||||
STRIPE_METER_EVENT_NAME="email_sent"
|
||||
```
|
||||
|
||||
### Internal
|
||||
|
||||
```bash
|
||||
# Node environment
|
||||
NODE_ENV="production"
|
||||
```
|
||||
|
||||
## Example .env File
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/plunk"
|
||||
DIRECT_DATABASE_URL="postgresql://postgres:password@localhost:5432/plunk"
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# Security
|
||||
JWT_SECRET="generated-secret-here"
|
||||
|
||||
# AWS SES
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
|
||||
AWS_SES_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
SES_CONFIGURATION_SET="plunk-tracking"
|
||||
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
|
||||
|
||||
# Protocol & URLs
|
||||
USE_HTTPS="false"
|
||||
API_URI="http://localhost:3001"
|
||||
DASHBOARD_URI="http://localhost:3000"
|
||||
LANDING_URI="http://localhost:3002"
|
||||
|
||||
# Next.js Public URLs
|
||||
NEXT_PUBLIC_API_URI="http://localhost:3001"
|
||||
NEXT_PUBLIC_DASHBOARD_URI="http://localhost:3000"
|
||||
NEXT_PUBLIC_LANDING_URI="http://localhost:3002"
|
||||
|
||||
# Node Environment
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
## Generating Secrets
|
||||
|
||||
### JWT Secret
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### Strong Passwords
|
||||
|
||||
```bash
|
||||
openssl rand -base64 24
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit secrets** to version control
|
||||
2. **Use environment-specific files** (.env.production, .env.development)
|
||||
3. **Rotate secrets regularly**
|
||||
4. **Use secret management** (AWS Secrets Manager, HashiCorp Vault) in production
|
||||
5. **Limit access** to production environment variables
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Set up database](/self-hosting/database-setup)
|
||||
- [Configure email delivery](/self-hosting/email-setup)
|
||||
- [Deploy with Docker](/self-hosting/docker)
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Self-Hosting Introduction
|
||||
description: Deploy Plunk on your own infrastructure
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Plunk is fully open-source and can be self-hosted on your own infrastructure. This gives you complete control over your data, email delivery, and infrastructure costs.
|
||||
|
||||
## Requirements
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Node.js**: Version 20 or higher
|
||||
- **PostgreSQL**: Version 14 or higher
|
||||
- **Redis**: Version 6 or higher
|
||||
- **Docker** (recommended): For easy deployment
|
||||
|
||||
### AWS Services
|
||||
|
||||
- **AWS SES** (Required): For email sending
|
||||
- **AWS S3** (Optional): For asset storage (Minio is included by default)
|
||||
|
||||
### Minimum Server Specs
|
||||
|
||||
For small to medium usage (up to 100K contacts):
|
||||
- **CPU**: 1-2 cores
|
||||
- **RAM**: 2GB
|
||||
- **Storage**: 10GB (grows with contact data)
|
||||
|
||||
For larger scale (1M+ contacts):
|
||||
- **CPU**: 2-4 cores
|
||||
- **RAM**: 4GB
|
||||
- **Storage**: 50GB+
|
||||
|
||||
## Architecture Components
|
||||
|
||||
Plunk uses a containerized architecture with the following components:
|
||||
|
||||
### 1. Plunk Application Container
|
||||
Single container running all application services (API, Worker, Web, Landing, Wiki) with nginx reverse proxy for subdomain-based routing.
|
||||
|
||||
**Resources**: 1GB RAM, 1 CPU core (can scale up as needed)
|
||||
|
||||
**Services included**:
|
||||
- **API Server**: Express.js application handling HTTP requests
|
||||
- **Worker Process**: BullMQ worker processing background jobs
|
||||
- **Web Dashboard**: Next.js application for the UI
|
||||
- **Landing Page**: Marketing website
|
||||
- **Documentation**: Wiki/docs site
|
||||
- **SMTP Relay**: Email relay server (ports 465, 587)
|
||||
|
||||
**Note**: Services can be run separately by setting the `SERVICE` environment variable (`api`, `worker`, `web`, `landing`, `wiki`, or `all`).
|
||||
|
||||
### 2. PostgreSQL Database
|
||||
Stores all data (contacts, campaigns, workflows, etc.).
|
||||
|
||||
**Resources**: 512MB-1GB RAM, SSD storage recommended
|
||||
|
||||
### 3. Redis
|
||||
Queue system for background jobs (BullMQ).
|
||||
|
||||
**Resources**: 256MB-512MB RAM
|
||||
|
||||
### 4. Minio (S3-compatible Storage)
|
||||
Object storage for file uploads and assets.
|
||||
|
||||
**Resources**: 256MB-512MB RAM, storage for uploaded files
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
Easiest way to get started. Includes all services pre-configured.
|
||||
|
||||
[View Docker Guide](/self-hosting/docker)
|
||||
|
||||
### Kubernetes
|
||||
|
||||
For production deployments at scale.
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
Deploy each component separately on your infrastructure.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Clone the repository
|
||||
2. Copy `.env.self-host.example` to `.env`
|
||||
3. Configure environment variables (see [Environment Variables](/self-hosting/environment-variables))
|
||||
4. Run `docker compose up -d`
|
||||
5. Access dashboard at `http://app.localhost` (or your configured domain)
|
||||
|
||||
## What's Included
|
||||
|
||||
- ✅ Full API server
|
||||
- ✅ Worker process
|
||||
- ✅ Web dashboard
|
||||
- ✅ PostgreSQL database
|
||||
- ✅ Redis queue
|
||||
- ✅ All features (campaigns, workflows, segments)
|
||||
- ✅ No feature limitations
|
||||
- ✅ No phone-home telemetry
|
||||
|
||||
## What's Not Included
|
||||
|
||||
- ❌ Managed infrastructure
|
||||
- ❌ Automatic updates
|
||||
- ❌ Support (community only)
|
||||
- ❌ SLA guarantees
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### AWS SES Costs
|
||||
|
||||
- First 62,000 emails/month: **FREE** (when sent from EC2)
|
||||
- Additional emails: **$0.10 per 1,000 emails**
|
||||
|
||||
### Infrastructure Costs
|
||||
|
||||
**All-in-One VPS** (recommended for small to medium usage):
|
||||
- **Single VPS**: $5-12/month (2GB RAM, services like DigitalOcean, Hetzner, Vultr)
|
||||
- Runs all containers (Plunk, PostgreSQL, Redis, Minio)
|
||||
- **AWS SES**: $0-10/month (depends on volume)
|
||||
- **Storage**: Included in VPS
|
||||
|
||||
**Total**: ~$5-20/month for self-hosting
|
||||
|
||||
**Managed Services** (for larger scale or production):
|
||||
- **VPS**: $10-20/month (4GB RAM for Plunk application)
|
||||
- **Managed PostgreSQL**: $10-15/month (512MB-1GB)
|
||||
- **Managed Redis**: $5-10/month (256MB-512MB)
|
||||
- **AWS SES**: $10-50/month (depends on volume)
|
||||
- **AWS S3**: $1-5/month (if not using Minio)
|
||||
|
||||
**Total**: ~$35-100/month for production with managed services
|
||||
|
||||
## Support
|
||||
|
||||
### Community Support
|
||||
|
||||
- GitHub Issues
|
||||
- Community Forum
|
||||
- Documentation
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Deploy with Docker](/self-hosting/docker)
|
||||
- [Configure environment variables](/self-hosting/environment-variables)
|
||||
- [Set up database](/self-hosting/database-setup)
|
||||
- [Configure email delivery](/self-hosting/email-setup)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Self-Hosting",
|
||||
"pages": ["introduction", "docker", "environment-variables", "database-setup", "email-setup"]
|
||||
}
|
||||
Reference in New Issue
Block a user