# Technical Documentation: useplunk/plunk

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/useplunk/plunk)

### Overview (`README.md`)

<h1 align="center">Plunk</h1>

<p align="center">
    The Open-Source Email Platform
</p>

<p align="center">
    <a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
    <a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
    <a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
    <a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
    <a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
    <a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
    <a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
    <a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
    <a href="https://docs.useplunk.com"><img src="https://img.shields.io/badge/docs-docs.useplunk.com-black" alt="Documentation"/></a>
    <a href="https://useplunk.com/discord"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
</p>

## 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](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).

## 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](https://github.com/sponsors/driaug).

## 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](https://github.com/useplunk/plunk/pkgs/container/plunk).

A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/self-hosting/introduction).

## Community

- **Documentation**: [docs.useplunk.com](https://docs.useplunk.com)
- **Discord**: [useplunk.com/discord](https://useplunk.com/discord)

## Contributing

You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).

<a href="https://github.com/useplunk/plunk/graphs/contributors">
  <img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
</a>

## License

AGPL-3.0 License - see [LICENSE](LICENSE) for details.

---

### Overview (`apps/mcp/README.md`)

# @plunk/mcp

The official [Model Context Protocol](https://modelcontextprotocol.io) server for
[Plunk](https://www.useplunk.com). 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.                                                  |
| `PLUNK_API_KEY_<NAME>`          | no       | A named project's secret key. Set one per project to use several at once — see below.                        |
| `PLUNK_PUBLIC_KEY_<NAME>`       | no       | That project's public key.                                                                                   |

Flags `--read-only` and `--api-url=<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.

### Several projects

To give one agent access to more than one project, set a key per project with the project's name as a
suffix instead of `PLUNK_API_KEY`:

```bash
claude mcp add plunk \
  --env PLUNK_API_KEY_ACME=sk_acme_key \
  --env PLUNK_API_KEY_SIDE_PROJECT=sk_other_key \
  -- npx -y @plunk/mcp
```

Every tool then takes a required `project` argument (`acme` or `side_project` here), so each call says
which project it acts on — there is no "current project" the agent could lose track of. Confirmation
prompts name the project too.

- `PLUNK_PUBLIC_KEY_<NAME>` sets that project's public key, just like `PLUNK_PUBLIC_KEY` does for a single one.
- Names may use letters, digits and underscores, and are lowercased.
- Once a named key is set, a plain `PLUNK_API_KEY` is ignored, so a key inherited from a self-hosted Plunk
  server's environment cannot slip in as an extra project. `PLUNK_MCP_API_KEY` and `PLUNK_PUBLIC_KEY` cannot be
  combined with named keys.
- All projects share one API URL and one read-only setting.

## 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 ada@example.com" 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

```
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:<sender@yourdomain.com>
RCPT TO:<recipient@example.com>
DATA
Subject: Test Email
From: sender@yourdomain.com
To: recipient@example.com

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:

```
smtp.example.com.  A  1.2.3.4
```

Optionally, add MX records if receiving email:

```
example.com.  MX 10  smtp.example.com.
```

## Security

### Authentication

- Username must be `plunk` (case-insensitive)
- Password is the project secret (API key)
- Invalid credentials are rejected with proper SMTP error codes

### Domain Validation

- Sender domain must be added to the project
- Sender domain must be verified (DNS records validated)
- Unverified domains are rejected with clear error messages

### Rate Limiting

- Maximum 5 recipients per email by default (configurable)
- 10MB maximum message size
- Proper error handling for oversized messages

## Monitoring

The SMTP server logs all operations using `signale`:

```
✅ SMTP server listening on port 465 (secure=true)
✅ SMTP server listening on port 587 (secure=false)
✅ Email relayed: sender@domain.com → recipient@example.com
❌ 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:

```
POST /v1/send
Authorization: Bearer {project_secret}

{
  "from": "sender@domain.com",
  "name": "Sender Name",
  "to": ["recipient@example.com"],
  "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
description: Open-source email platform for developers
icon: House
---

## What is Plunk?

Plunk is an open-source email platform designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built to handle millions of contacts, it provides everything you need to manage your email communications — sending, automation, segmentation, deliverability, and analytics.

## Key Features

<Cards>
  <Card title="Transactional Emails" href="/concepts/transactional-emails">
    Send transactional emails via API with template support and variable substitution
  </Card>
  <Card title="Email Campaigns" href="/concepts/campaigns">
    Create and send one-time email broadcasts to your contacts with advanced scheduling
  </Card>
  <Card title="Marketing Automation" href="/concepts/workflows">
    Build automated email sequences with visual workflow builder and conditional logic
  </Card>
  <Card title="Contact Management" href="/concepts/contacts">
    Manage millions of contacts with custom fields and CSV import capabilities
  </Card>
  <Card title="Dynamic Segmentation" href="/concepts/segments">
    Create dynamic audience segments based on contact data and behavior
  </Card>
  <Card title="Email Templates" href="/concepts/templates">
    Create reusable email templates for transactional and marketing emails
  </Card>
  <Card title="Event Tracking" href="/guides/tracking">
    Track events and use them to trigger workflows and segment audiences
  </Card>
  <Card title="Billing" href="/concepts/billing">
    Understand Plunk's billing model and pricing
  </Card>
</Cards>

## Quick Links

<Cards>
  <Card title="API Reference" href="/api-reference/overview">
    Explore the complete API documentation
  </Card>
  <Card title="Self-Hosting" href="/self-hosting/introduction">
    Deploy Plunk on your own infrastructure
  </Card>
  <Card title="Domain Verification" href="/guides/verifying-domains">
    Learn how to verify your domains for sending emails
  </Card>
  <Card title="API Keys" href="/guides/api-keys">
    Manage your API keys and authentication
  </Card>
</Cards>

## Open Source

Plunk is fully open-source and available on GitHub. Contributions are welcome!

## Support

If you need help or have questions:

- Read the documentation
- Check the GitHub repository
- Join our Discord community

---

### Apps Wiki Content Docs Api Reference Overview (`apps/wiki/content/docs/api-reference/overview.mdx`)

---
title: API Reference
description: Complete Plunk API documentation
---

## Base URL

```
{{API_URL}}
```

All API requests use this base URL.

## Authentication

Include your API key in the `Authorization` header:

```bash
Authorization: Bearer YOUR_API_KEY
```

- **Secret Key (sk_*)** — Required for all endpoints except `/v1/track`
- **Public Key (pk_*)** — Only works with `/v1/track` for client-side event tracking

## Making requests

### Send transactional email

```bash
curl -X POST {{API_URL}}/v1/send \
  -H "Authorization: Bearer sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "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": "user@example.com",
    "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": "user@example.com",
    "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": "user@example.com",
  "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](/api-reference/errors) 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-reference/errors).

## 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: "you@example.com" }`).                      |
| 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": "user@example.com", "subject": "Hello", "body": "Message"}'
```

## What's next

- [Send a transactional email](/api-reference/public-api/sendEmail)
- [Track an event](/api-reference/public-api/trackEvent)
- [Error codes reference](/api-reference/errors)

---

### Apps Wiki Content Docs Recipes Index (`apps/wiki/content/docs/recipes/index.mdx`)

---
title: Recipes
description: End-to-end walkthroughs for common patterns built on Plunk events and workflows
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](/concepts/workflows) and walks you through the exact API calls, workflow steps, and template variables involved.

<Cards>
  <Card title="Waitlist with confirmation email" href="/recipes/waitlist">
    Capture signups with a single tracked event, then automatically email each person who joins.
  </Card>
  <Card title="Sync unsubscribes to your database" href="/recipes/sync-unsubscribes">
    Keep your own user table in step with Plunk's subscription state using a webhook step.
  </Card>
  <Card title="Double opt-in" href="/recipes/double-opt-in">
    Add a confirmation step before a contact starts receiving marketing email, using `{{subscribeUrl}}`.
  </Card>
</Cards>

---

### 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
description: API error codes and troubleshooting
---

## Overview

The Plunk API uses standardized error responses to help you quickly identify and resolve issues. All errors include:

- **Machine-readable error codes** for programmatic handling
- **Human-readable messages** explaining what went wrong
- **Helpful suggestions** to guide you toward a solution
- **Request IDs** for debugging and support requests
- **Field-level validation details** when applicable

## HTTP Status Codes

### 200 OK
Request successful.

### 201 Created
Resource created successfully.

### 400 Bad Request
Invalid request format or parameters. Check the error details for specific issues.

### 401 Unauthorized
Authentication failed or missing. Verify your API key.

### 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: 'user@example.com'
    })
  });

  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': 'user@example.com'
    }
)

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

<Accordions type="single">

<Accordion title="Authentication Issues (401) — INVALID_API_KEY or MISSING_AUTH">

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

</Accordion>

<Accordion title="Validation Issues (422) — VALIDATION_ERROR">

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

</Accordion>

<Accordion title="Not Found Issues (404) — TEMPLATE_NOT_FOUND, CONTACT_NOT_FOUND, etc.">

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

</Accordion>

<Accordion title="Rate Limit Issues (429) — RATE_LIMIT_EXCEEDED">

- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries).
- Reduce request frequency.
- Consider upgrading your plan for higher limits.
- Batch operations when possible.

</Accordion>

<Accordion title="Server Errors (500) — INTERNAL_SERVER_ERROR">

- Note the request ID from the error response.
- Wait a moment and retry the request.
- Check [status.useplunk.com](https://status.useplunk.com) for incidents.
- Contact support with the request ID if the issue persists.

</Accordion>

</Accordions>

### Best Practices

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

## Getting Help

If you continue experiencing issues:

1. Review the error `suggestion` field for guidance
2. Check the [API Reference](/api-reference/overview) for correct usage
3. Search our documentation for your specific error code
4. Contact support with your request ID
5. Join our community for help from other developers

---

### 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
description: How Plunk's pricing, limits, and consumption work
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](https://www.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

<Cards>
  <Card title="List hygiene" href="/guides/list-hygiene">
    Reduce wasted sends by keeping bounce and complaint rates low.
  </Card>
  <Card title="Receiving emails" href="/guides/receiving-emails">
    Inbound emails count toward your monthly usage.
  </Card>
  <Card title="Self-hosting" href="/self-hosting/introduction">
    Run Plunk on your own infrastructure for full control over costs.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Concepts Campaigns (`apps/wiki/content/docs/concepts/campaigns.mdx`)

---
title: Campaigns
description: One-off broadcast emails sent to a defined audience
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.' },
    description: { type: 'string', description: 'Optional internal note.' },
    subject: { type: 'string', required: true, description: 'Email subject line. Supports the same template variables as [Templates](/concepts/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 <hello@acme.com>`).' },
    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](/concepts/templates#templates-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](/guides/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](/concepts/segments). 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](/guides/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](/guides/unsubscribe-pages#knowing-which-email-caused-it).

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: "you@example.com" }` — 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](/api-reference/overview#campaigns) for the full set of campaign endpoints — list, get, create, update, delete, send, cancel, duplicate, test, and stats.

## What's next

<Cards>
  <Card title="Templates" href="/concepts/templates">
    Create reusable email designs to send through campaigns.
  </Card>
  <Card title="Segments" href="/concepts/segments">
    Build audiences for `audienceType: SEGMENT` campaigns.
  </Card>
  <Card title="Verifying domains" href="/guides/verifying-domains">
    Required before you can send from your own domain.
  </Card>
  <Card title="List hygiene" href="/guides/list-hygiene">
    Keep bounce and complaint rates healthy.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Concepts Contacts (`apps/wiki/content/docs/concepts/contacts.mdx`)

---
title: Contacts
description: Manage and organize your contacts effectively
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`](/api-reference/public-api/trackEvent) — tracking an event for a contact that doesn't exist yet automatically creates it (subscribed by default).
- Using [`POST /contacts`](/api-reference/contacts/createContact) — 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.

