plunk Documentation

The Open-Source Email Platform

5,531 stars TypeScript TypeScript 42 files · ~70,039 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy plunk on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name plunk -p 55432:55432 --restart always postgres:16-alpine
Configured Services & Ports
⚙️ db
image: postgres:16-alpine
🔌 Ports: 55432:5432
⚙️ redis
image: redis
🔌 Ports: 56379:6379
⚙️ minio
image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z
🔌 Ports: 9000:9000, 9001:9001
docker-compose.yml
yaml (docker-compose.yml)
services:
  db:
    image: postgres:16-alpine
    ports:
      - 55432:5432
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U postgres" ]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis
    command: redis-server --appendonly yes
    ports:
      - 56379:6379
    volumes:
      - redis_data:/data
    healthcheck:
      test: [ "CMD", "redis-cli", "ping" ]
      interval: 10s
      timeout: 5s
      retries: 5

  minio:
    # MinIO stopped publishing images; pgsty/minio is the community build the self-host compose uses too
    image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z
    command: server /data --console-address ":9001"
    ports:
      - 9000:9000
      - 9001:9001
    environment:
      MINIO_ROOT_USER: plunk
      MINIO_ROOT_PASSWORD: plunkminiopass
    volumes:
      - minio_data:/data
    healthcheck:
      test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
      interval: 30s
      timeout: 20s
      retries: 3

volumes:
  redis_data:
  minio_data:
  postgres_data:

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

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

bash
claude mcp add plunk --env PLUNK_API_KEY=sk_your_key -- npx -y @plunk/mcp

Claude Desktop, Cursor, and other clients

json
{
  "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:

json
{
  "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 called
PLUNK_API_KEY for its own platform notification emails. That key belongs to a different project.
If both are present in the same environment, set PLUNK_MCP_API_KEY so 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. Without after a 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_step reconnects the flow around the step by default. Removing a condition
    requires choosing downstream: detach keeps the steps below it, delete removes 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_workflow prints the flow as numbered lines in the order a contact walks it and lists
    problems; plunk_set_workflow_enabled refuses 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 or
email. 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, and plunk_send_email with 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 with plunk_set_workflow_enabled asks too,
    and names the trigger event. So do changing the trigger of an enabled workflow that sends email and
    starting one for a contact with plunk_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 — set PLUNK_ALLOW_UNCONFIRMED_SENDS=true if you are
    running headless and accept that.
  • Read-only mode is enforced by registration, not by convention. With PLUNK_READ_ONLY=true the
    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

bash
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.js

License

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

text
SMTP Client → SMTP Server → Email Parser → API /v1/send → AWS SES

The SMTP server acts as a relay:

  1. Accepts SMTP connections with authentication
  2. Validates sender domains against the database
  3. Parses incoming emails
  4. Forwards to the Plunk API
  5. 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

bash
# Install dependencies
yarn install

# Start development server (with hot reload)
yarn workspace smtp dev

The 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

bash
# 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.
.
QUIT

Testing 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:

  1. Traefik acme.json (recommended for Traefik/Dokploy users)

    • Mount your Traefik acme.json file to /certs/acme.json
    • Set SMTP_DOMAIN environment variable to select the correct certificate
    • The server will automatically use the certificate for your domain
  2. PEM Files (standard certificate files)

    • Mount privkey.pem and fullchain.pem to /certs/
    • These are standard Let's Encrypt/Certbot filenames
    • SMTP_DOMAIN is optional when using PEM files

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

bash
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:latest

Option 2: With PEM files

bash
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:latest

Mount 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)

bash
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:

text
smtp.example.com.  A  1.2.3.4

Optionally, add MX records if receiving email:

text
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:

text
✅ 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 account

Use PM2 or similar process managers to monitor the service in production.

Troubleshooting

TLS Certificate Issues

If TLS is not working:

  1. Verify certificates are mounted correctly:
    bash
    docker exec <container> ls -la /certs/
  2. Check certificate permissions (should be readable)
  3. Review logs for certificate loading errors:
    bash
    docker logs <container> 2>&1 | grep -i "cert"
  4. If using Traefik acme.json, ensure SMTP_DOMAIN is set correctly
  5. If using PEM files, ensure both privkey.pem and fullchain.pem are present

Connection Refused

If clients cannot connect:

  1. Verify ports 465 and 587 are exposed and not blocked by firewall
  2. Check if the service is running: pm2 list
  3. Review logs: pm2 logs smtp

Authentication Failures

If authentication fails:

  1. Verify username is exactly plunk
  2. Verify password is the project secret, not public key
  3. Check database connectivity

Domain Verification Errors

If emails are rejected with domain errors:

  1. Verify domain is added to your project
  2. Check domain verification status in Plunk dashboard
  3. 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:

text
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 accepted
  • 4xx/5xx → 554 Transaction failed

Contributing

When modifying the SMTP server:

  1. Follow the existing code patterns
  2. Add proper error handling
  3. Update TypeScript types
  4. Test with real SMTP clients
  5. 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

Send transactional emails via API with template support and variable substitution Create and send one-time email broadcasts to your contacts with advanced scheduling Build automated email sequences with visual workflow builder and conditional logic Manage millions of contacts with custom fields and CSV import capabilities Create dynamic audience segments based on contact data and behavior Create reusable email templates for transactional and marketing emails Track events and use them to trigger workflows and segment audiences Understand Plunk's billing model and pricing Explore the complete API documentation Deploy Plunk on your own infrastructure Learn how to verify your domains for sending emails Manage your API keys and authentication

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

text
{{API_URL}}

All API requests use this base URL.

Authentication

Include your API key in the Authorization header:

bash
Authorization: Bearer YOUR_API_KEY
  • Secret Key (sk_*) — Required for all endpoints except /v1/track
  • Public Key (pk_*) — Only works with /v1/track for client-side event tracking

Making requests

Send transactional email

bash
curl -X POST {{API_URL}}/v1/send \
  -H "Authorization: Bearer sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "[email protected]",
    "subject": "Hello",
    "body": "<p>Your message here</p>"
  }'

