# Webhooks (/guides/webhooks)

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