<Callout title="Default data type" type="info">
  If a value doesn't match number, boolean, or the ISO 8601 date format, it's typed as a string.
</Callout>

### 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": "user@example.com",
  "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" |

<Callout title="Removing contact data" type="info">
  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.
</Callout>

### 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](#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](/concepts/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](/guides/unsubscribe-pages#snoozing) 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](/api-reference/public-api/sendTransactionalEmail)) | 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     |

<Callout title="Transactional emails and marketing templates" type="warn">
  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.
</Callout>

## What's next

<Cards>
  <Card title="Custom fields" href="/guides/custom-fields">
    How to set, type, and clean up arbitrary contact data.
  </Card>
  <Card title="Importing from CSV" href="/guides/importing-contacts">
    Bulk-load contacts and their custom fields.
  </Card>
  <Card title="Segments" href="/concepts/segments">
    Group contacts dynamically or statically for targeting.
  </Card>
  <Card title="Unsubscribe pages" href="/guides/unsubscribe-pages">
    Hosted unsubscribe / preferences pages and the URL template variables.
  </Card>
</Cards>

---

### 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
description: Group and target your contacts with dynamic or static 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](/guides/segment-filters).

## 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](/guides/webhooks) for the payload format.

### How tracking works

The member count updates immediately when you create or change a dynamic segment's filter. After that, Plunk recomputes membership in the background on a regular cadence — diffing current matches against the previous set, recording entries and exits, and emitting the corresponding events.

