From 01ec34a8cbbe0f4a158398c0eb8300d847bc29ab Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sun, 17 May 2026 18:08:26 +0200 Subject: [PATCH] docs: add new recipe pages for waitlist and sync unsubscribes --- apps/wiki/app/global.css | 13 +++ apps/wiki/content/docs/meta.json | 2 + .../content/docs/recipes/double-opt-in.mdx | 102 ++++++++++++++++++ apps/wiki/content/docs/recipes/index.mdx | 19 ++++ apps/wiki/content/docs/recipes/meta.json | 3 + .../docs/recipes/sync-unsubscribes.mdx | 87 +++++++++++++++ apps/wiki/content/docs/recipes/waitlist.mdx | 89 +++++++++++++++ 7 files changed, 315 insertions(+) create mode 100644 apps/wiki/content/docs/recipes/double-opt-in.mdx create mode 100644 apps/wiki/content/docs/recipes/index.mdx create mode 100644 apps/wiki/content/docs/recipes/meta.json create mode 100644 apps/wiki/content/docs/recipes/sync-unsubscribes.mdx create mode 100644 apps/wiki/content/docs/recipes/waitlist.mdx diff --git a/apps/wiki/app/global.css b/apps/wiki/app/global.css index 94d1ccb..8b0e9fd 100644 --- a/apps/wiki/app/global.css +++ b/apps/wiki/app/global.css @@ -6,3 +6,16 @@ body { font-family: 'Inter', sans-serif; } + +/* + * Two-tone palette: white content, gray chrome. + * `--color-fd-background` paints the page (content area + nav). + * `--color-fd-card` paints the sidebar (via `bg-fd-card` on `#nd-sidebar`) + * and the `` component — both read well as soft gray against white. + */ +:root { + --color-fd-background: hsl(0, 0%, 100%); + --color-fd-card: hsl(0, 0%, 96.5%); + --color-fd-secondary: hsl(0, 0%, 95%); + --color-fd-border: hsla(0, 0%, 80%, 60%); +} diff --git a/apps/wiki/content/docs/meta.json b/apps/wiki/content/docs/meta.json index e213543..1501227 100644 --- a/apps/wiki/content/docs/meta.json +++ b/apps/wiki/content/docs/meta.json @@ -4,6 +4,8 @@ "---Docs---", "concepts", "guides", + "---Recipes---", + "recipes", "---API Reference---", "api-reference", "---Self-Hosting---", diff --git a/apps/wiki/content/docs/recipes/double-opt-in.mdx b/apps/wiki/content/docs/recipes/double-opt-in.mdx new file mode 100644 index 0000000..a018ea2 --- /dev/null +++ b/apps/wiki/content/docs/recipes/double-opt-in.mdx @@ -0,0 +1,102 @@ +--- +title: Double opt-in +description: Require a confirmation click before a new signup starts receiving marketing email +icon: MailCheck +--- + +Double opt-in adds a confirmation step between "user signs up" and "user starts getting marketing email." It's the standard way to avoid mailing typoed addresses, role accounts, and anyone who didn't actually consent. + +The trick is `{{subscribeUrl}}`: a per-contact link Plunk auto-injects into every send. Clicking it flips `subscribed` to `true` and fires a `contact.subscribed` event. + +## Setup + +import {Step, Steps} from 'fumadocs-ui/components/steps'; + + + + + +### Create two templates + +- A **Transactional** template for the confirmation email, containing `{{subscribeUrl}}`: + + ```html +

Hi {{firstName}}, please confirm your email to start receiving updates:

+

Confirm my email

