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:
- 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.
- The body is a flat form-encoded key/value set, not a JSON envelope. No
type, nodataobject, no event id. The discriminator is thehookfield, whose value is the action name. - 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. 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 isapplication/x-www-form-urlencoded, not JSON.) - How do I handle
publish_post,publish_page, andcomment_post? - How do I secure an unsigned WordPress.com webhook endpoint?
- Why does
publish_postfire repeatedly for the same post ID? - Is
X-WC-Webhook-Signaturea 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 withhttp_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 acceptapplication/jsondefensively. Do not claim JSON is what WordPress.com sends. - Parsing. Express:
express.urlencoded({ extended: true }). Next.js:await request.formData()ornew URLSearchParams(await request.text()). FastAPI:await request.form()(needspython-multipart). - Strings only.
ID=123,comment_approved=1. Coerce explicitly. - Array-valued fields such as
post_categorymay arrive bracket-encoded (post_category[0]=1&post_category[1]=5) —extended: truehandles that in Express; the other examples group brackets themselves. - Sensitive fields are selectable:
post_password,comment_author_emailandcomment_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.
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