Send an SMS from WordPress and WooCommerce
In Cameroon, a customer who orders online expects a confirmation, and email does not get read. Text messages do. This page builds a small WordPress plugin that texts the buyer as soon as their order is paid, then records the delivery receipt on the order. About a hundred lines of PHP, no library: wp_remote_post does all the work.
Prerequisites
- WordPress 6 and PHP 8.0 or newer. WooCommerce for the order part — the HTTP client itself fits any use.
- File access to the site (FTP, SSH or your host's file manager) to drop the plugin in.
- A G-SMS API key starting with
gsk_, created in the console for a given application. - An HTTPS site if you want to receive delivery receipts.
Plugins bought on a marketplace often store the key in the database. That is a bad idea: the database travels with every backup, every export, every staging copy.
Where the key goes
<?php
// wp-config.php — above the "That's all, stop editing!" line.
// Secrets do not belong in the database: they would be exported with it.
define('GSMS_API_KEY', 'gsk_votre_cle_api');
define('GSMS_WEBHOOK_SECRET', 'whsec_your_secret');If a key leaks, revoke it from the console: a new one is issued in seconds and the old one stops being accepted immediately.
The plugin
Drop it in wp-content/plugins/gsms-order/gsms-order.php, then activate it from the plugins screen. The HTTP client throws nothing: a shop must not go down because a text message did not go out.
wp-content/plugins/gsms-order/gsms-order.php
<?php
/**
* Plugin Name: Order SMS (G-SMS)
* Description: Texts the customer when their WooCommerce order is paid.
* Version: 1.0.0
* Requires PHP: 8.0
*/
defined('ABSPATH') || exit;
const GSMS_BASE_URL = 'https://api.sms.gworldship.com';
/**
* Calls the API. Never throws: a 409 is not a failure, and a shop must not go
* down because a text message did not go out.
*
* @return array{status:int, body:array, error:?string}
*/
function gsms_request(string $path, ?array $payload = null): array
{
$args = [
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . (defined('GSMS_API_KEY') ? GSMS_API_KEY : ''),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
];
if ($payload === null) {
$response = wp_remote_get(GSMS_BASE_URL . $path, $args);
} else {
$args['body'] = wp_json_encode($payload);
$response = wp_remote_post(GSMS_BASE_URL . $path, $args);
}
if (is_wp_error($response)) {
return ['status' => 0, 'body' => [], 'error' => $response->get_error_message()];
}
return [
'status' => wp_remote_retrieve_response_code($response),
'body' => json_decode(wp_remote_retrieve_body($response), true) ?: [],
'error' => null,
];
}
/** POST /v1/sms. The reference protects you if the hook is replayed. */
function gsms_send(string $to, string $text, ?string $reference = null): array
{
$payload = ['to' => $to, 'text' => $text];
if ($reference !== null) {
$payload['clientReference'] = $reference;
}
return gsms_request('/v1/sms', $payload);
}
/**
* Turns whatever was typed at checkout into +2376XXXXXXXX.
* Returns null when it is clearly not a Cameroonian mobile — no point spending
* a network call to be told INVALID_NUMBER.
*/
function gsms_normalize_cm_phone(?string $raw): ?string
{
$digits = preg_replace('/\D+/', '', (string) $raw);
if (str_starts_with($digits, '237')) {
$digits = substr($digits, 3);
}
return preg_match('/^6\d{8}$/', $digits) === 1 ? '+237' . $digits : null;
}Texting the customer when the order is paid
WooCommerce fires an action on every order status change. woocommerce_order_status_processing means payment accepted; woocommerce_order_status_completed means shipped. The rest is a sentence and a call.
<?php
// Plugin, continued: the WooCommerce hook.
// woocommerce_order_status_processing: payment has just been accepted.
// To text on shipping instead, use woocommerce_order_status_completed.
add_action('woocommerce_order_status_processing', 'gsms_notify_order_paid', 10, 1);
function gsms_notify_order_paid(int $order_id): void
{
$order = wc_get_order($order_id);
if (! $order instanceof WC_Order) {
return;
}
$to = gsms_normalize_cm_phone($order->get_billing_phone());
if ($to === null) {
$order->add_order_note('SMS not sent: unusable billing phone number.');
return;
}
// The recipient sees the SIM number, not a sender name: say who you are
// in the very first word.
$text = sprintf(
'Awa Store: your order %s is confirmed. Total: %s FCFA.',
$order->get_order_number(),
number_format((float) $order->get_total(), 0, '.', ' ')
);
// One reference per order AND per step: WooCommerce can replay a hook.
$result = gsms_send($to, $text, 'wc-' . $order_id . '-paid');
$body = $result['body'];
switch ($result['status']) {
case 202:
$order->add_order_note(sprintf(
'SMS sent (%s, %d segment(s), %d FCFA).',
$body['messageId'], $body['segments'], $body['priceXaf']
));
break;
case 409:
// Already sent for this order: nothing to do, nothing charged.
break;
case 402:
$order->add_order_note('SMS not sent: G-SMS credit exhausted.');
break;
case 429:
$order->add_order_note('SMS not sent: too many sends per second.');
break;
default:
$order->add_order_note(sprintf(
'SMS not sent (HTTP %d, %s).',
$result['status'], $body['code'] ?? (string) $result['error']
));
}
}<?php
// Recommended in production: do not keep the checkout waiting.
// Action Scheduler ships with WooCommerce, there is nothing to install.
add_action('woocommerce_order_status_processing', function (int $order_id): void {
as_enqueue_async_action('gsms_send_order_sms', ['order_id' => $order_id], 'gsms');
}, 10, 1);
add_action('gsms_send_order_sms', 'gsms_notify_order_paid', 10, 1);
// The task may be replayed after an incident: that is exactly why
// gsms_notify_order_paid() always sends the same clientReference. At worst the
// API answers 409 and nothing is charged twice.The responses you have to handle
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 202 | — | Accepted, charged, queued. | Note messageId on the order. |
| 402 | INSUFFICIENT_BALANCE | Credit exhausted. Nothing charged. | Top up. The order is still valid, only the text is missing. |
| 409 | — | This order already got its message. | Do nothing: that is the intended behaviour on a replayed hook. |
| 429 | RATE_LIMITED | More than 10 requests per second on the key. | Reschedule the task one second later. |
| 400 | INVALID_NUMBER | The number typed at checkout is unusable. | Make the phone field required, and validate it. |
| 503 | OPERATOR_DISABLED | Sending to this carrier is paused. Nothing charged. | Reschedule after Retry-After (one hour). |
{ code, message } object like the other errors: it is the message already stored, shaped like the 202 and without balanceAfterXaf. Treating every code above 400 the same way would print "SMS not sent" on the order while the customer did receive it. Recording the delivery receipt on the order
A 202 means "accepted", not "received". The rest arrives as a push, on a REST route the plugin publishes itself. There is a single event, sms.sent, emitted on every status change: SENT, then DELIVERED or FAILED, the last two being final. A FAILED message is refunded automatically, once.
<?php
// Plugin, continued: receiving delivery status.
// URL to declare in the console: https://your-shop.cm/wp-json/gsms/v1/webhook
add_action('rest_api_init', function (): void {
register_rest_route('gsms/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'gsms_handle_webhook',
// Authentication here is the HMAC signature, not a WordPress user.
'permission_callback' => '__return_true',
]);
});
function gsms_handle_webhook(WP_REST_Request $request): WP_REST_Response
{
// The RAW body: the signature covers the bytes received, never an array
// re-serialised by WordPress.
$raw = $request->get_body();
$timestamp = (string) $request->get_header('x-gsms-timestamp');
$signature = (string) $request->get_header('x-gsms-signature');
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return new WP_REST_Response(null, 400); // outside the ±5 minute window
}
$secret = defined('GSMS_WEBHOOK_SECRET') ? GSMS_WEBHOOK_SECRET : '';
$expected = 'sha256=' . hash_hmac('sha256', $raw . '.' . $timestamp, $secret);
// hash_equals: constant-time comparison. A === here is a vulnerability.
if (! hash_equals($expected, $signature)) {
return new WP_REST_Response(null, 401);
}
$event = json_decode($raw, true);
// At-least-once delivery: the same eventId may arrive more than once.
$seen = 'gsms_evt_' . md5((string) $event['eventId']);
if (get_transient($seen) !== false) {
return new WP_REST_Response(null, 200);
}
set_transient($seen, 1, 2 * DAY_IN_SECONDS);
$data = $event['data'];
// The reference set when sending takes us back to the order.
if (preg_match('/^wc-(\d+)-/', (string) ($data['clientReference'] ?? ''), $matches)) {
$order = wc_get_order((int) $matches[1]);
if ($order instanceof WC_Order) {
$order->add_order_note(sprintf(
'SMS %s: %s%s',
$data['messageId'],
$data['status'],
$data['failureReason'] ? ' (' . $data['failureReason'] . ')' : ''
));
}
}
// Any 2xx counts as an acknowledgement. Answer within 10 s.
return new WP_REST_Response(null, 200);
}- The signature is
HMAC_SHA256(secret, raw_body + "." + timestamp), lowercase hexadecimal, prefixed withsha256=. - Reject a timestamp more than five minutes off: that is the replay protection.
- Deduplicate on
eventId, not onX-GSms-Delivery, which changes on every attempt. applicationIdholds the application slug, not its technical identifier, despite the name.- Unlike API responses, the webhook body keeps its null fields:
clientReferenceandfailureReasonare always present, possibly asnull. - On failure, attempts space out: 1 min, 5 min, 30 min, 2 h, 12 h, then every 12 h, stopping after 48 h. The delivery becomes
EXHAUSTEDand stays replayable from the console. - Watch out for security plugins that block
/wp-json/: the route has to stay reachable from outside, or you will never receive anything.
What the example on this page costs
| Text sent | "Awa Store: your order 1042 is confirmed. Total: 25 000 FCFA." — 60 characters |
| Encoding | GSM-7: every character is in the standard alphabet |
| Segments | 1 (up to 160 characters in GSM-7) |
| Recipient | An Orange or MTN mobile |
| Cost | 5 XAF |
7 XAF to Camtel and Nexttel, 10 XAF on the default route, used for prefixes outside the ART numbering plan. A hundred orders a month therefore cost around 500 XAF in text messages.
What will surprise you
- A non-breaking space can double the bill. As long as the text fits the GSM-7 alphabet, a segment holds 160 characters; otherwise it drops to 70. The non-breaking space that
wc_price()and many themes insert is not in that alphabet — no more than the typographic apostrophe "’", lowercase "ç" or guillemets "«»". The example here is 60 characters: comfortable in GSM-7, but in UCS-2 it would be ten characters away from costing twice as much. Hence thenumber_format()in the sample, rather than the amount formatted by WooCommerce. - The recipient sees a number, not a name. There is no custom sender ID: G-SMS carries transactional messages from a Cameroonian SIM. That is why the sample starts with the shop name — without it, the customer gets an anonymous message.
- This is not an SMS marketing plugin. G-SMS carries the messages a customer expects: confirmation, shipping, login code, appointment reminder. Not campaigns to a purchased list.
- The limits. 10 requests per second per key, 1,600 characters per message, 64 characters for
clientReference. The daily quota is disabled by default.
Ready to send your first SMS?
Create an account, get 100 FCFA of credit and an API key straight away.
Create account