Send WooCommerce order updates on WhatsApp
This guide is the build companion to WhatsApp order updates for ecommerce, which covers which updates are worth sending.
What you need
- A WhatsApp inbox. Copy its
idfrom List inboxes. - Approved templates. You need one per update, for example
order_confirmed,order_shippedandorder_cancelled, each with the customer's first name and the order number as body placeholders. - A service account API key. Store it outside the code, as described in Authentication.
- Action Scheduler. It ships with WooCommerce and runs the sends in the background.
- Customer consent. Message only customers who agreed at checkout to get order updates on WhatsApp.
Why do WooCommerce order updates need templates?
Order updates usually reach customers who haven't written to you in the last 24 hours. Outside that window, WhatsApp delivers only approved templates.
Free-form text works only while the customer's reply window is open.
List the templates an inbox can send with List WhatsApp templates:
curl "https://api.flownally.com/v1/inboxes/$FLOWNALLY_INBOX_ID/whatsapp/templates" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"{
"templates": [
{
"id": "987654321",
"name": "order_confirmed",
"language": "en",
"status": "APPROVED",
"category": "UTILITY",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, your order {{2}} is confirmed. We'll message you when it ships."
}
]
}
]
}- Send only approved templates. Templates with any
statusother thanAPPROVEDcan't be sent. - Copy the name and language exactly. The send request uses the same
nameandlanguagevalues. - Fill placeholders in order. Each
{{1}},{{2}}in the body takes one entry in the body component'sparameters.
Which WooCommerce statuses should send a message?
Every message should change something for the customer. A status that only matters to the warehouse is noise on their phone.
woocommerce_order_status_changed passes the order ID, the old status, the new status and the order. Statuses come without the wc- prefix.
| WooCommerce status | Customer moment | Template |
|---|---|---|
processing | Payment received, order confirmed | order_confirmed |
completed | Order fulfilled and on its way | order_shipped |
cancelled | Order canceled | order_cancelled |
Treat completed as shipped only if your team completes orders when they leave the warehouse. Skip pending and on-hold unless the customer has to act, as with an order waiting for a bank transfer.
Hook woocommerce_order_status_changed in a WordPress plugin
The hook runs while WooCommerce saves the order, so a slow API call there slows down checkout and the order screen. Queue the send, and let Action Scheduler run it in the background.
Status changes → Action queued → Template sent → Message ID added to the order notes
Add your API key and inbox ID to wp-config.php. Many managed WordPress hosts don't pass environment variables to PHP, so the plugin reads these constants first and only then the environment.
define( 'FLOWNALLY_API_TOKEN', 'flw_…' );
define( 'FLOWNALLY_INBOX_ID', 'ibx_…' );Save this file as flownally-order-updates.php in a wp-content/plugins/flownally-order-updates folder and activate the plugin.
/**
* Plugin Name: Flownally order updates
* Requires Plugins: woocommerce
*/
defined( 'ABSPATH' ) || exit;
const FLOWNALLY_API_URL = 'https://api.flownally.com/v1';
// Reads a setting from wp-config.php, then from the server environment.
function flownally_setting( $name ) {
return defined( $name ) ? (string) constant( $name ) : (string) getenv( $name );
}
// Idempotency keys are shared by the whole workspace, so the store's host keeps two stores apart.
function flownally_key( $order_id, $status ) {
$host = wp_parse_url( home_url(), PHP_URL_HOST );
return "woocommerce-{$host}-order-{$order_id}-{$status}";
}
// WooCommerce statuses, without the wc- prefix, mapped to approved template names.
const FLOWNALLY_ORDER_TEMPLATES = array(
'processing' => 'order_confirmed',
'completed' => 'order_shipped',
'cancelled' => 'order_cancelled',
);
add_action( 'woocommerce_order_status_changed', 'flownally_queue_order_update', 10, 4 );
add_action( 'flownally_order_update', 'flownally_send_order_update', 10, 3 );
function flownally_queue_order_update( $order_id, $old_status, $new_status, $order ) {
if ( ! isset( FLOWNALLY_ORDER_TEMPLATES[ $new_status ] ) ) {
return;
}
as_enqueue_async_action( 'flownally_order_update', array( $order_id, $new_status ), 'flownally' );
}
function flownally_send_order_update( $order_id, $status, $attempt = 1 ) {
$order = wc_get_order( $order_id );
$phone = $order ? flownally_order_phone( $order ) : '';
if ( '' === $phone ) {
return;
}
list( $code, $data ) = flownally_post(
'/messages',
array(
'inboxId' => flownally_setting( 'FLOWNALLY_INBOX_ID' ),
'recipient' => array(
'type' => 'phone',
'phoneNumber' => $phone,
),
'content' => array(
'type' => 'template',
'template' => array(
'name' => FLOWNALLY_ORDER_TEMPLATES[ $status ],
'language' => 'en',
'components' => array(
array(
'type' => 'body',
'parameters' => array(
array(
'type' => 'text',
'text' => $order->get_billing_first_name() ?: 'there',
),
array(
'type' => 'text',
'text' => $order->get_order_number(),
),
),
),
),
),
),
),
flownally_key( $order_id, $status )
);
if ( 202 === $code ) {
$order->add_order_note( 'WhatsApp update accepted by Flownally: ' . $data['message']['id'] );
return;
}
// Retry network errors, rate limits and server errors with the same Idempotency-Key.
if ( ( 0 === $code || 429 === $code || $code >= 500 ) && $attempt < 5 ) {
as_schedule_single_action(
time() + 60 * 2 ** $attempt,
'flownally_order_update',
array( $order_id, $status, $attempt + 1 ),
'flownally'
);
return;
}
$order->add_order_note( "WhatsApp update not sent: Flownally returned HTTP {$code}." );
}
// Returns the billing phone in E.164 format, or '' when it can't be built.
function flownally_order_phone( WC_Order $order ) {
$phone = wc_sanitize_phone_number( $order->get_billing_phone() );
if ( '' === $phone ) {
return '';
}
if ( str_starts_with( $phone, '+' ) ) {
return $phone;
}
if ( str_starts_with( $phone, '00' ) ) {
return '+' . substr( $phone, 2 );
}
// A national number: add the billing country's calling code and drop the leading 0.
$calling_code = WC()->countries->get_country_calling_code( $order->get_billing_country() );
return $calling_code ? $calling_code . ltrim( $phone, '0' ) : '';
}
// Sends a JSON POST to the Flownally API and returns [status code, decoded body].
function flownally_post( $path, array $body, $idempotency_key = '' ) {
$headers = array(
'Authorization' => 'Bearer ' . flownally_setting( 'FLOWNALLY_API_TOKEN' ),
'Content-Type' => 'application/json',
);
if ( '' !== $idempotency_key ) {
$headers['Idempotency-Key'] = $idempotency_key;
}
$response = wp_remote_post(
FLOWNALLY_API_URL . $path,
array(
'timeout' => 15,
'headers' => $headers,
'body' => wp_json_encode( $body ),
)
);
if ( is_wp_error( $response ) ) {
return array( 0, null );
}
return array(
wp_remote_retrieve_response_code( $response ),
json_decode( wp_remote_retrieve_body( $response ), true ),
);
}- It never blocks checkout.
flownally_queue_order_updateonly queues an action withas_enqueue_async_action. - It sends each update once. The
Idempotency-Keyiswoocommerce-{store host}-order-{id}-{status}, so when WooCommerce fires the hook twice or a retry repeats the request, Flownally returns the original message instead of sending another one. Keys are shared across your workspace; the store host stops a staging store's order #123 from blocking production's. - It leaves a trail for support. A
202means Flownally accepted the message, and the plugin adds the messageidto the order notes.
How do I format the WooCommerce billing phone as E.164?
Customers type phone numbers however they like, and a phone recipient needs the country code. flownally_order_phone keeps numbers typed with + or 00, and adds the billing country's calling code to numbers typed in national format.
Orders without a usable number are skipped.
The leading-zero rule isn't universal
Dropping the leading 0 from a national number fits most countries, but not all. To validate numbers properly, use libphonenumber-for-php, a PHP port of Google's libphonenumber. Sync store customers and phone numbers shows the same approach in Node.js and Python.
Retry failed sends without sending twice
A duplicate order update confuses the customer and costs another template message. Retries are safe only when they send the same Idempotency-Key.
- Retry network errors,
429and5xx. The plugin retries up to four times, with longer delays each time and the same key. That covers503with codemessaging_messaging_send_result_pending, which must be retried with the same key. - Keep the backoff on
429. Requests are rate limited per client IP address, and429responses carry noRetry-Afterheader. - Don't reuse a key for a different request.
409with codemessaging_messaging_idempotency_conflictmeans the key was already used for a different request. - Fix other
4xxerrors instead of retrying. Check the inbox ID, the templatenameandlanguage, and the number of parameters.
A 202 means accepted, not delivered. Delivery can still fail later, for example with failureCode template_unavailable or recipient_opted_out. Poll Get message, or subscribe to message.updated webhooks and verify their signatures.
When can I send free-form text instead?
If the customer messaged you within the last 24 hours, for example to ask about the order, plain text works. Replace content in the request body:
{
"inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
"recipient": {
"type": "phone",
"phoneNumber": "+48123456789"
},
"content": {
"type": "text",
"text": {
"body": "Hi Anna, order 4827 is packed and leaves the warehouse today."
}
}
}Outside the window, a text message fails with failureCode messaging_window_closed. Keep templates for updates you send automatically, and check the window first as shown in Handle the WhatsApp 24-hour reply window.
Let a journey send WooCommerce order updates
Templates hard-coded in the plugin mean a deploy for every wording change. With events, your marketing team changes the template, its variables or the timing in the Flownally app, and the plugin stays as it is.
Status changes → Event recorded → Journey sends the template
Create one event type per update with Create event type. payloadSchema lists the payload fields by name, each with a type. It's a simplified format, not a JSON Schema document, so don't wrap the fields in properties.
curl -X POST "https://api.flownally.com/v1/event-types" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "order.confirmed",
"description": "Store confirmed a paid order.",
"payloadSchema": {
"orderNumber": { "type": "string" },
"firstName": { "type": "string" },
"total": { "type": "number" },
"currency": { "type": "string" }
}
}'Create order.shipped and order.cancelled the same way. Events belong to a contact, so the plugin looks the contact up by phone number and creates it when it's missing.
Add these functions to the plugin, and point the background action at flownally_record_order_event:
add_action( 'flownally_order_update', 'flownally_record_order_event', 10, 2 );
// Event types per WooCommerce status, created with POST /event-types.
const FLOWNALLY_ORDER_EVENTS = array(
'processing' => 'order.confirmed',
'completed' => 'order.shipped',
'cancelled' => 'order.cancelled',
);
function flownally_record_order_event( $order_id, $status ) {
$order = wc_get_order( $order_id );
$phone = $order ? flownally_order_phone( $order ) : '';
$contact_id = '' !== $phone ? flownally_contact_id( $order, $phone ) : '';
if ( '' === $contact_id ) {
return;
}
list( $code ) = flownally_post(
"/contacts/{$contact_id}/events",
array(
'name' => FLOWNALLY_ORDER_EVENTS[ $status ],
'payload' => array(
'orderNumber' => $order->get_order_number(),
'firstName' => $order->get_billing_first_name(),
'total' => (float) $order->get_total(),
'currency' => $order->get_currency(),
),
'idempotencyKey' => flownally_key( $order_id, $status ),
)
);
// 409 means an event with this idempotency key was already recorded.
if ( 201 !== $code && 409 !== $code ) {
$order->add_order_note( "Flownally event not recorded: HTTP {$code}." );
}
}
// Returns the ID of the contact with this phone number, creating the contact if needed.
function flownally_contact_id( WC_Order $order, $phone ) {
list( $code, $found ) = flownally_post(
'/contacts:search',
array(
'query' => array(
'match' => 'all',
'conditions' => array(
array(
'type' => 'customFieldText',
'fieldId' => 'cf_phone',
'operator' => 'equals',
'value' => $phone,
),
array(
'type' => 'archiveStatus',
'status' => 'all',
),
),
),
'pageSize' => 1,
)
);
if ( 200 !== $code ) {
return '';
}
if ( ! empty( $found['contacts'] ) ) {
return $found['contacts'][0]['id'];
}
list( $code, $contact ) = flownally_post(
'/contacts',
array(
'name' => $order->get_formatted_billing_full_name(),
'phone' => $phone,
)
);
return 201 === $code ? $contact['id'] : '';
}Remove the add_action line for flownally_send_order_update, so each status change records an event instead of sending a message. Create contact event returns 400 when the payload doesn't match the event type, and 404 when the contact or event type doesn't exist.
Then build one journey per event type in the Flownally app, following How to create a journey. Start it when the event happens, add a Send message step with the template, and fill the template variables from the event details.
Allow contacts to enter again
A journey lets each contact in once by default. Allow contacts to enter again in the journey's Start block, or each customer gets only their first order update.
FAQ
Why does the send return 202 when the customer gets nothing?
202 means Flownally accepted the message, not that WhatsApp delivered it. Look the message up with Get message. A failed deliveryStatus comes with a failureCode, such as template_unavailable when the template is missing, paused or disabled, or its parameters don't match.
Does the hook fire when my team changes the status by hand?
Yes. woocommerce_order_status_changed fires on every status change, including ones made on the order screen. Marking an order completed by hand sends the shipped update.
Can an order get the same update twice?
Not with this plugin. The key woocommerce-{store host}-order-{id}-{status} sends each status once per order, and a retry within 24 hours returns the message it already sent instead of sending another. To send the same status again on purpose, use a new key, for example with a counter.
What happens to orders without a country code in the phone number?
flownally_order_phone adds the calling code of the billing country. When the number is empty or the order has no billing country, the plugin skips the order instead of guessing.