528 lines
18 KiB
Plaintext
528 lines
18 KiB
Plaintext
---
|
|
title: Webhooks
|
|
description: Send real-time event data from Plunk to your own application using webhooks
|
|
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.
|
|
|
|
## How it works
|
|
|
|
Webhooks in Plunk are powered by the workflow system. The basic flow is:
|
|
|
|
1. An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
|
|
2. A workflow is triggered by that event
|
|
3. The workflow executes a **Webhook** step, sending an HTTP request to your URL with relevant data
|
|
|
|
This means you can receive notifications for any event Plunk tracks, including both system events and your own custom events.
|
|
|
|
## Internal events
|
|
|
|
Plunk automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
|
|
|
|
### Email events
|
|
|
|
| Event | Description |
|
|
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
|
| `email.sent` | An email was successfully sent |
|
|
| `email.delivery` | An email was delivered to the recipient |
|
|
| `email.open` | A contact opened an email for the first time |
|
|
| `email.click` | A contact clicked a link in an email for the first time |
|
|
| `email.bounce` | An email bounced (hard or soft bounce) |
|
|
| `email.complaint` | A contact marked an email as spam |
|
|
| `email.received` | An email was received at your verified domain (requires [inbound email setup](/guides/receiving-emails)) |
|
|
|
|
### Contact events
|
|
|
|
| Event | Description |
|
|
| ---------------------- | ------------------------------------------------------- |
|
|
| `contact.subscribed` | A contact's subscription status changed to subscribed |
|
|
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |
|
|
|
|
### Segment events
|
|
|
|
| Event | Description |
|
|
| ---------------------- | --------------------------- |
|
|
| `segment.<name>.entry` | A contact entered a segment |
|
|
| `segment.<name>.exit` | A contact exited a segment |
|
|
|
|
<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
|
|
|
|
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:
|
|
|
|
- **URL**: The endpoint on your server that will receive the webhook (e.g. `https://api.example.com/webhooks/plunk`)
|
|
- **Method**: The HTTP method to use. Defaults to `POST`, which is recommended for most use cases.
|
|
- **Headers** (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
|
|
|
|
```json
|
|
{
|
|
"Authorization": "Bearer your-secret-token"
|
|
}
|
|
```
|
|
|
|
</Step>
|
|
|
|
<Step>
|
|
|
|
### Enable the workflow
|
|
|
|
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
|
|
|
|
</Step>
|
|
|
|
</Steps>
|
|
|
|
## Webhook payload
|
|
|
|
When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:
|
|
|
|
```json
|
|
{
|
|
"contact": {
|
|
"email": "user@example.com",
|
|
"subscribed": true,
|
|
"data": {
|
|
"name": "John",
|
|
"plan": "pro"
|
|
}
|
|
},
|
|
"workflow": {
|
|
"id": "wf_abc123",
|
|
"name": "Bounce Notifications"
|
|
},
|
|
"execution": {
|
|
"id": "exec_xyz789",
|
|
"startedAt": "2025-01-15T10:30:00.000Z"
|
|
},
|
|
"event": {
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": "camp_abc123",
|
|
"sourceType": "CAMPAIGN",
|
|
"bounceType": "Permanent",
|
|
"bouncedAt": "2025-01-15T10:30:00.000Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
The `event` field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
|
|
|
|
### Event data by type
|
|
|
|
#### Email events
|
|
|
|
Most email events share a common set of base fields:
|
|
|
|
<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. 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']}>
|
|
|
|
<Tab value="email.sent">
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": null,
|
|
"sourceType": "TRANSACTIONAL",
|
|
"sentAt": "2025-01-15T10:30:00.000Z"
|
|
}
|
|
```
|
|
|
|
<TypeTable
|
|
type={{
|
|
sentAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was accepted for delivery.' },
|
|
}}
|
|
/>
|
|
|
|
</Tab>
|
|
|
|
<Tab value="email.delivery">
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": "camp_abc123",
|
|
"sourceType": "CAMPAIGN",
|
|
"deliveredAt": "2025-01-15T10:30:05.000Z"
|
|
}
|
|
```
|
|
|
|
<TypeTable
|
|
type={{
|
|
deliveredAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was delivered to the recipient.' },
|
|
}}
|
|
/>
|
|
|
|
</Tab>
|
|
|
|
<Tab value="email.open">
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": null,
|
|
"sourceType": "TRANSACTIONAL",
|
|
"openedAt": "2025-01-15T11:00:00.000Z",
|
|
"opens": 1,
|
|
"isFirstOpen": true
|
|
}
|
|
```
|
|
|
|
<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>
|
|
|
|
<Tab value="email.click">
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": null,
|
|
"sourceType": "TRANSACTIONAL",
|
|
"link": "https://example.com/pricing",
|
|
"clickedAt": "2025-01-15T11:05:00.000Z",
|
|
"clicks": 1,
|
|
"isFirstClick": true
|
|
}
|
|
```
|
|
|
|
<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>
|
|
|
|
<Tab value="email.bounce">
|
|
|
|
Permanent bounce:
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": null,
|
|
"sourceType": "TRANSACTIONAL",
|
|
"bounceType": "Permanent",
|
|
"bouncedAt": "2025-01-15T10:31:00.000Z"
|
|
}
|
|
```
|
|
|
|
Transient (soft) bounce:
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": null,
|
|
"sourceType": "TRANSACTIONAL",
|
|
"bounceType": "Transient",
|
|
"transientBounce": true
|
|
}
|
|
```
|
|
|
|
<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" 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>
|
|
|
|
</Tab>
|
|
|
|
<Tab value="email.complaint">
|
|
|
|
```json
|
|
{
|
|
"subject": "Welcome to Plunk",
|
|
"from": "hello@example.com",
|
|
"fromName": "Plunk Team",
|
|
"messageId": "ses-message-id",
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"templateId": null,
|
|
"campaignId": null,
|
|
"sourceType": "TRANSACTIONAL",
|
|
"complainedAt": "2025-01-15T10:35:00.000Z"
|
|
}
|
|
```
|
|
|
|
<TypeTable
|
|
type={{
|
|
complainedAt: { type: 'string', description: 'ISO 8601 timestamp of when the complaint was received.' },
|
|
}}
|
|
/>
|
|
|
|
</Tab>
|
|
|
|
<Tab value="email.received">
|
|
|
|
This event fires when an email is received at your verified domain. See [Receiving Emails](/guides/receiving-emails) for setup instructions.
|
|
|
|
```json
|
|
{
|
|
"messageId": "ses-message-id",
|
|
"from": "sender@example.com",
|
|
"fromHeader": "Jane Smith <sender@example.com>",
|
|
"to": "support@yourdomain.com",
|
|
"subject": "Re: Your question",
|
|
"timestamp": "2025-01-15T10:30:00.000Z",
|
|
"recipients": ["support@yourdomain.com"],
|
|
"hasContent": true,
|
|
"body": "<html><body>This is the email body content...</body></html>",
|
|
"spamVerdict": "PASS",
|
|
"virusVerdict": "PASS",
|
|
"spfVerdict": "PASS",
|
|
"dkimVerdict": "PASS",
|
|
"dmarcVerdict": "PASS",
|
|
"processingTimeMillis": 142
|
|
}
|
|
```
|
|
|
|
<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>
|
|
|
|
</Tabs>
|
|
|
|
#### Contact events
|
|
|
|
`contact.subscribed` and `contact.unsubscribed` carry no event data by default. The `event` field will be an empty object `{}`.
|
|
|
|
The exception is when an unsubscription is triggered automatically by an email bounce or complaint — in that case `event` includes a `reason` field:
|
|
|
|
```json
|
|
{
|
|
"reason": "bounce"
|
|
}
|
|
```
|
|
|
|
<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
|
|
|
|
Both `segment.<name>.entry` and `segment.<name>.exit` include:
|
|
|
|
```json
|
|
{
|
|
"segmentId": "seg_abc123",
|
|
"segmentName": "VIP Users"
|
|
}
|
|
```
|
|
|
|
<TypeTable
|
|
type={{
|
|
segmentId: { type: 'string', description: 'The ID of the segment.' },
|
|
segmentName: { type: 'string', description: 'The display name of the segment.' },
|
|
}}
|
|
/>
|
|
|
|
#### Custom events
|
|
|
|
Custom events tracked via the API include whatever data you passed in the `data` field when calling `track`.
|
|
|
|
#### No event data
|
|
|
|
For events that carry no data, the `event` field will be an empty object `{}`.
|
|
|
|
## Correlating webhooks with send requests
|
|
|
|
All email events include an `emailId` field that matches the Plunk email record ID returned when you send an email via `POST /v1/send`. This allows you to directly correlate webhook events with your API requests.
|
|
|
|
**Example workflow:**
|
|
|
|
1. Send email via API:
|
|
|
|
```json
|
|
POST /v1/send
|
|
{
|
|
"to": "user@example.com",
|
|
"subject": "Welcome",
|
|
"body": "Hello!"
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"emails": [
|
|
{
|
|
"contact": {"id": "cnt_abc", "email": "user@example.com"},
|
|
"email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
2. Store the `email` ID (`ac32f08e-c6b9-45d3-9824-a73dff1e3bbf`) in your database
|
|
|
|
3. When webhook events fire (e.g., `email.open`, `email.bounce`), match them using `event.emailId`:
|
|
|
|
```json
|
|
{
|
|
"event": {
|
|
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
|
|
"messageId": "ses-message-id",
|
|
"openedAt": "2025-01-15T11:00:00.000Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
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
|
|
|
|
### Bounce and complaint monitoring
|
|
|
|
Create a workflow triggered by `email.bounce` or `email.complaint` to forward these events to your application. This allows you to keep your own database in sync with Plunk's contact statuses.
|
|
|
|
You can use additional workflow steps before the webhook to add logic:
|
|
|
|
- **Condition**: Only send the webhook for hard bounces by checking the `bounceType` field
|
|
- **Delay**: Add a short delay to batch-process related events
|
|
- **Update Contact**: Mark the contact with metadata before sending the webhook
|
|
|
|
### Syncing unsubscribes
|
|
|
|
Trigger a workflow on `contact.unsubscribed` to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
|
|
|
|
### Custom event forwarding
|
|
|
|
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:
|
|
|
|
- Use a **Condition** step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
|
|
- Use a **Wait for Event** step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
|
|
- Use a **Delay** step to add a time buffer before the webhook fires
|