Because of this background cadence, the `memberCount` shown in the dashboard can lag the live state by a few minutes. If you need a fresh value or want to drive a workflow off `entry` / `exit` immediately:

- `POST /segments/:id/refresh` — force a count refresh (cheap, no events).
- `POST /segments/:id/compute` — force a full membership recomputation, which fires any pending entry/exit events.

## Using segments

Segments can be used to:

- Target contacts in [email campaigns](/concepts/campaigns) (set the campaign audience to a segment)
- Trigger workflows in [marketing automation](/concepts/workflows) (use the `segment.<slug>.entry` event)

## 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](/api-reference/overview#segments) — list, get, create, update, delete, list members, add/remove static members, refresh count, and compute membership.

## What's next

<Cards>
  <Card title="Filter reference" href="/guides/segment-filters">
    Deep-link reference for every filter field, operator, and value type.
  </Card>
  <Card title="Custom fields" href="/guides/custom-fields">
    How `data.*` fields are typed and used in filters.
  </Card>
  <Card title="Campaigns" href="/concepts/campaigns">
    Use a segment as a campaign audience.
  </Card>
  <Card title="Workflows" href="/concepts/workflows">
    Trigger workflows from segment entry / exit events.
  </Card>
  <Card title="Segments overview" href="https://www.useplunk.com/features/segments">
    What dynamic segments are for and how they compare to static lists.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Concepts Templates (`apps/wiki/content/docs/concepts/templates.mdx`)