Track event

bash
curl -X POST {{API_URL}}/v1/track \
  -H "Authorization: Bearer pk_your_public_key" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "event": "signed_up"
  }'

Create contact

bash
curl -X POST {{API_URL}}/contacts \
  -H "Authorization: Bearer sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "subscribed": true,
    "data": {
      "firstName": "John",
      "plan": "pro"
    }
  }'

Response format

Success responses

Public API endpoints (/v1/send, /v1/track, /v1/verify) return a wrapped envelope:

json
{
  "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:

json
{
  "id": "cnt_abc123",
  "email": "[email protected]",
  "createdAt": "2025-11-30T10:30:00.000Z"
}

List endpoints with cursor pagination return:

json
{
  "data": [ /* items */ ],
  "cursor": "def456",
  "hasMore": true,
  "total": 10000
}

Error response

All errors include detailed information to help you debug issues:

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "statusCode": 422,
    "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "errors": [
      {
        "field": "email",
        "message": "Invalid email",
        "code": "invalid_string"
      }
    ],
    "suggestion": "One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Error fields:

  • code — Machine-readable error code for programmatic handling
  • message — Human-readable description
  • statusCode — HTTP status code
  • requestId — Unique ID for debugging (include when contacting support)
  • errors — Field-level validation details (when applicable)
  • suggestion — Helpful guidance for fixing the error

See the Error Codes documentation for complete details and examples.

Pagination

Most list endpoints use cursor-based pagination:

bash
GET /contacts?limit=100&cursor=abc123

Parameters:

  • limit — items per page (default: 20, max: 100)
  • cursor — pagination cursor from the previous response's cursor field

Response:

json
{
  "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

javascript
const PLUNK_SECRET_KEY = process.env.PLUNK_SECRET_KEY;

async function sendEmail(to, subject, body) {
  const response = await fetch('{{API_URL}}/v1/send', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${PLUNK_SECRET_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ to, subject, body })
  });

  const data = await response.json();

  if (!data.success) {
    throw new Error(`[${data.error.code}] ${data.error.message}`);
  }

  return data.data;
}

