# Send WooCommerce order updates on WhatsApp

:::summary Quick answer
To send WooCommerce order updates on WhatsApp, hook `woocommerce_order_status_changed` in a small WordPress plugin and call `POST /messages` with an approved WhatsApp template. Send from a background action, so checkout stays fast. Build the `Idempotency-Key` from the order ID and status, so a retry never sends the same update twice.

**Send a template outside the 24-hour window, free-form text inside it.**
:::

This guide is the build companion to [WhatsApp order updates for ecommerce](https://www.flownally.com/blog/whatsapp-order-updates-ecommerce), which covers which updates are worth sending.

## What you need

- **A WhatsApp inbox.** Copy its `id` from [List inboxes](/api/reference/inboxes#get-inboxes).
- **Approved templates.** You need one per update, for example `order_confirmed`, `order_shipped` and `order_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](/api/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](/api/reply-window) is open.**

List the templates an inbox can send with [List WhatsApp templates](/api/reference/inboxes#list-whats-app-templates):

:::code
```bash
curl "https://api.flownally.com/v1/inboxes/$FLOWNALLY_INBOX_ID/whatsapp/templates" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch(
  `https://api.flownally.com/v1/inboxes/${process.env.FLOWNALLY_INBOX_ID}/whatsapp/templates`,
  {
    headers: {
      Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      Accept: "application/json",
    },
  },
);

if (!response.ok) {
  throw new Error(`Flownally API returned ${response.status}: ${await response.text()}`);
}

const { templates } = await response.json();
```

```python
import os

import requests

response = requests.get(
    f"https://api.flownally.com/v1/inboxes/{os.environ['FLOWNALLY_INBOX_ID']}/whatsapp/templates",
    headers={
        "Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}",
        "Accept": "application/json",
    },
)
response.raise_for_status()
templates = response.json()["templates"]
```

```php
$client = new GuzzleHttp\Client();

$response = $client->get(
    'https://api.flownally.com/v1/inboxes/' . getenv('FLOWNALLY_INBOX_ID') . '/whatsapp/templates',
    ['headers' => [
        'Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN'),
        'Accept' => 'application/json',
    ]],
);

$templates = json_decode((string) $response->getBody(), true)['templates'];
```
:::

```json
{
  "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 `status` other than `APPROVED` can't be sent.
- **Copy the name and language exactly.** The send request uses the same `name` and `language` values.
- **Fill placeholders in order.** Each `{{1}}`, `{{2}}` in the body takes one entry in the body component's `parameters`.

## 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.

```php
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.

```php
<?php
/**
 * 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_update` only queues an action with `as_enqueue_async_action`.
- **It sends each update once.** The `Idempotency-Key` is `woocommerce-{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 `202` means Flownally accepted the message, and the plugin adds the message `id` to 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.

:::callout 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](/api/sync-store-customers) 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`](/api/idempotency).**

- **Retry network errors, `429` and `5xx`.** The plugin retries up to four times, with longer delays each time and the same key. That covers `503` with code `messaging_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, and `429` responses carry no `Retry-After` header.
- **Don't reuse a key for a different request.** `409` with code `messaging_messaging_idempotency_conflict` means the key was already used for a different request.
- **Fix other `4xx` errors instead of retrying.** Check the inbox ID, the template `name` and `language`, 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](/api/reference/messaging#get-message), or subscribe to `message.updated` [webhooks](/api/webhooks) and [verify their signatures](/api/webhook-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:

```json
{
  "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](/api/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](/api/reference/events#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`.

:::code
```bash
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" }
    }
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/event-types", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "order.confirmed",
    description: "Store confirmed a paid order.",
    payloadSchema: {
      orderNumber: { type: "string" },
      firstName: { type: "string" },
      total: { type: "number" },
      currency: { type: "string" },
    },
  }),
});

if (!response.ok) {
  throw new Error(`Flownally API returned ${response.status}: ${await response.text()}`);
}
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/event-types",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "name": "order.confirmed",
        "description": "Store confirmed a paid order.",
        "payloadSchema": {
            "orderNumber": {"type": "string"},
            "firstName": {"type": "string"},
            "total": {"type": "number"},
            "currency": {"type": "string"},
        },
    },
)
response.raise_for_status()
```

```php
$client = new GuzzleHttp\Client();

$client->post('https://api.flownally.com/v1/event-types', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        '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`:

```php
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](/api/reference/events#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](/journeys/create-journey). Start it when the event happens, add a [Send message](/journeys/journey-blocks/send-message-block) step with the template, and fill the template variables from the event details.

:::callout 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](/api/reference/messaging#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.

## Related articles

- [WhatsApp order updates for ecommerce](https://www.flownally.com/blog/whatsapp-order-updates-ecommerce)
- [Send Shopify order updates on WhatsApp](/api/shopify-order-updates)
- [Retry requests safely with idempotency keys](/api/idempotency)
- [Handle the WhatsApp 24-hour reply window](/api/reply-window)
- [Sync store customers and phone numbers](/api/sync-store-customers)
