WP Webhooks / Blog / WordPress integrations
Article · WordPress integrations

Sending WooCommerce Order SMS Through Twilio Programmable Messaging

Build WooCommerce SMS order notifications on Twilio: the right status hook, E.164 phone normalisation, per-segment cost maths, and signed delivery callbacks.

9 min 2026-08-07
#woocommerce#twilio#automation

TL;DR: The Twilio call is the easy part. WooCommerce hands you a phone number Twilio will reject, on a hook that may never fire.

  • woocommerce_order_status_changed does not fire on a brand new order — it is skipped when there is no previous status.
  • WC_Validation::is_phone() only checks the allowed characters. It never normalises to E.164, so (555) 123-4567 passes checkout and fails at Twilio with error 21211.
  • Twilio's response status is queued, not delivered. The real outcome arrives later on a StatusCallback signed with X-Twilio-Signature.
  • One emoji drops the segment limit from 160 GSM-7 characters to 70 UCS-2 — and multiplies the bill.
  • US traffic from an unregistered 10DLC number is blocked with error 30034, no matter how correct your code is.

/ Hook choice

Which WooCommerce hook should fire the SMS?

The per-status hook, woocommerce_order_status_{to}, not the generic one — and the reason is a branch in WC_Order that costs people their first-order notifications.

Both hooks live in the same status-transition block in class-wc-order.php, but they are not siblings. The per-status action fires unconditionally; the generic one fires inside an if:

PHP — class-wc-order.php, status_transition()

// Always fires, including the very first status a new order gets.
do_action( 'woocommerce_order_status_' . $status_transition['to'],
    $this->get_id(), $this, $status_transition );

// ... status note added ...

if ( ! empty( $status_transition['from'] ) ) {
    do_action( 'woocommerce_order_status_' . $status_transition['from']
        . '_to_' . $status_transition['to'], $this->get_id(), $this );

    // Only reached when a PREVIOUS status existed.
    do_action( 'woocommerce_order_status_changed', $this->get_id(),
        $status_transition['from'], $status_transition['to'], $this );
}

An order created straight into processing by a gateway that captures immediately has an empty from. The per-status hook fires; woocommerce_order_status_changed does not. The bug this produces is nasty because it is partial: manual admin status changes work perfectly, so the feature tests fine and then misses a slice of real orders.

If you do bind the generic hook — for a state machine that genuinely needs the previous status — remember it passes four arguments and add_action() defaults to one:

PHP — the argument-count trap

// $from and $to are silently missing without the trailing 4.
add_action( 'woocommerce_order_status_changed', 'yp_order_changed', 10, 4 );

function yp_order_changed( $order_id, $from, $to, $order ) { /* ... */ }
Sending a WooCommerce order SMS through Twilio Programmable MessagingA WooCommerce order status transition fires two different hooks. The per-status hook fires on every transition including the first one, while woocommerce_order_status_changed only fires when there is a previous status, so a brand new order never reaches it. The chosen handler reads the billing phone, which WooCommerce validates only as a loose set of allowed characters and never normalises to E.164, so it must be converted before use. The send is queued rather than run inline, then posted to the Twilio Messages endpoint. Twilio answers with a queued status, and the real outcome arrives later on a signed status callback, where delivered, undelivered and failed are the terminal states.

no, brand new order

yes

no

yes

order status transition

woocommerce_order_status_{to}
fires on every transition

previous status exists?

woocommerce_order_status_changed
does NOT fire

woocommerce_order_status_changed
$order_id, $from, $to, $order

read billing phone

E.164 normalised?

Twilio 21211 Invalid To number

enqueue, do not send inline

POST /2010-04-01/Accounts/{Sid}/Messages.json

status: queued
this is not delivery

signed StatusCallback
X-Twilio-Signature

delivered / undelivered / failed

FIG 01 — From order transition to a billed SMS segment

/ Phone numbers

Why is the billing phone not safe to send?

Because WooCommerce never promised it was a phone number. This is the entire validator:

PHP — WC_Validation::is_phone()

public static function is_phone( $phone ) {
    if ( 0 < strlen( trim( preg_replace(
        '/[s#0-9_-+/().]/', '', $phone
    ) ) ) ) {
        return false;
    }

    return true;
}

Strip out digits, spaces and a handful of punctuation marks; if anything is left, reject. That is a character-class check, nothing more. It validates no country code, no length, no plausibility. Every one of these passes WooCommerce checkout and every one of them is rejected by Twilio with 21211 — Invalid 'To' Phone Number:

Customer typedis_phone()Twilio
(555) 123-4567passesrejected — no country code
07700 900123passesrejected — national format
+1 555 123 4567passesaccepted once spaces are stripped
555.123.4567 ext 12rejected — "ext" is not in the class
00 44 7700 900123passesrejected — 00 is not +
- - -passesrejected — no digits at all

Worse, the field is not necessarily required. The checkout field definition sets 'required' => 'required' === CartCheckoutUtils::get_phone_field_visibility(), so on a store configured with phone optional you will be handed an empty string for a meaningful share of orders.

