WooCommerce WhatsApp Notifications With Order Hooks
Your customers ignore order emails and then message you on WhatsApp asking where their parcel is. The fix everyone reaches for is WooCommerce WhatsApp notifications: a message when the order is confirmed, another when it ships. Plugins exist, but many are a black box that bills per message or quietly sends from a number you don't control.
Doing it yourself is a small plugin of about 100 lines. This guide builds it: an opt-in checkbox on the Checkout block, a hook on order status changes, a background job that calls a WhatsApp API, and an order note recording what happened. The main version sends through Tenashi from your store's existing WhatsApp number; a second version sends approved templates through Meta's Cloud API.
Key takeaways
- Hook
woocommerce_order_status_changed, but never call an external API inside it. Queue the send with Action Scheduler. - Ask for consent at checkout with a checkbox, and only message customers who ticked it.
- Normalise the billing phone to international format before sending. Bad numbers are a frequent reason a message fails.
- On Meta's Cloud API, every order update is an approved utility template, charged per delivered message.
Which order events deserve a WhatsApp message
Three or four messages per order is plenty. Every extra one trains customers to mute you.
| Status change | Message | Cloud API template category |
|---|---|---|
pending to processing | Order confirmed, we're packing it | Utility |
to completed | On its way, with tracking if you have it | Utility |
to on-hold | Waiting for your bank transfer or payment to clear | Utility |
to cancelled | Cancelled, reply if that was unexpected | Utility |
Statuses arrive in the hook without the wc- prefix you see in the database. Custom statuses from fulfilment plugins work the same way, so if you use a shipped status, add it to the list.
Abandoned-cart reminders and "you might also like" messages are not order updates. On Meta's platform they are marketing templates, priced higher and judged more strictly. WhatsApp template categories covers where the line is, and our broader guide to WhatsApp order notifications through the API goes further on timing and wording.
The plugin
Save this as wp-content/plugins/order-whatsapp-updates/order-whatsapp-updates.php, add your key to wp-config.php with define( 'TENASHI_KEY', 'tk_live_...' );, and activate it.
<?php
/**
* Plugin Name: Order WhatsApp Updates
* Description: Sends WhatsApp order updates through an API when an order changes status.
*/
defined( 'ABSPATH' ) || exit;
// In wp-config.php: define( 'TENASHI_KEY', 'tk_live_...' );
const MYSTORE_WA_FIELD = 'mystore/whatsapp-updates';
// Works with High-Performance Order Storage: we only use WC_Order methods.
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
} );
// 1. Opt-in checkbox on the Checkout block.
add_action( 'woocommerce_init', function () {
if ( function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
woocommerce_register_additional_checkout_field( array(
'id' => MYSTORE_WA_FIELD,
'label' => 'Send me order updates on WhatsApp',
'location' => 'contact',
'type' => 'checkbox',
) );
}
} );
// 2. Queue a message when the status changes. Never call an API inside this hook.
add_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {
if ( ! in_array( $to, array( 'processing', 'completed', 'on-hold', 'cancelled' ), true ) ) {
return;
}
// Checkout-block checkboxes are stored as "1" or "0" under the _wc_other/ prefix.
if ( '1' !== (string) $order->get_meta( '_wc_other/' . MYSTORE_WA_FIELD ) ) {
return; // no opt-in, no message
}
as_enqueue_async_action( 'mystore_send_order_whatsapp', array( $order_id, $to ), 'whatsapp' );
}, 10, 4 );
// 3. Runs moments later in the background, through Action Scheduler.
add_action( 'mystore_send_order_whatsapp', function ( $order_id, $status ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$phone = mystore_whatsapp_phone( $order );
if ( ! $phone ) {
$order->add_order_note( 'WhatsApp update skipped: no usable phone number.' );
return;
}
$response = wp_remote_post( 'https://usetenashi.com/api/v1/messages', array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Bearer ' . TENASHI_KEY,
'Content-Type' => 'application/json',
'Idempotency-Key' => 'order-' . $order_id . '-' . $status,
),
'body' => wp_json_encode( array(
'to' => $phone,
'text' => mystore_whatsapp_text( $order, $status ),
) ),
) );
if ( is_wp_error( $response ) ) {
throw new Exception( $response->get_error_message() ); // Action Scheduler logs the failure
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 202 === $code ) {
$order->add_order_note( sprintf( 'WhatsApp %s update queued (%s).', $status, $body['message']['id'] ) );
} else {
$order->add_order_note( 'WhatsApp update failed: ' . ( $body['error']['code'] ?? 'HTTP ' . $code ) );
}
}, 10, 2 );
/** Billing phone in international format, or null if it can't be trusted. */
function mystore_whatsapp_phone( WC_Order $order ) {
$raw = trim( $order->get_billing_phone() );
$digits = preg_replace( '/\D+/', '', $raw );
if ( strlen( $digits ) < 7 ) {
return null;
}
if ( '+' === substr( $raw, 0, 1 ) ) {
return '+' . $digits; // already international
}
if ( '00' === substr( $digits, 0, 2 ) ) {
return '+' . substr( $digits, 2 ); // 00 international prefix
}
$calling = WC()->countries->get_country_calling_code( $order->get_billing_country() ); // e.g. "+91"
if ( ! $calling ) {
return null;
}
$cc = ltrim( $calling, '+' );
if ( 0 === strpos( $digits, $cc ) && strlen( $digits ) > 10 ) {
return '+' . $digits; // typed with the country code but no plus
}
return $calling . ltrim( $digits, '0' ); // national number: drop the trunk 0
}
function mystore_whatsapp_text( WC_Order $order, $status ) {
$name = $order->get_billing_first_name();
$number = $order->get_order_number();
switch ( $status ) {
case 'processing':
return "Hi {$name}, thanks for order #{$number}. We're packing it now and will message you when it ships.";
case 'completed':
return "Hi {$name}, order #{$number} is on its way. Reply here if anything looks wrong.";
case 'on-hold':
return "Hi {$name}, we've reserved order #{$number} and are waiting for your payment to clear.";
case 'cancelled':
return "Hi {$name}, order #{$number} has been cancelled. Reply here if that wasn't expected.";
}
return '';
}
The rest of this guide explains the four decisions in that file.
Ask for opt-in at checkout
Since WooCommerce 8.3, new stores using a block theme get the Checkout block by default. Its Additional Checkout Fields API adds a field with one call: woocommerce_register_additional_checkout_field on woocommerce_init, with a namespaced ID, a label, a location (contact, address or order) and a type, here checkbox. WooCommerce saves the value on the order under the _wc_other/ meta prefix, as "1" when ticked.
Leave the box unticked by default and say exactly what it is for. Consent is more than a nicety. Meta's WhatsApp Business Messaging Policy requires opt-in before you message people through the platform, and on any number, customers who didn't expect a message block or report you, which is how numbers get banned. The opt-in policy guide covers what valid consent looks like.
Stores still on the classic shortcode checkout need the older approach instead: print a checkbox from a checkout form hook, then save it to the order during checkout processing. Read it the same way with $order->get_meta().
Hook the status change, send in the background
woocommerce_order_status_changed passes four arguments: the order ID, the old status, the new status and the WC_Order object, so register it with 10, 4. It fires from WC_Order::status_transition() only when there was a previous status, which covers checkout, payment confirmations and admin edits, but not orders created directly in a final status by an import.
The hook runs inside whatever request changed the status: the customer's checkout, a payment gateway's callback, or an admin clicking Update. A slow or failing API call there delays checkout or breaks a gateway callback. So the hook only decides whether to send and queues the work with as_enqueue_async_action. Action Scheduler ships with WooCommerce, runs the job in the background, passes the queued arguments to your callback in order, and records a failed run with its exception under WooCommerce, Status, Scheduled Actions. On quiet stores it relies on WP-Cron, so a real server cron hitting wp-cron.php keeps messages prompt.
The Idempotency-Key of order-1042-processing makes the send safe to repeat. If the job runs twice, or an admin flips the status away and back within 24 hours, Tenashi returns the first response and sends nothing new.
Get the phone number right
Billing phones arrive in every format: 098111 22233, 9811122233, +44 7911 123456, 0044 7911 123456. mystore_whatsapp_phone() turns them into +919811122233 style numbers using WC()->countries->get_country_calling_code() for the billing country, and handles customers who typed the country code without the plus. The ten-digit check is a heuristic, so test it against a few hundred real numbers from your own orders, especially if you sell into countries whose national numbers keep a leading zero.
When the function returns null, the plugin writes an order note instead of guessing. A message to the wrong number is worse than no message. Making the phone field required at checkout reduces how often that happens.
The Cloud API version: approved utility templates
On Meta's Cloud API, a store can't send free text to a customer who hasn't messaged it in the last 24 hours, so every order update is a template you create and get approved first, such as order_confirmed with the body "Hi {{1}}, your order {{2}} is confirmed. We'll message you when it ships." Swap the Tenashi call for this function:
<?php
// Cloud API variant. In wp-config.php: WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID.
function mystore_send_template( $phone, $template, array $params ) {
$response = wp_remote_post(
'https://graph.facebook.com/v23.0/' . WHATSAPP_PHONE_NUMBER_ID . '/messages',
array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Bearer ' . WHATSAPP_TOKEN,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array(
'messaging_product' => 'whatsapp',
'to' => $phone,
'type' => 'template',
'template' => array(
'name' => $template,
'language' => array( 'code' => 'en' ),
'components' => array( array(
'type' => 'body',
'parameters' => array_map( function ( $p ) {
return array( 'type' => 'text', 'text' => (string) $p );
}, $params ),
) ),
),
) ),
)
);
if ( is_wp_error( $response ) ) {
throw new Exception( $response->get_error_message() );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
throw new Exception( 'WhatsApp error ' . ( $body['error']['code'] ?? 'unknown' ) ); // 132001: template/language mismatch
}
return $body['messages'][0]['id']; // wamid; delivery arrives later by webhook
}
// In the background action, instead of the Tenashi call:
// mystore_send_template( $phone, 'order_confirmed', array( $order->get_billing_first_name(), $order->get_order_number() ) );
Use the current Graph API version from Meta's changelog in place of v23.0. Keep order templates strictly factual. A discount line can get a template recategorised as marketing, and Meta can change a template's category itself. Utility messages are charged per delivered message by country, and since October 1, 2026 that includes utility templates sent while a customer service window is open, which used to be free (as of October 2026, per Meta's pricing page). The utility message pricing guide works through the numbers. Delivery status only arrives by webhook, so add a webhook receiver if you want "delivered" in the order notes; the WhatsApp API in PHP guide has one ready to adapt.
Where Tenashi fits
Order notifications are the easy half. The hard half is what happens next: a customer replies "can you change the address?" and that reply lands on a phone at the warehouse or in a Cloud API dashboard nobody watches.
Tenashi links your store's existing WhatsApp or WhatsApp Business number with a QR scan. The plugin above sends through its API, and every update appears in a shared team inbox in the same thread as the customer's reply, with the contact, a deal or ticket and follow-ups under the chat. No templates to approve and no per-message fees. Limits worth knowing: sends are paced at one every 4 to 12 seconds per number and new chats through the API are capped at 50 a day per number, so a store shipping thousands of orders a day needs Meta's platform for notifications. WhatsApp can still ban numbers that spam. From $18/mo. If you also sell on Shopify, see WhatsApp for Shopify.
Read the API docs · Get started
FAQ
Which WooCommerce hook should I use for WhatsApp order notifications?
woocommerce_order_status_changed is the most flexible: one callback gets the order ID, old status, new status and order object for every transition. Status-specific hooks such as woocommerce_order_status_processing also work. Whichever you use, queue the API call with Action Scheduler instead of making it inside the hook, so checkout and payment callbacks stay fast.
Do customers have to opt in to WooCommerce WhatsApp messages?
On Meta's platform, yes: the WhatsApp Business Messaging Policy requires opt-in before you message people. On any number it is still the right call, because unexpected messages get blocked and reported, which puts the number at risk. A clear, unticked checkbox at checkout is the simplest way to collect and record consent.
Does this work with the classic checkout shortcode?
The sending part does. The opt-in checkbox in the plugin uses the Checkout block's Additional Checkout Fields API, which the classic shortcode checkout doesn't render. On a classic checkout, add the checkbox with a checkout form hook, save its value to the order meta during checkout processing, and read it the same way before queueing the message.
Is it compatible with High-Performance Order Storage?
Yes. The plugin reads and writes orders only through WC_Order methods such as get_meta() and add_order_note(), never through post meta functions, and declares compatibility with the custom_order_tables feature. That keeps it working whether your store uses HPOS or the older posts tables.
Can I include the tracking number in the shipped message?
Yes. Read the tracking number from wherever your shipping plugin stores it, usually order meta, and add it to the completed text. On the Cloud API, include it as a template variable rather than editing the template body, so the approved template stays the same for every order.
A hundred lines of PHP, an opt-in checkbox and a background job give you order updates customers actually read. To send them from your store's own number and answer the replies as a team, read the Tenashi API docs, and for the wider picture of templates, pricing and limits, see the complete guide to the WhatsApp API.