---
title: Generic webhook deploy
status: current
phase: 7
order: 9
---

# Generic webhook deploy

For any other host or CI: Cloudflare Pages, GitHub Actions, your own server, a Slack channel. Pluma sends a `POST` with JSON when something happens, signed so you can verify it comes from Pluma.

## What it sends

```http
POST /your-endpoint
Content-Type: application/json
User-Agent: Pluma-Webhooks/1
X-Pluma-Event: publish
X-Pluma-Delivery: 42
X-Pluma-Signature: sha256=5d1f…
```

```json
{
  "space": "acton-estero",
  "events": [
    { "type": "entry.published", "entry_id": "12", "content_type": "event", "at": "2026-09-28T12:00:00Z" }
  ],
  "triggered_at": "2026-09-28T12:00:30Z",
  "test": false
}
```

`events` holds everything that happened in the batch (see debounce below).

## Events

| Event | When |
| --- | --- |
| `entry.published` | An entry was published |
| `entry.unpublished` | A published entry was unpublished or archived |
| `content_type.changed` | A content type or its fields were created or changed |

Each webhook chooses which ones it listens to. By default, all three.

## Verify the signature

`X-Pluma-Signature` is `sha256=` + the HMAC-SHA256 of the **body exactly as it arrived**, using the webhook's secret. The secret is shown only once, when you create the webhook.

```js
import crypto from "node:crypto";
const expected = "sha256=" + crypto.createHmac("sha256", process.env.PLUMA_WEBHOOK_SECRET).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers["x-pluma-signature"]));
```

## Rules

- The URL must be **`https://`** and point to the public internet: Pluma doesn't call private addresses or `localhost`.
- **30-second debounce:** a batch of publishes goes out in a single ping.
- **Retries:** up to 5 attempts if the response isn't 2xx or doesn't arrive within 10 seconds, waiting longer each time.
- Every attempt is logged in **Deploy**, with the response code and how long it took.
