# Guide to Plunk Webhooks: Features and Best Practices

Plunk webhooks send email and contact activity to your own systems: a message bounces, a recipient marks it as spam, a contact unsubscribes or enters a segment, or a custom event you track fires. If you send email with Plunk, the open source email platform, webhooks keep your database, CRM and suppression lists in step with what happens to your messages.

This guide covers how Plunk's workflow-based webhooks work, the events that can trigger them and the payload they send, how to authenticate requests that Plunk doesn't sign, and how to cope with a sender that never retries.

## What are Plunk webhooks?

Plunk has no separate webhook settings page. Webhooks are a step in Plunk's workflow system: an event triggers a workflow, and the workflow's **Webhook** step sends an HTTP request to your URL. That means any event Plunk tracks, including your own custom events, can become a webhook, and you can put conditions, delays or other steps before it.

The Webhook step has a URL, a method, optional headers and an optional body. With no body set, Plunk sends a default JSON payload. The URL, header values and body can all include `{{variable}}` placeholders, filled from the contact, the trigger event and the execution.

Requests are not signed. Plunk's advice is to "configure a secret header on the webhook step and check it on your endpoint", and its example is `Authorization: Bearer your-shared-secret`.

## Plunk webhook features

| Feature | Details |
| --- | --- |
| Configuration | A **Webhook** step in a workflow, under **Workflows** in the dashboard |
| Triggers | Internal email, contact and segment events, or custom events |
| Method | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. Default `POST` |
| Body | Default JSON payload, or a custom body that replaces it |
| Templating | `{{variable}}` in the URL, header values and body |
| Authentication | Custom headers on the step |
| Signature | None |
| Timeout | 10 seconds |
| Redirects | Up to 5 followed |
| Retries | None. A non-`2xx` or a timeout fails the workflow step |
| Endpoint | Must be public. `http://` or `https://` only |

## Common events

| Area | Events |
| --- | --- |
| Email | `email.sent`, `email.delivery`, `email.open`, `email.click`, `email.bounce`, `email.complaint`, `email.received` |
| Contacts | `contact.subscribed`, `contact.unsubscribed` |
| Segments | `segment.<name>.entry`, `segment.<name>.exit` |
| Custom | Any event you track through the API, such as `user.signup` |

Segment event names use a slug of the segment's name, so a segment called "VIP Users" produces `segment.vip-users.entry`. Internal events can't be tracked through the API, only by Plunk itself.

With no custom body, the request looks like this:

```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"
  }
}

```

Notice what's missing: the name of the trigger event. The payload says which workflow ran and carries the event's data, but not `email.bounce`. Route on `workflow.id`, or set a custom body that includes the event name.

Email events share `subject`, `from`, `fromName`, `messageId`, `emailId`, `templateId`, `campaignId` and `sourceType`, and add their own fields: `deliveredAt`, `openedAt` with `opens` and `isFirstOpen`, `link` and `clickedAt`, `bounceType`, or `complainedAt`. A soft bounce has `bounceType: "Transient"` and `transientBounce: true`, and no `bouncedAt`. Contact events usually carry an empty `event`, except an automatic unsubscribe, which has a `reason` of `bounce` or `complaint`. `emailId` matches the ID returned when you send through `POST /v1/send`, which makes it easy to tie events back to your own records.

## Setting up Plunk webhooks

Pick a header and a long random value first, for example `Authorization` with `Bearer ` and the output of `openssl rand -hex 32`, and store the value server-side.

1. In the Plunk dashboard, go to **Workflows** and create a workflow. Choose the trigger event, such as `email.bounce`.
2. Add a **Webhook** step after the trigger.
3. Enter your HTTPS endpoint as the **URL** and leave **Method** as `POST`.
4. Under **Headers**, add your secret as JSON, with a static value:

```json
{
  "Authorization": "Bearer your-long-random-secret"
}

```

1. Optionally set a **Body**. To keep the event name, include it yourself, for example `{"type": "email.bounce", "emailId": "{{event.emailId}}", "email": "{{email}}"}`.
2. Enable the workflow.

**With Hookdeck.** Plunk is a supported source in Hookdeck. Create a source with the Plunk type, enter the header's name as the **API Key Header** and its full value as the **API Key**, including any `Bearer ` prefix, and use the Hookdeck source URL as the step's URL. Hookdeck's setup note says to add a secret header to the webhook step and to use a static value, not a `{{variable}}`. The source pre-selects `POST` without managing the method. If a step uses `GET`, `PUT`, `PATCH` or `DELETE`, allow that method on the source as well, because the source only accepts the methods you allow. A request without the right header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Plunk webhooks

