# WordPress.com Webhooks

WordPress.com (Automattic's hosted WordPress service) has a native webhooks
feature under Settings → Webhooks, documented at
[wordpress.com/support/webhooks/](https://wordpress.com/support/webhooks/). An
admin picks an action (the `hook`), ticks the fields to include, and enters a
URL. When the action fires, WordPress.com POSTs the selected fields to that URL.

Three things make this unlike most providers in this repo:

1. The deliveries are UNSIGNED. No HMAC, no signature header, no secret, no
  timestamp, no token, no custom headers, no handshake. The docs describe only
  three inputs — action, fields, URL — and nothing to verify.
2. The body is a flat form-encoded key/value set, not a JSON envelope. No
  `type`, no `data` object, no event id. The discriminator is the `hook`
  field, whose value is the action name.
3. Only the fields the admin ticked are sent. Every field is optional except
  `hook` — never assume a field is present, and every value arrives as a
  string.

## Not to be confused with

| This skill | Something else |
| --- | --- |
| WordPress.com Settings → Webhooks (this skill) | WooCommerce — different product, signed with `X-WC-Webhook-Signature` (HMAC-SHA256, base64). Use [woocommerce-webhooks](/webhooks/skills/woocommerce-webhooks). Never borrow its header or verifier. |
|  | WordPress.org plugins (WP Webhooks, HookPress, …) — separate software, separate formats. The docs say: "The Webhook settings mentioned on this page do not apply to plugin-enabled sites. Various plugins offer similar functionality." |
|  | The WordPress.com REST API (`public-api.wordpress.com`) — an API you call, not webhooks you receive. Useful here only for re-fetching authoritative data. |
|  | Jetpack Forms webhooks — a separate per-form feature, also unsigned. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wordpress-com-webhooks/references/overview.md). |

## When to Use This Skill

* How do I receive WordPress.com webhooks?
* How do I verify a WordPress.com webhook signature? (You cannot — there is none.)
* Why is `req.body` / `await request.json()` empty for my WordPress.com webhook?
  (It is `application/x-www-form-urlencoded`, not JSON.)
* How do I handle `publish_post`, `publish_page`, and `comment_post`?
* How do I secure an unsigned WordPress.com webhook endpoint?
* Why does `publish_post` fire repeatedly for the same post ID?
* Is `X-WC-Webhook-Signature` a WordPress.com header? (No — that is WooCommerce.)

## Verification (core): there is none — use a URL token

WordPress.com signs nothing, so do not write an HMAC verifier and do not
check for an invented header such as `X-WordPress-Signature`, `X-WP-Signature`
or `X-WPCOM-Signature`. None of those exist, and WordPress.com publishes no
source-IP allowlist for this feature.

The real control is channel-level: register the endpoint with a long random
secret in the query string and compare it in constant time, failing closed
when it is not configured.

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

// NOT a WordPress.com signature — a token YOU put in the registered URL
// (https://example.com/webhooks/wordpress-com?token=<random>) and compare on
// the way back in. Throws when unset so the endpoint fails CLOSED (500), never
// open. The docs neither mention nor forbid query strings in the webhook URL.
function verifyUrlToken(provided) {
  const expected = process.env.WORDPRESS_COM_WEBHOOK_TOKEN;
  if (!expected) throw new Error('WORDPRESS_COM_WEBHOOK_TOKEN is not set');
  if (typeof provided !== 'string') return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  // Length-guard first: timingSafeEqual throws on a length mismatch.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

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

The token is visible to every site admin and may appear in proxy logs — rotate
it if exposed. Pair it with HTTPS, and treat the payload as an untrusted hint:
re-fetch authoritative data before doing anything consequential.

```bash
curl https://public-api.wordpress.com/rest/v1.1/sites/example.wordpress.com/posts/123

```

With Hookdeck, the Hookdeck Source URL is the secret endpoint; Hookdeck's
WordPress.com source type performs no signature check (there is nothing to
check). Verify Hookdeck's own `x-hookdeck-signature` (HMAC-SHA256, base64, over
the raw forwarded body) on the request Hookdeck delivers to your app — that
signature is Hookdeck's, not WordPress.com's. See
[references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wordpress-com-webhooks/references/verification.md).

## The Payload: flat, form-encoded, all strings

```
POST /webhooks/wordpress-com?token=<random> HTTP/1.1
Content-Type: application/x-www-form-urlencoded

hook=publish_post&ID=42&post_title=Hello+world&post_status=publish&post_url=https%3A%2F%2Fexample.wordpress.com%2F2026%2F09%2F28%2Fhello-world%2F

```

* Encoding. The docs do not state a Content-Type. The feature descends from
  HookPress, whose sender hands a PHP array to `wp_remote_post`, which
  WordPress's HTTP API encodes with `http_build_query` — i.e.
  `application/x-www-form-urlencoded`. Treat form-encoded as the documented
  path (inferred from that lineage; WordPress.com's fork is closed source),
  and accept `application/json` defensively. Do not claim JSON is what
  WordPress.com sends.
* Parsing. Express: `express.urlencoded({ extended: true })`. Next.js:
  `await request.formData()` or `new URLSearchParams(await request.text())`.
  FastAPI: `await request.form()` (needs `python-multipart`).
* Strings only. `ID=123`, `comment_approved=1`. Coerce explicitly.
* Array-valued fields such as `post_category` may arrive bracket-encoded
  (`post_category[0]=1&post_category[1]=5`) — `extended: true` handles that in
  Express; the other examples group brackets themselves.
* Sensitive fields are selectable: `post_password`, `comment_author_email`
  and `comment_author_IP`. Only tick them if you need them.

## Events (the `hook` values) — exactly three

| `hook` | Fires when (verbatim from the docs) | Key fields |
| --- | --- | --- |
| `publish_post` | "Runs when a post is published, or if it is edited and its status is 'published'" | `ID`, `post_title`, `post_status`, `post_url`, `post_author`, `post_modified_gmt`, … |
| `publish_page` | "Runs when a page is published, or if it is edited and its status is 'published'" | same field set as `publish_post` |
| `comment_post` | "Runs just after a comment is saved in the database" | `comment_ID`, `comment_post_ID`, `comment_approved`, `comment_author`, `comment_content`, … |

There are no other hooks — no `post_updated`, `delete_post`, `user_register`
or `wp_insert_post`. Log an unknown `hook` and still answer 2xx. Full field
lists: [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wordpress-com-webhooks/references/overview.md).

`comment_approved` is a string: `1` approved, `0` pending moderation, `spam`
spam. Comments can arrive before moderation — never publish `comment_content`
blindly.

## Idempotency and delivery

`publish_post` / `publish_page` fire on the first publish and on every later
edit of a published item, so the same `ID` arrives repeatedly, and there is no
delivery-id header. Dedupe on `hook` + `ID` + `post_modified_gmt` (when that
field is selected), or make the handler an upsert keyed on `ID`. For comments,
key on `comment_ID`.

The docs describe no retries, no timeout, no delivery log and no test/ping
button — assume no retries and design for missed events (reconcile through the
REST API). HookPress sends synchronously inside the WordPress action
(WordPress.com's implementation may differ), so answer 2xx fast and do the work
asynchronously.

## Environment Variables

```bash
# REQUIRED. A long random token YOU add to the webhook URL registered in
# Settings -> Webhooks: https://example.com/webhooks/wordpress-com?token=...
# WordPress.com provides no secret; unset => the handler fails CLOSED (500).
WORDPRESS_COM_WEBHOOK_TOKEN=

# OPTIONAL. Site slug/ID used to re-fetch authoritative data from the
# WordPress.com REST API, e.g. example.wordpress.com
WORDPRESS_COM_SITE=

```

## Local Development

```bash
npx hookdeck-cli listen 3000 wordpress-com --path /webhooks/wordpress-com

```

No account required — the CLI creates a guest account on first run and gives you
a public HTTPS URL plus a web UI for inspecting requests. Hookdeck's
`WORDPRESS_COM` source type is schema-only (`http_method_managed_post`) with no
verification controller, matching the fact that there is nothing to verify.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wordpress-com-webhooks/references/overview.md) - The three hooks with their complete documented field lists, payload shape, the HookPress lineage, Jetpack Forms webhooks
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wordpress-com-webhooks/references/setup.md) - Configuring Settings → Webhooks (admin UI only), choosing fields, adding the URL token, Hookdeck setup
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/wordpress-com-webhooks/references/verification.md) - Why there is nothing to verify, the URL-token pattern, and Hookdeck's `x-hookdeck-signature`