# Plunk Webhooks

Plunk (the email platform) has no webhook subscriptions of its own. Webhooks come from the
**Webhook step** in a workflow: an event such as `email.bounce` triggers the workflow, and the
step sends an HTTP request to your URL. Requests are **not signed**. You authenticate them with
a secret header that you add to the step yourself.

## When to Use This Skill

* How do I receive Plunk webhooks?
* How do I authenticate a Plunk workflow Webhook step?
* Does Plunk sign webhook payloads? (No: use a secret header you add)
* How do I handle `email.bounce`, `email.complaint` or `contact.unsubscribed` events?
* Why does my Plunk webhook endpoint return 401?
* Which HTTP methods can a Plunk webhook step use?

## Verification (core)

Add a secret header to the step (Headers, as JSON), for example `{"Authorization": "Bearer your-shared-secret"}`.
The header name is yours, so the examples read it from `PLUNK_WEBHOOK_HEADER` (default
`Authorization`) and the expected value from `PLUNK_WEBHOOK_SECRET`: the **exact, whole header
value**, including any `Bearer ` prefix you typed. Do not strip or parse a prefix.

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

function safeEqual(a, b) {
  const ba = Buffer.from(String(a), 'utf8');
  const bb = Buffer.from(String(b), 'utf8');
  if (ba.length !== bb.length) return false;   // timingSafeEqual throws on unequal lengths
  return crypto.timingSafeEqual(ba, bb);
}

function verifyPlunkRequest(headers, secret = process.env.PLUNK_WEBHOOK_SECRET,
                            headerName = process.env.PLUNK_WEBHOOK_HEADER || 'Authorization') {
  if (!secret) return false;                   // never accept when unconfigured
  const received = headers[headerName.toLowerCase()];   // case-insensitive lookup
  return Boolean(received) && safeEqual(received, secret);
}

```

* Python: `hmac.compare_digest(received.encode(), secret.encode())`. Encode both sides to bytes;
  passing `str` raises on non-ASCII.
* Missing header or mismatch: return `401`. Check the credential first, for every method, before
  reading the body.
* Read the body raw (`express.raw({ type: '*/*' })`) so any content type works, then parse it.
* **A static shared credential authenticates the sender, not the body, and gives no replay
  protection** (no signature, no timestamp). Use HTTPS, and rotate the credential if it leaks.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plunk-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plunk-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plunk-webhooks/examples/fastapi/).

## Hookdeck Source Setup

In Hookdeck the source type is **PLUNK** (labelled "Plunk"). It uses API key verification (API Key
Header + exact whole API Key). Hookdeck shows this note for the source:

> Add a secret header to the webhook step in your workflow and enter it here. Use a static value, not a {{variable}}.

Hookdeck's Plunk source pre-selects `POST` without managing the method. The webhook step can use
`GET`, `POST`, `PUT`, `PATCH` or `DELETE`, so allow any other method your step uses on the source too.

## Methods, Body and Retries

* **Method:** a literal verb (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`), default `POST`. The method
  is not templated. The examples accept all five on `/webhooks/plunk`.
* **Body:** with no custom body, the request is JSON `{ contact, workflow, execution, event }`.
  A custom body replaces it entirely. The body may be empty (for example `GET`).
* **No event field:** there is no Plunk-defined event name on the wire. The trigger (`email.bounce`
  and so on) only selects the workflow. The examples infer the kind from the documented fields of
  `event` (`bouncedAt`, `complainedAt`, `clickedAt`, ...), and treat custom bodies as plain JSON.
* **No automatic retries:** a non-2xx response or a 10 second timeout fails the workflow step. Return
  `2xx` quickly and make your handler idempotent.
* **Header values and the URL support `{{variable}}`**, but verification needs a static value.

## Trigger Events

| Trigger | Fires When |
| --- | --- |
| `email.sent` | An email was sent |
| `email.delivery` | An email was delivered |
| `email.open` | A contact opened an email |
| `email.click` | A contact clicked a link |
| `email.bounce` | An email bounced |
| `email.complaint` | A contact marked an email as spam |
| `email.received` | An email arrived at your verified domain |
| `contact.subscribed` | A contact subscribed |
| `contact.unsubscribed` | A contact unsubscribed |
| `segment.<name>.entry` / `.exit` | A contact entered or left a segment |

Custom events you track through the API can also trigger a workflow.

## Environment Variables

```bash
PLUNK_WEBHOOK_HEADER=Authorization            # the header name you set on the webhook step
PLUNK_WEBHOOK_SECRET=Bearer your-shared-secret  # exact, whole header value

```

## Local Development

Plunk can't reach `localhost` (private addresses are blocked). Tunnel with the Hookdeck CLI
(no account required):

```bash
npx hookdeck-cli listen 3000 plunk --path /webhooks/plunk

```

Use the printed URL as the Webhook step URL.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plunk-webhooks/references/overview.md) - How Plunk webhooks work, trigger events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plunk-webhooks/references/setup.md) - Create the workflow and the secret header
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/plunk-webhooks/references/verification.md) - Secret header check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/plunk-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests