docs: expand documentation with new sections on importing contacts, unsubscribe pages, and API key management

This commit is contained in:
Dries Augustyns
2026-05-06 21:37:00 +02:00
parent 88be252a29
commit 4ddafdc041
27 changed files with 1943 additions and 483 deletions
+112 -10
View File
@@ -1,19 +1,121 @@
---
title: API Keys
description: Manage your API keys and understand their usage
description: How Plunk's two-key model works and how to use, rotate, and revoke keys safely
icon: Key
---
Each project has two unique API keys. A public key and a secret key. These keys are used to authenticate requests made to Plunk's API.
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.
## Public Key
The public API key can only be used with the [/v1/track](/api-reference/public-api/trackEvent) endpoint to track events. This key can be safely exposed in client-side applications.
## Where to find your keys
## Secret Key
The secret API key can be used with all other endpoints in Plunk's API. This key should be kept confidential and not exposed in client-side applications.
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).
If this key is compromised, a malicious actor could read and modify your project data, send emails, and perform other actions on your behalf.
## The two keys
## Regenerating API Keys
If you believe your API keys have been compromised, you can regenerate them in the project settings.
Keep in mind that regenerating an API key will invalidate both keys, so make sure to update your applications with the new key.
| 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": "[email protected]", "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: '[email protected]',
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': '[email protected]',
'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.
@@ -0,0 +1,135 @@
---
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": "[email protected]",
"event": "signed_up",
"data": {
"firstName": "Ada",
"plan": "pro",
"signupDate": "2026-05-06T12:00:00Z",
"lifetimeValue": 240
}
}'
# Via PATCH /contacts/:id
curl -X PATCH {{API_URL}}/contacts/cnt_abc123 \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{ "data": { "plan": "enterprise" } }'
```
`data` is patched — only the keys you send change. Other keys keep their existing values.
### Special values
| Value | Behaviour |
| -------------- | ------------------------------------------------------------------------------- |
| Primitive | Stored. Available for templates, segments, workflows. |
| `null` | Deletes the key from the contact. |
| `""` (empty) | Ignored — does not overwrite existing data. |
| `{ value, persistent: false }` | Used for this send only; not stored on the contact. Good for one-shot codes (password resets, magic links). |
## Type inference
Plunk infers a type for each field the first time it sees a non-null value in your project:
| Detected as | Examples |
| ----------- | -------------------------------------------------------------- |
| Number | `42`, `3.14` |
| Boolean | `true`, `false` |
| Date | Strings matching `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS[.sss]Z` |
| String | Anything else |
The inferred type powers the segment filter UI (showing date pickers for date fields, number inputs for numeric ones, etc.) and is sticky once set per project. If you mix types for a key, the original inference wins for the UI but Plunk still stores whatever value you send.
To use date-aware operators (`within`, `olderThan`) on a custom field, store the value as a full ISO 8601 string — `"2026-05-06T12:00:00Z"` works, `"05/06/2026"` doesn't.
## Reserved keys
Some keys are managed by Plunk and **silently filtered out** of any `data` payload — sending them won't store them and won't return an error:
`id`, `plunk_id`, `plunk_email`, `email`, `unsubscribeUrl`, `subscribeUrl`, `manageUrl`
Plus these core contact fields, which exist outside `data`: `email`, `subscribed`, `createdAt`, `updatedAt`. To change `email` or `subscribed`, use the dedicated request fields, not `data`.
`locale` is a special soft-reserved key — it's stored under `data` like other fields, but Plunk reads it for [localization](/guides/localization).
## Using custom fields
### In templates
Reference any field by name as a Handlebars 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}}`.
### 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.
@@ -0,0 +1,114 @@
---
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>`.
A minimal file:
```csv
email
[email protected]
[email protected]
[email protected]
```
A richer file with custom fields:
```csv
email,firstName,plan,signupDate
[email protected],Ada,pro,2026-01-15T00:00:00Z
[email protected],Grace,enterprise,2025-11-02T00:00:00Z
[email protected],Linus,free,2026-04-21T00:00:00Z
```
In this example, every imported contact ends up with `data.firstName`, `data.plan`, and `data.signupDate` set.
### Rules and limits
- **File size**: up to 5 MB per upload. For larger lists, split the file and run multiple imports.
- **Encoding**: UTF-8. Non-UTF-8 files may produce garbled custom field values.
- **Email column**: must be present and valid. Rows with missing or invalid emails are reported back as errors.
- **Reserved column names**: `id`, `subscribed`, `createdAt`, `updatedAt`, and the auto-generated URL variables (`unsubscribeUrl`, etc.) are silently filtered out. Don't include them as columns.
- **Date columns**: use ISO 8601 (`2026-05-06T12:00:00Z`) so they're typed as dates and become usable with `within` / `olderThan` segment operators.
- **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 "[email protected]"
```
The response includes a `jobId`:
```json
{ "jobId": "imp_abc123", "status": "queued" }
```
**Step 2 — poll for status**
```bash
curl {{API_URL}}/contacts/import/imp_abc123 \
-H "Authorization: Bearer sk_..."
```
Poll every few seconds until `status` is `completed` or `failed`. The response includes counts:
```json
{
"jobId": "imp_abc123",
"status": "completed",
"totalRows": 12000,
"imported": 11985,
"updated": 8,
"skipped": 0,
"errors": [
{ "row": 47, "email": "bad@", "reason": "invalid_email" },
{ "row": 1042, "email": "@example", "reason": "invalid_email" }
]
}
```
`imported` counts new contacts created. `updated` counts existing contacts whose `data` was patched. `errors` lists per-row issues with row number and reason so you can fix and re-upload.
</Tab>
</Tabs>
## Tips
- **Unsubscribed contacts**: imported contacts are created subscribed by default. If your CSV represents users who never opted in, set up a workflow that filters out anyone you shouldn't email — or import them and then bulk-unsubscribe them with `POST /contacts/bulk-unsubscribe` to keep them on file but unmailed.
- **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).
+20 -12
View File
@@ -8,24 +8,32 @@ Plunk automatically monitors the bounce and complaint rates of your emails to he
## Bounce Management
A bounce occurs when an email cannot be delivered to the recipient's inbox. When an email bounces, Plunk will automatically unsubscribe the contact and also send an event on the contact for `email.bounced`.
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 |
|------|-------------|
| Hard Bounce | Permanent delivery failure (e.g., invalid email address) |
| Soft Bounce | Temporary delivery failure (e.g., mailbox full) |
| 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)
- Regularly clean your email list
- Use double opt-in for subscriptions
- [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. When a complaint is received, Plunk will automatically unsubscribe the contact and send an event on the contact for `email.complaint`.
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 unsubscribe link in every email
- Monitor your email frequency to avoid overwhelming your contacts
- 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.
+58 -7
View File
@@ -1,15 +1,66 @@
---
title: Localization
description: Support for multiple languages and regions
description: Translate the unsubscribe footer and contact-facing pages into your contacts' languages
icon: Globe
---
Localization in Plunk allows you to configure the language of the unsubscribe footer and contact-facing pages (subscribe, unsubscribe, manage) to better suit your audience.
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.
## Configuring Localization
You can set the default language for your project in the project settings.
## Setting the project default
## Overriding language per contact
You can override the default language for individual contacts by setting the `locale` field in the contact data. This field should contain a valid [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code (e.g., 'en' for English, 'fr' for French, 'es' for Spanish).
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.
When sending emails, Plunk will use the contact's specified language if available; otherwise, it will fall back to the project's default language.
## Overriding per contact
Set a `locale` field on the contact's `data` to override the project default for that recipient:
```bash
curl -X POST {{API_URL}}/v1/track \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"event": "signed_up",
"data": { "locale": "fr" }
}'
```
Use a [BCP 47](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.
+13 -1
View File
@@ -1,3 +1,15 @@
{
"pages": ["list-hygiene", "verifying-domains", "receiving-emails", "tracking", "api-keys", "localization", "webhooks"]
"pages": [
"list-hygiene",
"verifying-domains",
"receiving-emails",
"tracking",
"api-keys",
"localization",
"webhooks",
"importing-contacts",
"custom-fields",
"segment-filters",
"unsubscribe-pages"
]
}
@@ -1,110 +1,145 @@
---
title: Receiving emails
description: Receive incoming emails at your verified domain and trigger automated workflows
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 your verified domain and turn them into `email.received` events. This allows you to build automated workflows that respond to inbound emails, such as support ticket systems, auto-responders, or email-based integrations.
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.
## How it works
## What happens when an email arrives
When someone sends an email to your verified domain (e.g., `[email protected]` or `[email protected]`), Plunk will:
When someone emails an address at your verified domain, Plunk:
1. Receive the email through AWS SES
2. Automatically create or update a contact for the sender
3. Trigger an `email.received` event that can start workflows
4. Make the email metadata available to your workflow steps
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 sender is automatically added to your contacts as a subscribed contact, allowing you to respond using Plunk's email sending capabilities.
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.
## Setting up inbound email
## Setup
<div className='fd-steps [&_h3]:fd-step'>
import {Step, Steps} from 'fumadocs-ui/components/steps';
### Verify your domain
<Steps>
Before you can receive emails, your domain must be verified in Plunk. Follow the [verifying domains](/guides/verifying-domains) guide to set up the required DKIM, SPF, and MX records for sending.
<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
In your project settings, navigate to the **Domains** tab and expand your verified domain. You'll see an optional "Inbound Email" section with an MX record configuration:
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.
- **Type**: MX
- **Name**: `yourdomain.com` (your root domain)
- **Value**: `10 inbound-smtp.eu-north-1.amazonaws.com`
Add this MX record to your domain's DNS settings. The priority value `10` ensures that inbound emails are routed to AWS SES for processing.
<Callout title="Multiple MX records" variant="warning">
If you're already using MX records for another email service (like Google Workspace or Microsoft 365), adding this MX
record may conflict. You can only have one primary email receiver per domain. Consider using a subdomain (e.g.,
`mail.yourdomain.com`) if you need to maintain both services.
<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 can take anywhere from a few minutes to 48 hours to fully propagate. You can verify the MX record is set correctly using:
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 AWS SES inbound endpoint in the response.
You should see the value Plunk gave you in the response.
</div>
</Step>
## Creating workflows with email.received
<Step>
Once your MX record is configured, you can create workflows that respond to incoming emails.
### Send a test email
### Event trigger
Send an email from any external account to any address at your domain (e.g. `[email protected]`) and check that:
Create a new workflow and use `email.received` as the trigger event. This workflow will run every time an email is received at your domain.
- 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.
### Available event data
</Step>
The `email.received` event includes the following data that you can use in your workflow steps:
</Steps>
| Field | Type | Description |
| ---------------------- | -------- | ------------------------------------------------- |
| `messageId` | string | Unique identifier for the received message |
| `from` | string | Email address of the sender |
| `fromHeader` | string | Full "From" header including display name |
| `to` | string | Primary recipient email address |
| `subject` | string | Email subject line |
| `timestamp` | string | ISO 8601 timestamp when the email was received |
| `recipients` | string[] | All recipient email addresses |
| `hasContent` | boolean | Whether the email body was captured |
| `body` | string | HTML body of the email (or plain text if no HTML) |
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
| `spfVerdict` | string | SPF authentication result |
| `dkimVerdict` | string | DKIM authentication result |
| `dmarcVerdict` | string | DMARC authentication result |
| `processingTimeMillis` | number | Time taken to process the email |
## What gets stored
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
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.
### Example: Auto-reply workflow
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.
Here's a simple workflow that sends an automatic reply when an email is received at `[email protected]`:
**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.
1. **Trigger**: `email.received`
2. **Condition**: Check if `{{event.to}}` equals `[email protected]`
3. **Send Email**:
## The `email.received` event
The event is emitted on the sender contact (auto-created if it doesn't exist yet) and carries the full parsed message in its data field:
import {TypeTable} from 'fumadocs-ui/components/type-table';
<TypeTable
type={{
messageId: { type: 'string', description: 'Unique message identifier for the received email.' },
from: { type: 'string', description: 'Sender email address (envelope / `From` header).' },
fromHeader: { type: 'string', description: 'Full "From" header including display name, e.g. `"Ada <[email protected]>"`.' },
to: { type: 'string', description: 'Primary recipient at your verified domain.' },
recipients: { type: 'array of strings', description: 'Every recipient address (covers `To`, `Cc`, and BCC envelope recipients).' },
subject: { type: 'string', description: 'Subject line.' },
timestamp: { type: 'string', description: 'ISO 8601 receive timestamp.' },
hasContent: { type: 'boolean', description: '`true` when a body was successfully parsed.' },
body: { type: 'string', description: 'Sanitized HTML body, or plain text if no HTML part. See sanitization rules above.' },
spamVerdict: { type: 'string', description: 'Spam check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
virusVerdict: { type: 'string', description: 'Virus scan result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
spfVerdict: { type: 'string', description: 'SPF authentication result.' },
dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
processingTimeMillis: { type: 'number', description: 'How long it took to process the email.' },
}}
/>
The event is **always** emitted — Plunk does not block emails based on the spam, virus, or authentication verdicts. Filter them yourself in the workflow that fires.
In templates and workflow steps, access these fields via the event variable namespace, e.g. `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
## Building workflows on inbound
### Auto-reply
A minimal auto-reply on `[email protected]`:
1. **Trigger**: `email.received`.
2. **Condition**: continue only if `event.to` equals `[email protected]` and `event.spamVerdict == "PASS"` and `event.virusVerdict == "PASS"`.
3. **Send email**:
- **To**: `{{event.from}}`
- **Subject**: `Re: {{event.subject}}`
- **Body**: `Thank you for your message. We received: "{{event.body}}". We'll get back to you soon!`
- **Body**: `Thanks for your message. We've received your email and will respond within one business day.`
This workflow reads the incoming email body and includes it in the auto-reply response.
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).
### Example: Forward to webhook
### Routing by recipient
For more advanced processing (like ticket creation or AI analysis), you can forward the email content to your own API:
Route different addresses to different downstream actions in a single workflow:
1. **Trigger**: `email.received`
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/support/tickets`
- **URL**: `https://api.example.com/inbound`
- **Method**: `POST`
- **Body**:
```json
@@ -112,59 +147,89 @@ For more advanced processing (like ticket creation or AI analysis), you can forw
"from": "{{event.from}}",
"subject": "{{event.subject}}",
"body": "{{event.body}}",
"timestamp": "{{event.timestamp}}"
"messageId": "{{event.messageId}}",
"timestamp": "{{event.timestamp}}",
"verdicts": {
"spam": "{{event.spamVerdict}}",
"virus": "{{event.virusVerdict}}",
"spf": "{{event.spfVerdict}}",
"dkim": "{{event.dkimVerdict}}",
"dmarc": "{{event.dmarcVerdict}}"
}
}
```
Your backend receives the full email content and can process it (create a ticket, run AI analysis, etc.).
### 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 you have verified the same domain in multiple projects, incoming emails will be processed for **all projects** that have the domain verified. Each project will:
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).
- Create/update the sender as a contact in that project
- Trigger the `email.received` event in that project
- Run any workflows configured for that event
If you don't want this, only verify the domain in one project at a time.
This allows you to segment inbound email handling across different projects if needed.
## Billing
## Limitations
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.
- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `[email protected]`). You can use workflow conditions to route emails based on the `to` field.
- **Attachments**: Email attachments are not currently captured or stored.
- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
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
Plunk captures several security verdicts for each incoming email:
- **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.
- **SPF (Sender Policy Framework)**: Verifies the sender's mail server is authorized
- **DKIM (DomainKeys Identified Mail)**: Validates the email hasn't been tampered with
- **DMARC (Domain-based Message Authentication)**: Combines SPF and DKIM for additional validation
- **Spam verdict**: AWS SES's spam detection result
- **Virus verdict**: AWS SES's virus scanning result
## Limitations
You can use these verdicts in workflow conditions to automatically filter or quarantine suspicious emails before processing them.
- **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
### Emails not being received
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
1. **Check DNS propagation**: Verify the MX record is correctly set using `dig MX yourdomain.com`
2. **Verify domain**: Ensure your domain is fully verified in Plunk (all DKIM, SPF, and MX records for sending)
3. **Check workflow**: Create a simple test workflow with just an `email.received` trigger and a webhook to verify events are being generated
4. **Check sender**: Try sending from a different email provider as some may cache DNS records
<Accordions type="single">
### Duplicate events
<Accordion title="Emails are not being received">
If you have the same domain verified in multiple projects, you will receive duplicate `email.received` events (one per project). This is expected behavior. Use project-specific workflows to handle this.
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.
### Security verdicts failing
</Accordion>
If incoming emails consistently show failing security verdicts:
<Accordion title="Email arrives in Activity but no workflow runs">
- **SPF failures**: The sender's domain may not have SPF configured correctly
- **DKIM failures**: The sender's domain may not have DKIM configured, or the email was forwarded/modified in transit
- **DMARC failures**: The sender fails both SPF and DKIM checks
- 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).
These are issues with the sender's configuration, not your Plunk setup. You can choose to process these emails anyway or filter them using workflow conditions.
</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>
@@ -0,0 +1,216 @@
---
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>
+24 -14
View File
@@ -1,32 +1,42 @@
---
title: Tracking
description: Tracking for opens and clicks in your emails
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. This allows you to monitor the engagement of your emails and gain insights into how your contacts interact with your content.
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 in your emails. When a contact opens the email, the pixel is loaded, and Plunk records the open event.
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 relies on the loading of images in the email client. If a contact has images disabled, the open event may not be recorded.
- Some email clients may pre-load images, which can result in false open events.
- 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
Click tracking is enabled by default for all emails sent through Plunk. When a contact clicks on a link in the email, Plunk records the click event and tracks which link was clicked.
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
- Click tracking works by rewriting the URLs in your email to point to Plunk's tracking servers
- Some email clients or security software may block tracking links, which can result in missed click events
- 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
There are three levels of configuration for tracking:
| Level | Description |
|-------|-------------|
| Enabled | Tracking is enabled for all emails |
| Disabled | Tracking is disabled for all emails |
| Marketing only | Tracking is enabled only for marketing emails (templates and campaigns) |
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>
@@ -0,0 +1,70 @@
---
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. |
| 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).
## 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 use your project's name and logo (configurable in **Settings → Project**). For more advanced customization, send marketing as **headless** templates and build the unsubscribe surface into your own product UI — link your in-product unsubscribe 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.
@@ -40,6 +40,10 @@ You'll need to add **1 TXT record** that lists the authorized sending servers.
- 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.
@@ -51,36 +55,62 @@ This **MX record** allows Plunk to receive bounce notifications and spam complai
- Prevents sending to invalid email addresses
- Required for deliverability monitoring
<Callout title="Receiving inbound emails" variant="idea">
The MX record above is for sending emails (handling bounces and complaints). If you want to **receive** emails at your
domain and trigger workflows, see the [receiving emails](/guides/receiving-emails) guide for additional MX record
setup.
<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:[email protected]
```
Start with `p=none` to monitor without affecting delivery. Once you've reviewed reports for a couple of weeks and confirmed all your legitimate senders are authenticated, tighten to `p=quarantine` and eventually `p=reject`.
### MAIL FROM domain (optional)
You can configure a custom MAIL FROM domain (typically a subdomain like `mail.yourdomain.com`) so the bounce envelope address aligns with your sending domain. This improves DMARC alignment and is required by some inbox providers for full pass-through.
If you set this up, you'll need an additional MX record and TXT record on the subdomain — Plunk's dashboard will show you the exact values to add when you enable it on a verified domain.
## Verification Status
After adding all DNS records, Plunk will automatically check the verification status. Verification typically completes within a few minutes but can take up to 72 hours depending on DNS propagation.
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.
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
### Records not verifying
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
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.
### Emails still going to spam
</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
- 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>
+121 -65
View File
@@ -5,6 +5,7 @@ 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.
@@ -48,19 +49,27 @@ Plunk automatically tracks a set of internal events that you can use as workflow
| `segment.<name>.entry` | A contact entered a segment |
| `segment.<name>.exit` | A contact exited a segment |
<Callout title="Segment event names" variant="idea">
<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
<div className='fd-steps [&_h3]:fd-step'>
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:
@@ -75,11 +84,17 @@ After the trigger, add a **Webhook** step and configure it:
}
```
</Step>
<Step>
### Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
</div>
</Step>
</Steps>
## Webhook payload
@@ -126,18 +141,20 @@ The `event` field contains the data associated with the event that triggered the
Most email events share a common set of base fields:
| Field | Description |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| `subject` | The email subject line |
| `from` | The sender email address |
| `fromName` | The sender display name |
| `messageId` | The AWS SES message ID (for correlating with SES events) |
| `emailId` | The Plunk email record ID (returned from `POST /v1/send`, for correlating webhooks with API responses) |
| `templateId` | The template ID, if the email was sent using a template (otherwise `null`) |
| `campaignId` | The campaign ID, if the email was part of a campaign (otherwise `null`) |
| `sourceType` | How the email was triggered: `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND` |
<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:
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']}>
@@ -157,9 +174,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| -------- | ----------------------- |
| `sentAt` | When the email was sent |
<TypeTable
type={{
sentAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was accepted for delivery.' },
}}
/>
</Tab>
@@ -179,9 +198,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| ------------- | ---------------------------- |
| `deliveredAt` | When the email was delivered |
<TypeTable
type={{
deliveredAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was delivered to the recipient.' },
}}
/>
</Tab>
@@ -203,11 +224,13 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| ------------- | ------------------------------------------------------------- |
| `openedAt` | When the email was first opened |
| `opens` | Total number of times this email has been opened |
| `isFirstOpen` | `true` if this is the first time the contact opened the email |
<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>
@@ -230,12 +253,14 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| -------------- | ----------------------------------------------------------------- |
| `link` | The URL that was clicked |
| `clickedAt` | When the first click occurred |
| `clicks` | Total number of times links in this email have been clicked |
| `isFirstClick` | `true` if this is the first click from this contact on this email |
<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>
@@ -275,13 +300,15 @@ Transient (soft) bounce:
}
```
| Field | Description |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `bounceType` | `Permanent` (hard bounce) or `Transient` (soft bounce, e.g. mailbox full or out-of-office) |
| `bouncedAt` | When the bounce occurred (permanent bounces only) |
| `transientBounce` | `true` for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
<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" variant="warn">
<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>
@@ -304,9 +331,11 @@ Transient (soft) bounce:
}
```
| Field | Description |
| -------------- | ------------------------------------ |
| `complainedAt` | When the spam complaint was received |
<TypeTable
type={{
complainedAt: { type: 'string', description: 'ISO 8601 timestamp of when the complaint was received.' },
}}
/>
</Tab>
@@ -334,23 +363,25 @@ This event fires when an email is received at your verified domain. See [Receivi
}
```
| Field | Description |
| ---------------------- | --------------------------------------------------------------------- |
| `messageId` | The AWS SES message ID |
| `from` | The sender's email address |
| `fromHeader` | The full `From` header, including display name if present |
| `to` | The recipient address at your verified domain |
| `subject` | The email subject line |
| `timestamp` | When SES received the email |
| `recipients` | All recipient addresses in the envelope |
| `hasContent` | Whether the email body content is available |
| `body` | HTML body of the email (or plain text if no HTML available) |
| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
| `virusVerdict` | SES virus check result |
| `spfVerdict` | SPF authentication result |
| `dkimVerdict` | DKIM authentication result |
| `dmarcVerdict` | DMARC authentication result |
| `processingTimeMillis` | Time SES took to process the inbound email |
<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>
@@ -368,9 +399,11 @@ The exception is when an unsubscription is triggered automatically by an email b
}
```
| Field | Value |
| -------- | --------------------------------------------------- |
| `reason` | `"bounce"` or `"complaint"` (when system-triggered) |
<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
@@ -383,10 +416,12 @@ Both `segment.<name>.entry` and `segment.<name>.exit` include:
}
```
| Field | Description |
| ------------- | ------------------------------- |
| `segmentId` | The ID of the segment |
| `segmentName` | The display name of the segment |
<TypeTable
type={{
segmentId: { type: 'string', description: 'The ID of the segment.' },
segmentName: { type: 'string', description: 'The display name of the segment.' },
}}
/>
#### Custom events
@@ -440,7 +475,7 @@ Response:
}
```
This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the SES `messageId`.
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
@@ -462,6 +497,27 @@ Trigger a workflow on `contact.unsubscribed` to notify your application when a c
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: