How to Send a Webhook When a WordPress Post Is Published
The cleanest way to send a webhook when a post is published in WordPress is to hook into the transition_post_status action and fire an HTTP POST using wp_remote_post(). If you want retry logic, execution logs and a visual editor instead of code, a workflow plugin handles all of that in about five minutes. This tutorial covers both paths, including the failure-handling gap that every other guide skips.
We tested both approaches on WordPress 6.5 with PHP 8.2. The code path works on any host.
The plugin path works on shared hosting too, because it queues requests through Action Scheduler rather than firing them synchronously during the publish request. That difference matters more than most tutorials admit.
Before you choose a path, it helps to understand what can go wrong. When WordPress publishes a post, the browser is waiting. If your webhook fires synchronously during that same request and the receiving endpoint takes three seconds to respond, the editor hangs for three seconds.
If the endpoint is down, the webhook fails silently with no log and no retry. Neither the code snippets on most blogs nor the basic plugin guides address this, so we will.
Which Approach Is Right for You?
| Situation | Best path | Why |
|---|---|---|
| One webhook, one destination, no retry needed | Code snippet | Zero plugin overhead, deployed in under 10 minutes |
| Multiple webhooks or multiple post types | Workflow plugin | Conditional logic without stacking if statements |
| Need retry on failure or execution logs | Workflow plugin | Automatic retry with backoff; per-step audit trail |
| Sending to Slack, Discord or a CRM | Workflow plugin | Native integrations replace hand-rolled JSON formatting |
| Shared hosting with no server cron | Workflow plugin | Action Scheduler decouples execution from the publish request |
| Client site, no code deployments | Workflow plugin | Non-developers can edit triggers without touching functions.php |
Path 1: The Code Approach
The correct hook is transition_post_status, not publish_post. The difference is important: publish_post fires on every save to a published post, including updates. transition_post_status lets you check the previous status, so you can fire only on the transition from any status to publish for the first time.
Add this to your theme’s functions.php or, better, a site-specific plugin so it survives theme changes. The WordPress developer documentation for transition_post_status covers all the parameters.
add_action( 'transition_post_status', 'my_send_publish_webhook', 10, 3 );
function my_send_publish_webhook( $new_status, $old_status, $post ) {
// Fire only on the first publish, not on updates
if ( $new_status !== 'publish' || $old_status === 'publish' ) {
return;
}
// Restrict to a specific post type if needed
if ( $post->post_type !== 'post' ) {
return;
}
$payload = wp_json_encode( array(
'post_id' => $post->ID,
'title' => get_the_title( $post ),
'url' => get_permalink( $post ),
'thumbnail_url' => get_the_post_thumbnail_url( $post, 'full' ),
'published_at' => get_the_date( 'c', $post ),
'categories' => wp_get_post_categories( $post->ID, array( 'fields' => 'names' ) ),
) );
wp_remote_post( 'https://your-endpoint.example.com/hook', array(
'body' => $payload,
'headers' => array( 'Content-Type' => 'application/json' ),
'timeout' => 5,
'blocking' => false, // Non-blocking: does not hold up the editor
) );
}
Setting blocking to false is the most important line in that snippet. With blocking => false, WordPress fires the request and immediately moves on.
The editor does not hang while waiting for a response. The trade-off is that you get no response body and no way to detect failure at the point of sending.
Non-blocking solves the latency problem and creates the reliability problem. You cannot have both without a queue.
Firing Only on First Publish, Not on Every Edit
The $old_status === 'publish' check is what prevents the webhook from firing again every time the post is edited after it goes live. Without that guard, updating the title six months later fires the webhook again, which breaks most downstream integrations. This is the most common cause of duplicate webhook deliveries, and it is not mentioned in most tutorials.
Restricting to a Post Type or Category
To fire only for a specific post type, change $post->post_type !== 'post' to match the post type slug you want, for example !== 'product' or !== 'news'. To fire only for posts in a specific category, add a check after the post type guard:
$categories = wp_get_post_categories( $post->ID );
if ( ! in_array( 42, $categories ) ) { // 42 is your category term ID
return;
}
What the Code Path Does Not Give You
- No retry: if the endpoint returns a 500 or times out, the webhook is lost with no record of the attempt.
- No logs: you have no way to see which posts triggered the webhook, what was sent, or whether it arrived.
- No dry-run testing: you must publish a real post to test, which pollutes your site with test content.
- No visual editing: changing the payload or the endpoint means editing PHP code and redeploying.
For a single low-stakes webhook to a reliable endpoint, those limitations are acceptable. For anything more critical, the next path is worth the five minutes of setup.
Path 2: The No-Code Workflow Approach
Krom Automation is a visual workflow plugin for WordPress. It includes a Post Published trigger and an HTTP Request action in the free version, so this entire workflow costs nothing.
Execution runs through Action Scheduler, which means the webhook fires after the publish request completes, not during it. That solves the synchronous timing problem without any code.
Install the free plugin from the WordPress.org plugin directory. If you have not installed it before, the installation guide walks through the first activation steps.
Step 1: Create a New Workflow
Go to Krom Automation in your WordPress admin sidebar and click Add New Workflow. You will see a blank canvas with a prompt to add a trigger. Click the trigger node to open the trigger selector panel on the right.
Step 2: Set the Trigger to Post Published
In the trigger panel, choose Post from the category list, then select Post Published. You will see configuration options appear below the trigger selector:
- Post type: choose
Post,Page, or any custom post type registered on your site. - Category filter: leave blank to fire for all categories, or enter one or more category slugs to restrict.
- Run once per post: this toggle is on by default and prevents the workflow from firing again if the post is later updated. Leave it on.
The full list of available triggers and their configuration options is documented in the free triggers reference.
Step 3: Add an HTTP Request Action
Click the plus icon below the trigger node to add an action. Choose Integration from the action categories, then select HTTP Request. The action panel opens with these fields:
- URL: paste your destination endpoint URL here.
- Method: select POST.
- Headers: add
Content-Type: application/json. - Body: paste your JSON payload here, using merge tags for dynamic values.
Step 4: Build the Payload with Merge Tags
Merge tags pull live data from the trigger event into any action field. In the body field, build your payload like this:
{
"post_id": "{{post.id}}",
"title": "{{post.title}}",
"url": "{{post.permalink}}",
"thumbnail_url": "{{post.thumbnail_url}}",
"published_at": "{{post.published_date}}",
"author": "{{post.author_name}}"
}
The merge tags documentation lists every available variable for post triggers, including custom field values and taxonomy terms. Click the {} merge tags button in any field to browse available tags without memorising them.
Step 5: Add Conditional Logic (Optional)
If you want the webhook to fire only when a post meets additional conditions, such as being in a specific category or having a certain custom field value, click the branching icon on the action node to add a condition. The conditions and branching documentation covers the full logic options. Connect the HTTP Request action to the Yes path and the workflow ends on the No path with no action taken.
Step 6: Test with the Workflow Simulator
Before going live, click the Simulate button in the top toolbar. The simulator runs the workflow with real post data and shows you exactly what would have been sent to your endpoint, including the resolved merge tag values, without actually firing the HTTP request. This is covered in the workflow simulator documentation.
Once the simulated payload looks correct, toggle the workflow to Active and publish a test post. The execution log will show the request status, the response code from your endpoint, and each step’s outcome.
A webhook that fails silently is worse than no webhook at all. At least when nothing is configured, you know nothing is running.
What Happens When the Endpoint Is Down
This is the question every other guide skips. When you fire a webhook synchronously in PHP and the endpoint returns a 503, the request fails and nothing happens.
You will not know unless you are actively watching server logs. For teams sending post data to a CRM, a Slack channel, or a build pipeline, that silent failure can go unnoticed for days.
The workflow plugin approach handles this differently. Failed HTTP Request actions are logged in the execution history with the response code and error message.
Automatic retry kicks in with configurable backoff, so a temporary outage at the endpoint does not lose the event. Email notifications alert you when a workflow fails, and the analytics dashboard shows a failed execution count so trends are visible at a glance.
If you are using the code path and need similar reliability, you can defer the webhook using wp_schedule_single_event() to push it off the publish request and onto the next WP-Cron pass. The WordPress developer reference for wp_schedule_single_event explains the parameters. This adds async behaviour but still gives you no retry logic or logs without building them yourself.
Sending to Slack or Discord
For Slack and Discord, the HTTP Request action works with their incoming webhook URLs. Format the payload to match the channel’s expected structure. Slack expects a JSON body with a text key or a blocks array.
Discord expects a content key. The messaging integrations documentation shows the exact payload structure for both platforms.
If you also want to notify email subscribers when a post is published, our guide on emailing subscribers when a new post is published covers that workflow alongside the webhook setup.
Common Failure Modes and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook fires on every post edit, not just first publish | Using publish_post hook without status guard | Switch to transition_post_status and check $old_status !== 'publish' |
| Webhook fires twice for the same post | Two hooks registered, or autosave triggering the action | Add a transient lock: set a transient keyed to post ID, skip if it exists |
| Editor hangs after clicking Publish | Synchronous wp_remote_post() with slow endpoint | Set blocking => false or switch to queued execution via plugin |
| Webhook body arrives empty at destination | Content-Type header missing, endpoint expects form data | Add Content-Type: application/json header; confirm endpoint accepts JSON |
| Webhook never fires on a staging site | WP-Cron disabled or Action Scheduler not running | Confirm DISABLE_WP_CRON is not set to true in wp-config.php |
| Thumbnail URL returns false in payload | Post has no featured image set | Use a fallback: get_the_post_thumbnail_url( $post, 'full' ) ?: '' |
What Gets Sent: A Reference Payload
This is the payload structure we use in production for a standard blog post notification. It covers the fields most downstream services actually need.
{
"post_id": 1842,
"title": "How to Configure Redis on WordPress",
"url": "https://example.com/configure-redis-wordpress/",
"thumbnail_url": "https://example.com/wp-content/uploads/redis-cover.jpg",
"excerpt": "A short plain-text excerpt from the post.",
"author_name": "Sakil",
"published_at": "2026-04-14T09:00:00+00:00",
"categories": ["Performance", "Hosting"],
"tags": ["redis", "caching", "server"]
}
Custom fields can be added to this payload using the get_post_meta() function in code, or via post meta merge tags in the workflow builder. If your destination service requires HMAC signature verification for security, that is a Pro feature covered in the incoming webhook receiver documentation.
Most webhook tutorials give you a snippet that works in development and fails quietly in production. The gap is always retry logic and observability, not the initial send.
Also Worth Knowing About
Pollify is wpRigel’s poll, survey and quiz plugin for WordPress. It builds polls as native Gutenberg blocks, so there are no shortcodes to paste and no separate admin interface to learn. If you are publishing content and want to gather reader feedback inline, it fits into the same post-publish workflow.
Commandify is a command palette for the WordPress admin. Press Cmd or Ctrl plus K to jump anywhere in the dashboard, search posts, users and products, and run admin actions without navigating menus. It is the only WordPress command palette with real WooCommerce order, product and customer commands built in.
Next Steps
If you are starting with the code path, copy the snippet above, replace the endpoint URL, and publish a test post on a staging site first. If you need retry logic, failure notifications or the ability to test without publishing real content, the free plugin is the faster path and costs nothing to try. The step-by-step workflow builder guide covers the full setup from first trigger to active workflow in one place.
Try Krom Automation free on WordPress.org
Frequently Asked Questions
Why is my WordPress publish webhook firing multiple times for the same post?
The most common cause is using the publish_post hook without checking whether the post was already published. Every time the post is updated after going live, that hook fires again.
Switch to transition_post_status and add an $old_status === 'publish' guard to skip anything that was already in the published state. A second cause is autosave or revision saves triggering the same action, which a transient lock keyed to the post ID will prevent.
How do I send a webhook only for specific post types or categories?
In the code path, add a $post->post_type check inside the callback and return early if the type does not match. For category filtering, use wp_get_post_categories() and check whether the returned term IDs include your target. In the workflow builder, the Post Published trigger has a post type selector and a category filter field built into the trigger configuration panel, so no code is needed.
What happens to the webhook if the receiving endpoint is down?
With a plain PHP snippet and blocking => false, the request is sent and forgotten. If the endpoint returns an error or is unreachable, the event is lost with no log.
With a queued workflow plugin, the failed attempt is logged with the response code, automatic retry runs with backoff, and an email notification is sent after a configurable number of failures. For any integration where losing the event matters, the queued approach is the correct one.
How do I add the post thumbnail URL and custom fields to the webhook payload?
In the code path, use get_the_post_thumbnail_url( $post, 'full' ) for the thumbnail and get_post_meta( $post->ID, 'your_field_key', true ) for custom fields, then include both in the array passed to wp_json_encode(). In the workflow builder, thumbnail URL and post meta values are available as merge tags. Click the {} merge tags button in the HTTP Request body field to browse all available variables for the Post Published trigger.
Can I test a WordPress publish webhook without publishing a real post?
With the code path, no. You must publish a real post or temporarily hook into a different action for testing.
With the workflow plugin, the built-in simulator runs the full workflow against real post data and shows the resolved payload and simulated action output without firing the HTTP request or touching any external service. This means you can verify the payload structure before the workflow goes active.