---
title: Templates
description: Create reusable email templates for your campaigns, workflows and transactional emails
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.' },
    description: { type: 'string', description: 'Optional internal note.' },
    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](#templates-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](/guides/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](https://liquidjs.com/), so besides variables like `{{ key }}` you also get conditionals, loops and filters. See [Template language](/guides/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:

```
Hello {{firstName ?? 'there'}}!
```

If `firstName` is not set, this will render as "Hello there!" Liquid's `default` filter does the same thing and can fall back to another variable rather than a literal:

```
Hello {{firstName | default: nickname}}!
```

#### Conditionals, loops and filters

Anything Liquid supports works in a template — branch on a custom field, loop over an array, or transform a value with a filter:

```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](/guides/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

<Cards>
  <Card title="Template language" href="/guides/template-language">
    Conditionals, loops and filters with Liquid.
  </Card>
  <Card title="Transactional emails" href="/concepts/transactional-emails">
    Send templated emails directly through `/v1/send`.
  </Card>
  <Card title="Campaigns" href="/concepts/campaigns">
    Broadcast a template to a segment or audience.
  </Card>
  <Card title="Workflows" href="/concepts/workflows">
    Send templates as part of automated journeys.
  </Card>
  <Card title="Localization" href="/guides/localization">
    Translate the unsubscribe footer and contact-facing pages.
  </Card>
  <Card title="Email editor overview" href="https://www.useplunk.com/features/email-editor">
    The visual and HTML editor, side by side.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Concepts Transactional Emails (`apps/wiki/content/docs/concepts/transactional-emails.mdx`)

---
title: Transactional Emails
description: Send emails via API
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](/api-reference/public-api/sendEmail) endpoint.

The total message size cannot exceed 40 MB. Self-hosters can adjust the defaults — see [Environment variables](/self-hosting/environment-variables).

## Sending from a template
You can also send transactional emails using a [template](/concepts/templates) 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

<Cards>
  <Card title="Send Email API" href="/api-reference/public-api/sendEmail">
    Full reference for `POST /v1/send`.
  </Card>
  <Card title="Templates" href="/concepts/templates">
    Author reusable email designs.
  </Card>
  <Card title="API keys" href="/guides/api-keys">
    Authenticating requests with `sk_*` and `pk_*` keys.
  </Card>
  <Card title="Webhooks" href="/guides/webhooks">
    Track delivery, opens, clicks, and bounces of your sends.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Concepts Workflows (`apps/wiki/content/docs/concepts/workflows.mdx`)

---
title: Workflows
description: Build automated, multi-step contact journeys triggered by events, segments, schedules, or manual entry
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](/guides/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](/api-reference/overview#workflows) for the full route list — the API exposes endpoints for the workflow itself, its steps, its transitions, and its executions.

## What's next

<Cards>
  <Card title="Templates" href="/concepts/templates">
    Author the email content used by `SEND_EMAIL` steps.
  </Card>
  <Card title="Receiving emails" href="/guides/receiving-emails">
    Trigger workflows from inbound mail with `email.received`.
  </Card>
  <Card title="Webhooks" href="/guides/webhooks">
    Use `WEBHOOK` steps to call your own services from a workflow.
  </Card>
  <Card title="Segments" href="/concepts/segments">
    Trigger workflows from segment entry / exit events.
  </Card>
  <Card title="Workflows overview" href="https://www.useplunk.com/features/workflows">
    How teams use workflows for onboarding, lifecycle, and product email.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Guides Api Keys (`apps/wiki/content/docs/guides/api-keys.mdx`)

---
title: API Keys
description: How Plunk's two-key model works and how to use, rotate, and revoke keys safely
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']}>

<Tab value="cURL">

```bash
curl https://next-api.useplunk.com/v1/send \
  -H "Authorization: Bearer $PLUNK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "user@example.com", "subject": "Hello", "body": "<p>Hi</p>" }'
```

</Tab>

<Tab value="JavaScript">

```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: 'user@example.com',
    subject: 'Hello',
    body: '<p>Hi</p>',
  }),
});
```

</Tab>

<Tab value="Python">

```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': 'user@example.com',
        'subject': 'Hello',
        'body': '<p>Hi</p>',
    },
)
```

</Tab>

</Tabs>

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
description: Store arbitrary data on your contacts and use it for personalization, segmentation, and workflows
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": "ada@example.com",
    "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](/guides/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](/guides/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](/concepts/segments) 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
description: How long Plunk keeps email content, request logs, and idempotency keys
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](/guides/segment-filters) are unaffected, since they read the event timestamps rather than the body.

<Callout type="warn">
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.
</Callout>

## 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`](/guides/idempotency) 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](/self-hosting/docker), 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`](/self-hosting/environment-variables).

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
description: Use an Idempotency-Key to guarantee an event or email is only ever processed once
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']}>
<Tab value="cURL">

```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": "customer@example.com",
    "subject": "Your receipt",
    "body": "<p>Thanks for your order!</p>"
  }'
