Send Shopify order notifications through IllyVoIP SMS

Build a Shopify paid-order SMS workflow with webhook HMAC verification, approved recipients, duplicate protection and IllyVoIP delivery tracking.

Send one approved Shopify paid-order notification through IllyVoIP SMS with a custom app webhook, a verified receiver and a durable worker. This guide uses the orders/paid event and keeps SMS eligibility separate from the presence of a phone number on an order.

This is a custom integration, not a native Shopify app supplied by IllyVoIP. You need permission to develop/install the app, access the required order data and operate a private HTTPS receiver.

Original explanatory diagram for Shopify order SMS: approve the exact order; verify the raw webhook; queue one business event; send, then track.
Original explanatory diagram for Shopify order SMS: approve the exact order; verify the raw webhook; queue one business event; send, then track. Open the image for a larger view.

1. Define the exact notification and recipient

Choose one transactional message, such as an approved paid-order confirmation. Confirm the customer's authorization and the correct telephone number. Marketing permission and transactional-notification eligibility are not interchangeable; a shipping phone field alone establishes neither.

In IllyVoIP, manage your API key under Account Settings → API and review the SMS endpoint in the API documentation or API Playground. Confirm that your account may send to the intended destination and that the sender is permitted. A virtual number does not automatically grant SMS capability or sender approval. Check the balance, destination price and customer authorization before the first real message.

The example deliberately uses a private approvals table containing an approved order ID, recipient and frozen message. It does not send automatically to every webhook phone field. For production, your checkout/customer-service integration must populate that table from your verified eligibility process before the paid event arrives.

2. Configure the app's paid-order subscription

  1. Create or use your merchant-approved Shopify app and obtain the app client secret through its protected configuration.
  2. Request only the order access your app actually needs. Check Shopify's protected-customer-data requirements before relying on personal fields.
  3. Add an HTTPS webhook subscription for orders/paid pointing to your receiver's /shopify path, using the app's supported webhook configuration or Admin API.
  4. Pin a currently supported webhook API version in that configuration and test its actual payload in staging. Do not silently assume every installed app uses the same schema.
  5. Keep the app secret on the receiver server; it is different from your IllyVoIP API key.

For an app managed with Shopify CLI, merge this subscription into its existing shopify.app.toml; preserve unrelated scopes and subscriptions. The example pins the supported 2026-07 version.

[webhooks]
api_version = "2026-07"

[[webhooks.subscriptions]]
topics = ["orders/paid"]
uri = "https://YOUR_INTEGRATION_HOST/shopify"

Ensure the app has the required read_orders access and merchant authorization. Use shopify app dev for the chosen development store. After acceptance, shopify app deploy releases the app configuration; it does not deploy this Python receiver. Deploy and verify the receiver separately, and inspect actual webhook deliveries before enabling the SMS worker. See Shopify's subscription setup and API versioning.

For this test, arrange the approval before marking an eligible test order paid. The receiver skips an order with no approval and acknowledges that skip. Adding approval afterward does not retroactively send a notification; handle any deliberate replay through a reviewed process.

Install the durable SMS worker

Use a private Python 3 application directory and virtual environment with requests and gunicorn. Save the following as sms_core.py. Keep its SQLite database on persistent local storage, owned by the application user with a private parent directory. This single-host example is not a shared network-filesystem or multi-region queue.

import hashlib, json, os, re, sqlite3, sys
import requests

DB = os.environ['SMS_DB']
SENDER = os.environ['ILLYVOIP_SMS_FROM']
API = 'https://api.illyvoip.com/api/v1/sms/messages'

def connect():
    c = sqlite3.connect(DB, timeout=10)
    c.execute('PRAGMA busy_timeout=10000')
    return c

def init():
    with connect() as c:
        c.execute('''CREATE TABLE IF NOT EXISTS jobs (
          event TEXT PRIMARY KEY, payload TEXT NOT NULL,
          idem TEXT NOT NULL UNIQUE, state TEXT NOT NULL,
          receipt TEXT, created TEXT DEFAULT CURRENT_TIMESTAMP)''')

def enqueue(event, phone, message):
    if not isinstance(event, str) or not 1 <= len(event) <= 300:
        raise ValueError('Invalid event')
    if not isinstance(phone, str) or not re.fullmatch(r'\+[1-9][0-9]{7,14}', phone):
        raise ValueError('Use a verified international recipient')
    if not isinstance(message, str) or not 1 <= len(message) <= 500:
        raise ValueError('This example accepts 1-500 characters')
    payload = json.dumps({'from': SENDER, 'to': [phone],
                          'message': message}, sort_keys=True,
                         ensure_ascii=False, separators=(',', ':'))
    idem = 'sms-' + hashlib.sha256(event.encode()).hexdigest()
    with connect() as c:
        c.execute('INSERT OR IGNORE INTO jobs(event,payload,idem,state) '
                  "VALUES(?,?,?,'pending')", (event, payload, idem))
        row = c.execute('SELECT payload,state FROM jobs WHERE event=?',
                        (event,)).fetchone()
        if row[0] != payload:
            raise ValueError('Event already exists with another payload')
        return row[1]

def dispatch(event, post=requests.post):
    with connect() as c:
        claim = c.execute("UPDATE jobs SET state='sending' "
                          "WHERE event=? AND state='pending'", (event,))
        if claim.rowcount != 1:
            return False
        payload, idem = c.execute('SELECT payload,idem FROM jobs WHERE event=?',
                                 (event,)).fetchone()
    # The durable claim is committed BEFORE a paid request can start.
    state, receipt = 'unknown', {'reason': 'No confirmed response'}
    try:
        r = post(API, headers={'X-Api-Key': os.environ['ILLYVOIP_API_KEY'],
                 'Idempotency-Key': idem, 'Content-Type': 'application/json'},
                 data=payload.encode(), timeout=(5, 20), allow_redirects=False)
        data = r.json()
        receipt = {'http_status': r.status_code, 'response': data}
        ids = data.get('queued_message_ids', []) if isinstance(data, dict) else []
        if 200 <= r.status_code < 300 and isinstance(ids, list) and len(ids) == 1 and isinstance(ids[0], (str, int)) and str(ids[0]):
            state = 'accepted'
        elif r.status_code in (400, 401, 403):
            state = 'rejected'
    except Exception:
        pass  # Never log credentials, recipients or an exception's request body.
    with connect() as c:
        c.execute('UPDATE jobs SET state=?,receipt=? WHERE event=?',
                  (state, json.dumps(receipt), event))
    return True

if __name__ == '__main__':
    init()
    if sys.argv[1:] == ['work']:
        with connect() as c:
            events = c.execute("SELECT event FROM jobs WHERE state='pending' "
                               'ORDER BY created LIMIT 20').fetchall()
        for (event,) in events:
            dispatch(event)
    elif sys.argv[1:] != ['init']:
        raise SystemExit('Use: python sms_core.py init | work')

Provide SMS_DB as the absolute database path, ILLYVOIP_SMS_FROM as your permitted sender and ILLYVOIP_API_KEY through your server's private secret environment. Initialize with python sms_core.py init. The key belongs on this server, never in a browser, CRM note, webhook URL or public repository.

Run python sms_core.py work through a service timer or job runner after the first controlled test. Each invocation handles at most twenty pending events. A conditional database update claims an event before the network request, so concurrent workers cannot both send it. Do not remove the database when redeploying.

The 500-character limit is a conservative limit for this example, not a claim about the API's maximum. SMS encoding and multipart segments affect cost; check the actual message and destination pricing.

3. Create the approval table

After initializing the worker database, create the following table once. Use parameterized SQL in the trusted process that inserts records. Do not expose an unauthenticated approval API or permit customers to choose arbitrary recipients and message text.

CREATE TABLE approvals (
  shop TEXT NOT NULL,
  order_id TEXT NOT NULL,
  phone TEXT NOT NULL,
  message TEXT NOT NULL,
  PRIMARY KEY (shop, order_id)
);

Set SHOPIFY_SHOP to the exact allowed your-store.myshopify.com domain. Set SHOPIFY_APP_SECRET through the private environment. Prepare one row using the actual test order's ID, the verified international recipient and the approved message. Keep approvals immutable after dispatch.

4. Verify the raw webhook before reading the event

Save this as receiver.py. HMAC verification uses the exact raw request bytes, before JSON parsing. Reserializing JSON first can change those bytes and invalidate verification.

import base64, hashlib, hmac, json, os
from sms_core import connect, enqueue

def application(environ, start_response):
    status, result = '400 Bad Request', {'error': 'Invalid request'}
    try:
        if environ.get('REQUEST_METHOD') != 'POST' or environ.get('PATH_INFO') != '/shopify':
            status = '404 Not Found'
        else:
            size = int(environ.get('CONTENT_LENGTH') or 0)
            if not 0 < size <= 1048576:
                raise ValueError('Invalid size')
            raw = environ['wsgi.input'].read(size)
            expected = base64.b64encode(hmac.new(
                os.environ['SHOPIFY_APP_SECRET'].encode(), raw,
                hashlib.sha256).digest()).decode()
            supplied = environ.get('HTTP_X_SHOPIFY_HMAC_SHA256', '')
            if not hmac.compare_digest(expected, supplied):
                status = '401 Unauthorized'
            elif (environ.get('HTTP_X_SHOPIFY_SHOP_DOMAIN') != os.environ['SHOPIFY_SHOP'] or
                  environ.get('HTTP_X_SHOPIFY_TOPIC') != 'orders/paid'):
                status = '403 Forbidden'
            else:
                data = json.loads(raw)
                if data.get('financial_status') != 'paid':
                    raise ValueError('Expected a paid order')
                order_id = str(data['id'])
                if not order_id.isdigit():
                    raise ValueError('Invalid order')
                # Exact eligible order, verified phone and frozen text were approved
                # before subscription/delivery, not inferred from a webhook phone field.
                with connect() as c:
                    row = c.execute('SELECT phone,message FROM approvals '
                                    'WHERE shop=? AND order_id=?',
                                    (os.environ['SHOPIFY_SHOP'], order_id)).fetchone()
                if row is None:
                    status, result = '200 OK', {'state': 'skipped_no_approval'}
                else:
                    event = f"shopify:{os.environ['SHOPIFY_SHOP']}:{order_id}:paid:v1"
                    state = enqueue(event, row[0], row[1])
                    status, result = '200 OK', {'state': state}
    except (ValueError, KeyError, TypeError):
        pass
    except Exception:
        status, result = '503 Service Unavailable', {'error': 'Queue unavailable'}
    body = json.dumps(result).encode()
    start_response(status, [('Content-Type', 'application/json'),
                            ('Content-Length', str(len(body)))])
    return [body]

Serve it with gunicorn --bind 127.0.0.1:8080 receiver:application behind your managed HTTPS reverse proxy. Preserve the body and Shopify headers, enforce body/rate limits and keep secrets and customer content out of logs. Return promptly after durable enqueue; do not wait for the paid SMS send before acknowledging Shopify.

The local event key includes the shop, order and paid-event version. Multiple webhook delivery IDs for that same business event therefore produce one SMS job. You may keep Shopify's webhook ID for diagnostics, but delivery-ID deduplication alone is not enough to prevent duplicate business notifications.

5. Exercise the failure cases, then one real send

  1. Send a fixture with an invalid HMAC; the receiver must reject it and create no job.
  2. Test a valid signature with the wrong shop or topic; no job should be created.
  3. Use an approved paid-order fixture and deliver it twice. Confirm one queue row.
  4. Change the approved text after that event and retry in staging. The immutable-payload check should stop the changed event instead of sending another SMS.
  5. Verify that an order without approval is recorded operationally as skipped, not silently described as delivered.
  6. Trigger one authorized test order and run the worker once. Inspect the queued ID and actual delivery result.

Refunds, cancellations and fulfillment notifications are different events. This paid-only example does not infer their order from delivery timing or send a message for them. Add any later workflow with its own explicit eligibility, event version and handling of delayed events.

Interpret acceptance and delivery separately

The local state pending means the event is durably queued in your integration. accepted means the IllyVoIP response contained the single queued message ID expected by this example. It does not mean the recipient received the SMS. Store the receipt privately and inspect rejected recipients separately.

Use the returned ID with the status endpoint, or configure delivery webhooks in Account Settings → Integrations / CRM & webhooks. Subscribe to the relevant SMS events and correlate data.message.message_id with the saved queued ID. The idempotency key is not included in those webhooks.

GET https://api.illyvoip.com/api/v1/sms/messages/status?message_id=RETURNED_ID
X-Api-Key: YOUR_SERVER_SIDE_KEY

unknown, or a job left in sending after a process crash, requires reconciliation. It must not be reset automatically to pending. Inspect IllyVoIP history/status and the private receipt before deciding whether another attempt is appropriate. If an approved retry is needed, keep the same saved payload and idempotency key; never generate a fresh key merely because a timeout occurred.

A rejected request needs its actual validation or permission issue corrected. If the intended recipient, sender or text changes, treat that as a separately approved event version, after resolving any earlier ambiguous send. Keep event identities longer than the source system's replay/retry period, including backups and manual replays.

Operate the integration

Monitor receiver failures, skipped approvals, pending queue age and unknown sends. Keep diagnostic event IDs without storing unnecessary customer payloads. During app-secret rotation, use a deliberate transition procedure and test signatures before removing the old credential.

Pause the worker timer to stop new dispatch while retaining queued work. Removing a webhook subscription does not cancel SMS already accepted by IllyVoIP. Before resuming after an outage, reconcile unknown jobs rather than replaying all orders.

References: Shopify webhook verification, webhook setup and IllyVoIP API reference. Reviewed October 11, 2026. Local signature/queue fixtures are separate from acceptance in your Shopify store and real SMS delivery.

Related IllyVoIP guides