Gareth Wilson Gareth Wilson

Guide to Plunk Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationA Webhook step in a workflow, under Workflows in the dashboard
TriggersInternal email, contact and segment events, or custom events
MethodGET, POST, PUT, PATCH or DELETE. Default POST
BodyDefault JSON payload, or a custom body that replaces it
Templating{{variable}} in the URL, header values and body
AuthenticationCustom headers on the step
SignatureNone
Timeout10 seconds
RedirectsUp to 5 followed
RetriesNone. A non-2xx or a timeout fails the workflow step
EndpointMust be public. http:// or https:// only

Common events

AreaEvents
Emailemail.sent, email.delivery, email.open, email.click, email.bounce, email.complaint, email.received
Contactscontact.subscribed, contact.unsubscribed
Segmentssegment.<name>.entry, segment.<name>.exit
CustomAny 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:

{
  "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:
{
  "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.

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:

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 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 on a schedule you control. A restart on your side no longer fails the workflow step, and 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 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.

Acknowledge fast and process asynchronously

Check the header, store the request, return 200, then do the work. See why to process webhooks asynchronously.

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 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 for free and handle Plunk webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.