Compare the secret header with the stored value in constant time. Look the header up case-insensitively, compare the whole value including any prefix, and check lengths before calling `crypto.timingSafeEqual`.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyPlunkRequest(headers) {
  const expected = process.env.PLUNK_WEBHOOK_SECRET; // e.g. "Bearer <secret>"
  if (!expected) return false; // fail closed when unconfigured
  const name = (process.env.PLUNK_WEBHOOK_HEADER || 'Authorization').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, expected);
}

const app = express();

app.post('/webhooks/plunk', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyPlunkRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  // Default payload: route on body.workflow.id. Custom body: route on your own field.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_plunk_request(headers) -> bool:
    expected = os.environ.get("PLUNK_WEBHOOK_SECRET")
    if not expected:
        return False
    name = os.environ.get("PLUNK_WEBHOOK_HEADER", "Authorization")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

A static header proves the sender knows the secret, but it doesn't prove the body is unchanged, and it gives no replay protection: nothing is signed. Use HTTPS, which Plunk prefers, keep the secret long and random, and rotate it "like any other shared secret". Plunk recommends the header over IP allowlisting because its egress IPs can change.

> **Make Plunk webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header on every Plunk request, stores each bounce, complaint and contact event, and adds the retries that Plunk's webhook step doesn't have.

## Plunk webhook limitations and pain points

### No automatic retries

**The Problem:** A deploy, a restart or a slow response loses events for good.

**Why It Happens:** Plunk says plainly: "No automatic retries: a non-2xx response or timeout fails the workflow step." The timeout is 10 seconds.

**Workarounds:**

* Return `2xx` well inside 10 seconds and process asynchronously.
* Use workflow logic, such as a fallback branch or a **Wait for Event** step, where Plunk suggests it for retry-like behaviour.
* Reconcile bounces and unsubscribes from Plunk after an outage.

**How Hookdeck Can Help:** Hookdeck answers Plunk as soon as a request arrives and stores it, then delivers it to your service with [automatic retries](/docs/retries) on a schedule you control. A restart on your side no longer fails the workflow step, and [Issues](/docs/issues) alert you when deliveries fail.

### The payload doesn't name the event

**The Problem:** One endpoint receiving several workflows can't tell a bounce from a complaint without guessing from field names.

**Why It Happens:** The trigger event selects the workflow, but the default payload carries only `contact`, `workflow`, `execution` and the event's data.

**Workarounds:**

* Route on `workflow.id`, which is stable for each workflow.
* Or set a custom body with an explicit `type` field.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `workflow.id` or your own `type` field, sending bounces and complaints to one service and segment changes to another.

### Templated headers can't be verified

**The Problem:** A header value that changes per contact can't be checked against a stored secret.

**Why It Happens:** Header values support `{{variable}}` interpolation, so it's easy to put contact data into what should be a constant.

**Workarounds:**

* Keep the secret header static, and put dynamic values in the body or URL.

**How Hookdeck Can Help:** Hookdeck's Plunk source compares the header you name against the exact static value you enter, and logs every request with its headers and body.

### A custom body replaces everything

**The Problem:** After you add a custom body, fields your handler relied on disappear.

**Why It Happens:** A custom body "replaces the default payload entirely".

**Workarounds:**

* Include every field your handler needs in the template, such as `{{event.emailId}}` and `{{email}}`.
* Version your custom bodies so the handler can tell old and new shapes apart.

**How Hookdeck Can Help:** Every request is stored, so you can replay a captured delivery against your handler while you change its parsing.

## Best practices

### One workflow per event type

Give each trigger its own workflow and Webhook step. It keeps routing simple, and a condition step can still narrow a workflow down, for example to hard bounces only.

### Use `emailId` to correlate

Store the email ID from `POST /v1/send`, and match webhooks on `event.emailId` instead of on the contact's address and a timestamp.

### Build idempotency in

Plunk doesn't retry, but your own processing might, and a gateway that retries will resend. Key on `execution.id`, or on `emailId` plus the event type. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Check the header, store the request, return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Plunk sends webhooks from a workflow's Webhook step: an unsigned request, `POST` by default, with a default JSON payload that names the workflow but not the trigger event. Add a static secret header and compare it in constant time over HTTPS, route on `workflow.id` or an explicit field in a custom body, and plan for the fact that Plunk never retries.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, answers Plunk straight away, and retries delivery to your service, so a short outage doesn't cost you bounce or unsubscribe events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Plunk webhooks reliably in minutes.