+ ``` + +- A **Marketing** template for the welcome email that goes out *after* they confirm. + + + A marketing template targeted at an unsubscribed contact is [silently skipped](/concepts/contacts#emails-by-subscription-state). Use a transactional template for the confirmation specifically — it bypasses the subscription check. + + +
+ + + +### Trigger the signup from your backend + +Two calls with your secret key (`sk_*`): create the contact unsubscribed, then track the event that fires the confirmation workflow. + +```bash +curl https://next-api.useplunk.com/contacts \ + -H "Authorization: Bearer sk_your_secret_key" \ + -d '{ "email": "ada@example.com", "subscribed": false, "data": { "firstName": "Ada" } }' + +curl https://next-api.useplunk.com/v1/track \ + -H "Authorization: Bearer sk_your_secret_key" \ + -d '{ "event": "signup.pending", "email": "ada@example.com", "subscribed": false }' +``` + +Both calls pass `subscribed: false`. If you skip the first call and rely on `/v1/track` alone, tracking on an unknown email creates the contact — but defaults it to subscribed, which defeats the point. + + + + + +### Workflow A: send the confirmation + +**Workflows → New workflow**: + +- **Trigger**: `EVENT` on `signup.pending` +- `SEND_EMAIL` step → transactional confirmation template + +Enable it. + + + + + +### Workflow B: welcome them after confirmation + +**Workflows → New workflow**: + +- **Trigger**: `EVENT` on `contact.subscribed` +- `SEND_EMAIL` step → marketing welcome template + +Enable it. `contact.subscribed` fires whenever a contact opts in — including via `{{subscribeUrl}}`, the preferences page, or the API — so this workflow handles both first-time confirmations and resubscribes. + + + +
+ +## Reminder if they don't confirm + +Extend Workflow A with a `WAIT_FOR_EVENT` step after the send: + +- **Event**: `contact.subscribed` +- **Timeout**: `86400` (24 hours) + +On timeout, send a single reminder (also transactional). Keep the number of reminders small — repeated confirmation prompts look like spam to mailbox providers as much as to recipients. + +## What's next + + + + Detail on `{{subscribeUrl}}` and the hosted pages. + + + The difference between Marketing, Transactional, and Headless templates. + + diff --git a/apps/wiki/content/docs/recipes/index.mdx b/apps/wiki/content/docs/recipes/index.mdx new file mode 100644 index 0000000..74fe6ac --- /dev/null +++ b/apps/wiki/content/docs/recipes/index.mdx @@ -0,0 +1,19 @@ +--- +title: Recipes +description: End-to-end walkthroughs for common patterns built on Plunk events and workflows +icon: ChefHat +--- + +Recipes are concrete, step-by-step builds for patterns we see most often in Plunk projects. Each one assumes you already understand the underlying [concepts](/concepts/workflows) and walks you through the exact API calls, workflow steps, and template variables involved. + + + + Capture signups with a single tracked event, then automatically email each person who joins. + + + Keep your own user table in step with Plunk's subscription state using a webhook step. + + + Add a confirmation step before a contact starts receiving marketing email, using `{{subscribeUrl}}`. + + diff --git a/apps/wiki/content/docs/recipes/meta.json b/apps/wiki/content/docs/recipes/meta.json new file mode 100644 index 0000000..e868245 --- /dev/null +++ b/apps/wiki/content/docs/recipes/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["index", "waitlist", "sync-unsubscribes", "double-opt-in"] +} diff --git a/apps/wiki/content/docs/recipes/sync-unsubscribes.mdx b/apps/wiki/content/docs/recipes/sync-unsubscribes.mdx new file mode 100644 index 0000000..b34c3bc --- /dev/null +++ b/apps/wiki/content/docs/recipes/sync-unsubscribes.mdx @@ -0,0 +1,87 @@ +--- +title: Sync unsubscribes to your database +description: Mirror Plunk's subscription state into your own user table using a workflow + webhook +icon: RefreshCw +--- + +Every flip of a contact's `subscribed` state — manual edits, the hosted unsubscribe page, bounces, complaints — fires a `contact.unsubscribed` event. Wire a workflow with a `WEBHOOK` step to forward that to your backend. + +## Setup + +import {Step, Steps} from 'fumadocs-ui/components/steps'; + + + + + +### Build the receiving endpoint + +A public HTTPS endpoint that verifies a shared secret and updates the user row. Webhook requests time out after 10 seconds, so do the work async if it's slow. + +```ts +app.post('/plunk/unsubscribes', async (req, res) => { + if (req.header('authorization') !== `Bearer ${process.env.PLUNK_WEBHOOK_SECRET}`) { + return res.status(401).end(); + } + + const { contact, event } = req.body; + await db.user.update({ + where: { email: contact.email }, + data: { + emailSubscribed: false, + emailUnsubscribedReason: event.reason ?? 'user_action', + }, + }); + + res.status(204).end(); +}); +``` + +`event.reason` is `"bounce"` or `"complaint"` for automatic unsubscribes, and absent for manual / self-service ones. + + + + + +### Create the workflow + +**Workflows → New workflow**: + +- **Trigger**: `EVENT` on `contact.unsubscribed` +- Add a `WEBHOOK` step: + - **URL**: `https://api.example.com/plunk/unsubscribes` + - **Headers**: `{ "Authorization": "Bearer your-shared-secret" }` + - Leave the body blank to get the [default payload](/guides/webhooks#webhook-payload). + +Enable the workflow. + + + + + +## Mirroring resubscribes + +Build a second workflow with the same shape, triggered by `contact.subscribed`. Keep it separate from the unsubscribe flow — two short workflows are easier to monitor than one branched one. + +## The reverse direction + +If your product is the source of truth (a user toggles their email preference in your settings UI), call `PATCH /contacts/:id` from your backend: + +```bash +curl -X PATCH https://next-api.useplunk.com/contacts/cnt_abc \ + -H "Authorization: Bearer sk_your_secret_key" \ + -d '{"subscribed": false}' +``` + +That flip also fires `contact.unsubscribed`, meaning your own webhook will round-trip back into your handler. That's usually harmless because the update is idempotent — but be aware of it. + +## What's next + + + + Webhook step reference, payload shape, and safety. + + + The hosted pages and template URL variables. + + diff --git a/apps/wiki/content/docs/recipes/waitlist.mdx b/apps/wiki/content/docs/recipes/waitlist.mdx new file mode 100644 index 0000000..6735f2d --- /dev/null +++ b/apps/wiki/content/docs/recipes/waitlist.mdx @@ -0,0 +1,89 @@ +--- +title: Waitlist with confirmation email +description: Track signups as a custom event, store everyone who joins as a contact, and automatically email them +icon: ListOrdered +--- + +A waitlist is the simplest possible Plunk workflow: one tracked event from your app, one workflow that listens for it, one email. + +## Setup + +import {Step, Steps} from 'fumadocs-ui/components/steps'; + + + + + +### Create the confirmation template + +In **Templates → New template**, create a **Marketing** template. Use `{{variable}}` placeholders for anything you want to personalise from contact data: + +```text +Subject: You're on the list, {{firstName}} + +Hi {{firstName}}, thanks for joining the {{product}} waitlist. +We'll let you know as soon as your spot opens up. +``` + + + + + +### Track the signup from your backend + +Call `POST /v1/track` when a user submits the form. Use a secret key (`sk_*`) — never call this from the browser. + +```bash +curl https://next-api.useplunk.com/v1/track \ + -H "Authorization: Bearer sk_your_secret_key" \ + -H "Content-Type: application/json" \ + -d '{ + "event": "waitlist.joined", + "email": "ada@example.com", + "data": { "firstName": "Ada", "product": "Beta" } + }' +``` + +This call upserts the contact (subscribed by default) and records `waitlist.joined` on them. Anything you put in `data` lands on the contact and is available as `{{firstName}}`, `{{product}}`, etc. in the template. + + + A workflow's trigger event **cannot be changed after the first execution**. Namespace it (`waitlist.joined`) rather than something generic you might want to reuse. + + + + + + +### Create the workflow + +**Workflows → New workflow**: + +- **Trigger**: `EVENT` on `waitlist.joined` +- Add a `SEND_EMAIL` step pointing at the template from step 1 + +Enable the workflow. Workflows are created disabled — until the toggle is on, nothing fires. + + + + + +## Tagging signups for later + +If you want to segment on waitlist signups later, add an `UPDATE_CONTACT` step before the email: + +```json +{ "stage": "waitlist", "waitlistSource": "{{event.referrer}}" } +``` + +You can then build a [segment](/concepts/segments) of contacts where `stage == "waitlist"` to target with follow-up campaigns. This is cleaner than filtering on "ever fired `waitlist.joined`." + +## What's next + + + + Step types and trigger semantics. + + + Full reference for `POST /v1/track`. + +