docs: add new recipe pages for waitlist and sync unsubscribes
This commit is contained in:
@@ -6,3 +6,16 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
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 `<Cards>` 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%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"---Docs---",
|
"---Docs---",
|
||||||
"concepts",
|
"concepts",
|
||||||
"guides",
|
"guides",
|
||||||
|
"---Recipes---",
|
||||||
|
"recipes",
|
||||||
"---API Reference---",
|
"---API Reference---",
|
||||||
"api-reference",
|
"api-reference",
|
||||||
"---Self-Hosting---",
|
"---Self-Hosting---",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Create two templates
|
||||||
|
|
||||||
|
- A **Transactional** template for the confirmation email, containing `{{subscribeUrl}}`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Hi {{firstName}}, please confirm your email to start receiving updates:</p>
|
||||||
|
<p><a href="{{subscribeUrl}}">Confirm my email</a></p>
|
||||||
|
```
|
||||||
|
|
||||||
|
- A **Marketing** template for the welcome email that goes out *after* they confirm.
|
||||||
|
|
||||||
|
<Callout title="The confirmation must be transactional" type="warn">
|
||||||
|
A marketing template targeted at an unsubscribed contact is [silently skipped](/concepts/contacts#emails-by-subscription-state). Use a transactional template for the confirmation specifically — it bypasses the subscription check.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Trigger the signup from your backend
|
||||||
|
|
||||||
|
Two calls with your secret key (`sk_*`): create the contact unsubscribed, then track the event that fires the confirmation workflow.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://next-api.useplunk.com/contacts \
|
||||||
|
-H "Authorization: Bearer sk_your_secret_key" \
|
||||||
|
-d '{ "email": "[email protected]", "subscribed": false, "data": { "firstName": "Ada" } }'
|
||||||
|
|
||||||
|
curl https://next-api.useplunk.com/v1/track \
|
||||||
|
-H "Authorization: Bearer sk_your_secret_key" \
|
||||||
|
-d '{ "event": "signup.pending", "email": "[email protected]", "subscribed": false }'
|
||||||
|
```
|
||||||
|
|
||||||
|
Both calls pass `subscribed: false`. If you skip the first call and rely on `/v1/track` alone, tracking on an unknown email creates the contact — but defaults it to subscribed, which defeats the point.
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Workflow A: send the confirmation
|
||||||
|
|
||||||
|
**Workflows → New workflow**:
|
||||||
|
|
||||||
|
- **Trigger**: `EVENT` on `signup.pending`
|
||||||
|
- `SEND_EMAIL` step → transactional confirmation template
|
||||||
|
|
||||||
|
Enable it.
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Workflow B: welcome them after confirmation
|
||||||
|
|
||||||
|
**Workflows → New workflow**:
|
||||||
|
|
||||||
|
- **Trigger**: `EVENT` on `contact.subscribed`
|
||||||
|
- `SEND_EMAIL` step → marketing welcome template
|
||||||
|
|
||||||
|
Enable it. `contact.subscribed` fires whenever a contact opts in — including via `{{subscribeUrl}}`, the preferences page, or the API — so this workflow handles both first-time confirmations and resubscribes.
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Reminder if they don't confirm
|
||||||
|
|
||||||
|
Extend Workflow A with a `WAIT_FOR_EVENT` step after the send:
|
||||||
|
|
||||||
|
- **Event**: `contact.subscribed`
|
||||||
|
- **Timeout**: `86400` (24 hours)
|
||||||
|
|
||||||
|
On timeout, send a single reminder (also transactional). Keep the number of reminders small — repeated confirmation prompts look like spam to mailbox providers as much as to recipients.
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
<Cards>
|
||||||
|
<Card title="Unsubscribe & preferences pages" href="/guides/unsubscribe-pages">
|
||||||
|
Detail on `{{subscribeUrl}}` and the hosted pages.
|
||||||
|
</Card>
|
||||||
|
<Card title="Templates" href="/concepts/templates">
|
||||||
|
The difference between Marketing, Transactional, and Headless templates.
|
||||||
|
</Card>
|
||||||
|
</Cards>
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
<Cards>
|
||||||
|
<Card title="Waitlist with confirmation email" href="/recipes/waitlist">
|
||||||
|
Capture signups with a single tracked event, then automatically email each person who joins.
|
||||||
|
</Card>
|
||||||
|
<Card title="Sync unsubscribes to your database" href="/recipes/sync-unsubscribes">
|
||||||
|
Keep your own user table in step with Plunk's subscription state using a webhook step.
|
||||||
|
</Card>
|
||||||
|
<Card title="Double opt-in" href="/recipes/double-opt-in">
|
||||||
|
Add a confirmation step before a contact starts receiving marketing email, using `{{subscribeUrl}}`.
|
||||||
|
</Card>
|
||||||
|
</Cards>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"pages": ["index", "waitlist", "sync-unsubscribes", "double-opt-in"]
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Build the receiving endpoint
|
||||||
|
|
||||||
|
A public HTTPS endpoint that verifies a shared secret and updates the user row. Webhook requests time out after 10 seconds, so do the work async if it's slow.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.post('/plunk/unsubscribes', async (req, res) => {
|
||||||
|
if (req.header('authorization') !== `Bearer ${process.env.PLUNK_WEBHOOK_SECRET}`) {
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contact, event } = req.body;
|
||||||
|
await db.user.update({
|
||||||
|
where: { email: contact.email },
|
||||||
|
data: {
|
||||||
|
emailSubscribed: false,
|
||||||
|
emailUnsubscribedReason: event.reason ?? 'user_action',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`event.reason` is `"bounce"` or `"complaint"` for automatic unsubscribes, and absent for manual / self-service ones.
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Create the workflow
|
||||||
|
|
||||||
|
**Workflows → New workflow**:
|
||||||
|
|
||||||
|
- **Trigger**: `EVENT` on `contact.unsubscribed`
|
||||||
|
- Add a `WEBHOOK` step:
|
||||||
|
- **URL**: `https://api.example.com/plunk/unsubscribes`
|
||||||
|
- **Headers**: `{ "Authorization": "Bearer your-shared-secret" }`
|
||||||
|
- Leave the body blank to get the [default payload](/guides/webhooks#webhook-payload).
|
||||||
|
|
||||||
|
Enable the workflow.
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Mirroring resubscribes
|
||||||
|
|
||||||
|
Build a second workflow with the same shape, triggered by `contact.subscribed`. Keep it separate from the unsubscribe flow — two short workflows are easier to monitor than one branched one.
|
||||||
|
|
||||||
|
## The reverse direction
|
||||||
|
|
||||||
|
If your product is the source of truth (a user toggles their email preference in your settings UI), call `PATCH /contacts/:id` from your backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://next-api.useplunk.com/contacts/cnt_abc \
|
||||||
|
-H "Authorization: Bearer sk_your_secret_key" \
|
||||||
|
-d '{"subscribed": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
That flip also fires `contact.unsubscribed`, meaning your own webhook will round-trip back into your handler. That's usually harmless because the update is idempotent — but be aware of it.
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
<Cards>
|
||||||
|
<Card title="Webhooks" href="/guides/webhooks">
|
||||||
|
Webhook step reference, payload shape, and safety.
|
||||||
|
</Card>
|
||||||
|
<Card title="Unsubscribe pages" href="/guides/unsubscribe-pages">
|
||||||
|
The hosted pages and template URL variables.
|
||||||
|
</Card>
|
||||||
|
</Cards>
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Create the confirmation template
|
||||||
|
|
||||||
|
In **Templates → New template**, create a **Marketing** template. Use `{{variable}}` placeholders for anything you want to personalise from contact data:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Subject: You're on the list, {{firstName}}
|
||||||
|
|
||||||
|
Hi {{firstName}}, thanks for joining the {{product}} waitlist.
|
||||||
|
We'll let you know as soon as your spot opens up.
|
||||||
|
```
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Track the signup from your backend
|
||||||
|
|
||||||
|
Call `POST /v1/track` when a user submits the form. Use a secret key (`sk_*`) — never call this from the browser.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://next-api.useplunk.com/v1/track \
|
||||||
|
-H "Authorization: Bearer sk_your_secret_key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"event": "waitlist.joined",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"data": { "firstName": "Ada", "product": "Beta" }
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
This call upserts the contact (subscribed by default) and records `waitlist.joined` on them. Anything you put in `data` lands on the contact and is available as `{{firstName}}`, `{{product}}`, etc. in the template.
|
||||||
|
|
||||||
|
<Callout title="Pick a stable event name" type="info">
|
||||||
|
A workflow's trigger event **cannot be changed after the first execution**. Namespace it (`waitlist.joined`) rather than something generic you might want to reuse.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
|
||||||
|
### Create the workflow
|
||||||
|
|
||||||
|
**Workflows → New workflow**:
|
||||||
|
|
||||||
|
- **Trigger**: `EVENT` on `waitlist.joined`
|
||||||
|
- Add a `SEND_EMAIL` step pointing at the template from step 1
|
||||||
|
|
||||||
|
Enable the workflow. Workflows are created disabled — until the toggle is on, nothing fires.
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Tagging signups for later
|
||||||
|
|
||||||
|
If you want to segment on waitlist signups later, add an `UPDATE_CONTACT` step before the email:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "stage": "waitlist", "waitlistSource": "{{event.referrer}}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then build a [segment](/concepts/segments) of contacts where `stage == "waitlist"` to target with follow-up campaigns. This is cleaner than filtering on "ever fired `waitlist.joined`."
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
<Cards>
|
||||||
|
<Card title="Workflows" href="/concepts/workflows">
|
||||||
|
Step types and trigger semantics.
|
||||||
|
</Card>
|
||||||
|
<Card title="Track event API" href="/api-reference/public-api/trackEvent">
|
||||||
|
Full reference for `POST /v1/track`.
|
||||||
|
</Card>
|
||||||
|
</Cards>
|
||||||
Reference in New Issue
Block a user