From 4ddafdc0419389818de7e45eb4a0c82c2e9382eb Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 6 May 2026 21:37:00 +0200 Subject: [PATCH] docs: expand documentation with new sections on importing contacts, unsubscribe pages, and API key management --- apps/api/src/app/constants.ts | 2 +- .../content/docs/api-reference/errors.mdx | 80 +++--- .../content/docs/api-reference/overview.mdx | 267 +++++++++++++----- apps/wiki/content/docs/concepts/billing.mdx | 90 +++++- apps/wiki/content/docs/concepts/campaigns.mdx | 92 +++++- apps/wiki/content/docs/concepts/contacts.mdx | 107 +++++-- apps/wiki/content/docs/concepts/segments.mdx | 109 +++++-- apps/wiki/content/docs/concepts/templates.mdx | 50 +++- .../docs/concepts/transactional-emails.mdx | 26 +- apps/wiki/content/docs/concepts/workflows.mdx | 115 ++++++-- apps/wiki/content/docs/guides/api-keys.mdx | 122 +++++++- .../content/docs/guides/custom-fields.mdx | 135 +++++++++ .../docs/guides/importing-contacts.mdx | 114 ++++++++ .../wiki/content/docs/guides/list-hygiene.mdx | 32 ++- .../wiki/content/docs/guides/localization.mdx | 65 ++++- apps/wiki/content/docs/guides/meta.json | 14 +- .../content/docs/guides/receiving-emails.mdx | 251 ++++++++++------ .../content/docs/guides/segment-filters.mdx | 216 ++++++++++++++ apps/wiki/content/docs/guides/tracking.mdx | 38 ++- .../content/docs/guides/unsubscribe-pages.mdx | 70 +++++ .../content/docs/guides/verifying-domains.mdx | 62 ++-- apps/wiki/content/docs/guides/webhooks.mdx | 186 +++++++----- apps/wiki/content/docs/index.mdx | 2 +- .../wiki/content/docs/self-hosting/docker.mdx | 27 +- .../content/docs/self-hosting/email-setup.mdx | 46 +++ .../self-hosting/environment-variables.mdx | 23 +- apps/wiki/openapi.json | 85 +++--- 27 files changed, 1943 insertions(+), 483 deletions(-) create mode 100644 apps/wiki/content/docs/guides/custom-fields.mdx create mode 100644 apps/wiki/content/docs/guides/importing-contacts.mdx create mode 100644 apps/wiki/content/docs/guides/segment-filters.mdx create mode 100644 apps/wiki/content/docs/guides/unsubscribe-pages.mdx diff --git a/apps/api/src/app/constants.ts b/apps/api/src/app/constants.ts index 9e28bd2..9ce2962 100644 --- a/apps/api/src/app/constants.ts +++ b/apps/api/src/app/constants.ts @@ -128,6 +128,6 @@ export const OPENROUTER_API_KEY = validateEnv('OPENROUTER_API_KEY', ''); export const OPENROUTER_MODEL = validateEnv('OPENROUTER_MODEL', 'anthropic/claude-3-haiku'); export const PHISHING_DETECTION_SAMPLE_RATE = Number(validateEnv('PHISHING_DETECTION_SAMPLE_RATE', '0.1')); // Default 10% of emails export const PHISHING_DETECTION_ENABLED = OPENROUTER_API_KEY !== ''; -export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % to disable project (default 85%) +export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % required to auto-disable project from a single detection export const PHISHING_CUMULATIVE_THRESHOLD = Number(validateEnv('PHISHING_CUMULATIVE_THRESHOLD', '3')); // Number of phishing detections before auto-disable (default 3) export const PHISHING_CUMULATIVE_WINDOW_MS = Number(validateEnv('PHISHING_CUMULATIVE_WINDOW_MS', '3600000')); // Time window for cumulative tracking in ms (default 1 hour) diff --git a/apps/wiki/content/docs/api-reference/errors.mdx b/apps/wiki/content/docs/api-reference/errors.mdx index 39fd6e7..9514bd3 100644 --- a/apps/wiki/content/docs/api-reference/errors.mdx +++ b/apps/wiki/content/docs/api-reference/errors.mdx @@ -27,12 +27,18 @@ Invalid request format or parameters. Check the error details for specific issue ### 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. +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. @@ -96,6 +102,7 @@ All errors include a machine-readable `code` field for programmatic handling. He | `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 @@ -442,55 +449,56 @@ if (!data.success) { ### Common Issues and Solutions -#### Authentication Issues (401) +import {Accordion, Accordions} from 'fumadocs-ui/components/accordion'; -**Problem**: `INVALID_API_KEY` or `MISSING_AUTH` + -**Solutions**: -- Verify your API key is copied correctly (no extra spaces) -- Check you're using the right key type (`sk_` for secret, `pk_` for public) -- Ensure the `Authorization` header uses Bearer token format -- Verify the key hasn't been revoked or regenerated + -#### Validation Issues (422) +- 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. -**Problem**: `VALIDATION_ERROR` with field errors + -**Solutions**: -- Check the `errors` array for specific field issues -- Verify all required fields are included -- Ensure field types match (strings quoted, numbers unquoted) -- Review the API reference for correct request format + -#### Not Found Issues (404) +- 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. -**Problem**: `TEMPLATE_NOT_FOUND`, `CONTACT_NOT_FOUND`, etc. + -**Solutions**: -- Verify the resource ID is correct -- Check the resource belongs to your project -- Ensure the resource hasn't been deleted -- List available resources via the API to confirm IDs + -#### Rate Limit Issues (429) +- 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. -**Problem**: `RATE_LIMIT_EXCEEDED` + -**Solutions**: -- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries) -- Reduce request frequency -- Consider upgrading your plan for higher limits -- Batch operations when possible + -#### Server Errors (500) +- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries). +- Reduce request frequency. +- Consider upgrading your plan for higher limits. +- Batch operations when possible. -**Problem**: `INTERNAL_SERVER_ERROR` + -**Solutions**: -- Note the request ID from the error response -- Wait a moment and retry the request -- Check [status.useplunk.com](https://status.useplunk.com) for incidents -- Contact support with the request ID if the issue persists + + +- Note the request ID from the error response. +- Wait a moment and retry the request. +- Check [status.useplunk.com](https://status.useplunk.com) for incidents. +- Contact support with the request ID if the issue persists. + + + + ### Best Practices diff --git a/apps/wiki/content/docs/api-reference/overview.mdx b/apps/wiki/content/docs/api-reference/overview.mdx index 0427c38..c37e4f9 100644 --- a/apps/wiki/content/docs/api-reference/overview.mdx +++ b/apps/wiki/content/docs/api-reference/overview.mdx @@ -67,11 +67,9 @@ curl -X POST {{API_URL}}/contacts \ ## Response format -All API responses follow a standardized format for easy parsing and error handling. +### Success responses -### Success response - -Public API endpoints (`/v1/send`, `/v1/track`): +Public API endpoints (`/v1/send`, `/v1/track`, `/v1/verify`) return a wrapped envelope: ```json { @@ -84,30 +82,24 @@ Public API endpoints (`/v1/send`, `/v1/track`): } ``` -Dashboard API endpoints (contacts, templates, campaigns): +Dashboard endpoints (contacts, templates, campaigns, segments, workflows, etc.) return the resource directly — no `success`/`data` wrapper: ```json { - "success": true, - "data": { - "id": "cnt_abc123", - "email": "user@example.com", - "createdAt": "2025-11-30T10:30:00.000Z" - } + "id": "cnt_abc123", + "email": "user@example.com", + "createdAt": "2025-11-30T10:30:00.000Z" } ``` -List endpoints with pagination: +List endpoints with cursor pagination return: ```json { - "success": true, - "data": { - "items": [...], - "nextCursor": "abc123", - "hasMore": true, - "total": 1000 - } + "data": [ /* items */ ], + "cursor": "def456", + "hasMore": true, + "total": 10000 } ``` @@ -148,35 +140,37 @@ See the [Error Codes documentation](/api-reference/errors) for complete details ## Pagination -List endpoints support cursor-based pagination: +Most list endpoints use **cursor-based** pagination: ```bash GET /contacts?limit=100&cursor=abc123 ``` **Parameters:** -- `limit` — Number of items per page (default: 20, max: 100) -- `cursor` — Pagination cursor from previous response +- `limit` — items per page (default: 20, max: 100) +- `cursor` — pagination cursor from the previous response's `cursor` field **Response:** ```json { - "items": [...], - "nextCursor": "def456", + "data": [ /* items */ ], + "cursor": "def456", "hasMore": true, "total": 10000 } ``` -Use `nextCursor` for the next page. When `hasMore` is false, you've reached the end. +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** — 14 emails/second (AWS SES default) -- **API requests** — 1000 requests/minute per project -- **Bulk operations** — Automatically queued for processing +- **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. @@ -202,75 +196,200 @@ For a complete list of error codes and troubleshooting guidance, see the [Error ## API endpoints -### Public API (transactional) +A complete, grouped reference of every endpoint exposed by the Plunk API. -**POST /v1/send** — Send transactional email(s) -- Accepts single or multiple recipients -- Template or inline content -- Variable substitution +### Public API -**POST /v1/track** — Track event for contact -- Creates/updates contact -- Tracks custom event -- Can use public key +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 -**GET /contacts** — List all contacts -**POST /contacts** — Create new contact -**GET /contacts/:id** — Get contact details -**PATCH /contacts/:id** — Update contact -**DELETE /contacts/:id** — Delete contact +| 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 -**GET /templates** — List all templates -**POST /templates** — Create new template -**GET /templates/:id** — Get template details -**PATCH /templates/:id** — Update template -**DELETE /templates/:id** — Delete template +| 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 -**GET /campaigns** — List all campaigns -**POST /campaigns** — Create new campaign -**GET /campaigns/:id** — Get campaign details -**PATCH /campaigns/:id** — Update campaign -**POST /campaigns/:id/send** — Send or schedule campaign -**POST /campaigns/:id/cancel** — Cancel scheduled campaign -**POST /campaigns/:id/test** — Send test email -**GET /campaigns/:id/stats** — Get campaign analytics +| 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 -**GET /segments** — List all segments -**POST /segments** — Create new segment (Dynamic or Static) -**GET /segments/:id** — Get segment details -**PATCH /segments/:id** — Update segment -**DELETE /segments/:id** — Delete segment -**GET /segments/:id/contacts** — List segment members -**POST /segments/:id/members** — Add contacts to a static segment (by email) -**DELETE /segments/:id/members** — Remove contacts from a static segment (by email) +| 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 -**GET /workflows** — List all workflows -**POST /workflows** — Create new workflow -**GET /workflows/:id** — Get workflow details -**PATCH /workflows/:id** — Update workflow -**DELETE /workflows/:id** — Delete workflow -**GET /workflows/:id/executions** — List workflow executions +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 -**GET /events** — List all events -**GET /events/names** — List unique event names +| 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 -**GET /domains** — List verified domains -**POST /domains** — Add domain for verification -**DELETE /domains/:id** — Remove domain +| 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 diff --git a/apps/wiki/content/docs/concepts/billing.mdx b/apps/wiki/content/docs/concepts/billing.mdx index e5e80f2..7d94492 100644 --- a/apps/wiki/content/docs/concepts/billing.mdx +++ b/apps/wiki/content/docs/concepts/billing.mdx @@ -1,19 +1,89 @@ --- title: Billing -description: Understand Plunk's billing model +description: How Plunk's pricing, limits, and consumption work icon: CreditCard --- -Plunk's pricing is based on the number of emails sent each month. You can send both marketing and transactional emails under the same plan. +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. -## Free Tier -Free tier projects can send up to 1,000 emails per month at no cost. Projects on this tier will include a Plunk-branded footer in all emails. +## Plans -## Pay-as-you-go -After upgrading from the free tier, you will be charged per email sent at $0.001 per email. There are no monthly fees or commitments, you only pay for what you use. +### Free tier -You are able to monitor your email usage and set billing limits per category in the billing tab of the project settings. +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. -## Special considerations -- Emails that contain an attachment will incur double the cost (e.g. 1 email with attachment = 2 emails for billing purposes) -- Inbound emails count towards the email limit at the same rate as outbound emails (e.g. 1 inbound email = 1 email for billing purposes) \ No newline at end of file +### 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**. + +## What counts as an email + +Every send and inbound receive contributes to your monthly usage: + +| Source | Cost | +| ------------------------------- | ------------------------------------ | +| Transactional sends (`/v1/send`) | 1 credit per recipient | +| Campaigns | 1 credit per recipient | +| Workflow `SEND_EMAIL` steps | 1 credit per send | +| Inbound emails | 1 credit per received email | +| **Emails with attachments** | **2 credits** per email | + +Emails with one or more attachments cost double. There's no extra charge based on attachment size — one credit per attached email regardless of whether it's 100 KB or 10 MB. + +## Per-category billing limits + +Set monthly caps per category to control runaway sends from any single source. This is useful if, for example, you want to ensure transactional capacity is always available even if a campaign accidentally over-sends. + +| Category | What it caps | +| --------------- | ------------------------------------------------------------- | +| Transactional | Emails sent through `/v1/send` | +| Campaigns | Emails sent as part of a campaign | +| Workflows | Emails sent by `SEND_EMAIL` steps inside a workflow | +| Inbound | Inbound emails received at your verified domain | + +Configure caps in **Settings → Billing → Limits**. Each category cap is independent — you can set just one, all of them, or none. + +## What happens when a limit is hit + +When you exceed a per-category cap or your overall plan allowance: + +- **Outbound API calls** (`/v1/send`, campaign sends, workflow sends) return `402 Payment Required` with error code `BILLING_LIMIT_EXCEEDED`. Your application should handle this gracefully — typically by queuing the work or surfacing an error to the user. +- **Inbound emails** are dropped silently for the affected project until the cap resets. They aren't queued or replayed. +- Limits reset at the start of each billing period. + +You'll also receive a notification (email and/or in-app) when a cap is approached or hit. + +## Invoices and payment + +Stripe handles billing. From the **Settings → Billing** page you can: + +- Open the **Stripe billing portal** to update payment methods, addresses, and tax IDs. +- View and download every invoice for the project. +- See current period consumption with a breakdown by category. + +If a payment fails, Stripe retries on its standard schedule. Repeated failures eventually pause the project's ability to send — keep your payment method up to date to avoid disruption. + +## API reference + +- `GET /users/@me/projects/:id/billing-consumption` — current period usage by category. +- `GET /users/@me/projects/:id/billing-limits` — read per-category caps. +- `PUT /users/@me/projects/:id/billing-limits` — update per-category caps. +- `GET /users/@me/projects/:id/billing-invoices` — list invoices. +- `POST /users/@me/projects/:id/billing-portal` — generate a Stripe billing portal URL. +- `POST /users/@me/projects/:id/checkout` — start a Stripe Checkout session for upgrades. + +## What's next + + + + Reduce wasted sends by keeping bounce and complaint rates low. + + + Inbound emails count toward your monthly usage. + + + Run Plunk on your own infrastructure for full control over costs. + + diff --git a/apps/wiki/content/docs/concepts/campaigns.mdx b/apps/wiki/content/docs/concepts/campaigns.mdx index 20b64c4..53caa2a 100644 --- a/apps/wiki/content/docs/concepts/campaigns.mdx +++ b/apps/wiki/content/docs/concepts/campaigns.mdx @@ -1,15 +1,93 @@ --- title: Campaigns -description: Broadcast to your contacts +description: One-off broadcast emails sent to a defined audience icon: Megaphone --- -Campaigns are a one-time email sent to a group of contacts. They are typically used for newsletters, announcements, or promotions. +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. -## Targeting contacts -When you create a campaign, you can send it to all subscribed contacts, or target a specific segment you have created in the [Segments](/concepts/segments) section. +## Anatomy of a campaign -## Sending a campaign -Once you have designed your campaign and selected the target audience, you can schedule it to be sent immediately or at a later time. You can also choose to send a test email to yourself or any other team member before sending it to your contacts. +A campaign captures everything needed to render and deliver one batch of emails: -Campaigns will be queued and sent in the background. You can monitor the sending progress and view detailed analytics on opens, clicks, and bounces in the campaign detail page. Depending on the size of your audience, it may take some time for all emails to be sent. \ No newline at end of file +import {TypeTable} from 'fumadocs-ui/components/type-table'; + +`).' }, + 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. + +## 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 + + + + Create reusable email designs to send through campaigns. + + + Build audiences for `audienceType: SEGMENT` campaigns. + + + Required before you can send from your own domain. + + + Keep bounce and complaint rates healthy. + + diff --git a/apps/wiki/content/docs/concepts/contacts.mdx b/apps/wiki/content/docs/concepts/contacts.mdx index 2feca3b..238719a 100644 --- a/apps/wiki/content/docs/concepts/contacts.mdx +++ b/apps/wiki/content/docs/concepts/contacts.mdx @@ -10,10 +10,12 @@ Contacts in Plunk represent an individual email recipient. Each contact has an i Contacts can be added to your Plunk project in several ways: -- Using [/v1/track](/api-reference/public-api/trackEvent), when tracking an event for a contact that does not yet exist, Plunk will automatically create it. -- Using [/contacts](/api-reference/contacts/createContact), to create a single contact. -- Import through CSV -- Manually through the dashboard +- 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 first column must be `email`; any other columns map to 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 @@ -21,18 +23,37 @@ You can associate custom data with each contact using key-value pairs. This data ### Data types -Contact data types are inferred based on the value provided: -| Type | Description | -|------|-------------| -| String | Any text value | -| Number | Numeric values, including integers and floats | -| Boolean | True or false values | -| Date | Date values in ISO 8601 format | +Contact data types are inferred from the **first non-null value** Plunk sees for a given key in your project: - - If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string. +| Type | Description | +| ------- | ------------------------------------------------------------------------------------------------------------ | +| String | Any text value that doesn't look like a date. | +| Number | Numeric values, including integers and floats. | +| Boolean | `true` or `false`. | +| Date | A string matching `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS[.sss]Z`. The full ISO 8601 form is required for date-aware operators in segments. | + +The type is sampled per-project on first write — once a key is typed as `number`, subsequent string values for that key are still stored, but the **inferred type** stays `number` for the segment filter UI. If you accidentally mix types, the safest reset is to delete the field via `DELETE /contacts/fields/:field` and re-import with the type you want. + + + If a value doesn't match number, boolean, or the ISO 8601 date format, it's typed as a string. +### Non-persistent values + +Send a value as `{ value, persistent: false }` to use it once for template rendering without storing it on the contact: + +```json +{ + "email": "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: @@ -43,20 +64,27 @@ When creating or updating contacts, certain values are handled specially: | `null` | Delete - field is removed from contact data | `{ name: null }` → Field is deleted | | Other values | Stored/updated normally | `{ name: "John" }` → Stored as "John" | - + To remove a field from a contact's data, set it to `null` when creating or updating the contact. Empty strings are automatically filtered out and won't overwrite existing data. ### Reserved keys -Certain keys are reserved by the system and automatically set by Plunk: -| Key | Description | -|-----|-------------| -| email | The contact's email address | -| createdAt | Timestamp of when the contact was created | -| updatedAt | Timestamp of the last update to the contact | -| subscribed | Boolean indicating if the contact is globally subscribed or not | +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 | + +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 @@ -66,15 +94,25 @@ Certain keys are reserved by the system and automatically set by Plunk: ## 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. +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 -- **Automatically** when an email to the contact bounces or results in a complaint +- **Manually** through the dashboard or via the API. +- **Self-service** by clicking the unsubscribe link in an email (powered by the public unsubscribe pages). +- **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). ### Emails by subscription state @@ -90,7 +128,24 @@ The subscription state controls whether a contact receives marketing emails. Tra | **Automations** (headless template) | Delivered | Not delivered | | **Automations** (transactional template) | Delivered | Delivered | - + Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed contact. Use a transactional template instead if the email must reach unsubscribed contacts. + +## What's next + + + + How to set, type, and clean up arbitrary contact data. + + + Bulk-load contacts and their custom fields. + + + Group contacts dynamically or statically for targeting. + + + Hosted unsubscribe / preferences pages and the URL template variables. + + diff --git a/apps/wiki/content/docs/concepts/segments.mdx b/apps/wiki/content/docs/concepts/segments.mdx index 585b550..0dd3cf3 100644 --- a/apps/wiki/content/docs/concepts/segments.mdx +++ b/apps/wiki/content/docs/concepts/segments.mdx @@ -6,51 +6,108 @@ 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 segments +## Dynamic vs Static -Dynamic segments evaluate a set of filter conditions against your contacts in real time. Membership is kept up to date automatically as contact data and events change — no manual work required. +| | 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 | -You can filter on: -- Contact fields (`email`, `subscribed`, custom data fields like `data.plan`) -- Contact dates (`createdAt`, `updatedAt`) -- Custom events (`event.signed_up`, `event.purchased`, …) -- Email activity (`email.opened`, `email.clicked`, `email.bounced`, …) +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. -Conditions can be combined with `AND`/`OR` logic and nested into groups for complex rules. +## 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 does not change automatically — you decide exactly who is in the segment. This is useful for things like beta testers, event attendees, or any group imported from an external source. +Static segments are manually curated lists. Membership doesn't change automatically — you decide exactly who is in. -## Creating a segment +### Creating -Go to **Segments** in the dashboard and click **Create Segment**. Use the toggle at the top to choose **Dynamic** or **Static**. +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. -**For dynamic segments**, use the filter builder to define your conditions. Plunk will show you a live count of matching contacts. +### Adding and removing members -**For static segments**, you can optionally add initial members right away using the contact search. Start typing an email address and select contacts from the list — selected contacts appear as chips you can remove before saving. +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. -## Managing static segment members +Programmatically, use: -Open a static segment and use the **Add Members** search to find and select 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. +- `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 }`. -To remove a member, click the remove button on their row in the members list. +Both endpoints **only** accept static segments. Calling them on a dynamic segment returns `400`. -## Track membership changes +Membership changes via these API calls are immediate but **do not** fire `entry`/`exit` events — those are reserved for dynamic, tracked segments. -Both segment types support **Track membership changes**. When enabled, Plunk fires a webhook event each time a contact enters or leaves the segment: +## Tracking membership changes -- `segment.trial-users.entry` — contact joined the segment -- `segment.trial-users.exit` — contact left the segment +Dynamic segments can opt into **Track membership changes**. When enabled, Plunk fires events whenever a contact enters or leaves the segment: -Where `trial-users` is derived from the segment name. See [Webhooks](/guides/webhooks) for the full event payload. +- `segment..entry` — contact joined +- `segment..exit` — contact left + +The `` 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 in: -- Targeting contacts in [email campaigns](/concepts/campaigns) -- Triggering workflows in [marketing automation](/concepts/workflows) +Segments can be used to: -## Managing members via API +- Target contacts in [email campaigns](/concepts/campaigns) (set the campaign audience to a segment) +- Trigger workflows in [marketing automation](/concepts/workflows) (use the `segment..entry` event) -If you need to manage static segment membership programmatically, use the `POST /segments/:id/members` and `DELETE /segments/:id/members` endpoints. See the [API reference](/api-reference/overview#segments) for details. +## 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 + + + + Deep-link reference for every filter field, operator, and value type. + + + How `data.*` fields are typed and used in filters. + + + Use a segment as a campaign audience. + + + Trigger workflows from segment entry / exit events. + + diff --git a/apps/wiki/content/docs/concepts/templates.mdx b/apps/wiki/content/docs/concepts/templates.mdx index ee913df..427cab2 100644 --- a/apps/wiki/content/docs/concepts/templates.mdx +++ b/apps/wiki/content/docs/concepts/templates.mdx @@ -4,7 +4,33 @@ description: Create reusable email templates for your campaigns, workflows and t icon: SwatchBook --- -Templates are stored in the Plunk dashboard and can be used in campaigns, workflows and transactional emails. +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'; + + + +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 @@ -49,6 +75,11 @@ The following fields are reserved by Plunk but can still be used. 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. @@ -58,3 +89,20 @@ There are three types of templates in Plunk. Each type is treated at the same pr | 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 + + + + Send templated emails directly through `/v1/send`. + + + Broadcast a template to a segment or audience. + + + Send templates as part of automated journeys. + + + Translate the unsubscribe footer and contact-facing pages. + + diff --git a/apps/wiki/content/docs/concepts/transactional-emails.mdx b/apps/wiki/content/docs/concepts/transactional-emails.mdx index f1bd82b..b8b4bee 100644 --- a/apps/wiki/content/docs/concepts/transactional-emails.mdx +++ b/apps/wiki/content/docs/concepts/transactional-emails.mdx @@ -9,12 +9,24 @@ Transactional emails are emails sent directly through the API. They are typicall ## 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. -Self-hosters can raise these limits via environment variables to match the capacity of the underlying email provider (AWS SES supports up to 40MB per message by default): - -| Variable | Default | Description | -|---|---|---| -| `MAX_ATTACHMENT_SIZE_MB` | `10` | Maximum total attachment size in megabytes | -| `MAX_ATTACHMENTS_COUNT` | `10` | Maximum number of attachments per email | +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. \ No newline at end of file +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 + + + + Full reference for `POST /v1/send`. + + + Author reusable email designs. + + + Authenticating requests with `sk_*` and `pk_*` keys. + + + Track delivery, opens, clicks, and bounces of your sends. + + \ No newline at end of file diff --git a/apps/wiki/content/docs/concepts/workflows.mdx b/apps/wiki/content/docs/concepts/workflows.mdx index 7767997..deb8459 100644 --- a/apps/wiki/content/docs/concepts/workflows.mdx +++ b/apps/wiki/content/docs/concepts/workflows.mdx @@ -1,41 +1,100 @@ --- title: Workflows -description: Set up automated email sequences and trigger them from your apps +description: Build automated, multi-step contact journeys triggered by events, segments, schedules, or manual entry icon: Workflow --- -Workflows in Plunk allow you to create automated email sequences that can be triggered based on events you send using the API. +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**. -## Prerequisites for workflows -### Sending an event -Workflows are triggered by sending events to Plunk using the [/v1/track](/api-reference/public-api/trackEvent) endpoint. When sending an event, you can specify the contact it is associated with and include any relevant data. +## Triggers -### Creating a template -Before setting up the workflow, ensure you have created a template that will be used for the emails sent by the workflow. You can create templates in the [Templates](/concepts/templates) section of the dashboard. +A workflow has a single **trigger** that decides how contacts enter: -## Creating a workflow -To create a workflow, navigate to the [Workflows](/concepts/workflows) section of the dashboard and create a workflow. +| 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 creating a workflow, you can not change the trigger event after the workflow has been created. Make sure to choose the correct event name that will trigger the workflow. - +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. -### Defining workflow steps -Workflows consists of multiple steps that define the sequence of actions to be taken. +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. -| Step Type | Description | -|-----------|-------------| -| Send Email | Sends an email to the contact using a specified template. You can customize the email content using variables from the event data. | -| Delay | Pauses the workflow for a specified duration before proceeding to the next step. | -| Wait for Event | Pauses the workflow until a specified event is received for the contact. You can also set a timeout duration to proceed if the event is not received within that time. | -| Condition | Evaluates a condition based on the event data or contact data and branches the workflow accordingly. | -| Webhook | Sends a webhook to a specified URL with the event and contact data. | -| Update Contact | Updates the contact's data with specified key-value pairs. | -| Exit | Terminates the workflow for the contact. | +## Lifecycle and the `enabled` flag -## Managing workflow executions -You can monitor and manage contacts going through workflows in the executions tab of the workflow detail page. Here you can see the status of each execution, cancel a specific execution or all executions. +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." -A workflow will be locked while there are active executions. If you want to make changes to a running workflow you will either need to pause it and wait for all executions to complete, or cancel all active executions. \ No newline at end of file +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`, optional `method`, `headers` | +| `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 + + + + Author the email content used by `SEND_EMAIL` steps. + + + Trigger workflows from inbound mail with `email.received`. + + + Use `WEBHOOK` steps to call your own services from a workflow. + + + Trigger workflows from segment entry / exit events. + + diff --git a/apps/wiki/content/docs/guides/api-keys.mdx b/apps/wiki/content/docs/guides/api-keys.mdx index bc3ae69..3163d6c 100644 --- a/apps/wiki/content/docs/guides/api-keys.mdx +++ b/apps/wiki/content/docs/guides/api-keys.mdx @@ -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. \ No newline at end of file +| 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'; + + + + + +```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": "

Hi

" }' +``` + +
+ + + +```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: '

Hi

', + }), +}); +``` + +
+ + + +```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': '

Hi

', + }, +) +``` + +
+ +
+ +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. diff --git a/apps/wiki/content/docs/guides/custom-fields.mdx b/apps/wiki/content/docs/guides/custom-fields.mdx new file mode 100644 index 0000000..c996863 --- /dev/null +++ b/apps/wiki/content/docs/guides/custom-fields.mdx @@ -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": "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 Handlebars variable: + +```html +

Hi {{firstName ?? "there"}},

+

You've been on the {{plan}} plan since {{signupDate}}.

+``` + +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. diff --git a/apps/wiki/content/docs/guides/importing-contacts.mdx b/apps/wiki/content/docs/guides/importing-contacts.mdx new file mode 100644 index 0000000..2950fd6 --- /dev/null +++ b/apps/wiki/content/docs/guides/importing-contacts.mdx @@ -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.`. + +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`, `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'; + + + + + +1. Open **Contacts** and click **Import**. +2. Pick your CSV file. Plunk validates the header and shows a preview of the first few rows. +3. Confirm. The import is queued and runs in the background. +4. The Imports page shows progress and any per-row errors when complete. + + + + + +For automation, use the import API: + +**Step 1 — upload the CSV** + +```bash +curl -X POST {{API_URL}}/contacts/import \ + -H "Authorization: Bearer sk_..." \ + -F "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. + + + + + +## 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.` 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). diff --git a/apps/wiki/content/docs/guides/list-hygiene.mdx b/apps/wiki/content/docs/guides/list-hygiene.mdx index 9c85013..ff22375 100644 --- a/apps/wiki/content/docs/guides/list-hygiene.mdx +++ b/apps/wiki/content/docs/guides/list-hygiene.mdx @@ -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 \ No newline at end of file +- 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. \ No newline at end of file diff --git a/apps/wiki/content/docs/guides/localization.mdx b/apps/wiki/content/docs/guides/localization.mdx index 7e346a8..cf0dba3 100644 --- a/apps/wiki/content/docs/guides/localization.mdx +++ b/apps/wiki/content/docs/guides/localization.mdx @@ -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. \ No newline at end of file +## 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. diff --git a/apps/wiki/content/docs/guides/meta.json b/apps/wiki/content/docs/guides/meta.json index 4a59a62..0a09b5d 100644 --- a/apps/wiki/content/docs/guides/meta.json +++ b/apps/wiki/content/docs/guides/meta.json @@ -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" + ] } diff --git a/apps/wiki/content/docs/guides/receiving-emails.mdx b/apps/wiki/content/docs/guides/receiving-emails.mdx index 6923fe2..6c07e7f 100644 --- a/apps/wiki/content/docs/guides/receiving-emails.mdx +++ b/apps/wiki/content/docs/guides/receiving-emails.mdx @@ -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., `support@yourdomain.com` or `hello@yourdomain.com`), 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 -
+import {Step, Steps} from 'fumadocs-ui/components/steps'; -### Verify your domain + -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. + + +### 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. + + + + ### 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. - - - 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. + +A domain can only have one primary inbound MX target. If you already use Google Workspace, Microsoft 365, or another provider for receiving email on the apex domain, your existing email will break if you switch the MX to point at Plunk. The usual fix is to receive Plunk inbound on a subdomain (e.g. `mail.yourdomain.com` or `support.yourdomain.com`) so you can keep your main mailbox on the existing provider. The subdomain still needs to be verified for sending in Plunk before its MX will be accepted. + + + + ### Wait for DNS propagation -DNS changes 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. -
+ -## Creating workflows with email.received + -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. `anything@yourdomain.com`) 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 + -The `email.received` event includes the following data that you can use in your workflow 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 `support@yourdomain.com`: +**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 `support@yourdomain.com` -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'; + +"`.' }, + 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**: `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., `anything@yourdomain.com`). 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 + -### Duplicate events + -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 + -If incoming emails consistently show failing security verdicts: + -- **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. + + + + +- Check **Billing → Limits** for an inbound cap that's been reached. +- Check the project's status in the dashboard — a disabled project doesn't process inbound mail. + + + + + +This points at the sender's configuration, not yours: + +- **SPF failures**: sender's domain has no SPF record or doesn't list their sending IP. +- **DKIM failures**: sender's domain has no DKIM, or the message was modified in transit (some forwarding services break DKIM). +- **DMARC failures**: sender fails both SPF and DKIM, or has strict alignment that forwarding broke. + +You can either drop these in your workflow or process them anyway — your call. + + + + diff --git a/apps/wiki/content/docs/guides/segment-filters.mdx b/apps/wiki/content/docs/guides/segment-filters.mdx new file mode 100644 index 0000000..6f0f02f --- /dev/null +++ b/apps/wiki/content/docs/guides/segment-filters.mdx @@ -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.` | Custom contact data — supports nested paths | `data.plan`, `data.profile.tier` | +| `event.` | Custom events tracked via `/v1/track` | `event.signed_up` | +| `email.` | Email engagement: `sent`, `delivered`, `opened`, `clicked`, `bounced`, `complained` | `email.opened` | +| `segment.` | 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.` 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.` 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.` 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.`), `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.` that holds a number: `equals`, `notEquals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`. No `unit` is needed. + +### Field existence + +For any `data.`, 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.` (custom events) and `email.` (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.`, where `` 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.", "operator": "memberOfSegment" }, + { "field": "data.lifetimeValue", "operator": "greaterThanOrEqual", "value": 500 } + ] + } + ] +} +``` + +## Related + + + + Static vs dynamic segments, membership tracking, and entry / exit events. + + + How `data.*` fields are typed and used in filters. + + diff --git a/apps/wiki/content/docs/guides/tracking.mdx b/apps/wiki/content/docs/guides/tracking.mdx index fcea473..a54b0d5 100644 --- a/apps/wiki/content/docs/guides/tracking.mdx +++ b/apps/wiki/content/docs/guides/tracking.mdx @@ -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) | \ No newline at end of file +Tracking is configured per project under **Settings → Tracking**. Three modes are available: + +| Mode | Behaviour | +| ---------------- | ------------------------------------------------------------------------------------------ | +| **Enabled** | Tracking is applied to every email. | +| **Disabled** | No tracking is applied to any email. | +| **Marketing only** | Tracking is applied to marketing sends (campaigns and emails sent by workflow steps using marketing or headless templates). Transactional sends — anything via `/v1/send` using a transactional template, or campaigns of type `TRANSACTIONAL` — are sent without tracking. | + +Tracking is **all or nothing** within a single send — there's no separate toggle for opens vs clicks. Pick the mode that fits your privacy and analytics requirements. + + +On self-hosted instances, the Marketing-only mode is only available when a no-tracking configuration set is configured. See [Self-hosting → Email setup](/self-hosting/email-setup) for details. + diff --git a/apps/wiki/content/docs/guides/unsubscribe-pages.mdx b/apps/wiki/content/docs/guides/unsubscribe-pages.mdx new file mode 100644 index 0000000..2cf2f50 --- /dev/null +++ b/apps/wiki/content/docs/guides/unsubscribe-pages.mdx @@ -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. diff --git a/apps/wiki/content/docs/guides/verifying-domains.mdx b/apps/wiki/content/docs/guides/verifying-domains.mdx index 7e78d6e..9e3c0ff 100644 --- a/apps/wiki/content/docs/guides/verifying-domains.mdx +++ b/apps/wiki/content/docs/guides/verifying-domains.mdx @@ -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 + +A domain can only have **one** SPF TXT record. If you already use another email provider (Google Workspace, Microsoft 365, another sending platform), you must **merge** Plunk's SPF mechanism into your existing record — don't add a second SPF record. For example, if your existing record is `v=spf1 include:_spf.google.com ~all`, the merged version is `v=spf1 include:_spf.google.com include:<Plunk's include from the dashboard> ~all`. Two separate SPF records will cause both to fail. + + ### Bounce Handling (1 MX record) This **MX record** allows Plunk to receive bounce notifications and spam complaints from email providers. @@ -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 - - 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. + +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. +### 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 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'; + + + + 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 + + + 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. + + + + diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx index 4612f31..8b17f36 100644 --- a/apps/wiki/content/docs/guides/webhooks.mdx +++ b/apps/wiki/content/docs/guides/webhooks.mdx @@ -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..entry` | A contact entered a segment | | `segment..exit` | A contact exited a segment | - + Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`. ## Setting up a webhook -
+import {Step, Steps} from 'fumadocs-ui/components/steps'; + + + + ### Create the workflow Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event. + + + + ### Add a Webhook step After the trigger, add a **Webhook** step and configure it: @@ -75,11 +84,17 @@ After the trigger, add a **Webhook** step and configure it: } ``` + + + + ### Enable the workflow Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs. -
+ + + ## 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` | + -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. @@ -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 | + @@ -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 | + @@ -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 | + @@ -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 | + @@ -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 | + - + Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription. `Transient` bounces are tracked for visibility only. @@ -304,9 +331,11 @@ Transient (soft) bounce: } ``` -| Field | Description | -| -------------- | ------------------------------------ | -| `complainedAt` | When the spam complaint was received | + @@ -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 | + @@ -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) | + #### Segment events @@ -383,10 +416,12 @@ Both `segment..entry` and `segment..exit` include: } ``` -| Field | Description | -| ------------- | ------------------------------- | -| `segmentId` | The ID of the segment | -| `segmentName` | 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: diff --git a/apps/wiki/content/docs/index.mdx b/apps/wiki/content/docs/index.mdx index 192d04a..049b1a5 100644 --- a/apps/wiki/content/docs/index.mdx +++ b/apps/wiki/content/docs/index.mdx @@ -6,7 +6,7 @@ icon: House ## What is Plunk? -Plunk is an open-source email platform built on top of AWS SES. It is designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built with modern technologies and designed to handle millions of contacts, Plunk provides everything you need to manage your email communications. +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 diff --git a/apps/wiki/content/docs/self-hosting/docker.mdx b/apps/wiki/content/docs/self-hosting/docker.mdx index 2462ff2..547c4f2 100644 --- a/apps/wiki/content/docs/self-hosting/docker.mdx +++ b/apps/wiki/content/docs/self-hosting/docker.mdx @@ -44,12 +44,27 @@ See [Environment Variables](/self-hosting/environment-variables) for all options ## Ports -| Port | Service | -|------|---------| -| 80 | Nginx (HTTP) | -| 465 | SMTP (implicit TLS) | -| 587 | SMTP (STARTTLS) | -| 9000 | Minio API | +| 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 diff --git a/apps/wiki/content/docs/self-hosting/email-setup.mdx b/apps/wiki/content/docs/self-hosting/email-setup.mdx index 33066a7..0756b01 100644 --- a/apps/wiki/content/docs/self-hosting/email-setup.mdx +++ b/apps/wiki/content/docs/self-hosting/email-setup.mdx @@ -74,3 +74,49 @@ SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking" 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..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..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. + diff --git a/apps/wiki/content/docs/self-hosting/environment-variables.mdx b/apps/wiki/content/docs/self-hosting/environment-variables.mdx index 594e1a0..8afe7b0 100644 --- a/apps/wiki/content/docs/self-hosting/environment-variables.mdx +++ b/apps/wiki/content/docs/self-hosting/environment-variables.mdx @@ -7,11 +7,13 @@ description: Configuration reference | 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. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` | -| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` | -| `PORT` | No | Port the API server listens on. | `8080` (default) | +| `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 @@ -52,6 +54,15 @@ The bundled Docker setup includes Minio with defaults that work out of the box. | `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` | +## 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` | + ## SMTP Server The optional SMTP relay lets you send emails through Plunk via the SMTP protocol. @@ -128,7 +139,7 @@ Plunk can use AI to detect and block phishing emails before they're sent. Requir | `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. | `85` | +| `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) | diff --git a/apps/wiki/openapi.json b/apps/wiki/openapi.json index 0d02380..27127bd 100644 --- a/apps/wiki/openapi.json +++ b/apps/wiki/openapi.json @@ -178,7 +178,7 @@ "post": { "tags": ["Public API"], "summary": "Send transactional email", - "description": "Send a transactional email via the public API. Automatically creates/updates contacts.", + "description": "Send a transactional email via the public API. Automatically creates or updates the recipient contact.\n\n**Required content:** either a `template` ID, **or** both `subject` and `body`. Template fields can be overridden by explicit request fields.\n\n**Sender:** `from` is required unless using a template that already has a `from` configured. The sender's domain must be verified.\n\n**Multiple recipients:** when `to` is an array, each recipient is processed sequentially with its own contact upsert and rendered email — there is no batch-send semantics. Sending is always immediate; for scheduled sends, use a Campaign.\n\n**Attachments:** up to 10 attachments per email and 10 MB total by default. The total message size cannot exceed 40 MB.", "operationId": "sendEmail", "requestBody": { "required": true, @@ -186,7 +186,7 @@ "application/json": { "schema": { "type": "object", - "required": ["to", "from"], + "required": ["to"], "properties": { "to": { "oneOf": [ @@ -243,11 +243,14 @@ }, "subject": { "type": "string", - "description": "Email subject (required if no template)" + "minLength": 1, + "maxLength": 998, + "description": "Email subject. Required if no `template` is provided. Cannot contain newline characters." }, "body": { "type": "string", - "description": "Email body HTML (required if no template)" + "minLength": 1, + "description": "Email body (HTML). Required if no `template` is provided." }, "template": { "type": "string", @@ -281,33 +284,32 @@ }, "name": { "type": "string", - "description": "Sender display name (alternative to using from.name)" + "description": "**Deprecated.** Sender display name. Prefer `from: { name, email }`. Used only as a fallback when `from` is a string and no name is set there." }, "subscribed": { "type": "boolean", - "description": "Whether recipient is subscribed to marketing emails. Defaults to false for transactional emails.", - "default": false + "description": "Subscription state to apply to the recipient. For **new** contacts, defaults to `false` on `/v1/send`. For **existing** contacts, omitting this preserves their current state — pass `true` or `false` to explicitly change it. A change emits `contact.subscribed` or `contact.unsubscribed`." }, "data": { "type": "object", "additionalProperties": true, - "description": "Template variables" + "description": "Variables for template rendering and contact data updates. Each value can be:\n- A primitive (string, number, boolean) — saved on the contact and available as a template variable.\n- `null` — deletes the field from the contact.\n- An empty string — skipped (does not overwrite existing data).\n- An object `{ value, persistent: false }` — used for this send only, not stored on the contact (good for one-shot password reset codes, magic links).\n\nReserved keys (`id`, `plunk_id`, `plunk_email`, `email`, `unsubscribeUrl`, `subscribeUrl`, `manageUrl`) are silently filtered out." }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, - "description": "Custom email headers" + "description": "Custom email headers. Header names cannot contain `\\r\\n`. Header values are limited to 998 characters and cannot contain `\\r\\n` (header injection is rejected)." }, "reply": { "type": "string", "format": "email", - "description": "Reply-to address" + "description": "Reply-to address." }, "attachments": { "type": "array", - "description": "Email attachments (max 10 attachments, 10MB total)", + "description": "Email attachments. Default cap: 10 attachments and 10 MB total. The full message size cannot exceed 40 MB.", "maxItems": 10, "items": { "type": "object", @@ -316,16 +318,26 @@ "filename": { "type": "string", "maxLength": 255, - "description": "Attachment filename" + "description": "Attachment filename. Cannot contain newline or quote characters." }, "content": { "type": "string", - "description": "Base64 encoded file content" + "description": "Base64-encoded file content." }, "contentType": { "type": "string", "maxLength": 255, - "description": "MIME type (e.g., application/pdf, image/png)" + "description": "MIME type (e.g., `application/pdf`, `image/png`)." + }, + "contentId": { + "type": "string", + "description": "Content-ID for inline images. Required when `disposition` is `inline`. Reference the image in the email body via ``." + }, + "disposition": { + "type": "string", + "enum": ["attachment", "inline"], + "default": "attachment", + "description": "Use `inline` together with `contentId` to embed images in the body. Use `attachment` (the default) for downloadable files." } } } @@ -529,7 +541,7 @@ "post": { "tags": ["Public API"], "summary": "Track event", - "description": "Track an event for a contact. Automatically creates/updates the contact.", + "description": "Track an event for a contact. Automatically creates or upserts the contact, then records the event. Tracked events can be used as workflow triggers, segment filters, and audience filters.\n\n**Reserved event names** (rejected with `VALIDATION_ERROR` and code `reserved_event`): anything matching `email.*`, `contact.subscribed`, `contact.unsubscribed`, `segment..entry`, `segment..exit`. These are emitted by Plunk itself.\n\n**No idempotency**: re-tracking the same event creates a new event record.", "operationId": "trackEvent", "requestBody": { "required": true, @@ -542,21 +554,20 @@ "email": { "type": "string", "format": "email", - "description": "Contact email" + "description": "Contact email. The contact is auto-created if it doesn't exist." }, "event": { "type": "string", - "description": "Event name" + "description": "Event name. Cannot match the reserved patterns above." }, "subscribed": { "type": "boolean", - "description": "Whether contact is subscribed to marketing emails. Defaults to true for event tracking.", - "default": true + "description": "Subscription state to apply to the contact. **New** contacts default to subscribed (`true`). **Existing** contacts keep their current state unless you pass an explicit value here. Pass `false` to track an event without resubscribing an unsubscribed contact." }, "data": { "type": "object", "additionalProperties": true, - "description": "Event data and contact metadata" + "description": "Contact data and one-off event variables. Persistent values (primitives, plain objects) are saved on the contact and become available as template variables. Pass `{ value, persistent: false }` for one-shot variables that should not be stored on the contact (e.g. order IDs, transaction details). `null` deletes a field. Empty strings are ignored. Reserved keys are filtered out — see the contacts concept page." } } }, @@ -830,7 +841,7 @@ "get": { "tags": ["Contacts"], "summary": "List contacts", - "description": "Get a paginated list of contacts with cursor-based pagination", + "description": "Get a paginated list of contacts with cursor-based pagination.", "operationId": "listContacts", "parameters": [ { @@ -839,21 +850,13 @@ { "$ref": "#/components/parameters/Cursor" }, - { - "name": "subscribed", - "in": "query", - "schema": { - "type": "boolean" - }, - "description": "Filter by subscription status" - }, { "name": "search", "in": "query", "schema": { "type": "string" }, - "description": "Search by email" + "description": "Case-insensitive substring match on email." } ], "responses": { @@ -864,7 +867,7 @@ "schema": { "type": "object", "properties": { - "contacts": { + "data": { "type": "array", "items": { "$ref": "#/components/schemas/Contact" @@ -873,14 +876,14 @@ "cursor": { "type": "string", "nullable": true, - "description": "Cursor for next page" + "description": "Cursor for the next page. Pass this back as the `cursor` query parameter to fetch the next page." }, "hasMore": { "type": "boolean" }, "total": { "type": "integer", - "description": "Total count (only included on first page)" + "description": "Total count. Only populated on the first page (when no `cursor` is supplied); subsequent pages return `0` to avoid the recount cost." } } } @@ -1027,7 +1030,7 @@ "patch": { "tags": ["Contacts"], "summary": "Update contact", - "description": "Update an existing contact", + "description": "Update an existing contact's email, subscription state, or `data` fields.", "operationId": "updateContact", "parameters": [ { @@ -1046,12 +1049,19 @@ "schema": { "type": "object", "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Change the contact's email address. Returns 409 if another contact in the project already uses this email." + }, "subscribed": { - "type": "boolean" + "type": "boolean", + "description": "Update subscription state. Flipping this fires `contact.subscribed` or `contact.unsubscribed`." }, "data": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "description": "Patch contact data. `null` deletes a key, empty strings are ignored, primitives are stored. Reserved keys are silently filtered out." } } } @@ -1068,6 +1078,9 @@ } } } + }, + "409": { + "description": "An existing contact already uses the email you're trying to set." } } },