Python

python
import os
import requests

PLUNK_SECRET_KEY = os.environ['PLUNK_SECRET_KEY']

def send_email(to, subject, body):
    response = requests.post(
        '{{API_URL}}/v1/send',
        headers={
            'Authorization': f'Bearer {PLUNK_SECRET_KEY}',
            'Content-Type': 'application/json'
        },
        json={'to': to, 'subject': subject, 'body': body}
    )

    data = response.json()

    if not data.get('success'):
        error = data['error']
        raise Exception(f"[{error['code']}] {error['message']}")

    return data['data']

cURL

bash
curl -X POST {{API_URL}}/v1/send \
  -H "Authorization: Bearer $PLUNK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "[email protected]", "subject": "Hello", "body": "Message"}'

What's next


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.

Capture signups with a single tracked event, then automatically email each person who joins. Keep your own user table in step with Plunk's subscription state using a webhook step. Add a confirmation step before a contact starts receiving marketing email, using `{{subscribeUrl}}`.

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:

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "statusCode": 422,
    "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "errors": [
      {
        "field": "email",
        "message": "Invalid email",
        "code": "invalid_string"
      }
    ],
    "suggestion": "One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Response Fields

Field Type Description
success boolean Always false for errors
error.code string Machine-readable error code (see below)
error.message string Human-readable error description
error.statusCode number HTTP status code
error.requestId string Unique request identifier for debugging
error.errors array Field-level validation errors (validation errors only)
error.details object Additional context about the error (optional)
error.suggestion string Helpful guidance for fixing the error (optional)
timestamp string ISO 8601 timestamp when the error occurred

Error Codes Reference

All errors include a machine-readable code field for programmatic handling. Here are all possible error codes:

Authentication & Authorization

Code Status Description
UNAUTHORIZED 401 General authentication failure
INVALID_CREDENTIALS 401 Login credentials are incorrect
MISSING_AUTH 401 Authorization header is missing or malformed
INVALID_API_KEY 401 API key is invalid or not found
FORBIDDEN 403 Not allowed to perform this action
PROJECT_ACCESS_DENIED 403 No access to this project
PROJECT_DISABLED 403 Project has been disabled
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