```

</Tab>
<Tab value="JavaScript">

```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: 'customer@example.com',
    subject: 'Your receipt',
    body: '<p>Thanks for your order!</p>',
  }),
});
```

</Tab>
<Tab value="Python">

```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": "customer@example.com",
        "subject": "Your receipt",
        "body": "<p>Thanks for your order!</p>",
    },
)
```

</Tab>
</Tabs>

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

<Callout type="warn">
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.
</Callout>

## 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`](/self-hosting/environment-variables).

## 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
description: Bulk-load contacts and their custom fields from a CSV file
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:

```csv
email
ada@example.com
grace@example.com
linus@example.com
```

A richer file with custom fields:

```csv
email,firstname,plan,signupdate
ada@example.com,Ada,pro,2026-01-15T00:00:00Z
grace@example.com,Grace,enterprise,2025-11-02T00:00:00Z
linus@example.com,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']}>

<Tab value="Dashboard">

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.

</Tab>

<Tab value="API">

For automation, use the import API:

**Step 1 — upload the CSV**

```bash
curl -X POST {{API_URL}}/contacts/import \
  -H "Authorization: Bearer sk_..." \
  -F "file=@contacts.csv"
```

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.

</Tab>

</Tabs>

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

## Related

- [Custom fields](/guides/custom-fields) — how the `data.<column>` fields you import are typed and used.
- [Bulk operations](/api-reference/overview#contacts) — `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
description: Understand and maintain a healthy email list
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](/api-reference/public-api/verifyEmail) 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 |
|---------|--------|
| `bounce@simulator.amazonses.com` | Permanent (hard) bounce |
| `complaint@simulator.amazonses.com` | Complaint |
| `success@simulator.amazonses.com` | Successful delivery |
| `ooto@simulator.amazonses.com` | Out-of-office auto-reply |
| `suppressionlist@simulator.amazonses.com` | 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](/guides/unsubscribe-pages#snoozing).

---

### Apps Wiki Content Docs Guides Localization (`apps/wiki/content/docs/guides/localization.mdx`)

---
title: Localization
description: Translate the unsubscribe footer and contact-facing pages into your contacts' languages
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": "ada@example.com",
    "event": "signed_up",
    "data": { "locale": "fr" }
  }'
```

Use a [BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) / 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
description: Connect Plunk to Claude, Cursor, and other AI agents with the official Model Context Protocol server
icon: Bot
---

The official Plunk [MCP](https://modelcontextprotocol.io) 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`](https://www.npmjs.com/package/@plunk/mcp).

## Setup

You need a **secret key** (`sk_…`) from **Settings → API Keys**. See [API Keys](/docs/guides/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']}>

<Tab value="Claude Code">

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

</Tab>

<Tab value="Claude Desktop / Cursor">

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

</Tab>

<Tab value="Self-hosted">

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"
      }
    }
  }
}
```

</Tab>

</Tabs>

## 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.                                             |
| `PLUNK_API_KEY_<NAME>`          | No       | A named project's secret key. Set one per project to use several at once — see below.                   |
| `PLUNK_PUBLIC_KEY_<NAME>`       | No       | That project's public key.                                                                              |

<Callout type="warn">
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.
</Callout>

## Several projects

To give one agent access to more than one project, set a key per project with the project's name as a
suffix instead of `PLUNK_API_KEY`:

```bash
claude mcp add plunk \
  --env PLUNK_API_KEY_ACME=sk_acme_key \
  --env PLUNK_API_KEY_SIDE_PROJECT=sk_other_key \
  -- npx -y @plunk/mcp
```

Every tool then takes a required `project` argument (`acme` or `side_project` here), so each call says
which project it acts on — there is no "current project" the agent could lose track of. Confirmation
prompts name the project too.

- `PLUNK_PUBLIC_KEY_<NAME>` sets that project's public key, just like `PLUNK_PUBLIC_KEY` does for a single one.
- Names may use letters, digits and underscores, and are lowercased.
- Once a named key is set, a plain `PLUNK_API_KEY` is ignored, so a key inherited from a self-hosted Plunk
  server's environment cannot slip in as an extra project. `PLUNK_MCP_API_KEY` and `PLUNK_PUBLIC_KEY` cannot be
  combined with named keys.
- All projects share one API URL and one read-only setting.

## 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](/docs/api-reference/overview) 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](/docs/guides/verifying-domains).

**Unsubscribing someone.** `plunk_unsubscribe_contact` takes an email address directly, so "unsubscribe
ada@example.com" 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
description: Receive incoming email at your verified domain and turn it into events you can drive workflows from
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';

<Steps>

<Step>

### 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](/guides/verifying-domains) guide first.

</Step>

<Step>

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

<Callout title="Conflict with existing email" type="warn">
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.
</Callout>

</Step>

<Step>

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

</Step>

<Step>

### Send a test email

Send an email from any external account to any address at your domain (e.g. `anything@yourdomain.com`) 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.

</Step>

</Steps>

## 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 <ada@example.com>"`.' },
    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 `support@yourdomain.com`:

1. **Trigger**: `email.received`.
2. **Condition**: continue only if `event.to` equals `support@yourdomain.com` 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';

<Accordions type="single">

<Accordion title="Emails are not being received">

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.

</Accordion>

<Accordion title="Email arrives in Activity but no workflow runs">

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

</Accordion>

<Accordion title="Inbound email is dropped">

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

</Accordion>

<Accordion title="Verdicts are consistently FAIL">

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.

</Accordion>

</Accordions>

---

### Apps Wiki Content Docs Guides Segment Filters (`apps/wiki/content/docs/guides/segment-filters.mdx`)

---
title: Segment filter reference
description: How to write filters for dynamic segments, campaign audiences, and workflow conditions
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](/concepts/segments).

## 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.<path>`              | 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 }
      ]
    }
  ]
}
```

## Related

<Cards>
  <Card title="Segments" href="/concepts/segments">
    Static vs dynamic segments, membership tracking, and entry / exit events.
  </Card>
  <Card title="Custom fields" href="/guides/custom-fields">
    How `data.*` fields are typed and used in filters.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Guides Template Language (`apps/wiki/content/docs/guides/template-language.mdx`)

---
title: Template language
description: Use conditionals, loops and filters in subjects and bodies with Liquid
icon: Braces
---

Plunk renders email subjects and bodies with [Liquid](https://liquidjs.com/), 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 %}
```

<Callout type="info">
  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 `""`.
</Callout>

## 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](https://liquidjs.com/filters/overview.html) 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](/guides/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

<Cards>
  <Card title="Custom fields" href="/guides/custom-fields">
    Store the data your templates branch on.
  </Card>
  <Card title="Templates" href="/concepts/templates">
    How templates fit into campaigns, workflows and `/v1/send`.
  </Card>
  <Card title="Localization" href="/guides/localization">
    Set a `locale` per contact to drive multilanguage templates.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Guides Tracking (`apps/wiki/content/docs/guides/tracking.mdx`)