The fix is to normalise before you queue, and to treat a number you cannot normalise as a skipped notification rather than a failed order:

PHP — normalise, or decline to send

function yp_to_e164( $raw, $country ) {
    $digits = preg_replace( '/[^0-9+]/', '', (string) $raw );

    if ( '' === $digits ) {
        return null;
    }
    if ( 0 === strpos( $digits, '+' ) ) {
        return $digits;                       // already E.164
    }
    if ( 0 === strpos( $digits, '00' ) ) {
        return '+' . substr( $digits, 2 );        // 00 44 ... -> +44 ...
    }

    // A national number. Guessing the country from the billing address is
    // a heuristic, not a rule — use a real E.164 library in production.
    $prefixes = [ 'US' => '+1', 'GB' => '+44', 'PL' => '+48', 'DE' => '+49' ];
    if ( ! isset( $prefixes[ $country ] ) ) {
        return null;
    }

    return $prefixes[ $country ] . ltrim( $digits, '0' );
}
A phone field that validates punctuation is not a phone field. Treat the billing phone as untrusted free text and the rest of the integration gets simpler.

/ The call

How do you call the Twilio Messages API?

One POST, form-encoded, with HTTP Basic auth. No SDK is required — wp_remote_post() covers it, which keeps the dependency footprint at zero:

PHP — sending one message

function yp_send_sms( $to, $body ) {
    $sid   = defined( 'YP_TWILIO_SID' ) ? YP_TWILIO_SID : '';
    $token = defined( 'YP_TWILIO_TOKEN' ) ? YP_TWILIO_TOKEN : '';

    $response = wp_remote_post(
        "https://api.twilio.com/2010-04-01/Accounts/{$sid}/Messages.json",
        [
            'timeout' => 15,
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode( "{$sid}:{$token}" ),
            ],
            'body'    => [
                'To'                  => $to,
                'MessagingServiceSid' => YP_TWILIO_SERVICE_SID,
                'Body'                => $body,
                'StatusCallback'      => rest_url( 'your-plugin/v1/sms-status' ),
                // Do not let a message sit in the queue for 10 hours.
                'ValidityPeriod'      => 600,
            ],
        ]
    );

    if ( is_wp_error( $response ) ) {
        return $response;                // transport failure — retryable
    }

    $code = wp_remote_retrieve_response_code( $response );
    $data = json_decode( wp_remote_retrieve_body( $response ), true );

    if ( 201 !== $code ) {
        // $data['code'] carries the Twilio error number, e.g. 21211.
        return new WP_Error( 'twilio', $data['message'] ?? '', $data );
    }

    return $data['sid'];        // status here is "queued", not "delivered"
}

Two parameters deserve more attention than they usually get. MessagingServiceSid and From are mutually exclusive, and using the service SID is what lets Twilio pick a sender from a pool — which is also where 10DLC registration is attached. ValidityPeriod defaults to 36,000 seconds, ten hours; for an order notification, a message that arrives ten hours late is worse than one that never arrives, so cap it.

/ Cost

What does an order SMS actually cost?

More than the per-message rate, because you are not billed per message — you are billed per segment. Twilio's limit is 160 GSM-7 characters per segment, or 70 UCS-2 characters once any character falls outside GSM-7, with a hard ceiling of 1,600 characters per message.

Take a plain notification at 150 characters. That is one segment. Now add a single emoji to make it friendlier:

  • Plain, 150 chars, GSM-7 → 150 ÷ 160 = 1 segment.
  • Same text plus one emoji → the whole message becomes UCS-2 → 151 ÷ 70 = 2.16 → 3 segments.
  • At a US rate of $0.0079 per segment: $0.0079 versus 3 × $0.0079 = $0.0237.

Scale that to volume and the emoji is a line item. At 4,000 orders a month: 4,000 × $0.0079 = $31.60 plain, against 4,000 × $0.0237 = $94.80 with the emoji — $63.20 a month, $758.40 a year, for one character. Add the order number and a tracking URL and a "short" message routinely lands at 3 segments before anyone notices.

The practical control is to measure the body before you send it and to keep URLs out of the concatenation where you can. Note also that concatenated segments use 153 GSM-7 or 67 UCS-2 characters each, since the header that reassembles them consumes part of every segment — so the arithmetic above is the optimistic version.

Cyberpunk illustration titled "Sending WooCommerce Order SMS Through Twilio Programmable Messaging": a neon server rack badged with the Woo logo and "ORD-7359 (status: processing)" streams a packet trail into a "Twilio — Programmable Messaging" panel showing API, gear and SMS nodes, which forwards to a phone displaying an SMS bubble.

/ Delivery

Should the send be inline or queued?

Queued, and the argument is not the usual hand-waving about performance. It is that woocommerce_order_status_changed fires inside the request that is processing the order — often the customer's checkout request, often a gateway's IPN callback.

A 15-second timeout on an HTTP call to Twilio is 15 seconds added to that request in the worst case. If it is the checkout, the buyer is looking at a spinner. If it is the gateway callback, the gateway may time out and retry, and now the order transitions again and you send a second SMS. The failure mode is not a slow page — it is a duplicate billed message caused by someone else's retry logic.

ConcernInline wp_remote_postQueued send
Checkout latencyUp to the full HTTP timeoutOne row written, then return
Twilio 500 or timeoutNotification lost, no recordRetried with backoff
Gateway retries the callbackSecond SMS, billed againDeduplicated on order + status
Flash sale burstEvery order opens a socketDrained at a controlled rate
Audit trailTwilio console onlyPer-attempt log on your side

WordPress already ships the queue: Action Scheduler comes bundled with WooCommerce, so as_enqueue_async_action() is available on any store without adding a dependency. If you are new to it, how Action Scheduler works and building a job queue in WordPress cover the mechanics.

/ Callbacks

How do you track delivery with a status callback?

Twilio's 201 means accepted, not delivered — the status field in that response is queued. Of the full set of values (accepted, scheduled, queued, sending, sent, delivered, undelivered, failed, canceled, and the inbound ones), only delivered, undelivered, failed and canceled are terminal. Everything before that is in flight.

The StatusCallback URL receives those transitions — and it is a public endpoint receiving claims about your billing, so it must be verified. Twilio signs with X-Twilio-Signature, an HMAC-SHA1 keyed with your auth token, over the full webhook URL with the POST parameters sorted alphabetically and appended:

PHP — validating the status callback

function yp_valid_twilio_sig( $url, $params, $signature, $token ) {
    ksort( $params );

    $data = $url;
    foreach ( $params as $key => $value ) {
        $data .= $key . $value;
    }

    $expected = base64_encode( hash_hmac( 'sha1', $data, $token, true ) );

    return hash_equals( $expected, (string) $signature );
}

The URL has to be the exact one Twilio called, including the query string and its original percent-encoding. Twilio's own guidance is explicit: never pull query parameters out and pass them separately, and never decode or re-encode the URL — either breaks validation. Behind a reverse proxy this bites, because $_SERVER['HTTPS'] may be unset and you will rebuild an http:// URL that never matches.

/ Limits

What does Twilio not protect you from?

The expensive things.

  1. Sending to an unregistered US number. Error 30034 — US A2P 10DLC: Message from an Unregistered Number blocks the message outright. Correct code, valid credentials, real phone number, and nothing arrives, because Brand and Campaign registration is a business process, not a code path. It also fires if the number is simply missing from the Sender Pool of the registered Messaging Service.
  2. Consent. Twilio will happily deliver a marketing message to someone who only ever bought a product. Transactional order updates and marketing are different legal categories, and the checkbox that separates them is yours to build and store.
  3. Spend. There is no per-order or per-customer cap by default. A retry loop that re-fires a status transition sends — and bills — every time. The order ID plus target status makes a natural idempotency key; use it.
  4. Your credentials at rest. The auth token signs the callbacks as well as authenticating the sends, so it is both a spending key and a forgery key. It belongs in wp-config.php or the environment, never in the options table and never in a file under the webroot.

An empty phone is not an error worth escalating. With the phone field optional, a store will always have orders with nothing to send to. Log it and move on — throwing from a status-transition callback risks disrupting the order flow itself, which is a far more expensive failure than a missed text message.

/Footnotes
¹ Hook ordering and the empty-from branch verified in class-wc-order.php against WooCommerce 10.8.1.
² Validator body and checkout field definition from includes/class-wc-validation.php and includes/class-wc-countries.php in the same release.
³ Endpoint, parameters, segment limits, ValidityPeriod range and the status value list from the Twilio Message resource reference.
Signature algorithm and URL-encoding rules from Twilio webhook security; error codes 21211 and 30034.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Which WooCommerce hook should trigger an order SMS? +
The per-status hook, woocommerce_order_status_processing or woocommerce_order_status_completed. The generic woocommerce_order_status_changed only fires when a previous status existed, so an order created directly into processing by an instant-capture gateway never reaches it.
Why does Twilio reject the WooCommerce billing phone number? +
Because WC_Validation::is_phone() only checks that the string contains allowed characters — digits, spaces and some punctuation. It never validates a country code or normalises to E.164. Values like (555) 123-4567 pass checkout and are rejected by Twilio with error 21211, Invalid To Phone Number.
How much does a WooCommerce SMS notification cost? +
You are billed per segment, not per message. A segment is 160 GSM-7 characters, or 70 UCS-2 characters once any character falls outside GSM-7. Adding a single emoji to a 150-character message turns 1 segment into 3, tripling the cost of every notification you send.
Does a 201 response from Twilio mean the SMS was delivered? +
No. The status in that response is queued. Only delivered, undelivered, failed and canceled are terminal states, and they arrive later on the StatusCallback URL. Verify those callbacks with the X-Twilio-Signature header, an HMAC-SHA1 of the full URL plus alphabetically sorted parameters, keyed with your auth token.
Why are my WooCommerce SMS messages blocked in the United States? +
Most likely Twilio error 30034, US A2P 10DLC message from an unregistered number. Application-to-person traffic to US numbers requires an approved Brand and Campaign registration, and the sending number must be in the Sender Pool of the linked Messaging Service. No code change fixes it.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate