Technical Documentation: useplunk/plunk
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
Plunk
Introduction
Transactional emails, marketing campaigns, and workflow automation — in one platform. Self-hostable, $0.001 per email, no contact limits.
An open-source, self-hosted alternative to tools SendGrid, Resend or Mailgun.
Features
- Transactional Emails: Send emails straight from your API with template support and variable substitution
- SMTP: Use Plunk as an SMTP relay to send emails from any existing tool or framework
- Campaigns: Send newsletters and product updates to large audiences
- Workflows: Create advanced automations with triggers, delays, and conditional logic
- Segments: Organize contacts with dynamic filtering and target the right audience
- Contact Management: Manage contacts with custom fields and full activity history
- Analytics: Track opens, clicks, bounces, and engagement metrics in real-time
- Custom Domains: Verify and send from your own domains with DKIM/SPF support
- Inbound Emails: Receive and process incoming emails with custom routing rules
Sponsors
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting
via GitHub Sponsors.
Self-hosting Plunk
The easiest way to self-host Plunk is by using the plunk Docker image.
You can pull the latest image from Github.
A complete guide on how to deploy Plunk can be found in
the documentation.
Community
- Documentation: docs.useplunk.com
- Discord: useplunk.com/discord
Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in CONTRIBUTING.md.
License
AGPL-3.0 License - see LICENSE for details.
Overview (`apps/mcp/README.md`)
@plunk/mcp
The official Model Context Protocol server for
Plunk. It lets an AI agent send transactional email, manage contacts and
segments, draft and send campaigns, and build automation workflows in your Plunk project.
Works with the hosted product and with self-hosted instances.
Setup
You need a secret key (sk_…) from your project's Settings → API Keys in the Plunk dashboard.
Claude Code
claude mcp add plunk --env PLUNK_API_KEY=sk_your_key -- npx -y @plunk/mcpClaude Desktop, Cursor, and other clients
{
"mcpServers": {
"plunk": {
"command": "npx",
"args": ["-y", "@plunk/mcp"],
"env": {
"PLUNK_API_KEY": "sk_your_key"
}
}
}
}Self-hosted Plunk
Point PLUNK_API_URL at your own API domain:
{
"mcpServers": {
"plunk": {
"command": "npx",
"args": ["-y", "@plunk/mcp"],
"env": {
"PLUNK_API_KEY": "sk_your_key",
"PLUNK_API_URL": "https://api.your-domain.com"
}
}
}
}Configuration
| Variable | Required | Description |
|---|---|---|
PLUNK_API_KEY |
yes | Your project's secret key (sk_…). |
PLUNK_API_URL |
no | API base URL. Defaults to the hosted API. Set this when self-hosting. |
PLUNK_PUBLIC_KEY |
no | Public key (pk_…). Lets plunk_track_event use the tracking endpoint directly — see below. |
PLUNK_READ_ONLY |
no | true registers only read-only tools. Nothing can be created, changed, sent, or deleted. |
PLUNK_ALLOW_UNCONFIRMED_SENDS |
no | true skips the confirmation prompt before sends. For headless automation only. |
PLUNK_MCP_API_KEY |
no | Takes precedence over PLUNK_API_KEY. Use it if PLUNK_API_KEY is already taken in your environment. |
PLUNK_MCP_API_URL |
no | Takes precedence over PLUNK_API_URL, for the same reason. |
Flags --read-only and --api-url= do the same as their environment variables and win over them.
Note on
PLUNK_API_KEY: if you self-host Plunk, the Plunk API server also uses a variable calledPLUNK_API_KEYfor its own platform notification emails. That key belongs to a different project.
If both are present in the same environment, setPLUNK_MCP_API_KEYso the agent talks to the project
you mean.
Tools
Read-only tools (the only ones registered when PLUNK_READ_ONLY=true):
| Tool | Description |
|---|---|
plunk_list_contacts |
Browse or search contacts, cursor-paginated |
plunk_get_contact |
Fetch one contact by ID or email address |
plunk_verify_email |
Check whether an address is deliverable |
plunk_list_templates |
List reusable email templates |
plunk_list_campaigns |
List campaigns and their status |
plunk_get_campaign |
Fetch one campaign in full, including its audience size |
plunk_get_campaign_stats |
Opens, clicks, bounces and rates for one campaign |
plunk_list_segments |
List audience segments |
plunk_list_domains |
List sender domains and whether each is verified |
plunk_check_domain |
Re-check a domain's DNS verification status |
plunk_list_workflows |
List automation workflows and whether each is enabled |
plunk_get_workflow |
Fetch one workflow with its steps and transitions |
plunk_check_workflow |
Outline a workflow and what would fail at run time |
plunk_list_workflow_fields |
Fields a workflow condition can test |
plunk_list_workflow_executions |
List the runs of one workflow and their status |
plunk_get_workflow_execution |
One run step by step, with outputs and errors |
Writing tools:
| Tool | Description |
|---|---|
plunk_create_contact |
Create or update a contact (upsert) |
plunk_update_contact |
Change a contact's email or custom data |
plunk_subscribe_contact |
Re-subscribe an existing contact, by email or ID |
plunk_unsubscribe_contact |
Opt a contact out of marketing email, by email or ID |
plunk_delete_contact |
Permanently delete a contact (destructive) |
plunk_send_email |
Send a transactional email to specific recipients |
plunk_track_event |
Record an event, which can trigger automation workflows |
plunk_create_template |
Create a reusable template |
plunk_create_campaign |
Create a campaign as a draft |
plunk_test_campaign |
Send one copy of a campaign to a project member |
plunk_send_campaign |
Send or schedule a campaign (destructive, irreversible) |
plunk_cancel_campaign |
Stop a scheduled or in-flight campaign (destructive) |
plunk_create_segment |
Create an audience segment |
plunk_create_workflow |
Create a workflow, disabled, triggered by an event |
plunk_update_workflow |
Rename, change the trigger event, or allow re-entry |
plunk_duplicate_workflow |
Copy a workflow, disabled, e.g. for another language |
plunk_delete_workflow |
Delete a disabled workflow (destructive) |
plunk_add_workflow_step |
Add any step type, appended, on a branch, or in between |
plunk_add_workflow_steps |
Build a whole sequence, branches included, in one call |
plunk_update_workflow_step |
Change a step's settings or template |
plunk_delete_workflow_step |
Remove a step, reconnecting around it (destructive) |
plunk_connect_workflow_steps |
Connect two steps, or a condition branch to a step |
plunk_disconnect_workflow_steps |
Remove a connection (destructive) |
plunk_set_workflow_enabled |
Enable or disable a workflow |
plunk_start_workflow_execution |
Run a workflow for one contact now |
plunk_cancel_workflow_executions |
Cancel one run or all runs in progress (destructive) |
Building workflows
The workflow tools cover every step type the dashboard builder offers — SEND_EMAIL, DELAY,WAIT_FOR_EVENT, CONDITION (yes/no or multi-branch), UPDATE_CONTACT, WEBHOOK and EXIT — so an
agent can build and edit a branching workflow without opening the builder.
- Steps are placed with
after(a step ID) and, after a condition,branch. Withoutaftera step goes
after the one step that leads nowhere yet (a condition's unconnected branches do not count); when
there are several, the tool refuses and lists the open positions instead of guessing. A position
that already leads somewhere gets the new step inserted in between. plunk_delete_workflow_stepreconnects the flow around the step by default. Removing a condition
requires choosingdownstream:detachkeeps the steps below it,deleteremoves them too.- The API stores step settings without validating them, and only the executor parses them, so a bad
setting would surface as a failed run for a real contact. The tools validate settings against the
same rules first, including condition fields the executor cannot resolve. plunk_check_workflowprints the flow as numbered lines in the order a contact walks it and lists
problems;plunk_set_workflow_enabledrefuses to enable a workflow while any are errors.
Addressing contacts by email
plunk_get_contact, plunk_subscribe_contact and plunk_unsubscribe_contact all take either id oremail. Passing email resolves it to the contact for you, matching exactly and case-insensitively,
so an agent handed "unsubscribe [email protected]" does it in one call instead of searching first and
guessing which row to patch.
Managing domains
Adding and removing sender domains is deliberately not exposed. Those endpoints skip the
admin-role check when called with an API key, so a tool would hand an agent authority that a non-admin
member of the same project does not have. Use the Plunk dashboard for those; the tools here only read.
Safety
Plunk's secret key is all-or-nothing over its project, so this server adds its own guardrails:
- Sends require human confirmation.
plunk_send_campaign, andplunk_send_emailwith more than
one recipient, ask you to confirm before anything goes out, and the prompt tells you how many people
will receive it. Enabling a workflow that has email steps withplunk_set_workflow_enabledasks too,
and names the trigger event. So do changing the trigger of an enabled workflow that sends email and
starting one for a contact withplunk_start_workflow_execution. Confirmation is not a tool argument, so the model cannot grant it to itself.
Clients that cannot show a prompt cannot send — setPLUNK_ALLOW_UNCONFIRMED_SENDS=trueif you are
running headless and accept that. - Read-only mode is enforced by registration, not by convention. With
PLUNK_READ_ONLY=truethe
mutating tools are never registered, so they cannot be called even by name. - Billing, project deletion, and key rotation are unreachable. Those endpoints require a dashboard
session, not an API key, so no tool here can touch them.
An API key still grants full read and write access to its project's data. Use a separate Plunk project
for anything you would not want an agent to change.
Event tracking and the two key types
Plunk's /v1/track endpoint requires the public key, and it is the only endpoint that does.
Without PLUNK_PUBLIC_KEY, plunk_track_event gets the same result with the secret key by upserting
the contact first and then recording the event against it. Both paths create the contact if it does
not exist. Setting PLUNK_PUBLIC_KEY just makes it one request instead of two.
Development
yarn workspace @plunk/mcp build
yarn vitest run apps/mcp
# Explore the server interactively
PLUNK_API_KEY=sk_… npx @modelcontextprotocol/inspector node apps/mcp/dist/index.jsLicense
AGPL-3.0-only, same as the rest of Plunk.
Overview (`apps/smtp/README.md`)
Plunk SMTP Relay Server
A production-ready SMTP relay server that accepts emails via SMTP protocol and forwards them to the Plunk API's/v1/send endpoint.
Features
- Secure Authentication: API key-based authentication using project secrets
- Domain Verification: Validates sender domains are verified before accepting emails
- TLS Support: Supports both implicit TLS (port 465) and STARTTLS (port 587)
- Flexible Certificate Handling: Works with Traefik's acme.json or standard PEM files
- Email Parsing: Full email parsing with support for HTML and plain text
- Rate Limiting: Configurable recipient limits per email
- Production Ready: Built with TypeScript, error handling, and logging
Architecture
SMTP Client → SMTP Server → Email Parser → API /v1/send → AWS SESThe SMTP server acts as a relay:
- Accepts SMTP connections with authentication
- Validates sender domains against the database
- Parses incoming emails
- Forwards to the Plunk API
- Returns success/error to the SMTP client
Environment Variables
| Variable | Default | Description |
|---|---|---|
API_URI |
http://localhost:3000 |
Plunk API base URL |
SMTP_DOMAIN |
(empty) | SMTP domain - required when using Traefik acme.json with multiple certificates |
PORT_SECURE |
465 |
SMTPS port (implicit TLS) |
PORT_SUBMISSION |
587 |
SMTP submission port (STARTTLS) |
MAX_RECIPIENTS |
5 |
Maximum recipients per email |
CERT_PATH |
/certs |
Path to certificate files |
ACME_JSON_PATH |
/certs/acme.json |
Path to Traefik acme.json file |
See .env.self-host.example in the repository root for full configuration options.
Development
Prerequisites
- Node.js 20+
- PostgreSQL database (running via
yarn services:up) - Redis (running via
yarn services:up)
Running Locally
# Install dependencies
yarn install
# Start development server (with hot reload)
yarn workspace smtp devThe SMTP server will start on ports 465 and 587. For local testing without TLS certificates, it will run in plaintext
mode on port 587.
Testing with Telnet
# Connect to SMTP server
telnet localhost 587
# Example SMTP session:
EHLO localhost
AUTH LOGIN
cGx1bms= # base64("plunk")
<your-api-key> # your project secret (plain text)
MAIL FROM:<[email protected]>
RCPT TO:<[email protected]>
DATA
Subject: Test Email
From: [email protected]
To: [email protected]
This is a test email.
.
QUITTesting with Mail Clients
Configure your email client with:
- SMTP Server:
localhost(or your domain in production) - Port: 587 (STARTTLS) or 465 (SSL/TLS)
- Username:
plunk - Password: Your Plunk API key (project secret)
- From Address: Must use a verified domain in your Plunk project
Production Deployment
TLS Certificate Support
The SMTP server supports TLS certificates through two methods:
Traefik acme.json (recommended for Traefik/Dokploy users)
- Mount your Traefik acme.json file to
/certs/acme.json - Set
SMTP_DOMAINenvironment variable to select the correct certificate - The server will automatically use the certificate for your domain
- Mount your Traefik acme.json file to
PEM Files (standard certificate files)
- Mount
privkey.pemandfullchain.pemto/certs/ - These are standard Let's Encrypt/Certbot filenames
SMTP_DOMAINis optional when using PEM files
- Mount
If no certificates are mounted, the server will run without TLS (not recommended for production).
Docker Deployment
The SMTP server is included in the main Plunk Docker image:
Option 1: With Traefik acme.json
docker run -d \
-p 465:465 \
-p 587:587 \
-e SERVICE=all \
-e API_URI=https://api.yourdomain.com \
-e SMTP_DOMAIN=smtp.yourdomain.com \
-e DATABASE_URL=postgresql://... \
-e REDIS_URL=redis://... \
-v /path/to/acme.json:/certs/acme.json:ro \
plunk:latestOption 2: With PEM files
docker run -d \
-p 465:465 \
-p 587:587 \
-e SERVICE=all \
-e API_URI=https://api.yourdomain.com \
-e DATABASE_URL=postgresql://... \
-e REDIS_URL=redis://... \
-v /path/to/certs:/certs:ro \
plunk:latestMount the directory holding privkey.pem and fullchain.pem, not the files: a single-file mount keeps the old file
when a renewal replaces it. For certbot, copy the files there from a deploy hook (see the self-hosting docs), since
certbot's live/ files are symlinks into directories only root can read.
Option 3: Without TLS (Development Only)
docker run -d \
-p 587:587 \
-e SERVICE=all \
-e API_URI=http://api.yourdomain.com \
-e DATABASE_URL=postgresql://... \
-e REDIS_URL=redis://... \
plunk:latest⚠️ Running without TLS is not recommended for production!
DNS Configuration
Add an A record pointing to your server:
smtp.example.com. A 1.2.3.4Optionally, add MX records if receiving email:
example.com. MX 10 smtp.example.com.Security
Authentication
- Username must be
plunk(case-insensitive) - Password is the project secret (API key)
- Invalid credentials are rejected with proper SMTP error codes
Domain Validation
- Sender domain must be added to the project
- Sender domain must be verified (DNS records validated)
- Unverified domains are rejected with clear error messages
Rate Limiting
- Maximum 5 recipients per email by default (configurable)
- 10MB maximum message size
- Proper error handling for oversized messages
Monitoring
The SMTP server logs all operations using signale:
✅ SMTP server listening on port 465 (secure=true)
✅ SMTP server listening on port 587 (secure=false)
✅ Email relayed: [email protected] → [email protected]
❌ Sender domain is not verified or not associated with your accountUse PM2 or similar process managers to monitor the service in production.
Troubleshooting
TLS Certificate Issues
If TLS is not working:
- Verify certificates are mounted correctly:bash
docker exec <container> ls -la /certs/ - Check certificate permissions (should be readable)
- Review logs for certificate loading errors:bash
docker logs <container> 2>&1 | grep -i "cert" - If using Traefik acme.json, ensure
SMTP_DOMAINis set correctly - If using PEM files, ensure both
privkey.pemandfullchain.pemare present
Connection Refused
If clients cannot connect:
- Verify ports 465 and 587 are exposed and not blocked by firewall
- Check if the service is running:
pm2 list - Review logs:
pm2 logs smtp
Authentication Failures
If authentication fails:
- Verify username is exactly
plunk - Verify password is the project secret, not public key
- Check database connectivity
Domain Verification Errors
If emails are rejected with domain errors:
- Verify domain is added to your project
- Check domain verification status in Plunk dashboard
- Ensure DNS records are properly configured
Performance Considerations
The SMTP server is designed for high-scale email sending:
- Async Processing: All database and API calls are non-blocking
- Connection Pooling: Prisma handles database connection pooling
- Minimal Memory: Streams email data without buffering entire messages
- Fast Authentication: Single database query per connection
For high-volume sending:
- Deploy multiple SMTP server instances behind a load balancer
- Use connection pooling in your SMTP clients
- Monitor API rate limits and adjust accordingly
API Integration
The SMTP server forwards emails to the API endpoint:
POST /v1/send
Authorization: Bearer {project_secret}
{
"from": "[email protected]",
"name": "Sender Name",
"to": ["[email protected]"],
"subject": "Email Subject",
"body": "<html>...</html>"
}The API response is translated to SMTP status codes:
200 OK→250 Message accepted4xx/5xx→554 Transaction failed
Contributing
When modifying the SMTP server:
- Follow the existing code patterns
- Add proper error handling
- Update TypeScript types
- Test with real SMTP clients
- Update this README if adding features
License
Part of the Plunk project - see repository root for license information.
Apps Wiki Content Docs Index (`apps/wiki/content/docs/index.mdx`)
title: Welcome to Plunk 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 to handle millions of contacts, it provides everything you need to manage your email communications — sending, automation, segmentation, deliverability, and analytics.
Key Features
Quick Links
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
Apps Wiki Content Docs Api Reference Overview (`apps/wiki/content/docs/api-reference/overview.mdx`)
title: API Reference
Base URL
{{API_URL}}All API requests use this base URL.
Authentication
Include your API key in the Authorization header:
Authorization: Bearer YOUR_API_KEY- Secret Key (sk_*) — Required for all endpoints except
/v1/track - Public Key (pk_*) — Only works with
/v1/trackfor client-side event tracking
Making requests
Send transactional email
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
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
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
Success responses
Public API endpoints (/v1/send, /v1/track, /v1/verify) return a wrapped envelope:
{
"success": true,
"data": {
"contact": "cnt_abc123",
"event": "evt_xyz789",
"timestamp": "2025-11-30T10:30:00.000Z"
}
}Dashboard endpoints (contacts, templates, campaigns, segments, workflows, etc.) return the resource directly — no success/data wrapper:
{
"id": "cnt_abc123",
"email": "[email protected]",
"createdAt": "2025-11-30T10:30:00.000Z"
}List endpoints with cursor pagination return:
{
"data": [ /* items */ ],
"cursor": "def456",
"hasMore": true,
"total": 10000
}Error response
All errors include detailed information to help you debug issues:
{
"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 handlingmessage— Human-readable descriptionstatusCode— HTTP status coderequestId— 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 for complete details and examples.
Pagination
Most list endpoints use cursor-based pagination:
GET /contacts?limit=100&cursor=abc123Parameters:
limit— items per page (default: 20, max: 100)cursor— pagination cursor from the previous response'scursorfield
Response:
{
"data": [ /* items */ ],
"cursor": "def456",
"hasMore": true,
"total": 10000
}Pass the response's cursor value as the next request's cursor query parameter. When hasMore is false, you've reached the end. The total count is only included on the first page (when no cursor is supplied) — subsequent pages return total: 0 to keep listing fast.
A few endpoints (e.g. GET /segments/:id/contacts) use page-based pagination instead, with page and pageSize parameters. Their responses include total, page, and pageSize fields.
Rate limits
Plunk enforces reasonable rate limits to ensure service quality:
- Email sending — throttled per project to protect deliverability.
- API requests — 1000 requests/minute per project.
- Bulk operations — automatically queued for asynchronous 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 endpoints
A complete, grouped reference of every endpoint exposed by the Plunk API.
Public API
The /v1/* endpoints are designed for use from your applications and accept simple, denormalised payloads. POST /v1/track is the only endpoint callable with a public (pk_*) key.
| Method | Path | Description | Key |
|---|---|---|---|
| POST | /v1/send |
Send a transactional email. Single or multiple recipients, template or inline content, attachments, headers, custom data. | sk_* |
| POST | /v1/track |
Track an event for a contact. Auto-creates or upserts the contact. Triggers workflows. | pk_* or sk_* |
| POST | /v1/verify |
Validate an email address — format, MX records, disposable domains, typo detection. | sk_* |
Contacts
| Method | Path | Description |
|---|---|---|
| GET | /contacts |
List contacts. Supports search (by email substring), limit, cursor. |
| POST | /contacts |
Create or upsert a contact by email. Returns _meta.isNew and _meta.isUpdate. |
| GET | /contacts/:id |
Get a single contact. |
| PATCH | /contacts/:id |
Update a contact's email, subscription state, or data fields. |
| DELETE | /contacts/:id |
Delete a contact. |
| POST | /contacts/lookup |
Bulk email-existence check (max 500 emails per call). |
| Custom fields | ||
| GET | /contacts/fields |
List standard and custom fields with inferred types and coverage percentages. |
| GET | /contacts/fields/:field/values |
Distinct values for a custom field — used by segment / workflow filter UIs. |
| GET | /contacts/fields/:field/usage |
Where a custom field is referenced (segments, campaigns, workflows). |
| DELETE | /contacts/fields/:field |
Delete a custom field across every contact in the project. |
| CSV import | ||
| POST | /contacts/import |
Upload a CSV file (multipart, ≤ 5 MB). Queued — returns a jobId. |
| GET | /contacts/import/:jobId |
Poll the status of a CSV import job. |
| Bulk operations | ||
| POST | /contacts/bulk-subscribe |
Subscribe up to 1,000 contacts by ID. Queued — returns a jobId. |
| POST | /contacts/bulk-unsubscribe |
Unsubscribe up to 1,000 contacts by ID. Queued. |
| POST | /contacts/bulk-delete |
Delete up to 1,000 contacts by ID. Queued. |
| GET | /contacts/bulk/:jobId |
Poll the status of a bulk job. |
Templates
| Method | Path | Description |
|---|---|---|
| GET | /templates |
List all templates. |
| POST | /templates |
Create a template. from must be on a verified domain. |
| GET | /templates/:id |
Get a template. |
| PATCH | /templates/:id |
Update a template. |
| DELETE | /templates/:id |
Delete a template. |
| POST | /templates/:id/duplicate |
Duplicate a template — returns the new template ID. |
| GET | /templates/:id/usage |
List campaigns and workflow steps that reference this template. |
Campaigns
| Method | Path | Description |
|---|---|---|
| GET | /campaigns |
List all campaigns. |
| POST | /campaigns |
Create a campaign in DRAFT. from must be on a verified domain. |
| GET | /campaigns/:id |
Get a campaign. |
| PUT | /campaigns/:id |
Update a campaign (replace). |
| DELETE | /campaigns/:id |
Delete a campaign. Returns 409 if it has active executions. |
| POST | /campaigns/:id/duplicate |
Duplicate a campaign — returns the new campaign in DRAFT. |
| POST | /campaigns/:id/send |
Send (or schedule) the campaign. Pass scheduledFor for delayed sends. |
| POST | /campaigns/:id/cancel |
Cancel a SCHEDULED or SENDING campaign. |
| POST | /campaigns/:id/test |
Send a test email to a single address ({ email: "[email protected]" }). |
| GET | /campaigns/:id/stats |
Get current send / open / click / bounce counts. |
Segments
| Method | Path | Description |
|---|---|---|
| GET | /segments |
List all segments (no pagination — small list). |
| POST | /segments |
Create a segment. type: "DYNAMIC" requires condition; type: "STATIC" rejects it. |
| GET | /segments/:id |
Get a segment, including cached memberCount. |
| PATCH | /segments/:id |
Update name, description, condition (dynamic only), or trackMembership. |
| DELETE | /segments/:id |
Delete a segment. Returns 409 if used by an active campaign. |
| GET | /segments/:id/contacts |
Page-based list of segment members. page, pageSize (max 100). Live for dynamic segments. |
| POST | /segments/:id/members |
Add emails to a static segment. Body: { emails, createMissing?, subscribed? }. |
| DELETE | /segments/:id/members |
Remove emails from a static segment. Body: { emails }. |
| POST | /segments/:id/compute |
Recompute membership for a tracked dynamic segment — fires entry/exit events. |
| POST | /segments/:id/refresh |
Cheap count refresh — no events, no membership writes. |
Workflows
Each workflow consists of a workflow record, a graph of steps, transitions between them, and per-contact executions. The API mirrors that structure.
| Method | Path | Description |
|---|---|---|
| GET | /workflows |
List all workflows. |
| GET | /workflows/fields |
Fields available to use in CONDITION step filters (contact + event fields). |
| POST | /workflows |
Create a workflow. Always starts with triggerType: EVENT and enabled: false. |
| GET | /workflows/:id |
Get a workflow with its steps and transitions. |
| PATCH | /workflows/:id |
Update workflow metadata, trigger type / config, enabled, allowReentry. |
| DELETE | /workflows/:id |
Delete a workflow. Active executions must be cancelled or completed first. |
| Steps | ||
| POST | /workflows/:id/steps |
Add a step (SEND_EMAIL, DELAY, WAIT_FOR_EVENT, CONDITION, WEBHOOK, UPDATE_CONTACT, EXIT). |
| PATCH | /workflows/:id/steps/:stepId |
Update a step's config. |
| DELETE | /workflows/:id/steps/:stepId?splice=true |
Delete a step. Pass splice=true to auto-reconnect surrounding transitions. |
| Transitions | ||
| POST | /workflows/:id/transitions |
Add a transition between two steps. For CONDITION steps, include branch: "yes" | "no". |
| DELETE | /workflows/:id/transitions/:transitionId |
Delete a transition. |
| Executions | ||
| POST | /workflows/:id/executions |
Manually start an execution for a contact. Optional context JSON for per-execution variables. |
| GET | /workflows/:id/executions |
List executions, filterable by status. |
| GET | /workflows/:id/executions/:executionId |
Get a single execution. |
| DELETE | /workflows/:id/executions/:executionId |
Cancel a running or waiting execution. |
| POST | /workflows/:id/executions/cancel-all |
Cancel every active execution at once. |
Events
| Method | Path | Description |
|---|---|---|
| POST | /events/track |
Internal alias for /v1/track, for dashboard use. Use /v1/track from your apps. |
| GET | /events |
List recent tracked events for a project. |
| GET | /events/stats |
Aggregated event statistics. |
| GET | /events/contact/:contactId |
All events for a single contact. |
| GET | /events/names |
All distinct event names tracked in this project. |
| GET | /events/:eventName/usage |
Where an event name is referenced (segment filters, workflow triggers, conditions). |
| DELETE | /events/:eventName |
Delete every event with the given name from the project. |
Domains
| Method | Path | Description |
|---|---|---|
| GET | /domains/project/:projectId |
List domains configured for a project (verified and pending). |
| POST | /domains |
Add a domain for verification. Returns the DNS records you need to add. |
| GET | /domains/:id/verify |
Force a verification check now (otherwise checked every 5 minutes in the background). |
| DELETE | /domains/:id |
Remove a domain. |
Activity & analytics
| Method | Path | Description |
|---|---|---|
| GET | /activity |
Cross-resource activity feed (sends, opens, clicks, bounces, complaints, inbound, etc.). |
| GET | /activity/stats |
Aggregated counts for dashboard charts. |
| GET | /activity/recent-count |
Recent event count for the dashboard's "live" indicator. |
| GET | /activity/types |
Distinct activity types in the project. |
| GET | /activity/upcoming |
Upcoming scheduled sends and active executions. |
| GET | /analytics/timeseries |
Email send / open / click time-series. |
| GET | /analytics/top-campaigns |
Top-performing campaigns by metric. |
| GET | /analytics/campaign-stats |
Campaign-level breakdown. |
| GET | /analytics/top-events |
Most frequent custom event names. |
Uploads
| Method | Path | Description |
|---|---|---|
| POST | /uploads/image |
Upload an image (multipart) for use inside template bodies. Returns a public URL. |
Authentication & user management
The dashboard authenticates with JWT cookies; these endpoints mostly aren't useful from server-to-server integrations but are documented here for completeness.
| Method | Path | Description |
|---|---|---|
| POST | /auth/login |
Email + password login. Sets a JWT cookie. |
| POST | /auth/signup |
Sign up a new user (subject to DISABLE_SIGNUPS). |
| GET | /auth/logout |
Clear the auth cookie. |
| GET | /auth/oauth-config |
Which OAuth providers are configured. |
| POST | /auth/verify-email |
Verify an email with a token from an email link. |
| POST | /auth/request-verification |
Resend the verification email. |
| POST | /auth/request-password-reset |
Send a password reset email. |
| POST | /auth/reset-password |
Reset a password with a token. |
| GET | /users/@me |
Get the current user. |
| GET | /users/@me/projects |
List the user's projects. |
| POST | /users/@me/projects |
Create a project. |
| PATCH | /users/@me/projects/:id |
Update project settings. |
| POST | /users/@me/projects/:id/regenerate-keys |
Rotate both API keys. |
| POST | /users/@me/projects/:id/checkout |
Create a Stripe Checkout session. |
| POST | /users/@me/projects/:id/billing-portal |
Open the Stripe billing portal. |
| GET | /users/@me/projects/:id/billing-limits |
Read per-category billing caps. |
| PUT | /users/@me/projects/:id/billing-limits |
Update per-category billing caps. |
| GET | /users/@me/projects/:id/billing-consumption |
Current period consumption for the project. |
| GET | /users/@me/projects/:id/billing-invoices |
Stripe invoices for the project. |
| GET | /users/@me/projects/:id/security |
Security info — bounce/complaint rates, recent suspensions. |
| POST | /users/@me/projects/:id/reset |
Wipe project data (irreversible). |
| DELETE | /users/@me/projects/:id |
Delete the project entirely. |
| GET | /projects/:id/setup-state |
Onboarding setup state. |
| GET | /projects/:id/security |
Security state for a single project. |
| GET | /projects/:id/members |
Project team members. |
| POST | /projects/:id/members |
Invite a team member. |
| PATCH | /projects/:id/members/:userId |
Change a member's role. |
| DELETE | /projects/:id/members/:userId |
Remove a member. |
Configuration
| Method | Path | Description |
|---|---|---|
| GET | /config |
Public, no-auth feature flags — which integrations are enabled (OAuth providers, billing, S3, SMTP, …). |
Internal webhook endpoints
These endpoints receive events from the underlying email and billing infrastructure. You don't call them from your applications — they're listed for completeness for self-hosters.
| Method | Path |
|---|---|
| POST | /webhooks/sns |
| POST | /webhooks/incoming/stripe |
Client libraries
Node.js
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
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
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
Apps Wiki Content Docs Recipes Index (`apps/wiki/content/docs/recipes/index.mdx`)
title: Recipes icon: ChefHat
Recipes are concrete, step-by-step builds for patterns we see most often in Plunk projects. Each one assumes you already understand the underlying concepts and walks you through the exact API calls, workflow steps, and template variables involved.
Apps Wiki Content Docs Meta.Json (`apps/wiki/content/docs/meta.json`)
{
"pages": [
"index",
"---Docs---",
"concepts",
"guides",
"---Recipes---",
"recipes",
"---API Reference---",
"api-reference",
"---Self-Hosting---",
"self-hosting"
]
}
Apps Wiki Content Docs Api Reference Errors (`apps/wiki/content/docs/api-reference/errors.mdx`)
title: Error Codes
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.
402 Payment Required
A billing limit has been reached or the operation requires a plan upgrade. Returned with BILLING_LIMIT_EXCEEDED and UPGRADE_REQUIRED.
403 Forbidden
Not authorized to access this resource. Check permissions or project status. Also returned when email verification is required (EMAIL_VERIFICATION_REQUIRED) or the project has been disabled (PROJECT_DISABLED).
404 Not Found
The requested resource does not exist. Verify the resource ID.
409 Conflict
The request conflicts with the current state of a resource — for example, creating a contact with an email that already exists, or updating to an email that's already taken. Returned with CONFLICT.
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:
{
"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 |
EMAIL_VERIFICATION_REQUIRED |
403 | The user's email address must be verified before this action |
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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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:
{
"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
try {
const response = await fetch('https://next-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
import requests
response = requests.post(
'https://next-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:
- Include the request ID in your message
- Describe what you were trying to do
- 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.requestIdfield - Response headers:
X-Request-IDheader (also included in successful responses) - Your application logs: Include the header in your logs for correlation
// Example: Logging request ID in your application
const response = await fetch('https://next-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
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
- 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
Authorizationheader uses Bearer token format. - Verify the key hasn't been revoked or regenerated.
- Check the
errorsarray for specific field issues. - Verify all required fields are included.
- Ensure field types match (strings quoted, numbers unquoted).
- Review the API reference for the correct request format.
- 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.
- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries).
- Reduce request frequency.
- Consider upgrading your plan for higher limits.
- Batch operations when possible.
- Note the request ID from the error response.
- Wait a moment and retry the request.
- Check status.useplunk.com for incidents.
- Contact support with the request ID if the issue persists.
Best Practices
- Always check the
successfield before processing responses - Log request IDs for debugging and support requests
- Handle errors gracefully with user-friendly messages
- Implement retry logic with exponential backoff for transient errors
- Monitor error rates to detect issues early
- Use error codes for programmatic error handling, not just status codes
Getting Help
If you continue experiencing issues:
- Review the error
suggestionfield for guidance - Check the API Reference for correct usage
- Search our documentation for your specific error code
- Contact support with your request ID
- Join our community for help from other developers
Apps Wiki Content Docs Api Reference Meta.Json (`apps/wiki/content/docs/api-reference/meta.json`)
{
"title": "API Reference",
"pages": [
"overview",
"---Public API---",
"public-api/sendEmail",
"public-api/trackEvent",
"public-api/verifyEmail",
"---Resources---",
"contacts",
"templates",
"campaigns",
"segments",
"---Reference---",
"errors"
]
}
Apps Wiki Content Docs Concepts Billing (`apps/wiki/content/docs/concepts/billing.mdx`)
title: Billing icon: CreditCard
Plunk's pricing is based on the number of emails sent each month. Marketing emails, transactional emails, workflow sends, and inbound emails are all counted under the same monthly usage. Pricing on this page applies to the hosted version at useplunk.com; self-hosted instances run on their own infrastructure.
Plans
Free tier
Free tier projects can send up to 1,000 emails per month at no cost. Free-tier projects include a Plunk-branded footer in marketing emails. Inbound emails count toward the 1,000-email allowance.
Pay-as-you-go
After upgrading from the free tier, you're charged per email sent at $0.001 per email. There are no monthly fees and no commitments — you only pay for what you use.
You can monitor consumption and set per-category caps under Settings → Billing.
Activation charges
Nothing is charged while you complete checkout. Immediately afterwards, Plunk places a single card verification charge of 2.00 on your card, in your billing currency.
It's credited back to your account balance in full, and that credit is applied against your usage before any invoice is charged.
The charge confirms your card accepts automatic monthly billing. Your monthly invoice is charged on your behalf, without you there, and some cards that accept a payment you approve yourself refuse those. This is most common with prepaid, virtual, and single-use cards. Verifying at activation means an incompatible card surfaces immediately rather than a month later, when it would interrupt your sending.
The same verification runs whenever you change your card, so the card your invoices will actually be charged to is always one that passed. Adding back a card that already passed doesn't charge it again. Prepaid cards aren't supported.
If the verification charge is declined, nothing is collected and sending is paused. You'll be emailed when this happens. Update your card under Settings → Billing and the new card is verified straight away; sending resumes as soon as it passes. If a project stays paused for 30 days, its subscription is cancelled.
Failed payments
If a monthly invoice can't be charged, sending is paused until it's paid. Updating your card retries the payment on the new card immediately, and sending resumes once nothing is outstanding. Starting a new subscription doesn't clear an unpaid invoice — you'll be taken to the invoice to pay it first.
What counts as an email
Every send and inbound receive contributes to your monthly usage:
| Source | Cost |
|---|---|
Transactional sends (/v1/send) |
1 credit per recipient |
| Campaigns | 1 credit per recipient |
Workflow SEND_EMAIL steps |
1 credit per send |
| Inbound emails | 1 credit per received email |
| Emails with attachments | 2 credits per email |
Emails with one or more attachments cost double. There's no extra charge based on attachment size — one credit per attached email regardless of whether it's 100 KB or 10 MB.
Per-category billing limits
Set monthly caps per category to control runaway sends from any single source. This is useful if, for example, you want to ensure transactional capacity is always available even if a campaign accidentally over-sends.
| Category | What it caps |
|---|---|
| Transactional | Emails sent through /v1/send |
| Campaigns | Emails sent as part of a campaign |
| Workflows | Emails sent by SEND_EMAIL steps inside a workflow |
| Inbound | Inbound emails received at your verified domain |
Configure caps in Settings → Billing → Limits. Each category cap is independent — you can set just one, all of them, or none.
What happens when a limit is hit
When you exceed a per-category cap or your overall plan allowance:
- Outbound API calls (
/v1/send, campaign sends, workflow sends) return402 Payment Requiredwith error codeBILLING_LIMIT_EXCEEDED. Your application should handle this gracefully — typically by queuing the work or surfacing an error to the user. - Inbound emails are dropped silently for the affected project until the cap resets. They aren't queued or replayed.
- Limits reset at the start of each billing period.
You'll also receive a notification (email and/or in-app) when a cap is approached or hit.
Invoices and payment
Stripe handles billing. From the Settings → Billing page you can:
- Open the Stripe billing portal to update payment methods, addresses, and tax IDs.
- View and download every invoice for the project.
- See current period consumption with a breakdown by category.
If a payment fails, Stripe retries on its standard schedule. Repeated failures eventually pause the project's ability to send — keep your payment method up to date to avoid disruption.
API reference
GET /users/@me/projects/:id/billing-consumption— current period usage by category.GET /users/@me/projects/:id/billing-limits— read per-category caps.PUT /users/@me/projects/:id/billing-limits— update per-category caps.GET /users/@me/projects/:id/billing-invoices— list invoices.POST /users/@me/projects/:id/billing-portal— generate a Stripe billing portal URL.POST /users/@me/projects/:id/checkout— start a Stripe Checkout session for upgrades.
What's next
Apps Wiki Content Docs Concepts Campaigns (`apps/wiki/content/docs/concepts/campaigns.mdx`)
title: Campaigns icon: Megaphone
Campaigns are one-off email sends to a defined audience — newsletters, announcements, product launches, promotions. Unlike workflows, a campaign sends once at a single point in time (immediately or at a scheduled time) and is then frozen.
Anatomy of a campaign
A campaign captures everything needed to render and deliver one batch of emails:
import {TypeTable} from 'fumadocs-ui/components/type-table';
<TypeTable
type={{
name: { type: 'string', required: true, description: 'Internal name shown in the dashboard.' },
subject: { type: 'string', required: true, description: 'Email subject line. Supports the same template variables as Templates.' },
body: { type: 'string', required: true, description: 'HTML body. Supports template variables.' },
from: { type: 'string', required: true, description: 'Sender email — must be on a verified domain.' },
fromName: { type: 'string', description: 'Optional sender display name (e.g. Acme Marketing <[email protected]>).' },
replyTo: { type: 'string', description: 'Optional reply-to address.' },
type: { type: 'string', required: true, description: 'One of MARKETING, TRANSACTIONAL, or HEADLESS. Same semantics as template types. Controls the unsubscribe footer and whether unsubscribed contacts are skipped.' },
audienceType: { type: 'string', required: true, description: 'Who this campaign is sent to. One of ALL, SEGMENT, or FILTERED — see below.' },
segmentId: { type: 'string', description: 'Required when audienceType = SEGMENT.' },
audienceCondition: { type: 'object', description: 'Inline filter when audienceType = FILTERED. Uses the same format as segment filters.' },
scheduledFor: { type: 'string', description: 'ISO 8601 timestamp. If set, the campaign sends at that time; if null, it sends immediately when you trigger send.' },
}}
/>
The campaign also keeps denormalised stats once it sends: totalRecipients, sentCount, deliveredCount, openedCount, clickedCount, bouncedCount. Fetch them via GET /campaigns/:id/stats.
Targeting an audience
Campaigns support three audience types:
ALL— every subscribed contact in the project. Quick way to broadcast to your full list.SEGMENT— every contact that's currently a member of a specific segment. Reuses logic you've already built.FILTERED— an inline filter condition (same JSON shape as segment filters). Lets you hand-craft an audience for a one-off send without saving a reusable segment.
MARKETING and HEADLESS campaigns automatically skip unsubscribed contacts. TRANSACTIONAL campaigns deliver to everyone matching the audience regardless of subscription state — use it sparingly and only for genuine transactional content.
Sender domain verification
The from address must be on a domain you've verified for sending. Creating or updating a campaign with an unverified from returns 403. See Verifying domains.
Lifecycle
| Status | Meaning |
|---|---|
DRAFT |
Created but not yet scheduled or sent. Editable. |
SCHEDULED |
Scheduled for a future time via scheduledFor. Cancellable. |
SENDING |
Send is in progress — the queue is processing recipients. |
SENT |
Send is complete. Stats are final. |
CANCELLED |
Cancelled before completion. Terminal state from DRAFT, SCHEDULED, or SENDING. |
Once a campaign is SENT or CANCELLED it can't be edited or resent — duplicate it instead with POST /campaigns/:id/duplicate to create an editable copy.
Sending and scheduling
Trigger a send with POST /campaigns/:id/send. The campaign is queued and sent in the background; depending on the audience size, this can take seconds for small lists to longer for very large ones. You can monitor progress on the campaign detail page in the dashboard.
To cancel a SCHEDULED or SENDING campaign, call POST /campaigns/:id/cancel. Cancellation stops further sending but cannot recall emails that have already been handed off to the recipient's mail server.
Stats
GET /campaigns/:id/stats reports the counts and rates the dashboard shows: recipients, sent, delivered, opened, clicked, bounced, complained, and unsubscribed, plus the matching rates. Counts are read from the campaign itself, so the call stays fast while a campaign is sending. Delivery, open, click, bounce and complaint counts are updated in the background as events arrive, so they can trail the latest event by a short interval. Unsubscribes are counted the moment they happen. Every rate is a percentage of sentCount.
What a campaign cost you
Three columns describe recipients you lost, and each one counts a given recipient exactly once:
| Field | Means | Traced by |
|---|---|---|
bouncedCount |
Mail that could not be delivered. Hard bounces suppress the contact. | Email.bouncedAt, set by the SES bounce webhook |
complainedCount |
Recipients who reported the campaign as spam. Suppresses the contact. | Email.complainedAt, set by the SES complaint webhook |
unsubscribedCount |
Recipients who deliberately opted out, including snoozes. A recipient who opts out more than once from the same email counts once. | Email.unsubscribedAt, set from the ?e= parameter on the unsubscribe link |
On the campaign page, click any of the three figures to see the contacts behind it.
Bounces and complaints flip the contact to unsubscribed as well, but they are reported in their own column and never in unsubscribedCount — the same lost recipient counted twice would overstate both.
unsubscribedCount also excludes opt-outs it cannot attribute: a dashboard toggle, a CSV import, PATCH /contacts/:id, or a link from mail sent before the parameter existed belongs to no campaign, so it is counted against none. See Unsubscribe pages.
Complaint rate deserves attention out of proportion to its size: mailbox providers measure it in fractions of a percent, and a sustained rate above roughly 0.1% puts sending at risk. A handful of complaints on one campaign is a signal worth acting on, which is why the dashboard leads that card with the count rather than the percentage.
Test sends
Send a single test email with POST /campaigns/:id/test. Body: { email: "[email protected]" } — a single address, not an array. Test sends use the same template variable resolution as the real send but always send to the address you specify, so they're useful for previewing personalisation against your own contact data.
Duplicating
POST /campaigns/:id/duplicate creates a new DRAFT campaign with the same content, audience, and settings as the source. Useful for A/B variants and recurring sends.
API reference
See the API overview for the full set of campaign endpoints — list, get, create, update, delete, send, cancel, duplicate, test, and stats.
What's next
Apps Wiki Content Docs Concepts Contacts (`apps/wiki/content/docs/concepts/contacts.mdx`)
title: Contacts icon: Users
Contacts in Plunk represent an individual email recipient. Each contact has an identifier and is linked to an email address.
Adding contacts
Contacts can be added to your Plunk project in several ways:
- Using
/v1/track— tracking an event for a contact that doesn't exist yet automatically creates it (subscribed by default). - Using
POST /contacts— create or upsert a single contact. Existing emails are updated, not rejected; the response includes a_meta: { isNew, isUpdate }block and uses status201for new contacts and200for updates. - Bulk CSV import via
POST /contacts/import— upload a CSV (≤ 5 MB) and pollGET /contacts/import/:jobIdfor status. The file needs anemailcolumn, an optionalsubscribedcolumn sets subscription status, and any other columns map to lowercase keys underdata.*. - Bulk subscribe / unsubscribe / delete via
POST /contacts/bulk-subscribe,bulk-unsubscribe, andbulk-delete— each accepts up to 1,000 contact IDs and returns a job ID; pollGET /contacts/bulk/:jobIdfor progress. - Adding emails to a static segment with
POST /segments/:id/membersandcreateMissing: truewill create any contacts that don't already exist. - Manually through the dashboard.
Contact Data
You can associate custom data with each contact using key-value pairs. This data can be used for segmentation and personalization.
Data types
Contact data types are inferred from the first non-null value Plunk sees for a given key in your project:
| Type | Description |
|---|---|
| String | Any text value that doesn't look like a date. |
| Number | Numeric values, including integers and floats. |
| Boolean | true or false. |
| Date | A string matching YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS[.sss]Z. The full ISO 8601 form is required for date-aware operators in segments. |
The type is sampled per-project on first write — once a key is typed as number, subsequent string values for that key are still stored, but the inferred type stays number for the segment filter UI. If you accidentally mix types, the safest reset is to delete the field via DELETE /contacts/fields/:field and re-import with the type you want.
Non-persistent values
Send a value as { value, persistent: false } to use it once for template rendering without storing it on the contact:
{
"email": "[email protected]",
"data": {
"firstName": "Ada",
"resetCode": { "value": "ABC123", "persistent": false }
}
}firstName is saved on the contact; resetCode is available to the template for this send only and is then discarded. Use this for one-shot values like password reset codes, magic links, and one-time tokens.
Special value handling
When creating or updating contacts, certain values are handled specially:
| Value | Behavior | Example |
|---|---|---|
Empty string ("") |
Ignored - field is not stored or updated | { name: "" } → Field is skipped |
null |
Delete - field is removed from contact data | { name: null } → Field is deleted |
| Other values | Stored/updated normally | { name: "John" } → Stored as "John" |
Reserved keys
Some keys are reserved by Plunk and managed at the contact level (not inside data). You can read them but you can't set them through data:
| Key | Description |
|---|---|
email |
The contact's email address |
createdAt |
Timestamp of when the contact was created |
updatedAt |
Timestamp of the last update |
subscribed |
Boolean indicating whether the contact is subscribed to marketing emails |
snoozedUntil |
Timestamp when a snooze ends, or null. See Snoozing |
The following keys are also silently filtered out of any data payload — sending them through /v1/track, POST /contacts, or PATCH /contacts/:id will not store them and will not return an error:
id, plunk_id, plunk_email, unsubscribeUrl, subscribeUrl, manageUrl
The last three are auto-generated per-recipient at send time and exposed as template variables (see Templates).
Special keys
| Key | Description |
|---|---|
| locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers |
Subscription State
Every contact has a subscribed field that determines which types of emails they will receive. A newly created contact is subscribed by default when created via /v1/track; contacts created via POST /contacts use whatever value you pass (default: false).
When you update a contact, omitting subscribed keeps the current state — it is not the same as passing false. To change the state, pass an explicit true or false.
Every flip of subscribed automatically tracks an event on the contact:
subscribedflipped totrue→contact.subscribedeventsubscribedflipped tofalse→contact.unsubscribedevent
These events fire on every path that changes subscription state — manual edits, the public unsubscribe page, bulk operations, automatic unsubscribes from bounces / complaints. You can branch on them in workflows.
How contacts become unsubscribed
A contact can become unsubscribed in several ways:
- Manually through the dashboard or via the API.
- Self-service by clicking the unsubscribe link in an email (powered by the public unsubscribe pages).
- Self-service, temporarily by snoozing instead of unsubscribing — see below.
- Automatically on a permanent (hard) email bounce. Soft bounces don't change subscription state.
- Automatically when a recipient marks one of your emails as spam (their mailbox provider reports the complaint back to Plunk).
Snoozing
A recipient can pause emails for 2 weeks, 1 month, 6 months, or a year instead of leaving permanently. A snoozed contact has subscribed: false — so every rule on this page applies to them unchanged — plus a snoozedUntil timestamp saying when they come back.
A background job resubscribes them when the window ends and fires contact.subscribed with reason: "snooze_expired". The snooze itself fires contact.unsubscribed with reason: "snooze".
Any other change to subscribed cancels a running snooze, including a bounce or complaint — a suppressed address is never resurrected by a snooze that was in flight.
See Unsubscribe & preferences pages for the full behaviour.
Emails by subscription state
The subscription state controls whether a contact receives marketing emails. Transactional emails are always delivered regardless of subscription state.
| Email type | Subscribed | Unsubscribed |
|---|---|---|
| Transactional (via /v1/send) | Delivered | Delivered |
| Campaigns (marketing) | Delivered | Not delivered |
| Campaigns (headless) | Delivered | Not delivered |
| Campaigns (transactional) | Delivered | Delivered |
| Automations (marketing template) | Delivered | Not delivered |
| Automations (headless template) | Delivered | Not delivered |
| Automations (transactional template) | Delivered | Delivered |
What's next
Apps Wiki Content Docs Concepts Meta.Json (`apps/wiki/content/docs/concepts/meta.json`)
{
"pages": ["contacts", "segments", "workflows", "campaigns", "transactional-emails", "templates", "billing"]
}
Apps Wiki Content Docs Concepts Segments (`apps/wiki/content/docs/concepts/segments.mdx`)
title: Segments icon: Layers
Segments let you create named groups of contacts that can be targeted in campaigns and used as triggers in workflows. There are two types: Dynamic and Static.
Dynamic vs Static
| Dynamic | Static | |
|---|---|---|
| Membership | Computed from filter conditions in real time | Manually curated — you decide who is in |
condition field |
Required — describes the filters | Must be omitted |
| Add/remove members via API | Not allowed (the filter decides) | POST / DELETE /segments/:id/members |
| Membership recomputation | Re-evaluated in the background when tracked | N/A — membership only changes when you call the API |
| Entry/exit events | Fired only when Track membership changes is enabled | Not fired by add/remove API calls |
| Updating the filter | Member count is recomputed | condition is silently ignored on update |
Use dynamic segments for behavioural targeting ("subscribed users on the Pro plan who opened any email in the last 14 days"). Use static segments for one-off curated lists like beta testers, conference attendees, or contacts imported from an external system.
Filtering on a dynamic segment
A dynamic segment's membership is defined by a filter — a set of conditions evaluated against your contacts. You can filter on:
- Built-in contact fields:
email,subscribed,createdAt,updatedAt. - Custom fields you've stored on contacts (anything under
data.*). - Custom events tracked via
/v1/track(event.signed_up,event.purchased, etc.). - Email engagement (
email.opened,email.clicked,email.bounced, etc.). - Membership of another segment.
Filters can be combined with AND or OR and nested into groups for more complex audiences — for example "subscribed Pro users and (opened or clicked an email in the last 14 days)".
For the full list of fields, operators, and value types — plus worked examples — see the Segment filter reference.
Static segments
Static segments are manually curated lists. Membership doesn't change automatically — you decide exactly who is in.
Creating
Go to Segments in the dashboard and click Create Segment, then choose Static. You can optionally add initial members straight away using the contact search.
Adding and removing members
Open a static segment and use the Add Members search to find contacts. The search looks up contacts already in your project, so you can't accidentally add someone who doesn't exist. Contacts already in the segment are greyed out.
Programmatically, use:
POST /segments/:id/members— add contacts by email. Body:{ emails: string[], createMissing?: boolean, subscribed?: boolean }. WithcreateMissing: true, contacts that don't exist yet are created (and start subscribed unless you passsubscribed: false). The response reports{ added, created, notFound }.DELETE /segments/:id/members— remove contacts by email. Body:{ emails: string[] }. Returns{ removed }.
Both endpoints only accept static segments. Calling them on a dynamic segment returns 400.
Membership changes via these API calls are immediate but do not fire entry/exit events — those are reserved for dynamic, tracked segments.
Tracking membership changes
Dynamic segments can opt into Track membership changes. When enabled, Plunk fires events whenever a contact enters or leaves the segment:
segment.<slug>.entry— contact joinedsegment.<slug>.exit— contact left
The <slug> is derived from the segment name: lowercased, accents and punctuation stripped, whitespace replaced with hyphens, repeated hyphens collapsed. "VIP Customers" becomes segment.vip-customers.entry. Pick segment names that produce stable slugs — renaming a segment changes the event name.
Use these events to drive workflows (welcome a contact when they enter a "Trial users" segment, send a re-engagement email when they exit "Active users", etc.). See Webhooks for the payload format.
How tracking works
The member count updates immediately when you create or change a dynamic segment's filter. After that, Plunk recomputes membership in the background on a regular cadence — diffing current matches against the previous set, recording entries and exits, and emitting the corresponding events.
Because of this background cadence, the memberCount shown in the dashboard can lag the live state by a few minutes. If you need a fresh value or want to drive a workflow off entry / exit immediately:
POST /segments/:id/refresh— force a count refresh (cheap, no events).POST /segments/:id/compute— force a full membership recomputation, which fires any pending entry/exit events.
Using segments
Segments can be used to:
- Target contacts in email campaigns (set the campaign audience to a segment)
- Trigger workflows in marketing automation (use the
segment.<slug>.entryevent)
Performance notes
- Date filters on
data.*rely on ISO 8601 string ordering — store dates as ISO 8601 strings (2026-05-06T12:00:00Z) rather than Unix timestamps if you want to usewithin/olderThanon them. - Nesting many untracked dynamic segments inside one another increases evaluation cost. If a segment is referenced by others, enable Track membership on it so its members are looked up directly instead of recomputed each time.
- The cached
memberCountmay be a few minutes behind reality. Treat it as approximate; usePOST /segments/:id/refreshto force a refresh if you need an exact count.
Deleting a segment
Deleting a segment that's referenced by an active campaign (in DRAFT, SCHEDULED, or SENDING state) returns 409 Conflict. Cancel the campaign or pick a different audience first.
API reference
See the segments API endpoints in the API overview — list, get, create, update, delete, list members, add/remove static members, refresh count, and compute membership.
What's next
Apps Wiki Content Docs Concepts Templates (`apps/wiki/content/docs/concepts/templates.mdx`)
title: Templates icon: SwatchBook
Templates are stored in the Plunk dashboard and can be used in campaigns, workflows, and transactional emails sent through /v1/send.
Anatomy of a template
A template bundles everything needed to render an email: content, sender, and behaviour.
import {TypeTable} from 'fumadocs-ui/components/type-table';
<TypeTable
type={{
name: { type: 'string', required: true, description: 'Internal name shown in the dashboard.' },
subject: { type: 'string', required: true, description: 'Email subject line. Supports template variables.' },
body: { type: 'string', required: true, description: 'HTML body. Supports template variables.' },
from: { type: 'string', required: true, description: 'Sender email — must be on a verified domain.' },
fromName: { type: 'string', description: 'Optional sender display name.' },
replyTo: { type: 'string', description: 'Optional reply-to address.' },
type: { type: 'string', required: true, description: 'One of MARKETING, TRANSACTIONAL, or HEADLESS. See Template types.' },
style: { type: 'object', description: "Editor style metadata used by the dashboard's drag-and-drop editor. You can ignore this when creating templates via the API." },
}}
/>
When a template is used by /v1/send, the request can override any of subject, body, from, fromName, replyTo — the template's values act as defaults.
Sender domain verification
The from address must be on a verified domain. Creating or updating a template with an unverified from returns 403. See Verifying domains.
Designing templates
Templates can be created using the built-in editor or by uploading your own HTML.
Personalization
You can use contact data to personalize your templates by using the handlebars syntax {{ key }}, where key is the contact data key. Subjects and bodies are rendered with Liquid, so besides variables like {{ key }} you also get conditionals, loops and filters. See Template language for the full reference.
<p>Hi {{firstName ?? 'there'}},</p>
{% if locale == 'es' %}
<p>Tu plan {{plan | upcase}} se renueva pronto.</p>
{% else %}
<p>Your {{plan | upcase}} plan renews soon.</p>
{% endif %}Always Available Variables
These variables are always available in your templates, regardless of custom contact data:
| Variable | Description |
|---|---|
{{id}} |
The contact's unique identifier |
{{email}} |
The contact's email address |
{{unsubscribeUrl}} |
URL to the unsubscribe page |
{{subscribeUrl}} |
URL to the subscribe/resubscribe page |
{{manageUrl}} |
URL to the preferences management page |
Fallback Values
You can provide fallback values for variables that might not be set:
Hello {{firstName ?? 'there'}}!If firstName is not set, this will render as "Hello there!" Liquid's default filter does the same thing and can fall back to another variable rather than a literal:
Hello {{firstName | default: nickname}}!Conditionals, loops and filters
Anything Liquid supports works in a template — branch on a custom field, loop over an array, or transform a value with a filter:
{% case plan %}
{% when 'pro' %}<p>Here's 20% off your renewal.</p>
{% when 'free' %}<p>Upgrade and save 20%.</p>
{% else %}<p>Thanks for being with us.</p>
{% endcase %}
<ul>
{% for item in cartItems %}
<li>{{item.name}} — {{item.price | times: quantity | round: 2}}</li>
{% endfor %}
</ul>This lets a single campaign cover combinations that would otherwise need one campaign per segment — e.g. three languages × two offers in one send instead of six. See Template language.
Special Fields
The following fields are reserved by Plunk but can still be used.
| Variable | Description |
|---|---|
{{subscribed}} |
Contact's subscription status (true/false) |
{{locale}} |
Contact's preferred locale (e.g., 'en', 'fr', 'es') |
Previewing templates
You can preview your templates by selecting a contact in the preview window. This will allow you to see how the template will look for that specific contact, with their data populated.
Duplicating and finding usage
POST /templates/:id/duplicate— creates an editable copy. Useful for A/B variants or as a starting point for related emails.GET /templates/:id/usage— lists every campaign, workflow step, and other reference that points at this template. Use this to answer "what breaks if I change this template?" or to find leftover references before deletion.
Templates types
There are three types of templates in Plunk. Each type is treated at the same priority when sending emails, you should not pick one type over the other based on deliverability or performance.
| Type | Respects opt-out | Plunk unsubscribe footer | Description |
|---|---|---|---|
| Marketing | Yes | Yes | Automatically includes a Plunk-hosted unsubscribe footer. Will not be sent to contacts who are unsubscribed |
| Transactional | No | No | Does not include any way to unsubscribe. Will be sent to any contact, regardless of subscription state |
| Headless | Yes | No | Respects opt-out like marketing, but no Plunk footer is appended. You are responsible for providing an unsubscribe mechanism in the email body. Use {{unsubscribeUrl}} or {{manageUrl}} to link to Plunk's managed unsubscribe page |
What's next
Apps Wiki Content Docs Concepts Transactional Emails (`apps/wiki/content/docs/concepts/transactional-emails.mdx`)
title: Transactional Emails icon: Send
Transactional emails are emails sent directly through the API. They are typically used for one-to-one communication, such as password resets, order confirmations, and notifications.
Sending with attachments
Plunk supports sending attachments with transactional emails. By default, you can include up to 10 attachments per email with a maximum total size of 10MB. Attachments should be base64 encoded and included in the attachments array when sending the email via the /v1/email/send endpoint.
The total message size cannot exceed 40 MB. Self-hosters can adjust the defaults — see Environment variables.
Sending from a template
You can also send transactional emails using a template you have created in the dashboard. This allows you to reuse the same design and content for multiple emails, while still personalizing them with contact data.
What's next
Apps Wiki Content Docs Concepts Workflows (`apps/wiki/content/docs/concepts/workflows.mdx`)
title: Workflows icon: Workflow
Workflows are graph-based automations that move contacts through a sequence of steps — sending emails, waiting for activity, branching on conditions, calling webhooks, updating contact data, or exiting. Each contact that enters a workflow runs through it independently as a workflow execution.
Triggers
A workflow has a single trigger that decides how contacts enter:
| Trigger type | When it fires |
|---|---|
EVENT |
Any time a matching event is tracked on a contact (custom event via /v1/track, system events such as email.received or contact.subscribed, or segment entry/exit events). |
MANUAL |
Only when you explicitly start an execution via POST /workflows/:id/executions. |
SCHEDULE |
On a recurring schedule defined by triggerConfig (e.g. cron expression). |
When you create a workflow via the API, the trigger defaults to EVENT with the event name you provide. To use MANUAL or SCHEDULE, change triggerType and triggerConfig with PATCH /workflows/:id after creation.
A workflow's trigger event name cannot be changed after the first execution — pick it carefully. Other trigger configuration (delays, conditions inside steps) remains editable.
Lifecycle and the `enabled` flag
Workflows are created with enabled: false. No executions start until you flip enabled to true — this is the most common reason "my workflow isn't firing."
The allowReentry flag (default false) controls whether a contact can enter the same workflow more than once. Leave it off for "first-touch only" sequences (welcome emails, onboarding) and turn it on for re-engageable journeys (re-prompts, repeated nudges).
Step types
A workflow always begins with a single auto-created TRIGGER step. You build the rest of the graph by adding steps and transitions (edges) between them.
| Step type | Purpose | Required config |
|---|---|---|
TRIGGER |
Auto-created entry point. Holds the trigger configuration. | eventName (for EVENT trigger) |
SEND_EMAIL |
Sends an email to the contact using a template. Template variables resolve from contact data + execution context. | templateId, optional from override |
DELAY |
Pauses the execution for a fixed duration before continuing. | amount, unit (minutes / hours / days) |
WAIT_FOR_EVENT |
Pauses until a specified event is tracked on the contact, with a timeout fallback. | eventName, timeout (seconds) |
CONDITION |
Branches the execution based on contact data or event data. Each CONDITION step has two outgoing transitions tagged yes / no. |
A filter expression (same shape as segment filters) |
WEBHOOK |
Calls an external HTTPS endpoint with contact + execution context as the JSON body. url, header values, and body support {{variables}}. |
url, optional method, headers, body |
UPDATE_CONTACT |
Patches contact data — useful for tagging contacts as they progress ({ stage: "activated" }). |
data object |
EXIT |
Terminates the execution. Optionally records an exitReason for analytics. |
optional reason |
Transitions
Transitions are the edges of the workflow graph. For most step types a transition is a simple "next" pointer. For CONDITION steps, each transition carries a branch discriminator ("yes" or "no") so the engine knows which path to take when the condition evaluates.
When deleting a step in the middle of a chain, pass ?splice=true on DELETE /workflows/:id/steps/:stepId to automatically reconnect the surrounding transitions; otherwise the deletion leaves the graph disconnected.
Executions
Each contact entering the workflow creates a WorkflowExecution. Executions move through these states:
| Status | Meaning |
|---|---|
RUNNING |
Currently processing a step (or about to). |
WAITING |
Paused inside a DELAY or WAIT_FOR_EVENT step. |
COMPLETED |
Reached the end of the graph successfully. |
EXITED |
Hit an EXIT step. exitReason records why. |
FAILED |
An unrecoverable error occurred (e.g. webhook returned non-2xx after retries, template not found). |
CANCELLED |
Cancelled manually via the API or dashboard. |
Each execution carries a context JSON object that's merged with the contact's data when rendering templates and evaluating conditions. When you start an execution manually with POST /workflows/:id/executions, you can pass an initial context — this is how you parameterise per-execution variables (a coupon code, a referrer name) without storing them on the contact.
Locking active workflows
A workflow is locked for structural edits while it has active (RUNNING or WAITING) executions. To make breaking changes — adding/removing steps, changing the trigger event, switching trigger type — first either:
- Disable the workflow and wait for active executions to complete, or
POST /workflows/:id/executions/cancel-allto cancel everything in flight.
Cosmetic changes (renaming, editing template content referenced by steps) don't require this.
Common patterns
- Welcome series —
EVENTtrigger onsigned_up, send welcome email, delay 2 days, send tips email, delay 5 days, send upgrade nudge. - Inbound auto-reply —
EVENTtrigger onemail.received,CONDITIONonevent.spamVerdict == "PASS", thenSEND_EMAILwith an auto-reply template. See Receiving emails. - Re-engagement — Triggered by a segment exit (
segment.active-users.exit), send a "we miss you" email, wait 7 days for anyemail.openedevent, branch on whether the contact engaged. - Webhook fan-out —
EVENTtrigger onpurchase.completed,WEBHOOKstep to your CRM,UPDATE_CONTACTto tag{ tier: "customer" },SEND_EMAILwith the receipt.
API reference
Workflows are managed through the /workflows API. See the API overview for the full route list — the API exposes endpoints for the workflow itself, its steps, its transitions, and its executions.
What's next
Apps Wiki Content Docs Guides Api Keys (`apps/wiki/content/docs/guides/api-keys.mdx`)
title: API Keys icon: Key
Every project in Plunk has exactly two API keys: a public key and a secret key. Both authenticate requests to the same API but cover different surfaces.
Where to find your keys
In the dashboard, open your project and go to Settings → API Keys. Both keys are visible — copy them and store them in environment variables (never commit them to source control).
The two keys
| Key | Prefix | Endpoints it can call | Where it's safe to use |
|---|---|---|---|
| Public | pk_ |
POST /v1/track only |
Client-side code (browser, mobile apps) |
| Secret | sk_ |
Every other endpoint — sending email, contacts, segments, campaigns, templates, workflows, domains, billing | Server-side only |
The public key is intentionally limited so you can call /v1/track from a browser or mobile app to record events without exposing your project's full API surface. Anything beyond event tracking — sending emails, reading contacts, creating campaigns — requires the secret key.
The project a request belongs to is derived from the key automatically. There's no separate project ID parameter on API requests.
Authenticating requests
Both keys use the same Authorization: Bearer format. Pass the key in the Authorization header on every request.
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
<Tabs items={['cURL', 'JavaScript', 'Python']}>
curl https://next-api.useplunk.com/v1/send \
-H "Authorization: Bearer $PLUNK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "[email protected]", "subject": "Hello", "body": "<p>Hi</p>" }'const response = await fetch('https://next-api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: '[email protected]',
subject: 'Hello',
body: '<p>Hi</p>',
}),
});import os
import requests
response = requests.post(
'https://next-api.useplunk.com/v1/send',
headers={
'Authorization': f"Bearer {os.environ['PLUNK_SECRET_KEY']}",
'Content-Type': 'application/json',
},
json={
'to': '[email protected]',
'subject': 'Hello',
'body': '<p>Hi</p>',
},
)If the key prefix doesn't match the endpoint (e.g. you use pk_ on /v1/send), the API returns 401 with code INVALID_API_KEY.
Rotating keys
Both keys live as a single pair. Rotating regenerates both keys simultaneously — there is no way to rotate one without invalidating the other.
When to rotate:
- A key has been committed to a public repository or shared with someone who shouldn't have it.
- You're offboarding a contractor or revoking a deployment's access.
- You're following a periodic rotation policy (e.g. every 90 days).
To rotate:
- Open Settings → API Keys in the dashboard, or call
POST /users/@me/projects/:id/regenerate-keys. - Confirm the rotation. The old keys stop working immediately.
- Update every consumer (your backend env vars, client-side bundles, third-party integrations).
There's no grace period — plan a brief deployment window if you have multiple consumers.
Storage best practices
- Server keys (
sk_) belong in environment variables on the server (.env.localfor development, your platform's secret store for production). Never bundle them into a frontend build. - Client keys (
pk_) can be shipped in browser code, but treat them as semi-sensitive — rotate if a malicious actor abuses your tracking endpoint. - Use separate Plunk projects for staging and production so a leaked staging key can't touch production data.
If a key is compromised
- Rotate immediately (above).
- Audit the Activity tab for unexpected sends, contact mutations, or campaign changes during the window the key was leaked.
- Check Billing → Consumption for usage spikes that suggest the key was abused.
- If you find unauthorized activity, contact support with the request IDs from the suspicious entries.
API reference
POST /users/@me/projects/:id/regenerate-keys— regenerate both keys for a project. The response includes the new keys.
Apps Wiki Content Docs Guides Custom Fields (`apps/wiki/content/docs/guides/custom-fields.mdx`)
title: Custom fields icon: Tags
Every contact in Plunk has a data object alongside the built-in fields (email, subscribed, createdAt, updatedAt). You decide what goes in data — first names, plan tiers, signup dates, internal user IDs, anything you want to use later in templates, segments, or workflow conditions.
This guide covers how custom fields work end to end: setting them, the rules around types, using them, and cleaning them up.
Setting custom fields
Set custom fields any time you create or update a contact:
# Via /v1/track (auto-creates the contact)
curl -X POST {{API_URL}}/v1/track \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"event": "signed_up",
"data": {
"firstName": "Ada",
"plan": "pro",
"signupDate": "2026-05-06T12:00:00Z",
"lifetimeValue": 240
}
}'
# Via PATCH /contacts/:id
curl -X PATCH {{API_URL}}/contacts/cnt_abc123 \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{ "data": { "plan": "enterprise" } }'data is patched — only the keys you send change. Other keys keep their existing values.
Special values
| Value | Behaviour |
|---|---|
| Primitive | Stored. Available for templates, segments, workflows. |
null |
Deletes the key from the contact. |
"" (empty) |
Ignored — does not overwrite existing data. |
{ value, persistent: false } |
Used for this send only; not stored on the contact. Good for one-shot codes (password resets, magic links). |
Type inference
Plunk infers a type for each field the first time it sees a non-null value in your project:
| Detected as | Examples |
|---|---|
| Number | 42, 3.14 |
| Boolean | true, false |
| Date | Strings matching YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS[.sss]Z |
| String | Anything else |
The inferred type powers the segment filter UI (showing date pickers for date fields, number inputs for numeric ones, etc.) and is sticky once set per project. If you mix types for a key, the original inference wins for the UI but Plunk still stores whatever value you send.
To use date-aware operators (within, olderThan) on a custom field, store the value as a full ISO 8601 string — "2026-05-06T12:00:00Z" works, "05/06/2026" doesn't.
Reserved keys
Some keys are managed by Plunk and silently filtered out of any data payload — sending them won't store them and won't return an error:
id, plunk_id, plunk_email, email, unsubscribeUrl, subscribeUrl, manageUrl
Plus these core contact fields, which exist outside data: email, subscribed, createdAt, updatedAt. To change email or subscribed, use the dedicated request fields, not data.
locale is a special soft-reserved key — it's stored under data like other fields, but Plunk reads it for localization.
Using custom fields
In templates
Reference any field by name as a template variable:
<p>Hi {{firstName ?? "there"}},</p>
<p>You've been on the {{plan}} plan since {{signupDate}}.</p>The ?? fallback syntax is Plunk-specific and lets you provide a default when the field is missing or null. Nested data (data.profile.tier) is accessible the same way: {{profile.tier}}.
Templates are rendered with Liquid, so custom fields can also drive conditionals, loops and filters:
{% if plan == 'pro' and lifetimeValue > 500 %}
<p>Your VIP renewal discount is ready.</p>
{% endif %}Field names built from CSV headers often contain spaces (a "First Name" column becomes first name). Reference those directly — {{first name}} — or with a bracket lookup when combining with filters: {{data["first name"] | capitalize}}. See Template language.
In segments
Reference custom fields with the data. prefix in segment filters:
{ "field": "data.plan", "operator": "equals", "value": "pro" }
{ "field": "data.lifetimeValue", "operator": "greaterThan", "value": 100 }
{ "field": "data.signupDate", "operator": "within", "value": 30, "unit": "days" }See the Segments concept page for the full filter taxonomy.
In workflows
CONDITION steps inside workflows use the same data. notation. The UPDATE_CONTACT step lets you write to custom fields as a workflow progresses (e.g. tag { stage: "activated" } after the welcome email is opened).
Inspecting your custom fields
GET /contacts/fields returns every field — standard plus custom — that's been used in your project, with the inferred type and the share of contacts that have it set:
{
"fields": [
{ "field": "email", "type": "string", "isCustom": false, "coverage": 1.0 },
{ "field": "data.plan", "type": "string", "isCustom": true, "coverage": 0.62 },
{ "field": "data.signupDate", "type": "date", "isCustom": true, "coverage": 0.95 }
]
}Use this to audit your custom fields, find stale ones, and feed dropdowns in your own UI.
For a single field, GET /contacts/fields/:field/values returns the distinct values seen for that field — useful for showing a "Filter by plan" dropdown in your dashboard.
Cleaning up unused fields
Custom fields tend to accumulate. Two endpoints help:
GET /contacts/fields/:field/usage— lists every segment, campaign, and workflow that references the field. Run this before deleting to make sure you don't break anything.DELETE /contacts/fields/:field— removes the field from every contact in the project.
Deleting only affects contact data — segments and workflows that referenced the field continue to exist but their conditions on that field will stop matching anything. The usage endpoint helps you find and clean those up first.
Best practices
- Pick stable field names. Renaming a field means updating every segment, template, and workflow that references it.
- Use ISO 8601 for dates. Otherwise date-aware segment operators won't work.
- Don't put secrets in
data. Custom fields are visible in the dashboard and to anyone with API access. Use{ value, persistent: false }for one-shot codes that shouldn't persist. - Prefer flat keys for high-cardinality fields. Nested paths (
data.profile.tier) work but are slightly harder to discover in the segment UI.
Apps Wiki Content Docs Guides Data Retention (`apps/wiki/content/docs/guides/data-retention.mdx`)
title: Data Retention icon: Archive
Plunk keeps the record of everything you send indefinitely. Some of the bulkier data attached to those records is aged out on a schedule to keep storage bounded.
| Data | Kept for | What happens after |
|---|---|---|
| Email metadata and metrics | Indefinitely | Never removed |
| Rendered email HTML | 90 days | Body is cleared; the email row remains |
| API request logs | 30 days | Log entry is deleted |
| Idempotency keys | 24 hours | Key is deleted and becomes reusable |
Email content
Every email Plunk sends is stored with its fully rendered HTML body, which is what the Preview button on a contact's activity feed displays. That body is by far the largest thing Plunk stores per email, so after 90 days it is cleared.
Clearing the body does not delete the email. Everything you analyse or segment on is kept permanently:
- Recipient, subject, sender, and send time
- Delivery status, and the timestamps for delivery, opens, clicks, bounces, and complaints
- Open and click counts
- The campaign, workflow, or template the email came from
The only visible effect is that emails older than 90 days no longer offer a preview of their content in the dashboard. Segment filters built on email activity are unaffected, since they read the event timestamps rather than the body.
API request logs
Plunk logs most API requests — public API calls, resource changes, authentication attempts, and any failed request — for debugging and audit purposes. These log entries are deleted after 30 days. This affects request-level troubleshooting only; the emails and events those requests created are unaffected.
Idempotency keys
A claimed Idempotency-Key is retained until it expires, 24 hours after it was claimed, and is then removed. Expiry is what makes a key reusable, so this cleanup is part of how idempotency works rather than a storage concern.
Self-hosting
Retention runs as scheduled jobs on the worker process, so a deployment that never runs the worker will grow without bound.
| Job | Schedule |
|---|---|
| Email body cleanup | Daily, 04:00 |
| API request log cleanup | Daily, 03:00 |
| Idempotency key cleanup | Hourly, on the hour |
Daily jobs run in the worker container's local timezone. Set TZ on that container if you need them to land at a specific hour.
The 90-day and 30-day windows are compiled in rather than read from the environment. The idempotency key TTL is the exception and can be tuned with IDEMPOTENCY_KEY_TTL_HOURS.
The first run after upgrading clears the backlog of every email body older than 90 days at once. On a large database this takes a while. Postgres will reuse the freed space for new rows, but the table will not shrink on disk without a VACUUM FULL.
Apps Wiki Content Docs Guides Idempotency (`apps/wiki/content/docs/guides/idempotency.mdx`)
title: Idempotency icon: KeyRound
A retried request is a duplicate email. A double-submitted checkout form is a double receipt. To make sure a request runs at most once, send an Idempotency-Key header.
If the key is new, Plunk processes the request normally. If your project has already used that key, Plunk refuses the request with 409 rather than performing it a second time.
Both public API write endpoints support it:
POST /v1/trackPOST /v1/send
Sending a key
The key is any string you choose, 1–255 printable ASCII characters. It must be unique per logical operation — a UUID, or something derived from your own data like receipt-order-1234.
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
<Tabs items={['cURL', 'JavaScript', 'Python']}>
curl -X POST https://api.useplunk.com/v1/send \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Idempotency-Key: receipt-order-1234" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Your receipt",
"body": "<p>Thanks for your order!</p>"
}'await fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_your_secret_key',
'Idempotency-Key': 'receipt-order-1234',
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: '[email protected]',
subject: 'Your receipt',
body: '<p>Thanks for your order!</p>',
}),
});import requests
requests.post(
"https://api.useplunk.com/v1/send",
headers={
"Authorization": "Bearer sk_your_secret_key",
"Idempotency-Key": "receipt-order-1234",
},
json={
"to": "[email protected]",
"subject": "Your receipt",
"body": "<p>Thanks for your order!</p>",
},
)What happens on reuse
Reusing a key returns 409 with the error code IDEMPOTENCY_KEY_REUSED. The details object tells you about the request that originally claimed the key:
{
"success": false,
"error": {
"code": "IDEMPOTENCY_KEY_REUSED",
"message": "Idempotency-Key \"receipt-order-1234\" has already been used",
"statusCode": 409,
"details": {
"key": "receipt-order-1234",
"originalRequest": "POST /v1/send",
"originalRequestAt": "2025-01-15T10:30:00.000Z",
"originalStatusCode": 200
}
}
}An originalStatusCode of null means the original request is still in flight — two requests with the same key arrived at once, and this one lost the race.
Which failures free the key
Not every failed request burns the key.
| Outcome of the first request | Key is | Why |
|---|---|---|
2xx success |
Kept | The operation happened. A retry would duplicate it. |
4xx client error |
Released | Validation and permission errors are rejected before anything is written, so it is safe to fix the request and retry with the same key. |
5xx server error |
Kept | The request may have partially completed. Refusing the retry is the whole point of the key. |
If a 5xx burns a key for a request you're confident never went through, retry with a new key.
Scope and expiry
Keys are scoped to your project, not to an endpoint. A key used on /v1/track cannot be reused on /v1/send. Two different projects can use the same key string independently.
A claimed key expires after 24 hours, after which it becomes reusable. Self-hosters can tune this with IDEMPOTENCY_KEY_TTL_HOURS.
Multiple recipients
POST /v1/send accepts an array of recipients and processes them one at a time. The idempotency key covers the request as a whole, not each recipient individually. If the request fails partway with a 5xx, some recipients may already have been sent to — the key stays claimed precisely so a blind retry doesn't send to them again.
For per-recipient control, send one request per recipient with its own key.
Apps Wiki Content Docs Guides Importing Contacts (`apps/wiki/content/docs/guides/importing-contacts.mdx`)
title: Importing contacts from CSV icon: Upload
Plunk supports bulk-importing contacts from a CSV file — useful for migrating from another platform, loading an initial list, or syncing a large batch of contacts that you can't reasonably stream through /v1/track.
CSV format
The CSV must have a header row. The only required column is email. Every other column becomes a custom field on the contact under data.<columnname>.
Headers are lowercased on import, so a firstName column becomes the field firstname. Use lowercase names in your templates and segment filters, like {{firstname}}, or name the columns in lowercase to begin with.
A minimal file:
A richer file with custom fields:
email,firstname,plan,signupdate
[email protected],Ada,pro,2026-01-15T00:00:00Z
[email protected],Grace,enterprise,2025-11-02T00:00:00Z
[email protected],Linus,free,2026-04-21T00:00:00ZIn this example, every imported contact ends up with data.firstname, data.plan, and data.signupdate set.
Rules and limits
- File size: up to 5 MB per upload. For larger lists, split the file and run multiple imports.
- Encoding: UTF-8. Non-UTF-8 files may produce garbled custom field values.
- Email column: must be present and valid. Rows with missing or invalid emails are reported back as errors.
- Reserved column names:
id,createdAt,updatedAt, and the auto-generated URL variables (unsubscribeUrl, etc.) are silently filtered out. Don't include them as columns. - Subscription status: an optional
subscribedcolumn sets whether each contact can receive marketing email.true,1andyessubscribe the contact; any other value, such asfalseorno, unsubscribes it. Leave the cell empty, or omit the column, to keep an existing contact's status unchanged. New contacts without a value are created subscribed. - Date columns: use ISO 8601 (
2026-05-06T12:00:00Z) so they're typed as dates and become usable withwithin/olderThansegment operators. - Boolean columns:
true,false,yes,no(case-insensitive) are stored as booleans and get the boolean toggle in segment filters. - Numeric columns: plain integers and decimals (
42,3.14) are stored as numbers and become usable withgt/ltsegment operators. Leading-zero values (01234),+-prefixed numbers, and scientific notation stay strings so IDs, zip codes, and phone numbers aren't corrupted. - Existing contacts: if a row's email matches an existing contact, the import updates the contact (merging the CSV's columns into
data). It doesn't create a duplicate or overwrite the whole record.
Importing your CSV
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
<Tabs items={['Dashboard', 'API']}>
- Open Contacts and click Import.
- Pick your CSV file. Plunk validates the header and shows a preview of the first few rows.
- Confirm. The import is queued and runs in the background.
- The Imports page shows progress and any per-row errors when complete.
For automation, use the import API:
Step 1 — upload the CSV
curl -X POST {{API_URL}}/contacts/import \
-H "Authorization: Bearer sk_..." \
-F "[email protected]"The response includes a jobId:
{ "jobId": "imp_abc123", "status": "queued" }Step 2 — poll for status
curl {{API_URL}}/contacts/import/imp_abc123 \
-H "Authorization: Bearer sk_..."Poll every few seconds until status is completed or failed. The response includes counts:
{
"jobId": "imp_abc123",
"status": "completed",
"totalRows": 12000,
"imported": 11985,
"updated": 8,
"skipped": 0,
"errors": [
{ "row": 47, "email": "bad@", "reason": "invalid_email" },
{ "row": 1042, "email": "@example", "reason": "invalid_email" }
]
}imported counts new contacts created. updated counts existing contacts whose data was patched. errors lists per-row issues with row number and reason so you can fix and re-upload.
Tips
- Migrating unsubscribes: when moving from another platform, export unsubscribed, bounced and complained contacts too, and import them with
subscribedset tofalse. They stay on file for their history but are never sent marketing email. - Custom fields appear in segments immediately: as soon as the import finishes, you can build dynamic segments on any of the imported columns.
- Idempotent reruns: re-running the same CSV updates contacts in place rather than creating duplicates. If you fix a column and re-upload, all matching rows get the corrected value.
Related
- Custom fields — how the
data.<column>fields you import are typed and used. - Bulk operations —
bulk-subscribe,bulk-unsubscribe,bulk-deletefor mass actions on existing contacts (max 1,000 per call).
Apps Wiki Content Docs Guides List Hygiene (`apps/wiki/content/docs/guides/list-hygiene.mdx`)
title: List Hygiene icon: ShieldCheck
Plunk automatically monitors the bounce and complaint rates of your emails to help maintain a health sender reputation. High bounce or complaint rates can negatively impact your deliverability and may lead to your account being suspended.
Bounce Management
A bounce occurs when an email cannot be delivered to the recipient's inbox. Plunk records every bounce as an email.bounce event on the contact, and automatically unsubscribes the contact only on permanent (hard) bounces. Transient (soft) bounces don't change subscription state — they're typically retried by the upstream mail server.
Types of Bounces
| Type | Description | Auto-unsubscribes? |
|---|---|---|
| Permanent (hard) | Permanent delivery failure — invalid address, blocked domain, recipient rejected. | Yes |
| Transient (soft) | Temporary delivery failure — mailbox full, server unavailable, greylisted. | No |
| Undetermined | The upstream provider couldn't classify the bounce. | No |
Both bounce types fire an email.bounce event you can branch on inside workflows or webhooks (use the event payload's bounceType field to distinguish them).
Preventing bounces
- Verify email addresses at signup before adding them to your list.
- Regularly clean your email list — segment unengaged contacts and re-confirm or remove them.
- Build a confirmation flow with a workflow that sends a verification email and only marks the contact as confirmed when they click through.
Complaint Management
A complaint occurs when a recipient marks your email as spam in their inbox provider. When Plunk receives that complaint, the contact is always automatically unsubscribed and Plunk fires an email.complaint event on the contact.
Complaints are taken more seriously than bounces by mail providers — even a small complaint rate can hurt your sender reputation and deliverability.
Preventing complaints
- Ensure your emails are relevant and valuable to your audience.
- Include a clear, working unsubscribe link in every marketing email — Plunk injects this automatically for
MARKETINGtemplate/campaign types. - Monitor your email frequency to avoid overwhelming your contacts.
- Make sure recipients clearly opted in. Avoid scraped, purchased, or stale lists.
Plain text alternative
Every email Plunk sends carries two versions of your content: the HTML you wrote, and a plain text version generated from it. You don't need to write or send the plain text yourself — Plunk derives it from your HTML on every send, for transactional emails, campaigns, and test sends alike.
This matters for deliverability. An HTML-only message is a long-standing spam signal, because legitimate mail has historically offered a text fallback and bulk senders often didn't. SpamAssassin, for example, scores HTML-only mail on MIME_HTML_ONLY and MPART_ALT_DIFF, and those points add to whatever else a message picks up. A clean message can land in spam on the accumulation alone.
The generated text keeps your content readable rather than stripping it to a blob:
- Links keep both their text and their destination, as
View invoice [https://example.com/invoice]. - Headings, paragraphs, and lists stay on separate lines.
- Layout tables are flattened, so they read as prose instead of ASCII art.
- Images and tracking pixels are dropped.
That faithfulness is the point. A text version that disagreed with the HTML would trip the same filters it's meant to satisfy, so the two parts say the same thing.
You can see it for yourself in most mail clients — "Show original" in Gmail, or the plain text view in Outlook. If an email has no readable text at all (a single image, say), Plunk omits the text part rather than attaching an empty one.
Testing bounces and complaints safely
To rehearse your bounce and complaint handling, send to the Amazon SES mailbox simulator instead of to a real address. Mail sent to @simulator.amazonses.com never leaves AWS, and the simulator replies with the outcome the address asks for:
| Address | Result |
|---|---|
[email protected] |
Permanent (hard) bounce |
[email protected] |
Complaint |
[email protected] |
Successful delivery |
[email protected] |
Out-of-office auto-reply |
[email protected] |
Permanent bounce, as if the address were on the suppression list |
These sends are fully visible in Plunk: the event fires, your workflows and webhooks run, and the bounce shows on the contact's activity feed. What they do not do is count toward your bounce and complaint rates, so they can never trigger a warning or suspend your project. AWS treats them the same way, excluding them from its own rates, quotas and reputation metrics.
Offer a pause, not just an exit
The most expensive complaint is the one from someone who wanted less email, not none. If unsubscribing is the only option on the page, a recipient who is merely overwhelmed either leaves permanently or — worse for your reputation — hits the spam button instead.
Plunk's hosted unsubscribe and preferences pages offer snoozing alongside unsubscribing: the recipient stops receiving email for 2 weeks, 1 month, 6 months, or a year, and is resubscribed automatically when the window ends. It is a genuine opt-out for the whole period, so it protects the recipient's inbox and your complaint rate at the same time, and it keeps a contact who would otherwise be gone for good.
Snoozes count toward a campaign's unsubscribe rate, so this does not hide a campaign that is burning through goodwill — it just means fewer of those opt-outs are permanent.
See Unsubscribe & preferences pages.
Apps Wiki Content Docs Guides Localization (`apps/wiki/content/docs/guides/localization.mdx`)
title: Localization icon: Globe
Plunk localizes the strings it controls — the auto-injected unsubscribe footer on marketing emails, the hosted unsubscribe / subscribe / preferences pages — into the language of your audience. Your own template content (subject lines, body copy) is not translated automatically; you control that yourself.
Setting the project default
Open Settings → Project and pick a default language. This is used for any contact who doesn't have a specific language set on their record.
Overriding per contact
Set a locale field on the contact's data to override the project default for that recipient:
curl -X POST {{API_URL}}/v1/track \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"event": "signed_up",
"data": { "locale": "fr" }
}'Use a BCP 47 / ISO 639 code that matches one of the supported languages below. You can also reference {{locale}} inside template variables to branch your own copy on the contact's language.
If the contact's locale doesn't match a supported language, Plunk falls back to the project default. If neither is set, English is used.
Supported languages
| Code | Language |
|---|---|
en |
English |
nl |
Dutch (Nederlands) |
fr |
French (Français) |
de |
German (Deutsch) |
es |
Spanish (Español) |
it |
Italian (Italiano) |
pt |
Portuguese (Português) |
pl |
Polish (Polski) |
cs |
Czech (Čeština) |
bg |
Bulgarian (Български) |
hi |
Hindi (हिंदी) |
zh-CN |
Chinese (Simplified, China) |
zh-TW |
Chinese (Traditional, Taiwan) |
zh-HK |
Chinese (Traditional, Hong Kong) |
What gets translated
| Surface | Translated? |
|---|---|
| Unsubscribe footer on marketing emails | Yes |
| Hosted unsubscribe / subscribe / preferences pages | Yes |
| Your own template subject and body | No — author per-language templates and branch on {{locale}} |
Translating your own templates
Plunk doesn't translate the content of your templates. Two common patterns:
- One template per language, picked in your code or workflow before sending. Name them with the locale suffix (
welcome-en,welcome-fr). - One template with branched copy, using
{{locale}}inside Handlebars conditionals to inline different language variants. Workable for short copy; gets unwieldy for long emails.
For larger localization needs, the per-language template approach is easier to maintain.
Apps Wiki Content Docs Guides Mcp Server (`apps/wiki/content/docs/guides/mcp-server.mdx`)
title: MCP Server icon: Bot
The official Plunk MCP server lets an AI agent work in your Plunk
project: sending transactional email, managing contacts and segments, drafting and sending
campaigns, and building automation workflows. It works with both the hosted product and self-hosted
instances.
The package is published as @plunk/mcp.
Setup
You need a secret key (sk_…) from Settings → API Keys. See API Keys
for how the two key types differ.
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
<Tabs items={['Claude Code', 'Claude Desktop / Cursor', 'Self-hosted']}>
claude mcp add plunk --env PLUNK_API_KEY=sk_your_key -- npx -y @plunk/mcp{
"mcpServers": {
"plunk": {
"command": "npx",
"args": ["-y", "@plunk/mcp"],
"env": {
"PLUNK_API_KEY": "sk_your_key"
}
}
}
}Point PLUNK_API_URL at your own API domain:
{
"mcpServers": {
"plunk": {
"command": "npx",
"args": ["-y", "@plunk/mcp"],
"env": {
"PLUNK_API_KEY": "sk_your_key",
"PLUNK_API_URL": "https://api.your-domain.com"
}
}
}
}Configuration
| Variable | Required | Description |
|---|---|---|
PLUNK_API_KEY |
Yes | Your project's secret key (sk_…). |
PLUNK_API_URL |
No | API base URL. Defaults to the hosted API; set it when self-hosting. |
PLUNK_PUBLIC_KEY |
No | Public key (pk_…). Makes event tracking a single request instead of two. |
PLUNK_READ_ONLY |
No | true registers only read-only tools. |
PLUNK_ALLOW_UNCONFIRMED_SENDS |
No | true skips the confirmation prompt before sending. Headless automation only. |
PLUNK_MCP_API_KEY |
No | Takes precedence over PLUNK_API_KEY, for environments where that name is already taken. |
PLUNK_MCP_API_URL |
No | Takes precedence over PLUNK_API_URL, for the same reason. |
Available tools
Read-only — the only tools registered when PLUNK_READ_ONLY=true:
plunk_list_contacts, plunk_get_contact, plunk_verify_email, plunk_list_templates,plunk_list_campaigns, plunk_get_campaign, plunk_get_campaign_stats, plunk_list_segments,plunk_list_domains, plunk_check_domain, plunk_list_workflows, plunk_get_workflow,plunk_check_workflow, plunk_list_workflow_fields, plunk_list_workflow_executions,plunk_get_workflow_execution
Writing:
plunk_create_contact, plunk_update_contact, plunk_subscribe_contact,plunk_unsubscribe_contact, plunk_delete_contact, plunk_send_email, plunk_track_event,plunk_create_template, plunk_create_campaign, plunk_test_campaign, plunk_send_campaign,plunk_cancel_campaign, plunk_create_segment, plunk_create_workflow, plunk_update_workflow,plunk_duplicate_workflow, plunk_delete_workflow, plunk_add_workflow_step, plunk_add_workflow_steps,plunk_update_workflow_step, plunk_delete_workflow_step, plunk_connect_workflow_steps,plunk_disconnect_workflow_steps, plunk_set_workflow_enabled, plunk_start_workflow_execution,plunk_cancel_workflow_executions
Safety
A Plunk secret key grants full access to its project, so the server adds its own limits on top:
- Sends ask for confirmation. Sending a campaign, or an email to more than one recipient, prompts
you first and tells you how many people will receive it. Enabling a workflow that sends email prompts
you too, as do changing the trigger of an enabled one and starting one for a contact. Confirmation is not something the model can supply itself — it comes from you, through your
MCP client. A client that cannot show a prompt cannot send. - Read-only mode is structural. With
PLUNK_READ_ONLY=truethe mutating tools are never
registered, so they cannot be invoked even by name. - Account-level actions are out of reach. Billing, project deletion, and key rotation all require
a dashboard session rather than an API key, so no tool can reach them.
Use a separate project for anything you would rather an agent could not change, and rotate the key
(Settings → API Keys) if you stop trusting a machine that held it. Note that rotation replaces both
keys at once.
Things to know
Transactional versus campaign. plunk_send_email is for one-off messages to specific people.
Reaching a whole list means creating a campaign and then sending it. The agent is instructed not to
loop sends over an audience, which would hit the rate limit and skip
unsubscribe handling.
Verified domains. Sender addresses must be on a domain verified for the project, or the send is
rejected. plunk_list_domains lets the agent check before it sends, and plunk_check_domain re-runs
the DNS check after you add records. Adding and removing domains stays in the dashboard — those
endpoints skip the admin-role check for API keys, so exposing them would give an agent authority a
non-admin member does not have. See Verifying domains.
Unsubscribing someone. plunk_unsubscribe_contact takes an email address directly, so "unsubscribe
[email protected]" is a single call. It matches exactly and case-insensitively rather than patching the
first result of a substring search. plunk_subscribe_contact reverses it, but only for a contact that
already exists — opting someone in never creates a new record.
Testing before a campaign goes out. plunk_test_campaign sends one copy for review. The recipient
must be a member of the Plunk project; any other address is rejected. If a send is already scheduled or
in flight, plunk_cancel_campaign stops whatever has not gone out yet — it cannot recall what has.
Event tracking. /v1/track requires the public key. Without PLUNK_PUBLIC_KEY the server gets
the same result using the secret key, creating or updating the contact first and then recording the
event. Either way the contact is created if it does not exist.
Workflows. plunk_create_workflow creates a workflow that starts when a contact triggers the given
event. It starts disabled, with only its trigger step. plunk_add_workflow_steps then adds the steps —
any type the dashboard builder offers, including yes/no and multi-branch conditions — in one call, andplunk_add_workflow_step, plunk_update_workflow_step, plunk_delete_workflow_step,plunk_connect_workflow_steps and plunk_disconnect_workflow_steps edit the graph afterwards. Step
settings are validated before they are saved, and plunk_check_workflow shows the flow as a numbered
outline with anything that would fail at run time; enabling is refused while such errors remain.
Deleting a step reconnects the flow around it; for a condition you choose whether the steps below it
are kept or deleted too.plunk_set_workflow_enabled turns the workflow on or off. Disabling stops new contacts from entering;
executions already in progress continue to the end.
Apps Wiki Content Docs Guides Meta.Json (`apps/wiki/content/docs/guides/meta.json`)
{
"pages": [
"list-hygiene",
"verifying-domains",
"receiving-emails",
"tracking",
"api-keys",
"mcp-server",
"idempotency",
"localization",
"webhooks",
"template-language",
"importing-contacts",
"custom-fields",
"segment-filters",
"unsubscribe-pages",
"data-retention"
]
}
Apps Wiki Content Docs Guides Receiving Emails (`apps/wiki/content/docs/guides/receiving-emails.mdx`)
title: Receiving emails icon: Inbox
Plunk can receive emails sent to any address at your verified domain, store them in your project, and emit an email.received event you can drive workflows from. This unlocks auto-replies, ticketing, conditional forwarding, and any other "do something when an email arrives" pattern.
What happens when an email arrives
When someone emails an address at your verified domain, Plunk:
- Parses the message and stores it as an inbound
Emailrecord visible in your project's Activity feed. - Creates the sender as a contact in your project (or updates them if they already exist), subscribed by default.
- Tracks an
email.receivedevent on the sender contact, which any workflow can listen to.
The HTML body is sanitized before being stored — scripts, iframes, event handlers, and javascript: URIs are stripped. Plain text is preserved as-is when no HTML part is available.
Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
Verify your domain for sending
Inbound is only enabled on domains that are fully verified for sending (DKIM + SPF + the bounce-feedback MX). Follow the Verifying domains guide first.
Add the inbound MX record
Open your project's Domains tab, expand the verified domain, and look for the Inbound Email section. Plunk shows you the exact MX record value to add to your DNS — copy it from there and add it as an MX record on your domain.
Wait for DNS propagation
DNS changes take anywhere from a few minutes to 48 hours to propagate. You can verify the MX record is live:
dig MX yourdomain.comYou should see the value Plunk gave you in the response.
Send a test email
Send an email from any external account to any address at your domain (e.g. [email protected]) and check that:
- A new
Emailrow appears in the project's Activity feed with type Inbound. - The sender shows up as a contact in the Contacts tab.
- An
email.receivedevent is recorded on that contact.
What gets stored
Every accepted inbound email shows up in your project's Activity feed alongside outbound emails — you can search, filter, and inspect them the same way.
Plunk stores: the parsed and sanitized HTML body (or plain text if no HTML is available), the headers surfaced in the event payload below, the authentication and spam verdicts, and the message ID.
Plunk does not store: the original raw message, attachments, threading headers (In-Reply-To, References), or headers beyond what's exposed on the event. If you need any of those, forward the email to your own service via a WEBHOOK step in the workflow that fires.
The `email.received` event
The event is emitted on the sender contact (auto-created if it doesn't exist yet) and carries the full parsed message in its data field:
import {TypeTable} from 'fumadocs-ui/components/type-table';
<TypeTable
type={{
messageId: { type: 'string', description: 'Unique message identifier for the received email.' },
from: { type: 'string', description: 'Sender email address (envelope / From header).' },
fromHeader: { type: 'string', description: 'Full "From" header including display name, e.g. "Ada <[email protected]>".' },
to: { type: 'string', description: 'Primary recipient at your verified domain.' },
recipients: { type: 'array of strings', description: 'Every recipient address (covers To, Cc, and BCC envelope recipients).' },
subject: { type: 'string', description: 'Subject line.' },
timestamp: { type: 'string', description: 'ISO 8601 receive timestamp.' },
hasContent: { type: 'boolean', description: 'true when a body was successfully parsed.' },
body: { type: 'string', description: 'Sanitized HTML body, or plain text if no HTML part. See sanitization rules above.' },
spamVerdict: { type: 'string', description: 'Spam check result. One of PASS, FAIL, GRAY, or PROCESSING_FAILED.' },
virusVerdict: { type: 'string', description: 'Virus scan result. One of PASS, FAIL, GRAY, or PROCESSING_FAILED.' },
spfVerdict: { type: 'string', description: 'SPF authentication result.' },
dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
processingTimeMillis: { type: 'number', description: 'How long it took to process the email.' },
}}
/>
The event is always emitted — Plunk does not block emails based on the spam, virus, or authentication verdicts. Filter them yourself in the workflow that fires.
In templates and workflow steps, access these fields via the event variable namespace, e.g. {{event.subject}}, {{event.from}}, or {{event.body}}.
Building workflows on inbound
Auto-reply
A minimal auto-reply on [email protected]:
- Trigger:
email.received. - Condition: continue only if
event.toequals[email protected]andevent.spamVerdict == "PASS"andevent.virusVerdict == "PASS". - Send email:
- To:
{{event.from}} - Subject:
Re: {{event.subject}} - Body:
Thanks for your message. We've received your email and will respond within one business day.
- To:
Use a TRANSACTIONAL template for the auto-reply so it bypasses subscription checks (the sender is auto-subscribed but you don't want to fail to reply if they later unsubscribe).
Routing by recipient
Route different addresses to different downstream actions in a single workflow:
- Trigger:
email.received. - Condition: branch on
event.to:support@…→ ticketing webhook → auto-reply.sales@…→ CRM webhook → notify Slack.billing@…→ billing system webhook.
Forward to your API
For richer processing (NLP classification, ticket creation, attachments not captured by Plunk), forward the email to your own backend:
- Trigger:
email.received. - Webhook:
- URL:
https://api.example.com/inbound - Method:
POST - Body:json
{ "from": "{{event.from}}", "subject": "{{event.subject}}", "body": "{{event.body}}", "messageId": "{{event.messageId}}", "timestamp": "{{event.timestamp}}", "verdicts": { "spam": "{{event.spamVerdict}}", "virus": "{{event.virusVerdict}}", "spf": "{{event.spfVerdict}}", "dkim": "{{event.dkimVerdict}}", "dmarc": "{{event.dmarcVerdict}}" } }
- URL:
Filter spam and virus before processing
Always include a CONDITION step early in the workflow that drops anything where spamVerdict or virusVerdict is FAIL. Plunk does not pre-filter for you.
Multi-project domains
If the same domain is verified in multiple projects, every inbound email is delivered to every project that has it verified — each gets its own Email record, its own contact upsert, and its own email.received event. This is by design (it lets you split a single inbox across staging and production projects, or hand off the same inbound stream to multiple teams).
If you don't want this, only verify the domain in one project at a time.
Billing
Inbound emails count toward your project's email usage at 1 credit per received email, just like outbound. The free tier and paid tiers consume the same pool.
You can set a per-project inbound cap under Billing → Limits. Once the cap is reached, inbound emails are silently dropped for that project until the cap resets — they aren't queued or replayed. Other projects sharing the same domain are unaffected by another project's cap.
Security considerations
- Treat the body as untrusted user input. Plunk sanitizes HTML to prevent the obvious script-injection paths, but the message can still contain phishing links, social-engineering content, and unicode lookalikes. Don't render the body verbatim in any UI you control without re-escaping it for that context.
- Authentication verdicts are advisory. Plunk records SPF / DKIM / DMARC results on the event but does not enforce them. Build your own policy: dropping unauthenticated mail at the workflow's first
CONDITIONstep is a sensible default for sensitive inboxes (billing, account changes). - Senders are auto-subscribed. Inbound senders enter your audience as subscribed contacts. If you don't want that, add an
UPDATE_CONTACTstep at the end of the inbound workflow to setsubscribed: false. - Reply-loop risk. If your auto-reply sends back to a domain you also receive on (or to a list address), you can create an infinite loop. Add a
CONDITIONthat drops messages whereevent.frommatches your own domain.
Limitations
- Catch-all only: Plunk routes anything sent to any address at your domain to the same handler. You filter on
event.toinside the workflow. - No attachments: attachments are dropped during parsing. Forward to your own service if you need them.
- No raw MIME: the original message is not retained.
- No threading: Plunk doesn't group inbound messages into threads or correlate them with outbound replies.
- 40 MB size cap: messages larger than 40 MB are rejected before processing.
Troubleshooting
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
- DNS:
dig MX yourdomain.com— confirm the value Plunk gave you appears in the response. - Domain verification: in the dashboard, confirm the domain is fully verified for sending. Inbound MX won't process anything on an unverified domain.
- Workflow not firing: build a test workflow with just an
email.receivedtrigger and a webhook to a service like webhook.site to see whether events are being emitted at all. If yes, the issue is in your workflow logic; if no, it's upstream. - Sender DNS cache: some senders cache MX lookups for hours. Test from a different email provider.
- Check the workflow trigger event name is exactly
email.received. - Check the workflow is enabled — workflows are created disabled.
- Check the workflow's
CONDITIONsteps aren't filtering everything out (verdict checks are a common culprit when the sender domain has loose authentication).
- Check Billing → Limits for an inbound cap that's been reached.
- Check the project's status in the dashboard — a disabled project doesn't process inbound mail.
This points at the sender's configuration, not yours:
- SPF failures: sender's domain has no SPF record or doesn't list their sending IP.
- DKIM failures: sender's domain has no DKIM, or the message was modified in transit (some forwarding services break DKIM).
- DMARC failures: sender fails both SPF and DKIM, or has strict alignment that forwarding broke.
You can either drop these in your workflow or process them anyway — your call.
Apps Wiki Content Docs Guides Segment Filters (`apps/wiki/content/docs/guides/segment-filters.mdx`)
title: Segment filter reference icon: ListFilter
The same filter format powers three things: dynamic segments, campaign audiences with audienceType: "FILTERED", and CONDITION steps inside workflows. Learn it once, use it everywhere.
For a conceptual introduction, see the Segments concept page.
How a filter is structured
A filter has two parts: a top-level connector (AND or OR) and a list of groups. Each group holds one or more conditions, which are combined with AND inside the group. The top-level connector then joins the groups together.
That sounds complicated, so a quick example: "subscribed users and on the Pro plan and (opened or clicked an email recently)".
{
"logic": "AND",
"groups": [
{
"filters": [
{ "field": "subscribed", "operator": "equals", "value": true },
{ "field": "data.plan", "operator": "equals", "value": "pro" }
]
},
{
"filters": [],
"conditions": {
"logic": "OR",
"groups": [
{ "filters": [{ "field": "email.opened", "operator": "triggeredWithin", "value": 14, "unit": "days" }] },
{ "filters": [{ "field": "email.clicked", "operator": "triggeredWithin", "value": 14, "unit": "days" }] }
]
}
}
]
}Groups can nest by setting conditions on a group — that lets you build "this AND (that OR this)" expressions without flattening logic. In the dashboard, the segment builder UI handles this for you visually.
Fields you can filter on
The field value is namespaced by prefix — the prefix tells Plunk what part of the contact you're querying.
| Field pattern | Targets | Examples |
|---|---|---|
email |
The contact's email address | email |
subscribed |
The contact's subscription state | subscribed |
createdAt, updatedAt |
Built-in contact timestamps | createdAt |
data. |
Custom contact data — supports nested paths | data.plan, data.profile.tier |
event.<eventName> |
Custom events tracked via /v1/track |
event.signed_up |
email.<activity> |
Email engagement: sent, delivered, opened, clicked, bounced, complained |
email.opened |
segment.<segmentId> |
Membership of another segment | segment.cuid_of_other_segment |
Operators
Operators are grouped by the kind of field they work on.
Text fields
For email and any data.<key> that holds a string.
| Operator | What it does |
|---|---|
equals |
Exact match. Case-insensitive on email, exact on data.*. |
notEquals |
Negation of equals. |
contains |
Case-insensitive substring match. |
notContains |
Negation of contains. |
Yes/no fields
For subscribed and any data.<key> that holds true or false.
| Operator | What it does |
|---|---|
equals |
Match true or false. |
notEquals |
Negation. |
Dates and timestamps
For createdAt, updatedAt, and any data.<key> that holds an ISO 8601 date string.
| Operator | What it does |
|---|---|
equals / notEquals |
Match the timestamp. If the value is just a date (YYYY-MM-DD), the comparison covers the whole UTC day. |
greaterThan / lessThan |
Strictly after / strictly before. |
greaterThanOrEqual / lessThanOrEqual |
Inclusive variants. |
within |
Within the last N units. Pair with unit: "days" / "hours" / "minutes". |
olderThan |
More than N units ago. Pair with unit. |
For custom date fields (data.<key>), within and olderThan only work when the value is stored as an ISO 8601 string (e.g. "2026-05-06T12:00:00Z"). Unix timestamps and other formats won't compare correctly.
Numbers
For any data.<key> that holds a number: equals, notEquals, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual. No unit is needed.
Field existence
For any data.<key>, regardless of value type.
| Operator | What it does |
|---|---|
exists |
The key is present on the contact and is not null. |
notExists |
The key is missing or explicitly null. |
Events and email activity
These operators apply to both event.<eventName> (custom events) and email.<activity> (email engagement).
| Operator | What it does |
|---|---|
triggered |
The contact has had this event at least once, ever. |
notTriggered |
The contact has never had this event. |
triggeredWithin |
At least one occurrence in the last N units. Pair with unit. |
triggeredOlderThan |
Has had this event, but not within the last N units. Pair with unit. |
notTriggeredWithin |
Has not had this event in the last N units. Includes contacts who have never triggered it. Pair with unit. |
Segment membership
For segment.<segmentId>, where <segmentId> is the ID of another segment in your project.
| Operator | What it does |
|---|---|
memberOfSegment |
Contact is currently in the referenced segment. |
notMemberOfSegment |
Contact is not in the referenced segment. |
Quick reference: what to pass with each operator
- Need a
value:equals,notEquals,contains,notContains,greaterThan,lessThan,greaterThanOrEqual,lessThanOrEqual,within,olderThan,triggeredWithin,triggeredOlderThan,notTriggeredWithin. - Need a
unit("days","hours", or"minutes"):within,olderThan,triggeredWithin,triggeredOlderThan,notTriggeredWithin. - Need nothing extra:
exists,notExists,triggered,notTriggered,memberOfSegment,notMemberOfSegment.
Examples
Engaged users on the Pro plan
Subscribed Pro-plan users who opened or clicked any email in the last 14 days but haven't clicked anything in the last 30 days.
{
"logic": "AND",
"groups": [
{
"filters": [
{ "field": "subscribed", "operator": "equals", "value": true },
{ "field": "data.plan", "operator": "equals", "value": "pro" }
]
},
{
"filters": [],
"conditions": {
"logic": "OR",
"groups": [
{ "filters": [{ "field": "email.opened", "operator": "triggeredWithin", "value": 14, "unit": "days" }] },
{ "filters": [{ "field": "email.clicked", "operator": "triggeredWithin", "value": 14, "unit": "days" }] }
]
}
},
{
"filters": [
{ "field": "email.clicked", "operator": "notTriggeredWithin", "value": 30, "unit": "days" }
]
}
]
}New trial users without a purchase
Subscribed contacts created in the last 7 days who haven't yet triggered a purchase event.
{
"logic": "AND",
"groups": [
{
"filters": [
{ "field": "subscribed", "operator": "equals", "value": true },
{ "field": "createdAt", "operator": "within", "value": 7, "unit": "days" },
{ "field": "event.purchase", "operator": "notTriggered" }
]
}
]
}Power users above a tier
Members of the power-users segment whose lifetime value is at least 500.
{
"logic": "AND",
"groups": [
{
"filters": [
{ "field": "segment.<powerUsersSegmentId>", "operator": "memberOfSegment" },
{ "field": "data.lifetimeValue", "operator": "greaterThanOrEqual", "value": 500 }
]
}
]
}Related
Apps Wiki Content Docs Guides Template Language (`apps/wiki/content/docs/guides/template-language.mdx`)
title: Template language icon: Braces
Plunk renders email subjects and bodies with Liquid, the template language used by Shopify, Zendesk and Netlify. Every {{variable}} placeholder Plunk has always supported keeps working exactly as before — Liquid adds conditionals, loops and filters on top.
The main thing this buys you is fewer campaigns. Instead of one campaign per segment, a single campaign can cover the whole combinatorial space: three languages × two offers is one send with branching copy rather than six sends to compare.
Variables
Reference any contact field by name. Custom fields under data are available both with and without the prefix:
<p>Hi {{firstName}}, you are on the {{plan}} plan.</p>
<p>Same thing: {{data.firstName}} / {{data.plan}}</p>These are always available regardless of contact data:
| Variable | Description |
|---|---|
{{id}} |
The contact's unique identifier |
{{email}} |
The contact's email address |
{{locale}} |
The contact's preferred locale |
{{subscribed}} |
Subscription status |
{{unsubscribeUrl}} |
URL to the unsubscribe page |
{{subscribeUrl}} |
URL to the subscribe/resubscribe page |
{{manageUrl}} |
URL to the preferences management page |
A variable that isn't set renders as an empty string — it never errors and never leaks the placeholder into the email.
Fields whose names contain spaces
CSV imports build field names from your column headers, so a "First Name" column becomes the field first name. Reference it directly, or with a bracket lookup if you want to combine it with filters:
<p>Hi {{first name}}</p>
<p>Hi {{data["first name"] | capitalize}}</p>Fallbacks
?? fallback is Plunk-specific shorthand and takes a literal:
<p>Hi {{firstName ?? there}}, welcome back.</p>Liquid's default filter is the general form and can fall back to another variable:
<p>Hi {{firstName | default: nickname}}</p>Both treat a missing, null, false or empty value as "not set".
Conditionals
if / elsif / else, unless, and case / when all work. This is the multilanguage pattern:
{% if locale == 'es' %}
<h1>Hola {{firstName ?? cliente}}</h1>
{% elsif locale == 'fr' %}
<h1>Bonjour {{firstName ?? client}}</h1>
{% else %}
<h1>Hi {{firstName ?? there}}</h1>
{% endif %}And the segment-specific-offer pattern, without splitting the campaign:
{% case plan %}
{% when 'pro' %}<p>Here's 20% off your renewal.</p>
{% when 'free' %}<p>Upgrade now and save 20%.</p>
{% else %}<p>Thanks for being with us.</p>
{% endcase %}Comparison (==, !=, >, <, >=, <=), boolean (and, or), contains, and blank / empty are all available:
{% if lifetimeValue > 500 and plan != 'free' %}
<p>You qualify for our VIP tier.</p>
{% endif %}Loops
Iterate arrays stored on a contact or passed to /v1/send:
<ul>
{% for item in cartItems %}
<li>{{forloop.index}}. {{item.name}} — {{item.price}}</li>
{% endfor %}
</ul>forloop.index, .index0, .first, .last and .length are available inside the loop, and limit / offset / reversed work on the for tag itself.
Outputting an array directly — {{items}} — wraps each entry in an <li> element. This predates Liquid support and is kept for compatibility; prefer an explicit {% for %} so you control the markup.
Filters
Filters transform a value with |. The full LiquidJS filter list applies; these come up most:
| Filter | Example | Result |
|---|---|---|
upcase / downcase |
{{plan | upcase}} |
PRO |
capitalize |
{{firstName | capitalize}} |
Ada |
default |
{{firstName | default: "there"}} |
there |
date |
{{signupDate | date: "%B %-d, %Y"}} |
May 6, 2026 |
plus / minus / times / divided_by |
{{price | times: 0.8 | round: 2}} |
40 |
join |
{{tags | join: ", "}} |
a, b |
truncate |
{{bio | truncate: 40}} |
truncated |
For date to work, store the value as a full ISO 8601 string (see Custom fields).
Computed values
assign and capture let you derive a value once and reuse it — useful when an offer depends on something other than a raw field:
{% assign discount = lifetimeValue | divided_by: 20 %}
{% if discount > 25 %}{% assign discount = 25 %}{% endif %}
<p>Here's {{discount}}% off, calculated from your account history.</p>Escaping template syntax
To show {{ }} literally in an email, wrap it in raw:
{% raw %}Use {{firstName}} to personalise your own emails.{% endraw %}What isn't available
{% include %}, {% render %} and {% layout %} are rejected.
Errors
Saving a template or campaign whose syntax Liquid can't parse returns 400 with the line and column of the problem, so mistakes surface while you're editing.
/v1/send is not checked this way. Transactional bodies are usually generated by another system, so an inline subject or body is always accepted and rendered on a best-effort basis — markup Liquid can't parse falls through to plain {{variable}} substitution instead of failing the request.
Rendering itself is deliberately forgiving — nothing about a template can fail a send:
- An unknown variable renders empty.
- An unknown filter is skipped and the value passes through unchanged.
- If a stored template somehow can't be parsed at send time, Plunk falls back to plain
{{variable}}substitution rather than dropping the email.
Notes for existing templates
Templates written before Liquid keep rendering the same output in almost every case — plain placeholders, nested paths, ?? fallback, missing variables and bare arrays are all unchanged. These are the cases that do render differently:
Fixes to previously wrong output
- A quoted fallback no longer leaks its quotes.
{{name ?? 'there'}}used to render'there'— quotes included — and now rendersthere, which is what this guide always described. - Falsy values render instead of disappearing.
0,falseandNaNused to come out as an empty string;{{count}}for a contact withcount: 0now renders0, and{{subscribed}}rendersfalse. Use{% unless subscribed %}to branch on it. - A fallback containing
??inside quotes is no longer truncated at the first??. - Placeholders spanning multiple lines, and array indexes like
{{items.0}}, now resolve; both used to render as literal text or empty.
Behaviour to check before upgrading
- Balanced tag markup in body copy is now executed. Prose like
Write {% if x %}…{% endif %} to branchused to print verbatim and now evaluates to nothing. Wrap it in{% raw %}to keep it as text. Unbalanced markup —Use {% if x %} in docs— still fails to parse and survives untouched. - A custom field whose name contains
|, quotes or other operator characters is now parsed as an expression. Field names made of letters, digits,_,-,.and spaces are unaffected.
What's next
Apps Wiki Content Docs Guides Tracking (`apps/wiki/content/docs/guides/tracking.mdx`)
title: Tracking icon: HatGlasses
Plunk provides built-in tracking for email opens and link clicks so you can monitor engagement and drive workflows or segments off email activity.
Open tracking
When open tracking is enabled, Plunk includes a small invisible tracking pixel at the bottom of your email. When the recipient's mail client loads the pixel, the open is recorded as an email.open event on the contact.
Considerations
- Open tracking depends on the mail client loading remote images. If a contact has images disabled, no open is recorded.
- Some clients (and corporate mail security gateways) pre-fetch images, which can produce false positive opens.
- Apple Mail Privacy Protection masks individual opens — opens from Apple Mail are recorded but reflect proxy fetches rather than human reads.
Click tracking
When click tracking is enabled, Plunk rewrites links in your email body to route through a tracking redirect. When a contact clicks a link, Plunk records the click and the original URL on an email.click event before forwarding the contact to the destination.
Considerations
- Some mail clients and security tools pre-scan links, which can produce false positive clicks.
- The rewrite is transparent to recipients but means link URLs in the email source no longer match the original destination.
Configuration
Tracking is configured per project under Settings → Tracking. Three modes are available:
| Mode | Behaviour |
|---|---|
| Enabled | Tracking is applied to every email. |
| Disabled | No tracking is applied to any email. |
| Marketing only | Tracking is applied to marketing sends (campaigns and emails sent by workflow steps using marketing or headless templates). Transactional sends — anything via /v1/send using a transactional template, or campaigns of type TRANSACTIONAL — are sent without tracking. |
Tracking is all or nothing within a single send — there's no separate toggle for opens vs clicks. Pick the mode that fits your privacy and analytics requirements.
Apps Wiki Content Docs Guides Unsubscribe Pages (`apps/wiki/content/docs/guides/unsubscribe-pages.mdx`)
title: Unsubscribe & preferences pages icon: UserX
Every email Plunk sends to a recipient who can unsubscribe (marketing or headless templates) carries a personalized link to a hosted page where they can manage their subscription. You don't need to build any of this — Plunk hosts the pages, handles the state changes, and tracks the events for you.
The three URL variables
Three template variables are auto-injected on every send and resolve to per-recipient signed URLs:
| Variable | Page it links to | What the recipient can do |
|---|---|---|
{{unsubscribeUrl}} |
One-click unsubscribe page | Confirm they want to stop receiving marketing emails |
{{subscribeUrl}} |
Resubscribe page | Opt back in after previously unsubscribing |
{{manageUrl}} |
Preferences page | View their current state and toggle subscription either way |
Use them anywhere in a template's body or subject line. The placeholder is replaced with a unique URL when the email is rendered for that specific contact.
Default behaviour by template type
Where Plunk drops these links depends on the template type:
| Template type | Auto footer with {{unsubscribeUrl}}? |
Notes |
|---|---|---|
| Marketing | Yes — Plunk appends a localized footer | You don't need to do anything. The footer respects the recipient's locale. |
| Headless | No | You must include {{unsubscribeUrl}} (or {{manageUrl}}) in your body or the recipient has no way to opt out. |
| Transactional | No | Transactional emails skip subscription checks; an unsubscribe link is not added (and shouldn't be needed). |
If you build your own footer in a marketing template, you can still rely on the auto-injected one or override it by including your own link with {{unsubscribeUrl}}.
Inside transactional sends
/v1/send accepts the same template variables. Whether an unsubscribe footer is appended depends on the template you reference:
- Sending with no template (just
subject+body) → no auto footer. - Sending with a marketing template → footer auto-injected.
- Sending with a headless or transactional template → no footer (you control the body).
You can always reference {{unsubscribeUrl}} / {{manageUrl}} in your inline body if you want to render your own link.
What happens when the recipient acts
When the recipient clicks one of the links and confirms:
| Action | Effect |
|---|---|
| Unsubscribe | Contact's subscribed flips to false. contact.unsubscribed event fires. |
| Snooze | Contact's subscribed flips to false and snoozedUntil is set. contact.unsubscribed event fires with reason: "snooze". |
| Resubscribe | Contact's subscribed flips to true. contact.subscribed event fires. |
| Update via preferences | Same as above, depending on which way they toggle. |
You can drive workflows off contact.unsubscribed / contact.subscribed (e.g. send a "we're sorry to see you go" survey, or trigger a winback when they re-opt-in).
Snoozing
Not everyone who clicks unsubscribe wants to leave for good. Some are mid-launch-sequence, some are going on holiday, and some just want the volume to stop for a while. If the only option is permanent, a share of them take it — and a share of the rest reach for the spam button instead, which costs you far more.
So the unsubscribe and preferences pages offer a second, secondary option: snooze. The recipient picks a window, receives nothing for its duration, and is resubscribed automatically when it ends.
| Window | Value passed to the API |
|---|---|
| 2 weeks | 2_weeks |
| 1 month | 1_month |
| 6 months | 6_months |
| 1 year | 1_year |
Snooze is deliberately the quieter of the two choices. Unsubscribe keeps its position and weight on the page; a recipient who came to leave never has to hunt for the button that does it.
It is a real opt-out, not a softer one
A snoozed contact has subscribed: false for the whole window. Every send path — campaigns, workflows, and marketing templates sent through /v1/send — filters on that flag, so a snoozed contact receives nothing at all until they come back. Nothing is "held back and delivered later".
The contact also carries a snoozedUntil timestamp. That is the only thing distinguishing a snooze from a permanent unsubscribe, and it exists so the return can be scheduled — it plays no part in whether mail is sent.
Coming back
A background job resubscribes contacts whose window has elapsed, clears snoozedUntil, and fires contact.subscribed with reason: "snooze_expired". Returns are processed within a few minutes of the window ending.
A recipient can also come back early: the "resume" link on the preferences page, or any visit to {{subscribeUrl}}, ends the snooze immediately.
What ends a snooze early
Any other change to a contact's subscription cancels the snooze and takes over — the pending return is dropped, not queued behind it:
- The recipient unsubscribes properly (their snooze becomes permanent — they will not be resubscribed later).
- The recipient, or you, resubscribes them.
- A hard bounce or spam complaint suppresses the address. A suppressed contact is never resurrected by a snooze that was running when it happened.
PATCH /contacts/:idwithsubscribed, a CSV import that sets the column, a bulk action, or a workflowUpdate contactstep.
Reacting to a snooze
Snoozing reuses contact.unsubscribed and contact.subscribed rather than introducing new event names, so existing workflows, Wait for event steps and campaign statistics pick it up with no changes. Branch on the event's reason field to tell them apart:
// contact.unsubscribed — the recipient snoozed
{
"reason": "snooze",
"duration": "6_months",
"snoozedUntil": "2027-03-11T09:14:22.104Z"
}
// contact.subscribed — the window ended
{
"reason": "snooze_expired"
}A "sorry to see you go" workflow triggered on contact.unsubscribed should check for reason and skip the ones that are snoozes — those people have not gone anywhere.
In campaign stats
A snooze counts toward the campaign's unsubscribe count, the same as a permanent opt-out. It is still a deliberate opt-out that the campaign prompted, and excluding it would let a campaign that burns through goodwill look clean simply because its recipients chose to pause rather than leave.
In the dashboard
Snoozed contacts show a distinct Snoozed badge in the contacts list, with their own option in the Status filter, so they are never counted as churn. A contact's detail page names the date they return, and the activity feed shows the snooze and the later resume as separate entries.
Knowing which email caused it
The links Plunk generates at send time carry an ?e= parameter identifying the message they were sent in — on the List-Unsubscribe header, the auto-injected marketing footer, and the {{unsubscribeUrl}} / {{subscribeUrl}} / {{manageUrl}} variables. When the recipient acts, that email is recorded on the resulting event, and the activity feed names the campaign or workflow the opt-out came from.
The parameter is carried across the hosted pages, so a recipient who lands on the preferences page and then unsubscribes is still attributed to the email they started from.
A few cases are unattributed by design:
- Links rendered before the email row exists — a campaign body's
{{unsubscribeUrl}}, which is rendered once per recipient at queue time rather than at send time. - Changes with no email behind them: the dashboard toggle, a CSV import, and
PATCH /contacts/:id. - Mail sent before this parameter existed.
Attribution is what lets a campaign report how many subscribers it cost: see campaign stats.
If you build your own opt-out surface, you can append ?e= yourself. The value is advisory — Plunk verifies the email belongs to the contact before recording it, and the contact id in the path is what governs the change, so a missing or wrong value costs attribution and nothing else.
Localization
The hosted pages and the auto-injected footer are localized into the contact's locale if set, or the project's default language otherwise. See Localization for the full list of supported languages.
Branding
The pages name you by your project name, inside the copy itself: a recipient sees "Stop receiving emails from Acme at [email protected]?", not a logo or header. The same name already appears in the auto-injected marketing footer ("you agreed to receive emails from Acme").
Because recipients read it, use the name they know you by. A project called Test or acme-prod-eu is shown to them exactly as written. You can rename a project in its settings.
A small "Sent with Plunk" line below the page credits Plunk as the mail provider.
For full control over the look, send marketing as headless templates and build the unsubscribe surface into your own product UI. Link your in-product controls to the same {{unsubscribeUrl}} for one-click revocation.
API reference
If you need to drive subscription changes programmatically rather than through the hosted pages — for example because you've built your own preferences UI — use the contacts API:
PATCH /contacts/:idwith{ "subscribed": false }— unsubscribe a contact.PATCH /contacts/:idwith{ "subscribed": true }— resubscribe.
Both flips automatically emit the corresponding contact.subscribed / contact.unsubscribed event, just like the hosted pages do. Either one also clears a running snooze.
Snoozing from your own preferences UI uses the same unauthenticated endpoint the hosted pages do:
POST /contacts/public/:id/snooze?e=<emailId>
Content-Type: application/json
{ "duration": "6_months" }It responds with the contact's subscribed and snoozedUntil. As with the other public list-management routes, the contact id in the path is what governs the change and ?e= is advisory attribution only.
Apps Wiki Content Docs Guides Verifying Domains (`apps/wiki/content/docs/guides/verifying-domains.mdx`)
title: Verifying domains icon: Globe
Verifying your domain is a requirement to send emails through Plunk. Domain verification helps improve email deliverability and ensures that your emails are less likely to be marked as spam.
Verifying a domain
You can verify a domain by adding it in the domain tab of the project settings. Once added, Plunk will provide you with the necessary DNS records to add to your domain's DNS settings.
Once you have added the DNS records, it may take some time for the changes to propagate. You can check the verification status in the domain tab of the project settings.
DNS Records
Domain verification requires adding several DNS records to your domain. Each record serves a specific purpose in email authentication and delivery.
DKIM Records (3 CNAME records)
DomainKeys Identified Mail (DKIM) adds a digital signature to your emails, proving they haven't been tampered with during transit.
You'll need to add 3 CNAME records provided by Plunk. These records contain cryptographic keys that email receivers use to verify your emails are authentic.
Why it matters:
- Prevents email spoofing and tampering
- Improves deliverability and inbox placement
- Required by most email providers (Gmail, Outlook, etc.)
SPF Record (1 TXT record)
Sender Policy Framework (SPF) specifies which mail servers are authorized to send emails on behalf of your domain.
You'll need to add 1 TXT record that lists the authorized sending servers.
Why it matters:
- Prevents unauthorized servers from sending emails using your domain
- Reduces the likelihood of your domain being used for spam
- Works together with DKIM for complete authentication
Bounce Handling (1 MX record)
This MX record allows Plunk to receive bounce notifications and spam complaints from email providers.
Why it matters:
- Automatically tracks which contacts have bounced or complained
- Helps maintain your sender reputation
- Prevents sending to invalid email addresses
- Required for deliverability monitoring
DMARC (optional, recommended)
Domain-based Message Authentication, Reporting and Conformance (DMARC) tells receiving mail servers what to do when an email fails SPF or DKIM checks, and where to send authentication reports.
Plunk doesn't require DMARC, but most major mailbox providers (Gmail, Yahoo, Microsoft) now expect it for bulk senders. Add a TXT record at _dmarc.yourdomain.com:
v=DMARC1; p=none; rua=mailto:[email protected]Start with p=none to monitor without affecting delivery. Once you've reviewed reports for a couple of weeks and confirmed all your legitimate senders are authenticated, tighten to p=quarantine and eventually p=reject.
MAIL FROM domain (optional)
You can configure a custom MAIL FROM domain (typically a subdomain like mail.yourdomain.com) so the bounce envelope address aligns with your sending domain. This improves DMARC alignment and is required by some inbox providers for full pass-through.
If you set this up, you'll need an additional MX record and TXT record on the subdomain — Plunk's dashboard will show you the exact values to add when you enable it on a verified domain.
Verification Status
After adding all DNS records, Plunk automatically checks verification status in the background. Verification typically completes within a few minutes, but can take up to 72 hours depending on DNS propagation.
You can check the status in the Domains section of your project settings. Each record type will show as verified once detected. If you've just added a record and don't want to wait, you can trigger a re-check from the dashboard.
Troubleshooting
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
If your DNS records aren't verifying after 24 hours:
- Double-check the values: ensure you copied the exact values without extra spaces.
- Check DNS propagation: use tools like
digor online DNS checkers to verify the records are published. - TTL settings: some DNS providers cache records. Try lowering the TTL (Time To Live) value.
- Contact your DNS provider: some providers have specific requirements or interfaces for adding these record types.
Even with a verified domain, emails may go to spam if:
- Your content triggers spam filters (excessive links, suspicious keywords).
- Your sender reputation is new or low.
- Recipients have marked your emails as spam in the past.
- You're sending to invalid or unengaged contacts.
Follow email best practices and maintain good list hygiene to improve deliverability.
Apps Wiki Content Docs Guides Webhooks (`apps/wiki/content/docs/guides/webhooks.mdx`)
title: Webhooks icon: Webhook
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
import {TypeTable} from 'fumadocs-ui/components/type-table';
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a workflow that uses the Webhook step to forward event data to your own endpoint.
How it works
Webhooks in Plunk are powered by the workflow system. The basic flow is:
- An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
- A workflow is triggered by that event
- The workflow executes a Webhook step, sending an HTTP request to your URL with relevant data
This means you can receive notifications for any event Plunk tracks, including both system events and your own custom events.
Internal events
Plunk automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
Email events
| Event | Description |
|---|---|
email.sent |
An email was successfully sent |
email.delivery |
An email was delivered to the recipient |
email.open |
A contact opened an email for the first time |
email.click |
A contact clicked a link in an email for the first time |
email.bounce |
An email bounced (hard or soft bounce) |
email.complaint |
A contact marked an email as spam |
email.received |
An email was received at your verified domain (requires inbound email setup) |
Contact events
| Event | Description |
|---|---|
contact.subscribed |
A contact's subscription status changed to subscribed |
contact.unsubscribed |
A contact's subscription status changed to unsubscribed |
Segment events
| Event | Description |
|---|---|
segment.<name>.entry |
A contact entered a segment |
segment.<name>.exit |
A contact exited a segment |
Setting up a webhook
import {Step, Steps} from 'fumadocs-ui/components/steps';
Create the workflow
Navigate to the Workflows section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use email.bounce as the trigger event.
Add a Webhook step
After the trigger, add a Webhook step and configure it:
- URL: The endpoint on your server that will receive the webhook (e.g.
https://api.example.com/webhooks/plunk) - Method: The HTTP method to use. Defaults to
POST, which is recommended for most use cases. - Headers (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
{
"Authorization": "Bearer your-secret-token"
}- Body (optional): Custom request body. When omitted, Plunk sends the default payload shown below. When provided, the value replaces the default payload entirely and is JSON-encoded before being sent.
Use variables in the request (optional)
The url, header values, and body all support {{variable}} interpolation. The available scope is the same as SEND_EMAIL templates, plus a webhook-only event namespace exposing the trigger event payload:
| Variable | Value |
|---|---|
{{id}}, {{email}} |
The contact's ID and email. |
{{<key>}} (top-level) |
Any key from the contact's data JSON (e.g. {{firstName}}, {{plan}}). |
{{data.<key>}} |
The same contact data, addressed via the data namespace. |
{{event.<key>}} |
Webhook-only. Fields from the trigger event payload (e.g. {{event.subject}}). |
{{<key>}} (from execution context) |
Keys passed in as context when starting a MANUAL execution. |
{{unsubscribeUrl}}, {{subscribeUrl}}, {{manageUrl}} |
Per-contact subscription management URLs. |
The HTTP method is not templated — it must be a literal verb (GET, POST, PUT, PATCH, DELETE). The url must include a static scheme (http:// or https://); placeholders are supported inside the URL but cannot replace the scheme.
Example — forward a contact event to your own API, parameterised by contact data:
URL
https://api.example.com/users/{{id}}/eventsHeaders
{
"Authorization": "Bearer your-secret-token"
}Body
{
"email": "{{email}}",
"plan": "{{plan}}",
"referrer": "{{event.referrer}}"
}Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
Webhook payload
When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:
{
"contact": {
"email": "[email protected]",
"subscribed": true,
"data": {
"name": "John",
"plan": "pro"
}
},
"workflow": {
"id": "wf_abc123",
"name": "Bounce Notifications"
},
"execution": {
"id": "exec_xyz789",
"startedAt": "2025-01-15T10:30:00.000Z"
},
"event": {
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": "camp_abc123",
"sourceType": "CAMPAIGN",
"bounceType": "Permanent",
"bouncedAt": "2025-01-15T10:30:00.000Z"
}
}The event field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
Event data by type
Email events
Most email events share a common set of base fields:
<TypeTable
type={{
subject: { type: 'string', description: 'The email subject line.' },
from: { type: 'string', description: 'The sender email address.' },
fromName: { type: 'string', description: 'The sender display name.' },
messageId: { type: 'string', description: 'Provider-side message identifier (useful for correlating with delivery logs).' },
emailId: { type: 'string', description: 'The Plunk email record ID — returned from POST /v1/send, used for correlating webhooks with API responses.' },
templateId: { type: 'string', description: 'The template ID, if the email was sent using a template. Otherwise null.' },
campaignId: { type: 'string', description: 'The campaign ID, if the email was part of a campaign. Otherwise null.' },
sourceType: { type: 'string', description: 'How the email was triggered. One of TRANSACTIONAL, CAMPAIGN, WORKFLOW, or INBOUND.' },
}}
/>
In addition to these base fields, each event includes the following event-specific fields. The base fields above (subject, from, fromName, messageId, emailId, templateId, campaignId, sourceType) are present on every email event in addition to the event-specific fields shown below.
<Tabs items={['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint', 'email.received']}>
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"sentAt": "2025-01-15T10:30:00.000Z"
}<TypeTable
type={{
sentAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was accepted for delivery.' },
}}
/>
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": "camp_abc123",
"sourceType": "CAMPAIGN",
"deliveredAt": "2025-01-15T10:30:05.000Z"
}<TypeTable
type={{
deliveredAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was delivered to the recipient.' },
}}
/>
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"openedAt": "2025-01-15T11:00:00.000Z",
"opens": 1,
"isFirstOpen": true
}<TypeTable
type={{
openedAt: { type: 'string', description: 'ISO 8601 timestamp of the first open.' },
opens: { type: 'number', description: 'Total number of times this email has been opened.' },
isFirstOpen: { type: 'boolean', description: 'true if this is the first time the contact opened this email.' },
}}
/>
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"link": "https://example.com/pricing",
"clickedAt": "2025-01-15T11:05:00.000Z",
"clicks": 1,
"isFirstClick": true
}<TypeTable
type={{
link: { type: 'string', description: 'The URL that was clicked.' },
clickedAt: { type: 'string', description: 'ISO 8601 timestamp of the first click.' },
clicks: { type: 'number', description: 'Total number of clicks on links in this email.' },
isFirstClick: { type: 'boolean', description: 'true if this is the first click from this contact on this email.' },
}}
/>
Permanent bounce:
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"bounceType": "Permanent",
"bouncedAt": "2025-01-15T10:31:00.000Z"
}Transient (soft) bounce:
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"bounceType": "Transient",
"transientBounce": true
}<TypeTable
type={{
bounceType: { type: 'string', description: 'Permanent for hard bounces, Transient for soft bounces (mailbox full, out-of-office, greylist).' },
bouncedAt: { type: 'string', description: 'ISO 8601 timestamp of when the bounce occurred. Only present on permanent bounces.' },
transientBounce: { type: 'boolean', description: 'true for soft bounces — these do not count toward your bounce rate and the contact stays subscribed.' },
}}
/>
{
"subject": "Welcome to Plunk",
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"complainedAt": "2025-01-15T10:35:00.000Z"
}<TypeTable
type={{
complainedAt: { type: 'string', description: 'ISO 8601 timestamp of when the complaint was received.' },
}}
/>
This event fires when an email is received at your verified domain. See Receiving Emails for setup instructions.
{
"messageId": "ses-message-id",
"from": "[email protected]",
"fromHeader": "Jane Smith <[email protected]>",
"to": "[email protected]",
"subject": "Re: Your question",
"timestamp": "2025-01-15T10:30:00.000Z",
"recipients": ["[email protected]"],
"hasContent": true,
"body": "<html><body>This is the email body content...</body></html>",
"spamVerdict": "PASS",
"virusVerdict": "PASS",
"spfVerdict": "PASS",
"dkimVerdict": "PASS",
"dmarcVerdict": "PASS",
"processingTimeMillis": 142
}<TypeTable
type={{
messageId: { type: 'string', description: 'Unique identifier for the received message.' },
from: { type: 'string', description: "The sender's email address." },
fromHeader: { type: 'string', description: 'The full From header, including display name if present.' },
to: { type: 'string', description: 'The recipient address at your verified domain.' },
subject: { type: 'string', description: 'The email subject line.' },
timestamp: { type: 'string', description: 'ISO 8601 timestamp when the email was received.' },
recipients: { type: 'array of strings', description: 'All recipient addresses in the envelope.' },
hasContent: { type: 'boolean', description: 'Whether the email body content is available.' },
body: { type: 'string', description: 'Sanitized HTML body of the email (or plain text if no HTML available).' },
spamVerdict: { type: 'string', description: 'Spam check result. One of PASS, FAIL, GRAY, or PROCESSING_FAILED.' },
virusVerdict: { type: 'string', description: 'Virus check result. One of PASS, FAIL, GRAY, or PROCESSING_FAILED.' },
spfVerdict: { type: 'string', description: 'SPF authentication result.' },
dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
processingTimeMillis: { type: 'number', description: 'Time taken to process the inbound email.' },
}}
/>
Contact events
contact.subscribed and contact.unsubscribed carry no event data by default. The event field will be an empty object {}.
The exception is when an unsubscription is triggered automatically by an email bounce or complaint — in that case event includes a reason field:
{
"reason": "bounce"
}<TypeTable
type={{
reason: { type: 'string', description: 'Why the contact was unsubscribed. One of bounce or complaint. Only present when triggered automatically by a bounce or complaint.' },
}}
/>
Segment events
Both segment.<name>.entry and segment.<name>.exit include:
{
"segmentId": "seg_abc123",
"segmentName": "VIP Users"
}<TypeTable
type={{
segmentId: { type: 'string', description: 'The ID of the segment.' },
segmentName: { type: 'string', description: 'The display name of the segment.' },
}}
/>
Custom events
Custom events tracked via the API include whatever data you passed in the data field when calling track.
No event data
For events that carry no data, the event field will be an empty object {}.
Correlating webhooks with send requests
All email events include an emailId field that matches the Plunk email record ID returned when you send an email via POST /v1/send. This allows you to directly correlate webhook events with your API requests.
Example workflow:
- Send email via API:
POST /v1/send
{
"to": "[email protected]",
"subject": "Welcome",
"body": "Hello!"
}
Response:
{
"success": true,
"data": {
"emails": [
{
"contact": {"id": "cnt_abc", "email": "[email protected]"},
"email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
}
]
}
}Store the
emailID (ac32f08e-c6b9-45d3-9824-a73dff1e3bbf) in your databaseWhen webhook events fire (e.g.,
email.open,email.bounce), match them usingevent.emailId:
{
"event": {
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"messageId": "ses-message-id",
"openedAt": "2025-01-15T11:00:00.000Z"
}
}This eliminates the need to match by contact email + timestamp or to listen for email.sent webhooks just to get the provider messageId.
Common use cases
Bounce and complaint monitoring
Create a workflow triggered by email.bounce or email.complaint to forward these events to your application. This allows you to keep your own database in sync with Plunk's contact statuses.
You can use additional workflow steps before the webhook to add logic:
- Condition: Only send the webhook for hard bounces by checking the
bounceTypefield - Delay: Add a short delay to batch-process related events
- Update Contact: Mark the contact with metadata before sending the webhook
Syncing unsubscribes
Trigger a workflow on contact.unsubscribed to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
Custom event forwarding
If you track custom events in Plunk (e.g. user.signup, order.completed), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
Receiving webhooks safely
Plunk's webhook step has a few characteristics worth knowing when you build the receiving endpoint:
Method: defaults to
POSTwithContent-Type: application/json. You can override the method per step.Timeout: each request times out after 10 seconds. Long-running endpoints should accept the request, queue the work, and return
2xxquickly.Redirects: up to 5 redirects are followed. Each hop is re-validated against the SSRF rules below.
Public URL required: your webhook endpoint must be reachable on the public internet. Webhooks pointed at private or internal addresses (loopback, RFC 1918 ranges, etc.) won't be delivered.
Schemes: only
http://andhttps://are accepted. Prefer HTTPS.No automatic retries: a non-2xx response or timeout fails the workflow step. Build idempotency into your handler and use workflow logic (a
WAIT_FOR_EVENTstep, a fallback branch) if you need retry semantics.Verify authenticity with a shared secret: configure a secret header on the webhook step and check it on your endpoint:
json// Webhook step → Headers { "Authorization": "Bearer your-shared-secret" }The secret travels with every request from that step. Rotate it like any other shared secret. Prefer this over IP allowlisting — egress IPs can change.
Adding conditions and delays
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
- Use a Condition step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
- Use a Wait for Event step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
- Use a Delay step to add a time buffer before the webhook fires
Apps Wiki Content Docs Recipes Double Opt In (`apps/wiki/content/docs/recipes/double-opt-in.mdx`)
title: Double opt-in icon: MailCheck
Double opt-in adds a confirmation step between "user signs up" and "user starts getting marketing email." It's the standard way to avoid mailing typoed addresses, role accounts, and anyone who didn't actually consent.
The trick is {{subscribeUrl}}: a per-contact link Plunk auto-injects into every send. Clicking it flips subscribed to true and fires a contact.subscribed event.
Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
Create two templates
A Transactional template for the confirmation email, containing
{{subscribeUrl}}:html<p>Hi {{firstName}}, please confirm your email to start receiving updates:</p> <p><a href="{{subscribeUrl}}">Confirm my email</a></p>A Marketing template for the welcome email that goes out after they confirm.
Trigger the signup from your backend
Two calls with your secret key (sk_*): create the contact unsubscribed, then track the event that fires the confirmation workflow.
curl https://next-api.useplunk.com/contacts \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{ "email": "[email protected]", "subscribed": false, "data": { "firstName": "Ada" } }'
curl https://next-api.useplunk.com/v1/track \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{ "event": "signup.pending", "email": "[email protected]", "subscribed": false }'Both calls pass subscribed: false. If you skip the first call and rely on /v1/track alone, tracking on an unknown email creates the contact — but defaults it to subscribed, which defeats the point.
Workflow A: send the confirmation
Workflows → New workflow:
- Trigger:
EVENTonsignup.pending SEND_EMAILstep → transactional confirmation template
Enable it.
Workflow B: welcome them after confirmation
Workflows → New workflow:
- Trigger:
EVENToncontact.subscribed SEND_EMAILstep → marketing welcome template
Enable it. contact.subscribed fires whenever a contact opts in — including via {{subscribeUrl}}, the preferences page, or the API — so this workflow handles both first-time confirmations and resubscribes.
Reminder if they don't confirm
Extend Workflow A with a WAIT_FOR_EVENT step after the send:
- Event:
contact.subscribed - Timeout:
86400(24 hours)
On timeout, send a single reminder (also transactional). Keep the number of reminders small — repeated confirmation prompts look like spam to mailbox providers as much as to recipients.
What's next
Apps Wiki Content Docs Recipes Meta.Json (`apps/wiki/content/docs/recipes/meta.json`)
{
"pages": ["index", "waitlist", "sync-unsubscribes", "double-opt-in"]
}
Apps Wiki Content Docs Recipes Sync Unsubscribes (`apps/wiki/content/docs/recipes/sync-unsubscribes.mdx`)
title: Sync unsubscribes to your database icon: RefreshCw
Every flip of a contact's subscribed state — manual edits, the hosted unsubscribe page, bounces, complaints — fires a contact.unsubscribed event. Wire a workflow with a WEBHOOK step to forward that to your backend.
Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
Build the receiving endpoint
A public HTTPS endpoint that verifies a shared secret and updates the user row. Webhook requests time out after 10 seconds, so do the work async if it's slow.
app.post('/plunk/unsubscribes', async (req, res) => {
if (req.header('authorization') !== `Bearer ${process.env.PLUNK_WEBHOOK_SECRET}`) {
return res.status(401).end();
}
const { contact, event } = req.body;
await db.user.update({
where: { email: contact.email },
data: {
emailSubscribed: false,
emailUnsubscribedReason: event.reason ?? 'user_action',
},
});
res.status(204).end();
});event.reason is "bounce" or "complaint" for automatic unsubscribes, and absent for manual / self-service ones.
Create the workflow
Workflows → New workflow:
- Trigger:
EVENToncontact.unsubscribed - Add a
WEBHOOKstep:- URL:
https://api.example.com/plunk/unsubscribes - Headers:
{ "Authorization": "Bearer your-shared-secret" } - Leave the body blank to get the default payload.
- URL:
Enable the workflow.
Mirroring resubscribes
Build a second workflow with the same shape, triggered by contact.subscribed. Keep it separate from the unsubscribe flow — two short workflows are easier to monitor than one branched one.
The reverse direction
If your product is the source of truth (a user toggles their email preference in your settings UI), call PATCH /contacts/:id from your backend:
curl -X PATCH https://next-api.useplunk.com/contacts/cnt_abc \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{"subscribed": false}'That flip also fires contact.unsubscribed, meaning your own webhook will round-trip back into your handler. That's usually harmless because the update is idempotent — but be aware of it.
What's next
Apps Wiki Content Docs Recipes Waitlist (`apps/wiki/content/docs/recipes/waitlist.mdx`)
title: Waitlist with confirmation email icon: ListOrdered
A waitlist is the simplest possible Plunk workflow: one tracked event from your app, one workflow that listens for it, one email.
Setup
import {Step, Steps} from 'fumadocs-ui/components/steps';
Create the confirmation template
In Templates → New template, create a Marketing template. Use {{variable}} placeholders for anything you want to personalise from contact data:
Subject: You're on the list, {{firstName}}
Hi {{firstName}}, thanks for joining the {{product}} waitlist.
We'll let you know as soon as your spot opens up.Track the signup from your backend
Call POST /v1/track when a user submits the form. Use a secret key (sk_*) — never call this from the browser.
curl https://next-api.useplunk.com/v1/track \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"event": "waitlist.joined",
"email": "[email protected]",
"data": { "firstName": "Ada", "product": "Beta" }
}'This call upserts the contact (subscribed by default) and records waitlist.joined on them. Anything you put in data lands on the contact and is available as {{firstName}}, {{product}}, etc. in the template.
Create the workflow
Workflows → New workflow:
- Trigger:
EVENTonwaitlist.joined - Add a
SEND_EMAILstep pointing at the template from step 1
Enable the workflow. Workflows are created disabled — until the toggle is on, nothing fires.
Tagging signups for later
If you want to segment on waitlist signups later, add an UPDATE_CONTACT step before the email:
{ "stage": "waitlist", "waitlistSource": "{{event.referrer}}" }You can then build a segment of contacts where stage == "waitlist" to target with follow-up campaigns. This is cleaner than filtering on "ever fired waitlist.joined."
What's next
Apps Wiki Content Docs Self Hosting Docker (`apps/wiki/content/docs/self-hosting/docker.mdx`)
title: Docker Deployment
Quick Start
- Download docker-compose.yml
- Configure environment variables
- Run
docker compose up -d
Minimal .env
# Required
JWT_SECRET="$(openssl rand -base64 32)"
DB_PASSWORD="your-secure-password"
# Domains (point these to your server)
API_DOMAIN="api.yourdomain.com"
DASHBOARD_DOMAIN="app.yourdomain.com"
LANDING_DOMAIN="www.yourdomain.com"
WIKI_DOMAIN="docs.yourdomain.com"
USE_HTTPS="true"
# AWS SES
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret"
SES_CONFIGURATION_SET="plunk-tracking"See Environment Variables for all options.
Services
| Service | Purpose |
|---|---|
plunk |
All apps + nginx (API, Web, Landing, Wiki, SMTP) |
postgres |
PostgreSQL 16 database |
redis |
Redis 7 queue |
minio |
S3-compatible storage |
ntfy |
Notifications |
Ports
| Port | Service |
|---|---|
| 80 | Nginx (HTTP) — fronts API, dashboard, landing, wiki |
| 465 | SMTP (implicit TLS) |
| 587 | SMTP (STARTTLS) |
| 8080 | API server (proxied through nginx; not usually exposed externally) |
| 9000 | Minio API |
| 9001 | Minio web console |
When deploying behind a reverse proxy or load balancer, expose port 80 (and 465/587 if using SMTP). Postgres, Redis, and Minio are kept internal to the Docker network by default.
Health check
The API exposes a health endpoint at /health for orchestrators (Docker Compose, Kubernetes, load balancers):
curl https://api.yourdomain.com/health
# { "status": "ok", "time": ..., "uptime": ... }Use this for liveness/readiness probes. It returns 200 when the API process is up and able to serve requests.
Running Individual Services
Set SERVICE environment variable:
SERVICE=api # API only
SERVICE=worker # Worker only
SERVICE=web # Dashboard only
SERVICE=all # Everything (default)SMTP TLS Certificates
For TLS on ports 465/587, provide certificates via one of these methods:
Traefik acme.json (Dokploy, Coolify)
Mount the acme.json file and set SMTP_DOMAIN:
environment:
SMTP_DOMAIN: "smtp.yourdomain.com"
volumes:
- /path/to/acme.json:/certs/acme.json:roPlunk automatically extracts the certificate for SMTP_DOMAIN from acme.json. Traefik rewrites acme.json in place, so mounting the single file picks up renewals.
PEM Files
Mount the directory that holds privkey.pem and fullchain.pem, not the files themselves: a single-file bind mount keeps pointing at the old file once a renewal replaces it.
volumes:
- /path/to/certs:/certs:roCertbot
Certbot's live/<domain>/ files are relative symlinks into archive/, and certbot makes both directories readable by root only, while the Plunk container runs as UID 1001. Have certbot copy the files into a certificates directory after every issuance and renewal with a deploy hook, and mount that directory as in PEM Files:
certbot certonly -d smtp.yourdomain.com --deploy-hook \
'install -m 644 "$RENEWED_LINEAGE/fullchain.pem" /path/to/certs/ && install -m 600 -o 1001 "$RENEWED_LINEAGE/privkey.pem" /path/to/certs/'For a certificate certbot already manages, add the hook with certbot reconfigure --cert-name smtp.yourdomain.com --deploy-hook '...'.
If no certificates are mounted, SMTP runs without TLS.
Certificates issued or renewed while Plunk is running are picked up within about a minute, without restarting the container.
Building from Source
docker build -t plunk:custom .Apps Wiki Content Docs Self Hosting Email Setup (`apps/wiki/content/docs/self-hosting/email-setup.mdx`)
title: AWS SES Setup
1. Create IAM User
- Go to IAM Console → Users → Create user
- Name:
plunk-ses - Attach a custom policy with required permissions (see below)
- Create access keys → Save credentials
Required IAM Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SetIdentityMailFromDomain",
"ses:GetIdentityDkimAttributes",
"ses:SendRawEmail",
"ses:GetIdentityVerificationAttributes",
"ses:VerifyDomainDkim",
"ses:ListIdentities",
"ses:SetIdentityFeedbackForwardingEnabled"
],
"Resource": "*"
}
]
}2. Create SNS Topic
- Go to SNS Console → Topics → Create topic
- Type: Standard
- Name:
plunk-ses-events - Create topic
- Create subscription:
- Protocol: HTTPS
- Endpoint:
https://api.yourdomain.com/webhooks/sns
- Plunk automatically confirms the subscription. If it fails, check your logs for the confirmation URL.
3. Create Configuration Sets
Tracking Configuration Set
- SES Console → Configuration sets → Create set
- Name:
plunk-tracking - Add event destination:
- Name:
sns-events - Event types: Sends, Deliveries, Opens, Clicks, Bounces, Complaints
- Destination: SNS → Select
plunk-ses-eventstopic
- Name:
No-Tracking Configuration Set
- Create another set named
plunk-no-tracking - Add event destination with only: Sends, Deliveries, Bounces, Complaints
4. Configure Environment
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-tracking"
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"5. Add Your Domain in Plunk
Once you have configured AWS SES with the above settings, you can add and verify your domain directly through the Plunk dashboard. Plunk will handle the domain verification and DKIM setup with AWS SES automatically and show you the right records to add to your DNS.
6. (Optional) Configure Inbound Email
If you want to use Plunk's Receiving emails feature, configure SES inbound separately. Plunk does not auto-provision receipt rules, so this is a manual one-time setup in your AWS account.
Not all AWS regions support SES inbound — confirm inbound-smtp.<your-region>.amazonaws.com exists for your AWS_SES_REGION before continuing. If your region doesn't support inbound, you can keep outbound on your current region and run a separate identity in a region that does.
Create a receipt rule set
- SES Console → Email receiving → Rule sets → Create rule set (or use an existing one)
- Add a rule:
- Recipient conditions:
*@yourdomain.com(or specific addresses you want to receive) - Actions: Publish to Amazon SNS topic → select your
plunk-ses-eventstopic (or create a separate topic that's also subscribed tohttps://api.yourdomain.com/webhooks/sns)
- Recipient conditions:
- Set the rule set as active
Inbound IAM permissions
Plunk doesn't call SES inbound APIs at runtime — the IAM policy from step 1 covers everything Plunk needs. Configuring receipt rules in AWS is a manual one-time action you do as an AWS admin.
Add an MX record
For each verified domain you want to receive on, add an MX record pointing at the SES inbound endpoint for your region:
Type: MX
Name: yourdomain.com
Value: 10 inbound-smtp.<your-region>.amazonaws.comPlunk's dashboard will show the exact value to use for your configured region.
7. (Optional) Move Out of the SES Sandbox
By default, new AWS SES accounts are in sandbox mode with strict limits:
- Send only to verified addresses
- 200 messages per 24 hours
- 1 message per second
Sandbox is fine for testing but useless for production. Request production access in the SES console (Account dashboard → Request production access) — AWS typically approves within 24 hours after a short questionnaire about your sending practices.
After approval, your sending quota will reflect a much higher daily and per-second limit specific to your account.
8. (Optional) Configure a MAIL FROM Domain
For better DMARC alignment, you can configure a custom MAIL FROM subdomain (e.g. mail.yourdomain.com). In the SES console under Verified identities → your domain → MAIL FROM domain, set a subdomain and add the additional MX and TXT records SES displays. Plunk's IAM policy already includes ses:SetIdentityMailFromDomain to support this.
Apps Wiki Content Docs Self Hosting Environment Variables (`apps/wiki/content/docs/self-hosting/environment-variables.mdx`)
title: Environment Variables
Security & Database
| Variable | Required | Description | Example |
|---|---|---|---|
JWT_SECRET |
Yes | Secret key used to sign JWT tokens. Generate with openssl rand -base64 32. |
s3cr3t... |
DB_PASSWORD |
Yes | PostgreSQL database password. Used by the Docker Compose setup. | changeme123 |
DATABASE_URL |
Yes | Full PostgreSQL connection string used by the application at runtime (typically through PgBouncer). Auto-configured in Docker. | postgresql://plunk:password@postgres:5432/plunk |
DIRECT_DATABASE_URL |
Yes | Direct PostgreSQL connection string used by Prisma migrations. Must bypass any connection pooler. Auto-configured in Docker. | postgresql://plunk:password@postgres:5432/plunk |
REDIS_URL |
Yes | Redis connection string. | redis://redis:6379 |
NODE_ENV |
No | Application environment. Set to production for production deployments. |
production |
PORT |
No | Port the API server listens on. | 8080 (default) |
URLs & Domains
Set your subdomains here. The application automatically derives all internal and client-side URLs from these at container startup — you don't need to set *_URI or NEXT_PUBLIC_* variables manually.
| Variable | Required | Description | Example |
|---|---|---|---|
API_DOMAIN |
Yes | Subdomain for the API server. | api.yourdomain.com |
DASHBOARD_DOMAIN |
Yes | Subdomain for the dashboard app. | app.yourdomain.com |
LANDING_DOMAIN |
Yes | Subdomain for the landing page. | www.yourdomain.com |
WIKI_DOMAIN |
Yes | Subdomain for the documentation site. | docs.yourdomain.com |
USE_HTTPS |
No | Set to true when running behind a TLS-terminating reverse proxy. Used to construct URLs with the correct protocol. |
false (default) |
AWS SES
| Variable | Required | Description | Example |
|---|---|---|---|
AWS_SES_REGION |
Yes | AWS region where SES is configured. | us-east-1 |
AWS_SES_ACCESS_KEY_ID |
Yes | AWS access key ID with SES send permissions. | AKIA... |
AWS_SES_SECRET_ACCESS_KEY |
Yes | AWS secret access key for SES. | wJalr... |
SES_CONFIGURATION_SET |
No | SES configuration set name used for open/click tracking. | plunk-configuration-set (default) |
SES_CONFIGURATION_SET_NO_TRACKING |
No | A second SES configuration set without tracking. When set, projects can toggle email tracking on/off. If omitted, the tracking toggle is hidden. | plunk-no-tracking-configuration-set (default) |
MAIL_FROM_SUBDOMAIN |
No | Subdomain prefix used when constructing the MAIL FROM hostname for a verified domain (e.g. with default plunk and domain yourdomain.com, the MAIL FROM is plunk.yourdomain.com). Override when the default subdomain is already in use (e.g. by an R2/CDN custom domain), since the MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME. |
plunk |
Storage (Minio)
The bundled Docker setup includes Minio with defaults that work out of the box. Only change these when connecting to an external S3-compatible bucket.
| Variable | Required | Description | Default |
|---|---|---|---|
MINIO_ROOT_USER |
No | Minio root username (Docker Compose only). | plunk |
MINIO_ROOT_PASSWORD |
No | Minio root password (Docker Compose only). | plunkminiopass |
MINIO_API_PORT |
No | Port for the Minio API (Docker Compose only). | 9000 |
MINIO_CONSOLE_PORT |
No | Port for the Minio console UI (Docker Compose only). | 9001 |
S3_ENDPOINT |
No | S3 or Minio endpoint URL. | http://minio:9000 |
S3_ACCESS_KEY_ID |
No | S3 or Minio access key. | — |
S3_ACCESS_KEY_SECRET |
No | S3 or Minio secret key. | — |
S3_BUCKET |
No | Bucket name for file uploads. | uploads |
S3_PUBLIC_URL |
No | Publicly accessible base URL for stored files. | — |
S3_FORCE_PATH_STYLE |
No | Use path-style URLs instead of virtual-hosted. Required for Minio. | true |
S3_PREFIX |
No | Upload folder in the bucket; only it is made publicly readable. | — |
Attachments
Plunk supports attachments on transactional emails. AWS SES caps total message size at 40 MB; the defaults below leave headroom but can be tuned for your use case.
| Variable | Required | Description | Default |
|---|---|---|---|
MAX_ATTACHMENT_SIZE_MB |
No | Maximum total attachment size in megabytes per email. Hard upper bound enforced by AWS SES is 40 MB. | 10 |
MAX_ATTACHMENTS_COUNT |
No | Maximum number of attachments per email. | 10 |
API Rate Limiting
The high-traffic API endpoints are protected by per-project token buckets stored in Redis. Each bucket has a sustained rate (*_PER_SECOND) and a burst ceiling (*_BURST) that absorbs spikes — useful for callers like real-time contact sync, which drains a backlog all at once and then idles.
Limits are keyed on the project rather than the IP address, so one customer's runaway integration cannot consume another's budget. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a throttled request returns 429 with a Retry-After header. If Redis is unreachable, requests are allowed through rather than rejected.
Rate limiting is off by default — upgrading Plunk will never start refusing traffic your callers were already sending. Set RATE_LIMIT_ENABLED=true to switch it on; none of the other variables have any effect until you do. Once enabled, set an individual *_PER_SECOND to 0 to disable a single bucket while leaving the others active.
| Variable | Required | Description | Default |
|---|---|---|---|
RATE_LIMIT_ENABLED |
No | Master switch for all API rate limiting. Turn on when exposed to untrusted API clients. | false |
RATE_LIMIT_TRACK_PER_SECOND |
No | Sustained requests per second for POST /v1/track, usually the highest-volume endpoint. |
100 |
RATE_LIMIT_TRACK_BURST |
No | Burst ceiling for POST /v1/track. |
200 |
RATE_LIMIT_SEND_PER_SECOND |
No | Sustained requests per second for POST /v1/send. Also bounded by the project's billing limit. |
20 |
RATE_LIMIT_SEND_BURST |
No | Burst ceiling for POST /v1/send. |
50 |
RATE_LIMIT_CONTACTS_PER_SECOND |
No | Sustained requests per second across contact writes (POST, PATCH, DELETE on /contacts). |
50 |
RATE_LIMIT_CONTACTS_BURST |
No | Burst ceiling shared by those contact writes. | 100 |
Contact writes share one budget because a sync integration drives create, update and delete from the same upstream change feed — separate budgets would let a single runaway sync spend three times the intended allowance.
Idempotency
Callers can pass an Idempotency-Key header to POST /v1/track and POST /v1/send to guarantee the request runs at most once. See Idempotency.
| Variable | Required | Description | Default |
|---|---|---|---|
IDEMPOTENCY_KEY_TTL_HOURS |
No | How long a used key stays claimed before it can be reused. Expired keys are swept hourly, so raising this grows the idempotency_keys table proportionally. |
24 |
SMTP Server
The optional SMTP relay lets you send emails through Plunk via the SMTP protocol.
| Variable | Required | Description | Default |
|---|---|---|---|
SMTP_DOMAIN |
No | SMTP relay domain. Required when using Traefik's acme.json with multiple certificates so the correct cert can be selected. |
localhost |
SMTP_ENABLED |
No | Explicitly enable SMTP features in the UI. Automatically enabled when SMTP_DOMAIN is set to a non-localhost value in production. |
false |
PORT_SECURE |
No | SMTPS port (implicit TLS). | 465 |
PORT_SUBMISSION |
No | SMTP submission port (STARTTLS). | 587 |
MAX_RECIPIENTS |
No | Maximum number of recipients per email. | 5 |
OAuth
Enables social login. Register an OAuth app with each provider and add the credentials here.
| Variable | Required | Description |
|---|---|---|
GITHUB_OAUTH_CLIENT |
No | GitHub OAuth app client ID. |
GITHUB_OAUTH_SECRET |
No | GitHub OAuth app client secret. |
GOOGLE_OAUTH_CLIENT |
No | Google OAuth app client ID. |
GOOGLE_OAUTH_SECRET |
No | Google OAuth app client secret. |
Stripe
Required if you want to enable billing features. All Stripe variables must be set together for billing to activate.
| Variable | Required | Description |
| -------------------------- | -------- | --------------------------------------------------- | ------------------ |
| STRIPE_SK | No | Stripe secret key. |
| STRIPE_WEBHOOK_SECRET | No | Stripe webhook signing secret for verifying events. |
| STRIPE_PRICE_EMAIL_USAGE | No | Stripe price ID for metered pay-per-email usage. |
| STRIPE_METER_EVENT_NAME | No | Stripe meter event name. | emails (default) |
Checkout collects nothing upfront. Right after it completes, Plunk places a single charge of 2.00 (in the customer's billing currency) to confirm the card accepts off-session, merchant-initiated payments. This charge is created directly rather than from a price ID, so there's no variable for it. It's credited back to the customer's Stripe balance once verification succeeds. The same check runs whenever the customer changes their default card, which requires the customer.updated and customer.subscription.updated events on your Stripe webhook endpoint. See Billing for the customer-facing description.
Platform Emails
When configured, Plunk will send email notifications to users for critical events (e.g. project disabled, billing limits reached). Without these, only ntfy notifications are sent.
| Variable | Required | Description | Example |
|---|---|---|---|
PLUNK_API_KEY |
No | API key for a Plunk instance to send transactional emails. | pk_... |
PLUNK_FROM_ADDRESS |
No | From address used for platform notification emails. | [email protected] |
Notifications (ntfy)
Plunk bundles a self-hosted ntfy server for internal system notifications.
| Variable | Required | Description | Default |
|---|---|---|---|
NTFY_PORT |
No | Port for the ntfy web UI (Docker Compose only). | 8080 |
NTFY_URL |
No | ntfy topic URL. Change this to use an external ntfy.sh server or your own instance. | http://ntfy/plunk-notifications |
User Management
| Variable | Required | Description | Default |
|---|---|---|---|
DISABLE_SIGNUPS |
No | When true, the signup endpoint rejects new registrations. Useful for private instances. |
false |
VERIFY_EMAIL_ON_SIGNUP |
No | When true, validates emails on signup — checks for disposable domains, plus-addressing, domain existence, and MX records. |
false |
Interface
| Variable | Required | Description | Default |
|---|---|---|---|
SHOW_SPONSOR |
No | When true, the dashboard navigation shows a Sponsor link to GitHub Sponsors. Set to false to hide it. |
true |
Security
| Variable | Required | Description | Default |
|---|---|---|---|
AUTO_PROJECT_DISABLE |
No | When true, projects are automatically suspended when bounce or complaint rate thresholds are exceeded. Set to false to manage project status manually. |
true |
EMAIL_RATE_LIMIT_PER_SECOND |
No | Override the email sending rate limit. If not set, Plunk automatically fetches the quota from your AWS SES account. | — |
EMAIL_WORKER_CONCURRENCY |
No | Number of emails the worker processes in parallel. When unset, derived from the effective rate limit so a higher SES quota scales throughput automatically. | — |
EMAIL_WORKER_MAX_CONCURRENCY |
No | Upper bound applied to the auto-derived worker concurrency. Raise this only after sizing the Prisma connection pool accordingly. | 50 |
Advanced
Variables for unusual deployments. The defaults work for the standard Docker Compose setup — only change these if you know you need to.
| Variable | Required | Description | Default |
|---|---|---|---|
NGINX_PORT |
No | Host port the bundled Nginx reverse proxy binds to. Override when port 80/443 is already in use on the host (e.g. running behind another reverse proxy that forwards to a different port). | 80 |
Phishing Detection
Plunk can use AI to detect and block phishing emails before they're sent. Requires an OpenRouter API key.
| Variable | Required | Description | Default |
|---|---|---|---|
OPENROUTER_API_KEY |
No | OpenRouter API key. When set, enables AI-powered phishing detection. | — |
OPENROUTER_MODEL |
No | LLM model to use for content analysis. See OpenRouter models. | anthropic/claude-3-haiku |
PHISHING_DETECTION_SAMPLE_RATE |
No | Percentage of emails to check (0.0-1.0). For example, 0.1 means 10% of emails are analyzed. |
0.1 (10%) |
PHISHING_CONFIDENCE_THRESHOLD |
No | Minimum confidence percentage (0-100) required to auto-disable a project from a single detection. | 95 |
PHISHING_CUMULATIVE_THRESHOLD |
No | Number of phishing detections within the time window required to auto-disable a project. | 3 |
PHISHING_CUMULATIVE_WINDOW_MS |
No | Time window in milliseconds for cumulative phishing tracking. | 3600000 (1 hour) |
How it works:
- A random sample of emails (controlled by
PHISHING_DETECTION_SAMPLE_RATE) are analyzed by the LLM for phishing content. - Projects are automatically disabled if either:
- A single email is detected with confidence ≥
PHISHING_CONFIDENCE_THRESHOLD, or PHISHING_CUMULATIVE_THRESHOLDor more emails are flagged within thePHISHING_CUMULATIVE_WINDOW_MStime window.
- A single email is detected with confidence ≥
- Detection history is stored in Redis and shared across all worker instances.
Apps Wiki Content Docs Self Hosting Introduction (`apps/wiki/content/docs/self-hosting/introduction.mdx`)
title: Self-Hosting
Requirements
- Docker and Docker Compose
- AWS SES account (for sending emails)
- Domain name with subdomains for each service
Quick Start
- Download the docker-compose.yml
- Create environment variables with your configuration (see Environment Variables)
- Run
docker compose up -d
Domain Setup
Configure these subdomains pointing to your server:
| Subdomain | Service |
|---|---|
app.yourdomain.com |
Dashboard |
api.yourdomain.com |
API |
docs.yourdomain.com |
Documentation |
www.yourdomain.com |
Landing page |
smtp.yourdomain.com |
SMTP relay (optional) |
Next Steps
- Docker Deployment — Configuration details
- Environment Variables — All settings
- AWS SES Setup — Email configuration
Apps Wiki Content Docs Self Hosting Meta.Json (`apps/wiki/content/docs/self-hosting/meta.json`)
{
"title": "Self-Hosting",
"pages": ["introduction", "docker", "environment-variables", "email-setup"]
}
--- METRICS ---
- Files Extracted: 42
- Estimated Token Budget: ~69961 tokens
- Canonical Reference: https://codewiki.google/github.com/useplunk/plunk