---
title: Tracking
description: Track opens and clicks on the emails you send through Plunk
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.

<Callout title="Tracking mode availability" type="info">
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.
</Callout>

---

### Apps Wiki Content Docs Guides Unsubscribe Pages (`apps/wiki/content/docs/guides/unsubscribe-pages.mdx`)

---
title: Unsubscribe & preferences pages
description: Plunk's hosted pages for letting recipients unsubscribe, resubscribe, and manage their email preferences
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=<emailId>` 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](/concepts/campaigns#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](/guides/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 **jane@example.com**?", 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
description: Ensure your emails reach the inbox by verifying your sending 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

<Callout title="Already have an SPF record?" type="warn">
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:&lt;Plunk's include from the dashboard&gt; ~all`. Two separate SPF records will cause both to fail.
</Callout>

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

<Callout title="Bounce MX vs inbound MX" type="info">
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.
</Callout>

### DMARC (optional, recommended)

**Domain-based Message Authentication, Reporting and Conformance (DMARC)** tells receiving mail servers what to do when an email fails SPF or DKIM checks, and where to send authentication reports.

Plunk doesn't require DMARC, but most major mailbox providers (Gmail, Yahoo, Microsoft) now expect it for bulk senders. Add a TXT record at `_dmarc.yourdomain.com`:

```
v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com
```

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

<Accordions type="single">

<Accordion title="Records not verifying">

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.

</Accordion>

<Accordion title="Emails still going to spam">

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](/guides/list-hygiene) to improve deliverability.

</Accordion>

</Accordions>

---

### Apps Wiki Content Docs Guides Webhooks (`apps/wiki/content/docs/guides/webhooks.mdx`)

---
title: Webhooks
description: Send real-time event data from Plunk to your own application using 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](/concepts/workflows) 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](/guides/receiving-emails)) |

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

<Callout title="Segment event names" type="info">
  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`.
</Callout>

## Setting up a webhook

import {Step, Steps} from 'fumadocs-ui/components/steps';

<Steps>

<Step>

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

</Step>

<Step>

### 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](#webhook-payload) shown below. When provided, the value replaces the default payload entirely and is JSON-encoded before being sent.

</Step>

<Step>

### 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}}"
}
```

</Step>

<Step>

### Enable the workflow

Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.

</Step>

</Steps>

## Webhook payload

When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:

```json
{
  "contact": {
    "email": "user@example.com",
    "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": "hello@example.com",
    "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']}>

<Tab value="email.sent">

```json
{
  "subject": "Welcome to Plunk",
  "from": "hello@example.com",
  "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.' },
  }}
/>

</Tab>

<Tab value="email.delivery">

```json
{
  "subject": "Welcome to Plunk",
  "from": "hello@example.com",
  "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.' },
  }}
/>

</Tab>

<Tab value="email.open">

```json
{
  "subject": "Welcome to Plunk",
  "from": "hello@example.com",
  "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.' },
  }}
/>

</Tab>

<Tab value="email.click">

```json
{
  "subject": "Welcome to Plunk",
  "from": "hello@example.com",
  "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.' },
  }}
/>

</Tab>

<Tab value="email.bounce">

Permanent bounce:

```json
{
  "subject": "Welcome to Plunk",
  "from": "hello@example.com",
  "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": "hello@example.com",
  "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.' },
  }}
/>

<Callout title="Bounce rate impact" type="warn">
  Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
  `Transient` bounces are tracked for visibility only.
</Callout>

</Tab>

<Tab value="email.complaint">

```json
{
  "subject": "Welcome to Plunk",
  "from": "hello@example.com",
  "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.' },
  }}
/>

</Tab>

<Tab value="email.received">

This event fires when an email is received at your verified domain. See [Receiving Emails](/guides/receiving-emails) for setup instructions.

```json
{
  "messageId": "ses-message-id",
  "from": "sender@example.com",
  "fromHeader": "Jane Smith <sender@example.com>",
  "to": "support@yourdomain.com",
  "subject": "Re: Your question",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "recipients": ["support@yourdomain.com"],
  "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.' },
  }}
/>

</Tab>

</Tabs>

#### 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": "user@example.com",
  "subject": "Welcome",
  "body": "Hello!"
}

Response:
{
  "success": true,
  "data": {
    "emails": [
      {
        "contact": {"id": "cnt_abc", "email": "user@example.com"},
        "email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
      }
    ]
  }
}
```

2. Store the `email` ID (`ac32f08e-c6b9-45d3-9824-a73dff1e3bbf`) in your database

3. 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
description: Require a confirmation click before a new signup starts receiving marketing email
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';

<Steps>

<Step>

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

<Callout title="The confirmation must be transactional" type="warn">
  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.
</Callout>

</Step>

<Step>

### 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": "ada@example.com", "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": "ada@example.com", "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.

</Step>

<Step>

### Workflow A: send the confirmation

**Workflows → New workflow**:

- **Trigger**: `EVENT` on `signup.pending`
- `SEND_EMAIL` step → transactional confirmation template

Enable it.

</Step>

<Step>

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

</Step>

</Steps>

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

<Cards>
  <Card title="Unsubscribe & preferences pages" href="/guides/unsubscribe-pages">
    Detail on `{{subscribeUrl}}` and the hosted pages.
  </Card>
  <Card title="Templates" href="/concepts/templates">
    The difference between Marketing, Transactional, and Headless templates.
  </Card>
</Cards>

---

### 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
description: Mirror Plunk's subscription state into your own user table using a workflow + webhook
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';

<Steps>

<Step>

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

</Step>

<Step>

### 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](/guides/webhooks#webhook-payload).

Enable the workflow.

</Step>

</Steps>

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

<Cards>
  <Card title="Webhooks" href="/guides/webhooks">
    Webhook step reference, payload shape, and safety.
  </Card>
  <Card title="Unsubscribe pages" href="/guides/unsubscribe-pages">
    The hosted pages and template URL variables.
  </Card>
</Cards>

---

### Apps Wiki Content Docs Recipes Waitlist (`apps/wiki/content/docs/recipes/waitlist.mdx`)

---
title: Waitlist with confirmation email
description: Track signups as a custom event, store everyone who joins as a contact, and automatically email them
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';

<Steps>

<Step>

### 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.
```

</Step>

<Step>

### 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": "ada@example.com",
    "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.

<Callout title="Pick a stable event name" type="info">
  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.
</Callout>

</Step>

<Step>

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

</Step>

</Steps>

## 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](/concepts/segments) of contacts where `stage == "waitlist"` to target with follow-up campaigns. This is cleaner than filtering on "ever fired `waitlist.joined`."

## What's next

<Cards>
  <Card title="Workflows" href="/concepts/workflows">
    Step types and trigger semantics.
  </Card>
  <Card title="Track event API" href="/api-reference/public-api/trackEvent">
    Full reference for `POST /v1/track`.
  </Card>
</Cards>

---

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

---
title: Docker Deployment
description: Deploy with Docker Compose
---

## Quick Start

1. Download [docker-compose.yml](https://raw.githubusercontent.com/useplunk/plunk/next/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](/self-hosting/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](#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
description: Configure email delivery
---

## 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](/guides/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:

```
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
description: Configuration reference
---

## 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.    | —                   |

<Callout type="warn">
  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.
</Callout>

## 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](/guides/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](/concepts/billing) for the customer-facing description.

<Callout type="info">
  `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.
</Callout>

<Callout type="warn">
  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.
</Callout>

## 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.        | `noreply@yourdomain.com` |

## Notifications (ntfy)

Plunk bundles a self-hosted [ntfy](https://ntfy.sh) 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](https://github.com/sponsors/driaug). 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](https://openrouter.ai) 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](https://openrouter.ai/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
description: Run Plunk, the open-source platform for transactional email, campaigns and workflows, on your own servers with Docker and Amazon SES.
---

## 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](https://raw.githubusercontent.com/useplunk/plunk/next/docker-compose.yml)
2. Create environment variables with your configuration (see [Environment Variables](/self-hosting/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

- [Docker Deployment](/self-hosting/docker) — Configuration details
- [Environment Variables](/self-hosting/environment-variables) — All settings
- [AWS SES Setup](/self-hosting/email-setup) — Email configuration

---

### Apps Wiki Content Docs Self Hosting Meta.Json (`apps/wiki/content/docs/self-hosting/meta.json`)

{
  "title": "Self-Hosting",
  "pages": ["introduction", "docker", "environment-variables", "email-setup"]
}

---

--- METRICS ---
- Files Extracted: 42
- Estimated Token Budget: ~70633 tokens
- Canonical Reference: https://codewiki.google/github.com/useplunk/plunk
