how-to · · 10 min read
how to send woocommerce orders to zapier (or any webhook)
Send WooCommerce orders to Zapier, Make, n8n or your own endpoint. The built-in webhook in five minutes, its four traps, and a small plugin that sends each paid order once.
By Big Bad Agency
Sooner or later someone wants the orders somewhere else. A row in a Google Sheet. A message in Slack when a big order lands. A customer added to the mailing list. A job card for whoever packs the box.
Zapier, Make and n8n can all do the “somewhere else” part. What they need from your store is a message each time an order comes in, sent to a URL they give you. That message is a webhook, and WooCommerce can already send one without any plugin.
This post covers both ways to do it: the built-in webhook, which takes five minutes and has four traps worth knowing about first, and a small plugin you own that sends each paid order exactly once, in the background, with only the fields you choose. We don’t sell an integration plugin. This is the whole job, in code.
what you’re building
Two ends:
- The catching end. In Zapier that’s a Zap whose trigger is Webhooks by Zapier → Catch Hook. Zapier gives you a URL like
https://hooks.zapier.com/hooks/catch/…. In Make it’s a Custom webhook module. In n8n it’s a Webhook node. Or it’s an endpoint on your own server. - The sending end. Your store POSTs JSON to that URL when something happens to an order.
Webhooks by Zapier is one of Zapier’s premium apps, so check that your plan includes it before you build the Zap. Zapier also has its own WooCommerce app, but it works through a separate paid extension from the WooCommerce marketplace. A webhook doesn’t need either one on the store side.
option 1: the built-in webhook
Go to WooCommerce → Settings → Advanced → Webhooks → Add webhook and fill in:
- Name: anything you’ll recognise, like “Orders to Zapier”.
- Status: Active.
- Topic: pick from the list, for example Order created or Order updated.
- Delivery URL: the catch URL from Zapier, Make or n8n.
- Secret: a long random string. WooCommerce uses it to sign every delivery (more on that below).
- API version: leave it on the current WP REST API version.
Save it, place a test order, and the order arrives at the other end as JSON. It’s the same shape the WooCommerce REST API returns for an order: totals, line items, billing and shipping addresses, payment method, meta data, the lot.
That works. These four things catch people out.
trap 1: “order created” doesn’t mean “paid”
For most payment methods, WooCommerce creates the order the moment the customer presses Place order, with a status of Pending payment, and only then sends them to pay. So Order created fires for orders that are never paid: an abandoned card form, a declined card, a bank transfer nobody sends.
If your Zap adds every Order created customer to a “thanks for buying” list, it’s thanking people who didn’t buy. Either send on payment (option 2 does this), or add a filter step in the automation that only lets through orders whose status is processing or completed.
trap 2: “order updated” fires a lot
Order updated fires whenever the order is saved: the status changing, a refund, a tracking number added, someone editing the address. One order can produce several deliveries. WooCommerce stops the same order being sent twice within one request, but not across separate ones, so the automation has to cope with repeats. That means filtering on status and deduplicating on the order id, or it’ll post the same order to Slack three times.
trap 3: the first thing Zapier catches is a ping, not an order
When you save an active webhook, WooCommerce sends a small test request to the delivery URL to check that it answers. The body of that request is just webhook_id=12, not an order. If you click “test trigger” in Zapier straight after saving, that’s the sample it picks up, and you’ll be mapping fields from a sample that has none.
Place a real test order first, then pull in a new sample in Zapier and choose the one with order fields in it.
trap 4: it switches itself off when deliveries fail
Deliveries are sent in the background by Action Scheduler, the job queue that ships inside WooCommerce. If the delivery URL fails five times in a row (an error, a timeout, a non-2xx answer), WooCommerce sets the webhook to Disabled. It doesn’t email you. If you turn the Zap off, delete it or change its URL, the store quietly stops sending, and the orders from while it was off don’t get re-sent later.
When something looks missing, check three places:
- WooCommerce → Settings → Advanced → Webhooks: is the status still Active?
- WooCommerce → Status → Logs: delivery logs are in the files whose name starts with
webhooks-delivery. - WooCommerce → Status → Scheduled Actions: search for
woocommerce_deliver_webhook_asyncto see pending and failed deliveries.
and the “action” topic sends almost nothing
The topic list has an Action option, which lets you fire on any WordPress action hook, for example woocommerce_payment_complete. It looks like the answer to trap 1. The catch is the payload: for an action topic WooCommerce sends the name of the action and its first argument. For a payment that’s the order ID and nothing else, so the automation would then have to call your store’s REST API to fetch the order, with API keys stored in Zapier. It can be done, but it’s two systems doing one job.
option 2: a small plugin that sends each paid order once
If what you want is “when an order is paid, send these fields to this URL, once”, it’s under 100 lines of PHP. It goes in its own plugin folder (how to add custom PHP to WordPress covers that step if you haven’t done it before). What it does:
- sends when an order reaches Processing or Completed, so unpaid orders never go out
- sends each order once, and remembers that it did
- sends in the background, so checkout never waits on Zapier
- signs each delivery the same way WooCommerce does, so the other end can check it came from you
- retries a failure three times, then leaves a note on the order instead of failing silently
- sends only the fields you list, not the customer’s whole record
1. keep the URL and secret out of the code
Put these two lines in wp-config.php, above the “That’s all, stop editing!” line:
define( 'BB_ORDER_WEBHOOK_URL', 'https://hooks.zapier.com/hooks/catch/…' );
define( 'BB_ORDER_WEBHOOK_SECRET', 'a long random string' );
A catch URL is effectively a password. Anyone who has it can post fake “orders” into your automation. Keeping it in wp-config.php keeps it out of your theme, your plugin folder and any repository you push those to.
2. queue the send when an order is paid
function bb_owh_configured() {
return defined( 'BB_ORDER_WEBHOOK_URL' ) && defined( 'BB_ORDER_WEBHOOK_SECRET' )
&& BB_ORDER_WEBHOOK_URL && BB_ORDER_WEBHOOK_SECRET;
}
function bb_owh_paid_statuses() {
return array( 'processing', 'completed' );
}
add_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {
if ( ! bb_owh_configured() || ! in_array( $to, bb_owh_paid_statuses(), true ) ) {
return;
}
if ( $order->get_meta( '_bb_webhook_sent' ) ) {
return;
}
if ( as_has_scheduled_action( 'bb_owh_send', array( $order_id, 1 ), 'bb-order-webhook' ) ) {
return;
}
as_enqueue_async_action( 'bb_owh_send', array( $order_id, 1 ), 'bb-order-webhook' );
}, 10, 4 );
The status, not the payment method, is the signal. Card gateways move a paid order to Processing. Cash on delivery does the same. A bank transfer sits in On hold until you mark it paid, and that’s when it should go out. An order that goes straight to Completed (some virtual products do) is covered too.
as_enqueue_async_action hands the job to Action Scheduler instead of calling Zapier during checkout. If Zapier is slow or down, your customer doesn’t wait for it, and the order still gets placed.
The two checks before it stop an order being queued twice: once if it’s already been sent, and once if it’s already waiting in the queue. That matters because one order can pass through Processing and then Completed.
3. choose what you send
function bb_owh_payload( WC_Order $order ) {
$dp = wc_get_price_decimals();
$items = array();
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
$items[] = array(
'name' => $item->get_name(),
'sku' => $product ? $product->get_sku() : '',
'quantity' => $item->get_quantity(),
'total' => wc_format_decimal( $item->get_total(), $dp ),
'meta' => wp_list_pluck( $item->get_formatted_meta_data(), 'value', 'key' ),
);
}
$paid = $order->get_date_paid();
return array(
'event' => 'order.paid',
'order_id' => $order->get_id(),
'number' => $order->get_order_number(),
'status' => $order->get_status(),
'currency' => $order->get_currency(),
'total' => wc_format_decimal( $order->get_total(), $dp ),
'date_paid' => $paid ? $paid->format( DATE_ATOM ) : null,
'email' => $order->get_billing_email(),
'first_name' => $order->get_billing_first_name(),
'items' => $items,
);
}
This is the part worth thinking about. The built-in webhook sends everything, including addresses and phone numbers, to a third party. Every field here leaves your store and gets stored in someone else’s system, so send what the automation actually uses. If the Zap only posts “new order, $33.90” to Slack, take out email and first_name. If it adds people to a mailing list, keep the email, and make sure your checkout asks for that consent.
A few details:
numbervsorder_id. They’re often the same, but an order-number plugin can make them differ.numberis what the customer sees, andorder_idis what you’d use to look the order up.totalis a string with fixed decimals, like"33.90", so a spreadsheet doesn’t turn it into33.9.date_paidkeeps its time zone offset. It’s sent as2026-10-17T10:00:00-04:00, not a bare time that the other end has to guess the zone of.metacarries each line’s item meta. If you added a custom field to the product page, say engraving text, it arrives here as"Engraving": "Sam", ready to drop into a job sheet. Internal keys starting with_are left out.
4. send it, sign it, retry it
add_action( 'bb_owh_send', 'bb_owh_send', 10, 2 );
function bb_owh_send( $order_id, $attempt = 1 ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! bb_owh_configured() || $order->get_meta( '_bb_webhook_sent' ) ) {
return;
}
$body = wp_json_encode( bb_owh_payload( $order ) );
$response = wp_remote_post( BB_ORDER_WEBHOOK_URL, array(
'timeout' => 15,
'headers' => array(
'Content-Type' => 'application/json',
'X-BB-Signature' => base64_encode( hash_hmac( 'sha256', $body, BB_ORDER_WEBHOOK_SECRET, true ) ),
),
'body' => $body,
) );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 200 && $code < 300 ) {
$order->update_meta_data( '_bb_webhook_sent', time() );
$order->save();
return;
}
$reason = is_wp_error( $response ) ? $response->get_error_message() : 'HTTP ' . $code;
if ( $attempt < 4 ) {
as_schedule_single_action( time() + 300 * $attempt, 'bb_owh_send', array( $order_id, $attempt + 1 ), 'bb-order-webhook' );
$order->add_order_note( sprintf( 'Order webhook failed (%s). Retry %d of 3 scheduled.', $reason, $attempt ) );
return;
}
$order->add_order_note( sprintf( 'Order webhook failed (%s) after %d attempts. Not retrying.', $reason, $attempt ) );
}
It only counts a 2xx answer as delivered. A timeout, a DNS failure or a 500 from the other end is a failure. The order is only marked sent when the other end says it got it.
A failure retries 5, 10 and then 15 minutes later, then stops. Each failure leaves a note on the order saying what went wrong, so the person who notices a missing Slack message can open the order and see why. The built-in webhook switches itself off instead (trap 4). This keeps trying for each order and tells you about the ones it gave up on.
The order meta is written with update_meta_data and save, not update_post_meta, so it works whether your store uses the newer order tables (HPOS) or the old post storage.
The signature is the same recipe WooCommerce uses for its own X-WC-Webhook-Signature: an HMAC-SHA256 of the raw body with your secret, base64-encoded. If the receiving end is your own code, check it before trusting the body:
$body = file_get_contents( 'php://input' );
$expected = base64_encode( hash_hmac( 'sha256', $body, $secret, true ) );
if ( ! hash_equals( $expected, $_SERVER['HTTP_X_BB_SIGNATURE'] ?? '' ) ) {
http_response_code( 401 );
exit;
}
hash_equals compares in constant time, so the check doesn’t leak how much of a guessed signature was right. Zapier’s Catch Hook doesn’t check signatures. There, the secrecy of the URL is the protection, which is why it lives in wp-config.php.
testing it
Do this on a staging copy of the store, not the live one:
- Point
BB_ORDER_WEBHOOK_URLat a fresh Catch Hook (or a request-inspector URL, if you just want to see the body). - Turn on Cash on delivery on staging. It puts orders straight into Processing, so you can place a “paid” order without a card.
- Place an order. Open WooCommerce → Status → Scheduled Actions, search for
bb_owh_send, and check the action completed. - Check the body that arrived. Is the total right, are the line items there, is any custom field in
meta? - Mark the same order Completed. Nothing new should be sent.
- Break the URL on purpose (change one character), place another order, and check the order notes. You should see “Retry 1 of 3 scheduled”.
Only then point it at the real Zap.
what this doesn’t do
- Refunds and cancellations aren’t sent. This sends “paid” and nothing else. If the other system needs to know about refunds, add a second event (
woocommerce_order_refundedis a good place) with its owneventname in the payload, so the automation can tell them apart. - Orders from before you installed it aren’t sent. It acts on status changes from now on. For a backfill, export the old orders once instead.
- One URL. If you need different orders going to different places, send everything to one Zap and branch there, or extend
bb_owh_sendto pick the URL from the order. - Delivery isn’t exactly-once. It’s once in the normal case. If your store and Zapier both time out at exactly the wrong moment, Zapier can receive an order that the store then retries. If a duplicate would do damage (like charging something), deduplicate on
order_idat the receiving end too.
when to reach for a plugin or the official app instead
If the store needs two-way sync, like stock levels coming back in, products created from a spreadsheet or customers updated from a CRM, that’s more than a webhook. Use Zapier’s WooCommerce app with its extension, or a dedicated integration for the system you’re connecting. Rebuilding one in functions.php isn’t a good use of anyone’s time.
If what you need is “tell another tool when an order is paid”, the built-in webhook works if you can live with the traps above, and the plugin below works if you’d rather not.
the full file
Everything above in one plugin. Save it as wp-content/plugins/order-webhook/order-webhook.php, add the two define lines to wp-config.php, and activate it under Plugins:
<?php
/**
* Plugin Name: Order Webhook
* Description: Sends each paid WooCommerce order to one webhook URL (Zapier, Make, n8n or your own), once, in the background.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
function bb_owh_configured() {
return defined( 'BB_ORDER_WEBHOOK_URL' ) && defined( 'BB_ORDER_WEBHOOK_SECRET' )
&& BB_ORDER_WEBHOOK_URL && BB_ORDER_WEBHOOK_SECRET;
}
function bb_owh_paid_statuses() {
return array( 'processing', 'completed' );
}
add_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {
if ( ! bb_owh_configured() || ! in_array( $to, bb_owh_paid_statuses(), true ) ) {
return;
}
if ( $order->get_meta( '_bb_webhook_sent' ) ) {
return;
}
if ( as_has_scheduled_action( 'bb_owh_send', array( $order_id, 1 ), 'bb-order-webhook' ) ) {
return;
}
as_enqueue_async_action( 'bb_owh_send', array( $order_id, 1 ), 'bb-order-webhook' );
}, 10, 4 );
function bb_owh_payload( WC_Order $order ) {
$dp = wc_get_price_decimals();
$items = array();
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
$items[] = array(
'name' => $item->get_name(),
'sku' => $product ? $product->get_sku() : '',
'quantity' => $item->get_quantity(),
'total' => wc_format_decimal( $item->get_total(), $dp ),
'meta' => wp_list_pluck( $item->get_formatted_meta_data(), 'value', 'key' ),
);
}
$paid = $order->get_date_paid();
return array(
'event' => 'order.paid',
'order_id' => $order->get_id(),
'number' => $order->get_order_number(),
'status' => $order->get_status(),
'currency' => $order->get_currency(),
'total' => wc_format_decimal( $order->get_total(), $dp ),
'date_paid' => $paid ? $paid->format( DATE_ATOM ) : null,
'email' => $order->get_billing_email(),
'first_name' => $order->get_billing_first_name(),
'items' => $items,
);
}
add_action( 'bb_owh_send', 'bb_owh_send', 10, 2 );
function bb_owh_send( $order_id, $attempt = 1 ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! bb_owh_configured() || $order->get_meta( '_bb_webhook_sent' ) ) {
return;
}
$body = wp_json_encode( bb_owh_payload( $order ) );
$response = wp_remote_post( BB_ORDER_WEBHOOK_URL, array(
'timeout' => 15,
'headers' => array(
'Content-Type' => 'application/json',
'X-BB-Signature' => base64_encode( hash_hmac( 'sha256', $body, BB_ORDER_WEBHOOK_SECRET, true ) ),
),
'body' => $body,
) );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 200 && $code < 300 ) {
$order->update_meta_data( '_bb_webhook_sent', time() );
$order->save();
return;
}
$reason = is_wp_error( $response ) ? $response->get_error_message() : 'HTTP ' . $code;
if ( $attempt < 4 ) {
as_schedule_single_action( time() + 300 * $attempt, 'bb_owh_send', array( $order_id, $attempt + 1 ), 'bb-order-webhook' );
$order->add_order_note( sprintf( 'Order webhook failed (%s). Retry %d of 3 scheduled.', $reason, $attempt ) );
return;
}
$order->add_order_note( sprintf( 'Order webhook failed (%s) after %d attempts. Not retrying.', $reason, $attempt ) );
}