Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+5
View File
@@ -0,0 +1,5 @@
# URLS (Frontend - Next.js Public Variables)
NEXT_PUBLIC_API_URI=http://localhost:8080
NEXT_PUBLIC_DASHBOARD_URI=http://localhost:3000
NEXT_PUBLIC_LANDING_URI=http://localhost:4000
NEXT_PUBLIC_WIKI_URI=http://localhost:1000
+9
View File
@@ -0,0 +1,9 @@
# Generated OpenAPI files
openapi.local.json
# Generated API documentation
content/docs/api-reference/campaigns/
content/docs/api-reference/contacts/
content/docs/api-reference/templates/
content/docs/api-reference/segments/
content/docs/api-reference/public-api/
File diff suppressed because one or more lines are too long
+33
View File
@@ -0,0 +1,33 @@
// source.config.ts
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
// lib/remark-replace-env.mjs
import { visit } from "unist-util-visit";
var API_URL = process.env.NEXT_PUBLIC_API_URI || "https://api.useplunk.com";
var DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URI || "https://app.useplunk.com";
function remarkReplaceEnv() {
return (tree) => {
visit(tree, ["code", "inlineCode", "text", "link"], (node) => {
if (node.value && typeof node.value === "string") {
node.value = node.value.replace(/\{\{API_URL\}\}/g, API_URL).replace(/\{\{DASHBOARD_URL\}\}/g, DASHBOARD_URL);
}
if (node.url && typeof node.url === "string") {
node.url = node.url.replace(/\{\{API_URL\}\}/g, API_URL).replace(/\{\{DASHBOARD_URL\}\}/g, DASHBOARD_URL);
}
});
};
}
// source.config.ts
var docs = defineDocs({
dir: "content/docs"
});
var source_config_default = defineConfig({
mdxOptions: {
remarkPlugins: [remarkReplaceEnv]
}
});
export {
source_config_default as default,
docs
};
+45
View File
@@ -0,0 +1,45 @@
import {createRelativeLink} from 'fumadocs-ui/mdx';
import {DocsBody, DocsDescription, DocsPage, DocsTitle} from 'fumadocs-ui/page';
import {notFound} from 'next/navigation';
import {source} from '@/lib/source';
import {getMDXComponents} from '@/mdx-components';
export const dynamic = 'force-dynamic';
export default async function Page(props: {params: Promise<{slug?: string[]}>}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDXContent = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDXContent
components={getMDXComponents({
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {params: Promise<{slug?: string[]}>}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}
+5
View File
@@ -0,0 +1,5 @@
import {createFromSource} from 'fumadocs-core/search/server';
import {source} from '@/lib/source';
export const {GET} = createFromSource(source);
+8
View File
@@ -0,0 +1,8 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
body {
font-family: 'Inter', sans-serif;
}
+42
View File
@@ -0,0 +1,42 @@
import type {BaseLayoutProps} from 'fumadocs-ui/layouts/shared';
import {AppWindowIcon, GithubIcon, MessageSquareShareIcon} from 'lucide-react';
import Image from 'next/image';
import React from 'react';
/**
* Shared layout configurations
*
* you can customise layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export const baseOptions: BaseLayoutProps = {
themeSwitch: {
enabled: false,
},
nav: {
title: (
<div className="flex items-center gap-2">
<Image src="/assets/logo.png" alt="Plunk" width={24} height={24} className="rounded" />
<span>Plunk</span>
</div>
),
},
links: [
{
icon: <AppWindowIcon />,
text: 'Dashboard',
url: 'https://app.useplunk.com',
},
{
icon: <GithubIcon />,
text: 'GitHub',
url: 'https://github.com/useplunk/plunk',
},
{
icon: <MessageSquareShareIcon />,
text: 'Discord',
url: 'https://useplunk.com/discord',
},
],
};
+80
View File
@@ -0,0 +1,80 @@
import './global.css';
import {DocsLayout} from 'fumadocs-ui/layouts/docs';
import {RootProvider} from 'fumadocs-ui/provider/next';
import type {ReactNode} from 'react';
import React from 'react';
import {baseOptions} from '@/app/layout.config';
import {source} from '@/lib/source';
export default function Layout({children}: {children: ReactNode}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Runtime environment configuration - must load before any app code */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script src="/__env.js" />
{/* Primary Meta Tags */}
<title>Plunk Documentation</title>
<meta name="title" content="Plunk Documentation" />
<meta
name="description"
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
/>
{/* Open Graph / Facebook */}
<meta property="og:type" content="website" />
<meta property="og:url" content="https://docs.useplunk.com/" />
<meta property="og:title" content="Plunk Documentation" />
<meta
property="og:description"
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
/>
<meta property="og:image" content="https://docs.useplunk.com/assets/card.png" />
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://docs.useplunk.com/" />
<meta property="twitter:title" content="Plunk Documentation" />
<meta
property="twitter:description"
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
/>
<meta property="twitter:image" content="https://docs.useplunk.com/assets/card.png" />
{/* Fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
{/* Favicon */}
<link rel="icon" type="image/png" href="/favicon/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon/favicon-16x16.png" sizes="16x16" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="apple-mobile-web-app-title" content="Plunk" />
<meta name="application-name" content="Plunk" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<link rel="manifest" href="/favicon/site.webmanifest" />
</head>
<body className="flex flex-col min-h-screen antialiased text-neutral-800" suppressHydrationWarning>
<RootProvider
theme={{
enabled: false,
}}
>
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
</RootProvider>
</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
'use client';
import {defineClientConfig} from 'fumadocs-openapi/ui/client';
export default defineClientConfig({
// Client configuration options
});
+7
View File
@@ -0,0 +1,7 @@
import {createAPIPage} from 'fumadocs-openapi/ui';
import {openapi} from '@/lib/openapi';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
client,
});
+50
View File
@@ -0,0 +1,50 @@
// Simple components that render the base URLs
// Runtime environment configuration
// Reads from window.__ENV__ (set by /__env.js at runtime in Docker)
// Falls back to process.env.NEXT_PUBLIC_* (for development)
'use client';
declare global {
interface Window {
__ENV__?: {
API_URI?: string;
DASHBOARD_URI?: string;
LANDING_URI?: string;
WIKI_URI?: string;
};
}
}
const getRuntimeEnv = (key: keyof NonNullable<typeof window.__ENV__>) => {
// Client-side: read from window.__ENV__
if (typeof window !== 'undefined' && window.__ENV__) {
return window.__ENV__[key];
}
// Server-side: read from process.env (loaded from .env file in standalone mode)
if (typeof window === 'undefined') {
return process.env[key];
}
return undefined;
};
export function ApiUrl({path = ''}: {path?: string}) {
const baseUrl = getRuntimeEnv('API_URI') || process.env.NEXT_PUBLIC_API_URI || 'https://api.useplunk.com';
return (
<code>
{baseUrl}
{path}
</code>
);
}
export function DashboardUrl({path = ''}: {path?: string}) {
const baseUrl =
getRuntimeEnv('DASHBOARD_URI') || process.env.NEXT_PUBLIC_DASHBOARD_URI || 'https://app.useplunk.com';
return (
<code>
{baseUrl}
{path}
</code>
);
}
@@ -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)
+181
View File
@@ -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)
+247
View File
@@ -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
+436
View File
@@ -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)
+223
View File
@@ -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
+17
View File
@@ -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
+342
View File
@@ -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
+256
View File
@@ -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)
+262
View File
@@ -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
+445
View File
@@ -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
+67
View File
@@ -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
+13
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
import config from '@plunk/eslint-config/next';
const wikiConfig = [
...config,
{
ignores: ['.source/**'],
},
];
export default wikiConfig;
+5
View File
@@ -0,0 +1,5 @@
import {createOpenAPI} from 'fumadocs-openapi/server';
export const openapi = createOpenAPI({
input: ['./openapi.local.json'],
});
+25
View File
@@ -0,0 +1,25 @@
import { visit } from 'unist-util-visit';
// Default URLs that will be replaced at container startup by sed
const API_URL = process.env.NEXT_PUBLIC_API_URI || 'https://api.useplunk.com';
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URI || 'https://app.useplunk.com';
export function remarkReplaceEnv() {
return (tree) => {
visit(tree, ['code', 'inlineCode', 'text', 'link'], (node) => {
// Replace in text values
if (node.value && typeof node.value === 'string') {
node.value = node.value
.replace(/\{\{API_URL\}\}/g, API_URL)
.replace(/\{\{DASHBOARD_URL\}\}/g, DASHBOARD_URL);
}
// Replace in link URLs
if (node.url && typeof node.url === 'string') {
node.url = node.url
.replace(/\{\{API_URL\}\}/g, API_URL)
.replace(/\{\{DASHBOARD_URL\}\}/g, DASHBOARD_URL);
}
});
};
}
+19
View File
@@ -0,0 +1,19 @@
import {loader} from 'fumadocs-core/source';
import {icons} from 'lucide-react';
import {createElement} from 'react';
import {docs} from '@/.source';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/',
source: docs.toFumadocsSource(),
icon(icon) {
if (!icon) {
// You may set a default icon
return;
}
if (icon in icons) return createElement(icons[icon as keyof typeof icons]);
},
});
+12
View File
@@ -0,0 +1,12 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type {MDXComponents} from 'mdx/types';
import {APIPage} from '@/components/api-page';
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...(defaultMdxComponents as MDXComponents),
APIPage,
...(components || {}),
} as MDXComponents;
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_WIKI || 'https://wiki.swyp.be',
generateRobotsTxt: true,
};
+14
View File
@@ -0,0 +1,14 @@
import {createMDX} from 'fumadocs-mdx/next';
// Note: openapi.local.json is generated by the prebuild/predev script
// See scripts/generate-openapi.js
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
output: 'standalone', // Optimized for Docker
};
export default withMDX(config);
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "wiki",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"prebuild": "node scripts/generate-openapi.js && npx tsx scripts/generate-docs.mts && fumadocs-mdx",
"build": "next build",
"sitemap": "next-sitemap",
"predev": "node scripts/generate-openapi.js && npx tsx scripts/generate-docs.mts && fumadocs-mdx",
"dev": "next dev --turbo --port 1000",
"start": "next start",
"postinstall": "fumadocs-mdx",
"generate-docs": "node scripts/generate-openapi.js && npx tsx scripts/generate-docs.mts",
"lint": "eslint .",
"clean": "rimraf node_modules .next .turbo"
},
"dependencies": {
"fumadocs-core": "16.0.8",
"fumadocs-mdx": "13.0.5",
"fumadocs-openapi": "^10.0.11",
"fumadocs-ui": "16.0.8",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"next-sitemap": "^4.2.3",
"react": "^19.2",
"react-dom": "^19.2",
"shiki": "^3.15.0",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@types/mdx": "^2.0.13",
"@types/node": "24.10.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.4.33",
"tailwindcss": "^4.1.17",
"typescript": "5.7.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
// Runtime environment configuration placeholder
// This file is overridden at Docker container startup with actual runtime values
// In development, the app falls back to NEXT_PUBLIC_* environment variables
window.__ENV__ = {};
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/favicon/mstile-150x150.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,73 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1080.000000pt" height="1080.000000pt" viewBox="0 0 1080.000000 1080.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1080.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M6218 10755 c-1 -1 -52 -6 -113 -9 -109 -6 -147 -10 -235 -21 -25 -3
-65 -8 -90 -11 -38 -5 -115 -17 -255 -40 -16 -3 -68 -14 -115 -24 -47 -10 -96
-21 -110 -23 -23 -5 -66 -16 -239 -64 -136 -37 -411 -134 -546 -193 -252 -109
-535 -257 -735 -384 -59 -38 -184 -121 -195 -129 -5 -4 -59 -44 -120 -88 -266
-197 -581 -493 -785 -739 -19 -23 -43 -52 -54 -64 -10 -11 -39 -48 -64 -81
-25 -33 -49 -62 -52 -65 -18 -14 -260 -357 -260 -367 0 -3 -10 -20 -23 -37
-69 -93 -387 -656 -392 -696 -1 -3 -11 -24 -23 -47 -108 -206 -293 -687 -382
-993 -12 -41 -26 -86 -30 -100 -5 -14 -19 -63 -30 -110 -12 -47 -28 -105 -35
-130 -8 -25 -16 -58 -19 -75 -3 -16 -14 -68 -26 -115 -11 -47 -25 -107 -30
-135 -6 -27 -12 -61 -15 -75 -3 -14 -7 -36 -10 -50 -2 -14 -9 -50 -15 -80 -6
-30 -13 -66 -16 -80 -2 -14 -7 -41 -10 -60 -7 -36 -9 -47 -18 -115 -3 -22 -8
-49 -10 -61 -2 -12 -7 -45 -11 -75 -3 -30 -8 -61 -10 -69 -2 -8 -6 -37 -10
-65 -3 -27 -7 -63 -9 -80 -2 -16 -7 -59 -10 -95 -4 -36 -9 -76 -11 -90 -2 -14
-7 -61 -11 -105 -4 -44 -8 -93 -10 -110 -3 -38 -14 -184 -19 -255 -27 -407
-27 -1218 0 -1455 2 -16 6 -64 10 -105 4 -41 9 -84 11 -95 2 -11 6 -46 9 -77
3 -32 10 -83 16 -115 5 -32 12 -76 15 -98 10 -68 73 -355 100 -450 237 -845
667 -1476 1235 -1810 46 -26 238 -121 299 -147 69 -30 263 -91 323 -103 15 -3
39 -7 55 -10 15 -3 32 -7 39 -10 6 -2 38 -7 70 -11 32 -3 62 -8 66 -11 15 -9
391 -12 452 -3 144 20 160 22 166 26 3 2 19 5 36 8 148 27 377 140 510 253
203 172 308 322 457 656 18 39 94 267 102 302 2 11 13 58 25 105 11 47 22 96
24 110 2 14 17 97 34 185 17 88 33 180 36 204 3 23 7 46 9 50 2 3 7 28 10 55
4 27 9 52 11 56 2 3 6 24 9 45 2 22 23 141 46 265 23 124 43 236 45 250 4 27
4 28 40 230 25 134 33 182 41 230 3 14 7 36 9 50 3 14 8 37 10 53 3 15 7 39
10 55 3 15 12 68 20 117 9 50 20 110 25 135 5 25 11 59 14 75 3 26 14 88 30
170 2 11 25 142 51 290 26 149 50 273 54 276 3 4 50 10 104 13 93 6 137 11
217 22 19 3 58 7 85 10 28 2 64 6 80 9 17 3 48 7 70 10 142 21 400 73 575 116
560 139 1036 346 1478 640 111 74 294 208 312 228 3 3 25 22 50 42 82 67 183
163 304 290 399 418 702 991 821 1549 11 55 36 196 44 255 40 283 33 767 -14
995 -2 14 -12 60 -20 102 -115 560 -455 1118 -898 1470 -328 261 -755 473
-1157 573 -81 20 -249 56 -305 66 -72 12 -78 12 -155 23 -25 4 -56 8 -70 11
-14 2 -61 7 -105 11 -44 3 -93 8 -110 11 -36 5 -672 13 -677 8z m142 -1239
c84 -6 217 -21 275 -31 11 -1 36 -6 55 -9 72 -12 87 -16 165 -35 367 -91 649
-262 871 -529 77 -92 74 -88 126 -172 84 -135 144 -280 187 -455 19 -77 24
-108 38 -225 14 -112 6 -513 -11 -608 -2 -9 -7 -42 -11 -72 -8 -63 -7 -54 -34
-185 -12 -55 -35 -145 -53 -200 -17 -55 -32 -102 -32 -105 -3 -17 -59 -142
-109 -240 -51 -100 -148 -252 -204 -321 -300 -366 -742 -625 -1309 -767 -142
-36 -267 -62 -359 -75 -16 -3 -46 -7 -65 -11 -106 -18 -102 -19 -96 22 14 88
18 112 32 192 9 47 17 97 19 112 4 27 21 122 30 168 3 14 8 43 11 65 2 22 18
119 35 215 16 96 32 189 35 205 5 33 15 89 20 120 2 11 15 88 29 170 14 83 34
200 45 260 11 61 22 126 24 145 3 19 9 60 15 90 11 59 17 92 27 155 3 22 12
74 20 115 8 41 17 91 19 110 3 19 9 64 14 100 10 69 7 336 -3 383 -54 239
-237 378 -531 404 -232 20 -416 -5 -580 -81 -223 -103 -343 -243 -414 -482
-16 -54 -85 -415 -135 -699 -15 -88 -58 -325 -76 -425 -12 -63 -23 -126 -25
-140 -2 -14 -13 -77 -25 -140 -11 -63 -23 -130 -26 -149 -3 -18 -7 -43 -10
-55 -4 -19 -56 -320 -68 -391 -3 -16 -12 -70 -21 -120 -9 -49 -18 -101 -20
-115 -2 -14 -22 -135 -44 -270 -22 -135 -52 -315 -65 -400 -14 -85 -28 -166
-30 -180 -16 -87 -69 -389 -71 -407 -2 -12 -6 -34 -9 -50 -3 -15 -8 -44 -11
-63 -4 -19 -10 -54 -16 -78 -5 -23 -12 -59 -15 -80 -5 -32 -37 -223 -50 -292
-2 -14 -7 -40 -10 -59 -6 -38 -11 -64 -19 -101 -3 -14 -7 -41 -9 -60 -3 -19
-19 -114 -36 -210 -17 -96 -33 -188 -36 -205 -3 -16 -7 -37 -9 -45 -2 -8 -6
-33 -10 -55 -3 -22 -12 -76 -20 -120 -8 -44 -17 -97 -21 -119 -3 -21 -8 -48
-10 -60 -2 -11 -29 -163 -59 -336 -68 -390 -62 -356 -90 -475 -147 -622 -384
-760 -719 -420 -129 131 -256 357 -354 632 -42 117 -104 320 -117 380 -2 10
-8 38 -14 63 -5 25 -12 59 -15 75 -3 17 -8 40 -10 52 -5 24 -34 226 -41 283
-2 19 -6 53 -9 75 -5 40 -10 107 -22 280 -18 274 -2 931 32 1290 13 146 34
338 39 375 2 14 9 61 15 105 6 44 13 95 16 114 2 19 6 43 9 55 2 12 7 38 10
59 5 35 50 280 59 322 2 11 12 56 21 100 69 335 236 874 359 1160 116 268 243
523 340 684 31 51 56 95 56 97 0 8 169 256 228 334 207 274 434 510 672 700
221 176 482 331 735 435 154 64 412 142 560 170 147 27 261 41 405 51 46 3 85
6 87 7 4 5 335 -1 423 -7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Plunk",
"short_name": "Plunk",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+36
View File
@@ -0,0 +1,36 @@
import {generateFiles} from 'fumadocs-openapi';
import fs from 'fs';
// Import the shared openapi instance (fumadocs recommended pattern)
import {openapi} from '../lib/openapi.js';
try {
// Check if openapi.local.json exists
if (!fs.existsSync('./openapi.local.json')) {
console.error('❌ openapi.local.json not found! Run generate-openapi.js first.');
process.exit(1);
}
// Must await the promise! The 'void' keyword was causing early exit
await generateFiles({
input: openapi,
output: './content/docs/api-reference',
groupBy: 'tag',
per: 'operation',
includeDescription: true,
});
// Verify files were created
const generatedFiles = fs
.readdirSync('./content/docs/api-reference', {recursive: true})
.filter(f => f.toString().endsWith('.mdx') && f.toString().includes('/'));
console.log(`✅ API documentation generated successfully! Created ${generatedFiles.length} endpoint pages.`);
if (generatedFiles.length === 0) {
console.warn('⚠️ Warning: No endpoint pages were generated. Check your OpenAPI spec.');
}
} catch (error) {
console.error('❌ Failed to generate API documentation:', error.message);
process.exit(1);
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { config } from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..');
// Load environment variables from .env file (optional - for local development)
const envPath = path.join(projectRoot, '.env');
if (fs.existsSync(envPath)) {
config({ path: envPath });
}
const templatePath = path.join(projectRoot, 'openapi.json');
const localPath = path.join(projectRoot, 'openapi.local.json');
if (!fs.existsSync(templatePath)) {
console.error('❌ openapi.json template not found');
process.exit(1);
}
try {
// In development: Replace URLs with local values
// In Docker: Just copy the file - URLs will be replaced at container startup
const isDevelopment = process.env.API_URI?.includes('localhost') ||
process.env.NEXT_PUBLIC_API_URI?.includes('localhost');
if (isDevelopment) {
// Local development - replace with localhost URLs
let content = fs.readFileSync(templatePath, 'utf-8');
const apiUrl = process.env.API_URI || process.env.NEXT_PUBLIC_API_URI || 'http://localhost:8080';
const description = 'Development server';
content = content.replace(
/"url":\s*"https:\/\/api\.useplunk\.com"/,
`"url": "${apiUrl}"`
);
content = content.replace(
/"description":\s*"Production server"/,
`"description": "${description}"`
);
fs.writeFileSync(localPath, content, 'utf-8');
console.log(`✓ Generated openapi.local.json with server URL: ${apiUrl}`);
} else {
// Docker build - just copy template (URLs will be replaced at runtime)
fs.copyFileSync(templatePath, localPath);
console.log('✓ Copied openapi.json to openapi.local.json (URLs will be replaced at runtime)');
}
} catch (error) {
console.error('❌ Failed to generate openapi.local.json:', error.message);
process.exit(1);
}
+13
View File
@@ -0,0 +1,13 @@
import {defineConfig, defineDocs} from 'fumadocs-mdx/config';
import {remarkReplaceEnv} from './lib/remark-replace-env.mjs';
// Options: https://fumadocs.vercel.app/docs/mdx/collections#define-docs
export const docs = defineDocs({
dir: 'content/docs',
});
export default defineConfig({
mdxOptions: {
remarkPlugins: [remarkReplaceEnv],
},
});
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/.source": ["./.source/index.ts"],
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long