json
{
  "success": false,
  "error": {
    "code": "INVALID_API_KEY",
    "message": "Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.",
    "statusCode": 401,
    "requestId": "abc-123",
    "suggestion": "Verify your API key is correct and starts with \"sk_\" for secret keys or \"pk_\" for public keys."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Solution: Check your API key is correct. Secret endpoints require keys starting with sk_, while tracking endpoints use pk_ keys.

Missing Authorization Header

json
{
  "success": false,
  "error": {
    "code": "MISSING_AUTH",
    "message": "Authorization header is required",
    "statusCode": 401,
    "requestId": "abc-123",
    "suggestion": "Include an Authorization header with format: \"Authorization: Bearer YOUR_API_KEY\""
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Solution: Add the Authorization header with your API key in Bearer token format.

Validation Errors

Invalid Email Format

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "statusCode": 422,
    "requestId": "abc-123",
    "errors": [
      {
        "field": "email",
        "message": "Invalid email",
        "code": "invalid_string"
      }
    ],
    "suggestion": "One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Solution: Provide a valid email address. The errors array shows which fields failed validation.

Missing Required Fields

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "statusCode": 422,
    "requestId": "abc-123",
    "errors": [
      {
        "field": "event",
        "message": "Required",
        "code": "invalid_type"
      },
      {
        "field": "email",
        "message": "Required",
        "code": "invalid_type"
      }
    ],
    "suggestion": "Required fields are missing. Ensure all required fields are included in your request."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Solution: Include all required fields in your request body.

Resource Errors

Template Not Found

json
{
  "success": false,
  "error": {
    "code": "TEMPLATE_NOT_FOUND",
    "message": "Template with ID \"tpl_abc123\" was not found",
    "statusCode": 404,
    "requestId": "abc-123",
    "details": {
      "resource": "Template",
      "id": "tpl_abc123"
    },
    "suggestion": "Ensure the template ID is correct and belongs to your project. You can list available templates via the API."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Solution: Verify the template ID exists and belongs to your project.

Rate Limiting

Rate Limit Exceeded

json
{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Please try again later.",
    "statusCode": 429,
    "requestId": "abc-123",
    "suggestion": "You have exceeded the rate limit. Wait a moment before retrying, or upgrade your plan."
  },
  "timestamp": "2025-11-30T10:30:00.000Z"
}

Solution: Implement exponential backoff and retry logic. Consider upgrading your plan for higher limits.

Success Response Format

Successful API requests return a standardized format with success: true and a data object:

json
{
  "success": true,
  "data": {
    "contact": "cnt_abc123",
    "event": "evt_xyz789",
    "timestamp": "2025-11-30T10:30:00.000Z"
  }
}

Response Fields

Field Type Description
success boolean Always true for successful requests
data object Response data specific to the endpoint

Handling Errors in Your Code

JavaScript/TypeScript Example

typescript
try {
  const response = await fetch('https://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

python
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:

  1. Include the request ID in your message
  2. Describe what you were trying to do
  3. Share the full error response if possible

Example: If you receive request ID f47ac10b-58cc-4372-a567-0e02b2c3d479, we can search our logs for that ID and see:

  • The exact request body you sent
  • Which database queries were executed
  • Any background jobs that were triggered
  • The full error stack trace (if applicable)

This helps us quickly locate and diagnose the issue without asking you for additional information.

How to Find Request IDs

Request IDs are included in:

  • Error responses: error.requestId field
  • Response headers: X-Request-ID header (also included in successful responses)
  • Your application logs: Include the header in your logs for correlation
javascript
// Example: Logging request ID in your application
const response = await fetch('https://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 Authorization header uses Bearer token format.
  • Verify the key hasn't been revoked or regenerated.
  • Check the errors array for specific field issues.
  • Verify all required fields are included.
  • Ensure field types match (strings quoted, numbers unquoted).
  • Review the API reference for 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

  1. Always check the success field before processing responses
  2. Log request IDs for debugging and support requests
  3. Handle errors gracefully with user-friendly messages
  4. Implement retry logic with exponential backoff for transient errors
  5. Monitor error rates to detect issues early
  6. Use error codes for programmatic error handling, not just status codes

Getting Help

If you continue experiencing issues:

  1. Review the error suggestion field for guidance
  2. Check the API Reference for correct usage
  3. Search our documentation for your specific error code
  4. Contact support with your request ID
  5. Join our community for help from other developers

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) return 402 Payment Required with error code BILLING_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

Reduce wasted sends by keeping bounce and complaint rates low. Inbound emails count toward your monthly usage. Run Plunk on your own infrastructure for full control over costs.

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

Create reusable email designs to send through campaigns. Build audiences for `audienceType: SEGMENT` campaigns. Required before you can send from your own domain. Keep bounce and complaint rates healthy.

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 status 201 for new contacts and 200 for updates.
  • Bulk CSV import via POST /contacts/import — upload a CSV (≤ 5 MB) and poll GET /contacts/import/:jobId for status. The file needs an email column, an optional subscribed column sets subscription status, and any other columns map to lowercase keys under data.*.
  • Bulk subscribe / unsubscribe / delete via POST /contacts/bulk-subscribe, bulk-unsubscribe, and bulk-delete — each accepts up to 1,000 contact IDs and returns a job ID; poll GET /contacts/bulk/:jobId for progress.
  • Adding emails to a static segment with POST /segments/:id/members and createMissing: true will 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.

If a value doesn't match number, boolean, or the ISO 8601 date format, it's typed as a string.

Non-persistent values

Send a value as { value, persistent: false } to use it once for template rendering without storing it on the contact:

json
{
  "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"
To remove a field from a contact's data, set it to `null` when creating or updating the contact. Empty strings are automatically filtered out and won't overwrite existing data.

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:

  • subscribed flipped to true → contact.subscribed event
  • subscribed flipped to false → contact.unsubscribed event

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
Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed contact. Use a transactional template instead if the email must reach unsubscribed contacts.

What's next

How to set, type, and clean up arbitrary contact data. Bulk-load contacts and their custom fields. Group contacts dynamically or statically for targeting. Hosted unsubscribe / preferences pages and the URL template variables.

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 }. With createMissing: true, contacts that don't exist yet are created (and start subscribed unless you pass subscribed: 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 joined
  • segment.<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:

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 use within / olderThan on 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 memberCount may be a few minutes behind reality. Treat it as approximate; use POST /segments/:id/refresh to 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

Deep-link reference for every filter field, operator, and value type. How `data.*` fields are typed and used in filters. Use a segment as a campaign audience. Trigger workflows from segment entry / exit events. What dynamic segments are for and how they compare to static lists.

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.

html
<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:

text
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:

text
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:

html
{% 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

Conditionals, loops and filters with Liquid. Send templated emails directly through `/v1/send`. Broadcast a template to a segment or audience. Send templates as part of automated journeys. Translate the unsubscribe footer and contact-facing pages. The visual and HTML editor, side by side.

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

Full reference for `POST /v1/send`. Author reusable email designs. Authenticating requests with `sk_*` and `pk_*` keys. Track delivery, opens, clicks, and bounces of your sends.

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-all to cancel everything in flight.

Cosmetic changes (renaming, editing template content referenced by steps) don't require this.

Common patterns

  • Welcome series — EVENT trigger on signed_up, send welcome email, delay 2 days, send tips email, delay 5 days, send upgrade nudge.
  • Inbound auto-reply — EVENT trigger on email.received, CONDITION on event.spamVerdict == "PASS", then SEND_EMAIL with 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 any email.opened event, branch on whether the contact engaged.
  • Webhook fan-out — EVENT trigger on purchase.completed, WEBHOOK step to your CRM, UPDATE_CONTACT to tag { tier: "customer" }, SEND_EMAIL with 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

Author the email content used by `SEND_EMAIL` steps. Trigger workflows from inbound mail with `email.received`. Use `WEBHOOK` steps to call your own services from a workflow. Trigger workflows from segment entry / exit events. How teams use workflows for onboarding, lifecycle, and product email.

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']}>

bash
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>" }'
javascript
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>',
  }),
});
python
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:

  1. Open Settings → API Keys in the dashboard, or call POST /users/@me/projects/:id/regenerate-keys.
  2. Confirm the rotation. The old keys stop working immediately.
  3. 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.local for 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

  1. Rotate immediately (above).
  2. Audit the Activity tab for unexpected sends, contact mutations, or campaign changes during the window the key was leaked.
  3. Check Billing → Consumption for usage spikes that suggest the key was abused.
  4. 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:

bash
# 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:

html
<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:

html
{% 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:

json
{ "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:

json
{
  "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.

Clearing is irreversible. If you need a permanent archive of exact sent content, capture it on your side at send time, or reconstruct it from the [template](/concepts/templates) and the contact's data.

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/track
  • POST /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']}>

bash
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>"
  }'
javascript
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>',
  }),
});
python
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:

json
{
  "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.

Plunk **refuses** a reused key; it does not replay the original response. A `409` tells you the operation was not performed twice, but it does not return the original email or event ID. If you need that ID, record it when the first request succeeds.

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:

csv
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:00Z

In 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 subscribed column sets whether each contact can receive marketing email. true, 1 and yes subscribe the contact; any other value, such as false or no, 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 with within / olderThan segment 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 with gt / lt segment 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']}>

  1. Open Contacts and click Import.
  2. Pick your CSV file. Plunk validates the header and shows a preview of the first few rows.
  3. Confirm. The import is queued and runs in the background.
  4. The Imports page shows progress and any per-row errors when complete.

For automation, use the import API:

Step 1 — upload the CSV

bash
curl -X POST {{API_URL}}/contacts/import \
  -H "Authorization: Bearer sk_..." \
  -F "[email protected]"

The response includes a jobId:

json
{ "jobId": "imp_abc123", "status": "queued" }

Step 2 — poll for status

bash
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:

json
{
  "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 subscribed set to false. 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.
  • Custom fields — how the data.<column> fields you import are typed and used.
  • Bulk operations — bulk-subscribe, bulk-unsubscribe, bulk-delete for 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 MARKETING template/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:

bash
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']}>

bash
claude mcp add plunk --env PLUNK_API_KEY=sk_your_key -- npx -y @plunk/mcp
json
{
  "mcpServers": {
    "plunk": {
      "command": "npx",
      "args": ["-y", "@plunk/mcp"],
      "env": {
        "PLUNK_API_KEY": "sk_your_key"
      }
    }
  }
}

Point PLUNK_API_URL at your own API domain:

json
{
  "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.
If you self-host, the Plunk API server itself uses a variable called `PLUNK_API_KEY` for its platform notification emails — and that key belongs to a different project. When both live in the same environment, set `PLUNK_MCP_API_KEY` so the agent works on the project you intend.

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=true the 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, and
plunk_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:

  1. Parses the message and stores it as an inbound Email record visible in your project's Activity feed.
  2. Creates the sender as a contact in your project (or updates them if they already exist), subscribed by default.
  3. Tracks an email.received event 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.

A domain can only have one primary inbound MX target. If you already use Google Workspace, Microsoft 365, or another provider for receiving email on the apex domain, your existing email will break if you switch the MX to point at Plunk. The usual fix is to receive Plunk inbound on a subdomain (e.g. `mail.yourdomain.com` or `support.yourdomain.com`) so you can keep your main mailbox on the existing provider. The subdomain still needs to be verified for sending in Plunk before its MX will be accepted.

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:

bash
dig MX yourdomain.com

You 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 Email row appears in the project's Activity feed with type Inbound.
  • The sender shows up as a contact in the Contacts tab.
  • An email.received event 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]:

  1. Trigger: email.received.
  2. Condition: continue only if event.to equals [email protected] and event.spamVerdict == "PASS" and event.virusVerdict == "PASS".
  3. 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.

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:

  1. Trigger: email.received.
  2. 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:

  1. Trigger: email.received.
  2. 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}}"
        }
      }

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 CONDITION step 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_CONTACT step at the end of the inbound workflow to set subscribed: 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 CONDITION that drops messages where event.from matches 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.to inside 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';

  1. DNS: dig MX yourdomain.com — confirm the value Plunk gave you appears in the response.
  2. Domain verification: in the dashboard, confirm the domain is fully verified for sending. Inbound MX won't process anything on an unverified domain.
  3. Workflow not firing: build a test workflow with just an email.received trigger 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.
  4. 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 CONDITION steps 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)".

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "logic": "AND",
  "groups": [
    {
      "filters": [
        { "field": "segment.<powerUsersSegmentId>", "operator": "memberOfSegment" },
        { "field": "data.lifetimeValue", "operator": "greaterThanOrEqual", "value": 500 }
      ]
    }
  ]
}
Static vs dynamic segments, membership tracking, and entry / exit events. How `data.*` fields are typed and used in filters.

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:

