Agent skill

WordPress.com Webhooks Skill

Receive WordPress.com webhooks (Settings -> Webhooks, the native /wp-admin/options-general.php?page=webhooks feature). Use when building a WordPress.com webhook receiver, because these deliveries are UNSIGNED: there is no HMAC, no signature header, no secret, no timestamp and no handshake, and the body is a FLAT application/x-www-form-urlencoded set of key/value pairs whose only discriminator is the `hook` field (`publish_post`, `publish_page`, `comment_post`). Use when wiring express.urlencoded / request.formData() / await request.form(), protecting an unsigned endpoint with a query-string token, or deduping repeated publish_post deliveries. NOT WooCommerce (X-WC-Webhook-Signature) and not a WordPress.org plugin.

Install this skill

npx skills add hookdeck/webhook-skills --skill wordpress-com-webhooks


WordPress.com (Automattic's hosted WordPress service) has a native webhooks feature under Settings → Webhooks, documented at 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 skillSomething else
WordPress.com Settings → Webhooks (this skill)WooCommerce — different product, signed with X-WC-Webhook-Signature (HMAC-SHA256, base64). Use 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.

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.

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/, examples/nextjs/, 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.

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.

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

hookFires 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.

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

# 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

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 - The three hooks with their complete documented field lists, payload shape, the HookPress lineage, Jetpack Forms webhooks
  • references/setup.md - Configuring Settings → Webhooks (admin UI only), choosing fields, adding the URL token, Hookdeck setup
  • references/verification.md - Why there is nothing to verify, the URL-token pattern, and Hookdeck's x-hookdeck-signature

Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Sep 28, 2026

View on GitHub →