Skip to content
CaptiFi Connect

Behind the scenes9 min read

WooCommerce SMS order notifications with an SMS API

Send WooCommerce SMS notifications from the order status hooks inside WordPress: which hook fires when, a queued wp_remote_post call, and the key in wp-config.

In short

  • WooCommerce fires woocommerce_order_status_ followed by the new status, then a from_to_ hook and woocommerce_order_status_changed, each time an order's status changes and the order is saved.
  • WooCommerce's order status documentation says a Refunded order can reach that status without the customer's money having been returned when the Manual Refund option is used.
  • WordPress gives an HTTP request made with wp_remote_post a default timeout of 5 seconds and waits for the answer unless blocking is set to false.
  • High-Performance Order Storage has been on by default for new WooCommerce installations since version 8.2 in October 2023, so order data should be read through wc_get_order and not post meta.
  • The ONS estimated average weekly online retail sales in Great Britain at £2,640.1 million in September 2026, up from £960.5 million in September 2016, not seasonally adjusted.
A woman folding a large cardboard box at a packing table in a workshop
Photo: M. Cooper, Unsplash

WooCommerce already tells customers when their order moves on, by email. Its order status documentation says it "sends specific emails to you and your customers when order statuses change", and the obvious next step for a lot of shops is a text alongside the email: paid, packed, on its way. WooCommerce SMS order notifications don't need a separate service watching your store. A small plugin can listen for the order status hooks inside WordPress and call an SMS API, and the whole thing runs on the server you already have.

This post is about doing that properly, which mostly means three things: picking the right hook, keeping the text out of the customer's checkout request, and keeping the API key out of the database. It follows the WooCommerce code reference and the WordPress developer docs as they read on 4 November 2026.

There's plenty to text about. The ONS estimated that Great Britain's retailers took £2,640.1 million online in an average week of September 2026, close to three times the £960.5 million of September 2016 (in cash terms, before inflation).

Average weekly internet retail sales in September, Great Britain, 2016 to 2026
£0m£750m£1,500m£2,250m£3,000m2016, Average weekly online sales: £960.5m20162017, Average weekly online sales: £1,090.7m20172018, Average weekly online sales: £1,219m20182019, Average weekly online sales: £1,321.8m20192020, Average weekly online sales: £2,058m20202021, Average weekly online sales: £2,196.4m20212022, Average weekly online sales: £2,016.2m20222023, Average weekly online sales: £2,181.7m20232024, Average weekly online sales: £2,329.7m20242025, Average weekly online sales: £2,461.3m20252026, Average weekly online sales: £2,640.1m£2,640.1m2026

September of each year, current prices, not seasonally adjusted, so not adjusted for inflation. Released 23 October 2026; the latest month is a first estimate and may be revised.

Source: ONS, Retail Sales Index internet sales, table INTERNET (series JE2J)

See the figures as a table
 Average weekly online sales (£m)
2016960.5
20171,090.7
20181,219
20191,321.8
20202,058
20212,196.4
20222,016.2
20232,181.7
20242,329.7
20252,461.3
20262,640.1

Which order statuses deserve a text

WooCommerce's own definitions are the best guide to which statuses deserve a message.

  • Processing: "Payment has been received (paid), and the stock has been reduced. The order is awaiting fulfillment." The docs call it "the store owner's or warehouse's cue to ship the order and manually change the status to Completed". It's the order confirmation text.
  • Completed: "The order has been fulfilled and is complete." For most shops that means dispatched, so this is the on its way text.
  • On hold: "awaiting payment confirmation", typically a bank transfer. A text here can say what to pay and where.
  • Failed and Cancelled: the customer either knows already or needs to.
  • Refunded, with a catch. WooCommerce warns that "If you use the Manual Refund option, the order can reach this status without the customer's funds having been returned." A text that says "your refund is on its way" sent off this status could be untrue, so word it around what you did, or send it from your refund process.

Draft is a temporary checkout record from the block-based checkout and Pending payment means nothing has been paid. Neither should send a text.

The order status hooks

Every status change runs through one protected method, WC_Order::status_transition(), which is called at the end of save(). The code reference shows it firing three actions in a fixed order.

  1. woocommerce_order_status_{to}, so woocommerce_order_status_processing or woocommerce_order_status_completed. It passes the order id, the WC_Order object and a $status_transition array with from, to, note and manual, which is true when someone changed the status by hand.
  2. woocommerce_order_status_{from}_to_{to}, for example woocommerce_order_status_processing_to_completed, with the order id and the order.
  3. woocommerce_order_status_changed, with the order id, the old status, the new status and the order.

The second and third only fire when there was a previous status. Statuses come without the wc- prefix you see in the database. There's also woocommerce_order_payment_status_changed (since 3.9.0), which "Fires when the order progresses from a pending payment status to a paid one", meaning from pending or failed into one of the paid statuses.

For texts, the specific hooks are the easiest to reason about. add_action( 'woocommerce_order_status_completed', 'fernley_queue_dispatch_text', 10, 2 ); runs your function with the order id and the order whenever an order becomes Completed, from any earlier status. If you only want the text when an order goes straight from Processing to Completed (and not when someone reopens and recompletes an old order), use the from_to hook. One more detail from the source is worth knowing before you debug anything: the three actions run inside a try block, and an exception thrown by your code is caught, logged and added to the order as a note reading "Error during status transition." The order still saves, and the text never goes.

That manual flag is handy. Staff changing a status by hand in the admin sets it to true, so you can skip texts for tidying up old orders, or send them only for manual changes if your warehouse marks orders Completed one at a time. Changing a test order's status by hand is also the quickest way to see a hook fire.

Why the hook beats the built-in webhook here

WooCommerce has webhooks too, under WooCommerce, Settings, Advanced, Webhooks, with a secret used "to generate a hash of the delivered webhook" and automatic disabling "after more than five consecutive delivery failures". They exist to tell another system about a change, and the order topics fire as orders are added, edited or deleted. To text from one you'd need something at another URL to receive it, check its hash, work out whether the status changed and from what, and then call the SMS API.

The PHP hook already knows the old and new status, runs where the order lives, and needs no public endpoint. For a shop that only wants texts, it's less to build and less to keep running. (If you're on Shopify instead, where code can't run inside the store, our post on Shopify SMS notifications covers the webhook route and its signature check.)

Reading the order the HPOS way

From WooCommerce 8.2, released in October 2023, High-Performance Order Storage is "enabled by default for new installations". Orders live in their own tables (wc_orders, wc_order_addresses and two more) instead of WordPress posts. WooCommerce's recipe book tells extension authors to replace direct post and meta access with wc_get_order() and the order's own methods, and to declare compatibility with FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ) on the before_woocommerce_init action.

For a text you need very little from the order. $order->get_billing_phone() is the number the customer typed at checkout. get_shipping_phone(), added in 5.6.0, is there if your checkout asks for one. get_billing_first_name() gives you a name, and get_order_number() gives the number "for display", which is the order id unless a plugin changes it. Read these in the background job (below) with wc_get_order( $order_id ), not from the object handed to the hook, so the text reflects the order as it is when the job runs.

The billing phone field takes whatever the customer types, landlines and overseas numbers included. Our API accepts any common UK format and lists any number it can't text under rejected with a reason, such as not_mobile or unsupported_destination, and doesn't charge for it. Add that reason to the order as a note and move on; there's nothing to retry.

Keep the text out of the checkout

Processing is usually set while a payment is being confirmed, often inside the customer's own checkout request, and Completed is usually set by someone clicking in the admin. Either way, a hook that calls the SMS API directly makes that person wait for it. WordPress's HTTP functions default to a timeout of 5 seconds and to blocking true, which means "the calling code requires the result of the request".

Queue it instead. Action Scheduler is the job queue WooCommerce uses for its own webhooks, and its scheduled actions show under WooCommerce, Status, Scheduled Actions on any WooCommerce store. In the status hook, call as_enqueue_async_action( 'fernley_send_order_text', array( $order_id, 'completed' ), 'fernley-sms' ). That stores a job "to run one time, as soon as possible" and returns straight away. If the same order gets saved twice and queues two jobs, the idempotency key in the next section stops the second one sending. Action Scheduler runs every minute from WP-Cron, works in batches of 25 actions, marks an action failed if it runs for more than five minutes, and logs when each one is created, started, completed or failed. That log is the first place to look when a customer says they never got a text.

Calling the SMS API with wp_remote_post

The job then makes one request. With CaptiFi Connect it's wp_remote_post( 'https://captificonnect.co.uk/api/v1/messages', $args ), where $args holds:

  • headers: Authorization set to Bearer and your key, Content-Type: application/json, and an Idempotency-Key built from the order and the stage, such as order-1042-completed.
  • body: wp_json_encode() of an array with to (the billing phone; any common UK format works), body (the text) and client_ref (the order number, up to 64 characters).
  • timeout: 15 is reasonable in a background job, where nobody is waiting.

Check is_wp_error() first, then wp_remote_retrieve_response_code(). A 201 means accepted, so add an order note with the message id from the response and the shop has a record on the order screen. A 429 means slow down: read Retry-After and reschedule with as_schedule_single_action( time() + $wait, ... ) using the same idempotency key. A 402 means the balance is empty and nothing was sent, which wants an email to the shop owner and no retry. Because the idempotency key is fixed per order and stage, a retried job or a status set twice can't text the customer twice; our post on sending a text exactly once explains why. The WordPress docs suggest wp_safe_remote_post() "If the URL is user-controlled". Yours is a constant, so the plain function is fine.

The texts, for a shop called Fernley Tea:

Fernley Tea: thanks, Sam, we've got order 1042 and it's paid. We'll text again when it's posted.
Fernley Tea: order 1042 is on its way. Tracking details are in your email.

Order texts like these are service messages, so marketing consent doesn't come into it as long as nothing in them promotes anything. Our page on order and delivery updates has more wording.

Keeping the API key in wp-config.php

A key that sends texts spends money, so keep it out of the wp_options table, where any plugin can read it and every database export carries it. Define it as a constant in wp-config.php instead, with define( 'FERNLEY_SMS_KEY', 'cc_live_...' ); placed above the line that reads "That's all, stop editing!", and read the constant in your plugin. WordPress's hardening guide adds that wp-config.php "can be stored ONE directory level above" the WordPress installation, outside the web root, and should be readable only by you and the web server, "a 400 or 440 permission". Our post on keeping API keys safe covers rotation.

On a staging copy, put a cc_test_ key in the same constant. Test sends go through the same checks, cost nothing, reach no phone and still fire our webhooks, so you can push a dozen orders through every status before the live key goes in. The quickstart shows the request and response in PHP.

Common questions

Does WooCommerce send SMS notifications on its own?

WooCommerce's order status documentation describes emails, sent to the shop and the customer when an order's status changes. Texts need an extension or a few lines of your own code calling an SMS API from the order status hooks.

Which hook fires when a WooCommerce order is completed?

woocommerce_order_status_completed, with the order id and the order object. woocommerce_order_status_processing_to_completed fires only for that exact change, and woocommerce_order_status_changed fires for every change with the old and new status.

Will sending a text slow down WooCommerce checkout?

It will if the hook calls the API directly, because wp_remote_post waits for an answer by default, for up to 5 seconds. Queue the send with Action Scheduler's as_enqueue_async_action and the checkout carries on while the text goes out in the background.

Where should I store the SMS API key in WordPress?

In wp-config.php as a constant, not in the options table. That keeps it out of database exports and away from other plugins' settings screens.

Sources

Read for this post on 4 November 2026.

Written by the CaptiFi Connect team. Published 4 November 2026. More in Behind the scenes

Send your first text this week

Set up your account and contacts now. Until we've approved it, a person reads each text before it goes out.