html
<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:

html
<p>Hi {{first name}}</p>
<p>Hi {{data["first name"] | capitalize}}</p>

Fallbacks

?? fallback is Plunk-specific shorthand and takes a literal:

html
<p>Hi {{firstName ?? there}}, welcome back.</p>

Liquid's default filter is the general form and can fall back to another variable:

html
<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:

html
{% 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:

html
{% 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:

html
{% if lifetimeValue > 500 and plan != 'free' %}
  <p>You qualify for our VIP tier.</p>
{% endif %}
Plunk uses JavaScript truthiness rather than Liquid's stricter rules, because imported contact data very often has a column present but blank. An empty string is falsy, so `{% if firstName %}` is false for a contact whose `firstName` is `""`.

Loops

Iterate arrays stored on a contact or passed to /v1/send:

html
<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:

html
{% 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:

html
{% 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 renders there, which is what this guide always described.
  • Falsy values render instead of disappearing. 0, false and NaN used to come out as an empty string; {{count}} for a contact with count: 0 now renders 0, and {{subscribed}} renders false. 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 branch used 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

Store the data your templates branch on. How templates fit into campaigns, workflows and `/v1/send`. Set a `locale` per contact to drive multilanguage templates.

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.

On self-hosted instances, the Marketing-only mode is only available when a no-tracking configuration set is configured. See [Self-hosting → Email setup](/self-hosting/email-setup) for details.

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/:id with subscribed, a CSV import that sets the column, a bulk action, or a workflow Update contact step.

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:

json
// 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/:id with { "subscribed": false } — unsubscribe a contact.
  • PATCH /contacts/:id with { "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:

http
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
A domain can only have **one** SPF TXT record. If you already use another email provider (Google Workspace, Microsoft 365, another sending platform), you must **merge** Plunk's SPF mechanism into your existing record — don't add a second SPF record. For example, if your existing record is `v=spf1 include:_spf.google.com ~all`, the merged version is `v=spf1 include:_spf.google.com include:<Plunk's include from the dashboard> ~all`. Two separate SPF records will cause both to fail.

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
This MX record handles delivery feedback (bounces, complaints) for emails Plunk sends out — it's separate from the [inbound MX record](/guides/receiving-emails) used to **receive** emails sent to your domain. They serve different purposes; you can have either one or both.

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:

text
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:

  1. Double-check the values: ensure you copied the exact values without extra spaces.
  2. Check DNS propagation: use tools like dig or online DNS checkers to verify the records are published.
  3. TTL settings: some DNS providers cache records. Try lowering the TTL (Time To Live) value.
  4. 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:

  1. An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
  2. A workflow is triggered by that event
  3. 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
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`.

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.
json
{
  "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

text
https://api.example.com/users/{{id}}/events

Headers

json
{
  "Authorization": "Bearer your-secret-token"
}

Body

json
{
  "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:

json
{
  "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']}>

json
{
  "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.' },
}}
/>

json
{
  "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.' },
}}
/>

json
{
  "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.' },
}}
/>

json
{
  "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:

json
{
  "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:

json
{
  "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.' },
}}
/>

Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription. `Transient` bounces are tracked for visibility only.
json
{
  "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.

json
{
  "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:

json
{
  "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:

json
{
  "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:

  1. Send email via API:
json
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"
      }
    ]
  }
}
  1. Store the email ID (ac32f08e-c6b9-45d3-9824-a73dff1e3bbf) in your database

  2. When webhook events fire (e.g., email.open, email.bounce), match them using event.emailId:

json
{
  "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 bounceType field
  • 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 POST with Content-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 2xx quickly.

  • 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:// and https:// 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_EVENT step, 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.

A marketing template targeted at an unsubscribed contact is [silently skipped](/concepts/contacts#emails-by-subscription-state). Use a transactional template for the confirmation specifically — it bypasses the subscription check.

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.

bash
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: EVENT on signup.pending
  • SEND_EMAIL step → transactional confirmation template

Enable it.

Workflow B: welcome them after confirmation

Workflows → New workflow:

  • Trigger: EVENT on contact.subscribed
  • SEND_EMAIL step → 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

Detail on `{{subscribeUrl}}` and the hosted pages. The difference between Marketing, Transactional, and Headless templates.

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.

ts
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: EVENT on contact.unsubscribed
  • Add a WEBHOOK step:
    • URL: https://api.example.com/plunk/unsubscribes
    • Headers: { "Authorization": "Bearer your-shared-secret" }
    • Leave the body blank to get the default payload.

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:

bash
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

Webhook step reference, payload shape, and safety. The hosted pages and template URL variables.

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:

text
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.

bash
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.

A workflow's trigger event **cannot be changed after the first execution**. Namespace it (`waitlist.joined`) rather than something generic you might want to reuse.

Create the workflow

Workflows → New workflow:

  • Trigger: EVENT on waitlist.joined
  • Add a SEND_EMAIL step 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:

json
{ "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

Step types and trigger semantics. Full reference for `POST /v1/track`.

Apps Wiki Content Docs Self Hosting Docker (`apps/wiki/content/docs/self-hosting/docker.mdx`)


title: Docker Deployment

Quick Start

  1. Download docker-compose.yml
  2. Configure environment variables
  3. Run docker compose up -d

Minimal .env

bash
# 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):

bash
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:

bash
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:

yaml
environment:
  SMTP_DOMAIN: "smtp.yourdomain.com"
volumes:
  - /path/to/acme.json:/certs/acme.json:ro

Plunk 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.

yaml
volumes:
  - /path/to/certs:/certs:ro

Certbot

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:

bash
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

bash
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

  1. Go to IAM Console → Users → Create user
  2. Name: plunk-ses
  3. Attach a custom policy with required permissions (see below)
  4. Create access keys → Save credentials

Required IAM Policy

json
{
  "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

  1. Go to SNS Console → Topics → Create topic
  2. Type: Standard
  3. Name: plunk-ses-events
  4. Create topic
  5. Create subscription:
    • Protocol: HTTPS
    • Endpoint: https://api.yourdomain.com/webhooks/sns
  6. Plunk automatically confirms the subscription. If it fails, check your logs for the confirmation URL.

3. Create Configuration Sets

Tracking Configuration Set

  1. SES Console → Configuration sets → Create set
  2. Name: plunk-tracking
  3. Add event destination:
    • Name: sns-events
    • Event types: Sends, Deliveries, Opens, Clicks, Bounces, Complaints
    • Destination: SNS → Select plunk-ses-events topic

No-Tracking Configuration Set

  1. Create another set named plunk-no-tracking
  2. Add event destination with only: Sends, Deliveries, Bounces, Complaints

4. Configure Environment

bash
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-access-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
SES_CONFIGURATION_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

  1. SES Console → Email receiving → Rule sets → Create rule set (or use an existing one)
  2. Add a rule:
    • Recipient conditions: *@yourdomain.com (or specific addresses you want to receive)
    • Actions: Publish to Amazon SNS topic → select your plunk-ses-events topic (or create a separate topic that's also subscribed to https://api.yourdomain.com/webhooks/sns)
  3. 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:

text
Type:  MX
Name:  yourdomain.com
Value: 10 inbound-smtp.<your-region>.amazonaws.com

Plunk'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. —
Setting `S3_PREFIX` on an instance that already has uploads breaks every image uploaded before it. The bucket policy is replaced to only make objects under the prefix publicly readable, so files at the bucket root (already embedded in templates, campaigns and sent emails) start returning 403. Moving them under the prefix doesn't help, since that changes their URLs. Only set it on a new install. `S3_PUBLIC_URL` must still point at the bucket root, since the prefix is appended to it.

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.

`STRIPE_PRICE_ONBOARDING` has been removed. The separate one-time onboarding fee no longer exists, so you can delete the variable from your environment; it's ignored if still set. If you use a Stripe Radar rule to request 3D Secure, exclude off-session payments from it (`and not :is_off_session:`). The verification charge runs with no cardholder present, so a 3DS request it cannot satisfy will leave every verification unresolved.

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:
    1. A single email is detected with confidence ≥ PHISHING_CONFIDENCE_THRESHOLD, or
    2. PHISHING_CUMULATIVE_THRESHOLD or more emails are flagged within the PHISHING_CUMULATIVE_WINDOW_MS time window.
  • 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

  1. Download the docker-compose.yml
  2. Create environment variables with your configuration (see Environment Variables)
  3. 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


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 ---