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

This commit is contained in:
Dries Augustyns
2026-05-06 21:37:00 +02:00
parent 88be252a29
commit 4ddafdc041
27 changed files with 1943 additions and 483 deletions
+121 -65
View File
@@ -5,6 +5,7 @@ icon: Webhook
---
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
import {TypeTable} from 'fumadocs-ui/components/type-table';
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a [workflow](/concepts/workflows) that uses the **Webhook** step to forward event data to your own endpoint.
@@ -48,19 +49,27 @@ Plunk automatically tracks a set of internal events that you can use as workflow
| `segment.<name>.entry` | A contact entered a segment |
| `segment.<name>.exit` | A contact exited a segment |
<Callout title="Segment event names" variant="idea">
<Callout title="Segment event names" type="info">
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce
the events `segment.vip-users.entry` and `segment.vip-users.exit`.
</Callout>
## Setting up a webhook
<div className='fd-steps [&_h3]:fd-step'>
import {Step, Steps} from 'fumadocs-ui/components/steps';
<Steps>
<Step>
### Create the workflow
Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event.
</Step>
<Step>
### Add a Webhook step
After the trigger, add a **Webhook** step and configure it:
@@ -75,11 +84,17 @@ After the trigger, add a **Webhook** step and configure it:
}
```
</Step>
<Step>
### Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
</div>
</Step>
</Steps>
## Webhook payload
@@ -126,18 +141,20 @@ The `event` field contains the data associated with the event that triggered the
Most email events share a common set of base fields:
| Field | Description |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| `subject` | The email subject line |
| `from` | The sender email address |
| `fromName` | The sender display name |
| `messageId` | The AWS SES message ID (for correlating with SES events) |
| `emailId` | The Plunk email record ID (returned from `POST /v1/send`, for correlating webhooks with API responses) |
| `templateId` | The template ID, if the email was sent using a template (otherwise `null`) |
| `campaignId` | The campaign ID, if the email was part of a campaign (otherwise `null`) |
| `sourceType` | How the email was triggered: `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND` |
<TypeTable
type={{
subject: { type: 'string', description: 'The email subject line.' },
from: { type: 'string', description: 'The sender email address.' },
fromName: { type: 'string', description: 'The sender display name.' },
messageId: { type: 'string', description: 'Provider-side message identifier (useful for correlating with delivery logs).' },
emailId: { type: 'string', description: 'The Plunk email record ID returned from `POST /v1/send`, used for correlating webhooks with API responses.' },
templateId: { type: 'string', description: 'The template ID, if the email was sent using a template. Otherwise `null`.' },
campaignId: { type: 'string', description: 'The campaign ID, if the email was part of a campaign. Otherwise `null`.' },
sourceType: { type: 'string', description: 'How the email was triggered. One of `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND`.' },
}}
/>
In addition to these base fields, each event includes the following event-specific fields:
In addition to these base fields, each event includes the following event-specific fields. The base fields above (`subject`, `from`, `fromName`, `messageId`, `emailId`, `templateId`, `campaignId`, `sourceType`) are present on every email event in addition to the event-specific fields shown below.
<Tabs items={['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint', 'email.received']}>
@@ -157,9 +174,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| -------- | ----------------------- |
| `sentAt` | When the email was sent |
<TypeTable
type={{
sentAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was accepted for delivery.' },
}}
/>
</Tab>
@@ -179,9 +198,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| ------------- | ---------------------------- |
| `deliveredAt` | When the email was delivered |
<TypeTable
type={{
deliveredAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was delivered to the recipient.' },
}}
/>
</Tab>
@@ -203,11 +224,13 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| ------------- | ------------------------------------------------------------- |
| `openedAt` | When the email was first opened |
| `opens` | Total number of times this email has been opened |
| `isFirstOpen` | `true` if this is the first time the contact opened the email |
<TypeTable
type={{
openedAt: { type: 'string', description: 'ISO 8601 timestamp of the first open.' },
opens: { type: 'number', description: 'Total number of times this email has been opened.' },
isFirstOpen: { type: 'boolean', description: '`true` if this is the first time the contact opened this email.' },
}}
/>
</Tab>
@@ -230,12 +253,14 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| -------------- | ----------------------------------------------------------------- |
| `link` | The URL that was clicked |
| `clickedAt` | When the first click occurred |
| `clicks` | Total number of times links in this email have been clicked |
| `isFirstClick` | `true` if this is the first click from this contact on this email |
<TypeTable
type={{
link: { type: 'string', description: 'The URL that was clicked.' },
clickedAt: { type: 'string', description: 'ISO 8601 timestamp of the first click.' },
clicks: { type: 'number', description: 'Total number of clicks on links in this email.' },
isFirstClick: { type: 'boolean', description: '`true` if this is the first click from this contact on this email.' },
}}
/>
</Tab>
@@ -275,13 +300,15 @@ Transient (soft) bounce:
}
```
| Field | Description |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `bounceType` | `Permanent` (hard bounce) or `Transient` (soft bounce, e.g. mailbox full or out-of-office) |
| `bouncedAt` | When the bounce occurred (permanent bounces only) |
| `transientBounce` | `true` for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
<TypeTable
type={{
bounceType: { type: 'string', description: '`Permanent` for hard bounces, `Transient` for soft bounces (mailbox full, out-of-office, greylist).' },
bouncedAt: { type: 'string', description: 'ISO 8601 timestamp of when the bounce occurred. Only present on permanent bounces.' },
transientBounce: { type: 'boolean', description: '`true` for soft bounces — these do not count toward your bounce rate and the contact stays subscribed.' },
}}
/>
<Callout title="Bounce rate impact" variant="warn">
<Callout title="Bounce rate impact" type="warn">
Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
`Transient` bounces are tracked for visibility only.
</Callout>
@@ -304,9 +331,11 @@ Transient (soft) bounce:
}
```
| Field | Description |
| -------------- | ------------------------------------ |
| `complainedAt` | When the spam complaint was received |
<TypeTable
type={{
complainedAt: { type: 'string', description: 'ISO 8601 timestamp of when the complaint was received.' },
}}
/>
</Tab>
@@ -334,23 +363,25 @@ This event fires when an email is received at your verified domain. See [Receivi
}
```
| Field | Description |
| ---------------------- | --------------------------------------------------------------------- |
| `messageId` | The AWS SES message ID |
| `from` | The sender's email address |
| `fromHeader` | The full `From` header, including display name if present |
| `to` | The recipient address at your verified domain |
| `subject` | The email subject line |
| `timestamp` | When SES received the email |
| `recipients` | All recipient addresses in the envelope |
| `hasContent` | Whether the email body content is available |
| `body` | HTML body of the email (or plain text if no HTML available) |
| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
| `virusVerdict` | SES virus check result |
| `spfVerdict` | SPF authentication result |
| `dkimVerdict` | DKIM authentication result |
| `dmarcVerdict` | DMARC authentication result |
| `processingTimeMillis` | Time SES took to process the inbound email |
<TypeTable
type={{
messageId: { type: 'string', description: 'Unique identifier for the received message.' },
from: { type: 'string', description: "The sender's email address." },
fromHeader: { type: 'string', description: 'The full `From` header, including display name if present.' },
to: { type: 'string', description: 'The recipient address at your verified domain.' },
subject: { type: 'string', description: 'The email subject line.' },
timestamp: { type: 'string', description: 'ISO 8601 timestamp when the email was received.' },
recipients: { type: 'array of strings', description: 'All recipient addresses in the envelope.' },
hasContent: { type: 'boolean', description: 'Whether the email body content is available.' },
body: { type: 'string', description: 'Sanitized HTML body of the email (or plain text if no HTML available).' },
spamVerdict: { type: 'string', description: 'Spam check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
virusVerdict: { type: 'string', description: 'Virus check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
spfVerdict: { type: 'string', description: 'SPF authentication result.' },
dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
processingTimeMillis: { type: 'number', description: 'Time taken to process the inbound email.' },
}}
/>
</Tab>
@@ -368,9 +399,11 @@ The exception is when an unsubscription is triggered automatically by an email b
}
```
| Field | Value |
| -------- | --------------------------------------------------- |
| `reason` | `"bounce"` or `"complaint"` (when system-triggered) |
<TypeTable
type={{
reason: { type: 'string', description: 'Why the contact was unsubscribed. One of `bounce` or `complaint`. Only present when triggered automatically by a bounce or complaint.' },
}}
/>
#### Segment events
@@ -383,10 +416,12 @@ Both `segment.<name>.entry` and `segment.<name>.exit` include:
}
```
| Field | Description |
| ------------- | ------------------------------- |
| `segmentId` | The ID of the segment |
| `segmentName` | The display name of the segment |
<TypeTable
type={{
segmentId: { type: 'string', description: 'The ID of the segment.' },
segmentName: { type: 'string', description: 'The display name of the segment.' },
}}
/>
#### Custom events
@@ -440,7 +475,7 @@ Response:
}
```
This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the SES `messageId`.
This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the provider `messageId`.
## Common use cases
@@ -462,6 +497,27 @@ Trigger a workflow on `contact.unsubscribed` to notify your application when a c
If you track custom events in Plunk (e.g. `user.signup`, `order.completed`), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
## Receiving webhooks safely
Plunk's webhook step has a few characteristics worth knowing when you build the receiving endpoint:
- **Method**: defaults to `POST` with `Content-Type: application/json`. You can override the method per step.
- **Timeout**: each request times out after **10 seconds**. Long-running endpoints should accept the request, queue the work, and return `2xx` quickly.
- **Redirects**: up to 5 redirects are followed. Each hop is re-validated against the SSRF rules below.
- **Public URL required**: your webhook endpoint must be reachable on the public internet. Webhooks pointed at private or internal addresses (loopback, RFC 1918 ranges, etc.) won't be delivered.
- **Schemes**: only `http://` and `https://` are accepted. Prefer HTTPS.
- **No automatic retries**: a non-2xx response or timeout fails the workflow step. Build idempotency into your handler and use workflow logic (a `WAIT_FOR_EVENT` step, a fallback branch) if you need retry semantics.
- **Verify authenticity with a shared secret**: configure a secret header on the webhook step and check it on your endpoint:
```json
// Webhook step → Headers
{
"Authorization": "Bearer your-shared-secret"
}
```
The secret travels with every request from that step. Rotate it like any other shared secret. Prefer this over IP allowlisting — egress IPs can change.
## Adding conditions and delays
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups: