Give your customers the peace of mind they expect with real-time order updates sent directly to their phones. Webflow and Twilio integration turns every order status change into a direct line of communication.
Some Webflow e-commerce stores send transactional emails and stop there. SMS open rates run around 98% compared to roughly 20% for email; for order confirmations and shipping updates, that gap matters.
The setup is simpler than it looks: a Webflow e-commerce webhook fires when an order is placed or updated, a Route Handler in Webflow Cloud receives it, and Twilio's Messages API sends the SMS. No third-party automation tools, no extra services between the order and the customer's phone.
In this guide, we explore how to use fetch directly against the Twilio Messages API, which runs cleanly on Webflow Cloud's Workers runtime from day one.
What do you need to send an order status SMS from Webflow E-commerce?
You need a Webflow Cloud project, a Webflow E-commerce site, and a Twilio account with a phone number. No additional infrastructure is required; a single Route Handler handles the webhook listener, signature validation, and SMS dispatch.
Confirm you have all these before you proceed:
- A Webflow Cloud project running a Next.js app, deployed or in local dev
- A Webflow E-commerce site on an Ecommerce site plan (Standard, Plus, or Advanced), which is what enables Webflow Ecommerce and order webhooks
- A checkout that collects a phone number through an Additional Info field, since Webflow's standard order payload carries only name and email (covered in Step 3 and troubleshooting)
- A Webflow site token with write access for Sites (
sites:write), which is the documented scope for creating a webhook, generated in Site settings > Apps & integrations > API access. Webhook scope requirements otherwise depend on the trigger type, so add Ecommerce read access if you find your ecomm triggers rejected - A Twilio account with a verified phone number capable of sending SMS (a Twilio trial account works for testing, but requires verified recipient numbers during trial)
- Node.js 22 or higher locally
Once these are ready, you’re ready to start with the full integration.
5 steps to send order status SMS from Webflow E-commerce with Twilio
Webflow sends an ecomm_new_order or ecomm_order_changed webhook to a Route Handler in your Cloud app. The Route Handler validates the webhook signature (to prevent spoofed requests), extracts the customer's phone number and order details, and then calls the Twilio Messages API. The customer receives an SMS within seconds.
Here are the steps to follow.
1. Get your Twilio credentials and phone number
Log in to the Twilio Console. Your Account SID is on the dashboard under Account Info; copy it.
For the credential itself, create an API key rather than using the Auth Token. Twilio recommends using API keys for all applications and treating the Auth Token as a local-testing credential; the practical argument is revocability: an API key can be rotated on its own, while rotating the Auth Token breaks every other integration on the account at once.
Go to Account > API keys & tokens, create a standard key, and copy the key SID and secret. The secret is shown once.
Next, go to Phone Numbers > Manage > Active Numbers and copy your Twilio phone number. If you don't have one, go to Phone Numbers > Buy a Number, filter by SMS capabilities, and purchase a number.
During the trial, Twilio only lets you send to phone numbers you've verified in Verified Caller IDs. Add your test number to Phone Numbers > Verified Caller IDs > Add a new number before testing.
2. Add Twilio credentials to Webflow Cloud environment variables
Add the following environment variables to your .env.local for local development, and to production by opening the Deployments dashboard for the environment and clicking Environment Variables:
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_KEY_SID=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_KEY_SECRET=your_api_key_secret_here
TWILIO_FROM_NUMBER=+15551234567
WEBFLOW_SECRET_NEW_ORDER=secret_from_the_new_order_webhook
WEBFLOW_SECRET_ORDER_CHANGED=secret_from_the_order_changed_webhook
The two Webflow values are the secret keys returned when you create each webhook via the API (covered in Step 4). Webhooks created with a site token after April 14, 2025 each get their own `secretKey` in the creation response, so each trigger has its own key rather than one per site.
For Twilio, prefer an API key over the account Auth Token. Twilio's guidance is to "use API keys for all applications" and treat the Auth Token as a local-testing credential; you can revoke an API key without breaking every other integration on the account.
Create one under Account > API keys & tokens. The Basic auth pair becomes the key SID and its secret rather than the Account SID and Auth Token.
Mark each one as a Secret when you add it in Webflow Cloud, which encrypts the value and masks it in the dashboard.
None of these variables should have a NEXT_PUBLIC_ prefix. Any variable prefixed with NEXT_PUBLIC_ is included in the browser JavaScript bundle. Exposing your Twilio Auth Token in a public bundle would let anyone send SMS messages from your account.
3. Build the order SMS Route Handler in Webflow Cloud
Create app/api/webhooks/order/route.ts. This handler receives the Webflow webhook, validates the HMAC signature using the Web Crypto API (`crypto.subtle`, a standard Web API available in the Workers runtime), and calls the Twilio Messages API via fetch:
// app/api/webhooks/order/route.ts
import { NextResponse, type NextRequest } from 'next/server'
// Verify the Webflow webhook signature using the Web Crypto API.
// crypto.subtle is a standard Web API available in the Workers
// runtime, so it needs no Node crypto import and is fully portable.
async function verifyWebflowSignature(
secret: string,
timestamp: string,
rawBody: string,
providedSignature: string
): Promise<boolean> {
const data = `${timestamp}:${rawBody}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(data)
)
const computedHex = Array.from(new Uint8Array(signatureBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
// Validate the timestamp to block replay attacks. Parse first and
// reject anything unparseable: `parseInt('')` is NaN, and every
// comparison against NaN is false, so a naive `age > limit` check
// silently passes when the header is missing.
const sentAt = Number.parseInt(timestamp, 10)
if (!Number.isFinite(sentAt)) return false
const requestAge = Date.now() - sentAt
if (requestAge > 300_000 || requestAge < -60_000) return false
// Constant-time comparison, as in Webflow's own reference
// implementation. A plain === leaks timing information.
if (computedHex.length !== providedSignature.length) return false
let mismatch = 0
for (let i = 0; i < computedHex.length; i++) {
mismatch |= computedHex.charCodeAt(i) ^ providedSignature.charCodeAt(i)
}
return mismatch === 0
}
async function sendOrderSMS(to: string, body: string): Promise<void> {
const accountSid = process.env.TWILIO_ACCOUNT_SID!
const keySid = process.env.TWILIO_API_KEY_SID!
const keySecret = process.env.TWILIO_API_KEY_SECRET!
const from = process.env.TWILIO_FROM_NUMBER!
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
// Authenticate with an API key rather than the account Auth Token,
// so this credential can be revoked on its own.
const credentials = btoa(`${keySid}:${keySecret}`)
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ To: to, From: from, Body: body }).toString(),
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`Twilio error ${res.status}: ${errorText}`)
}
}
export async function POST(request: NextRequest) {
const rawBody = await request.text()
const timestamp = request.headers.get('x-webflow-timestamp') ?? ''
const signature = request.headers.get('x-webflow-signature') ?? ''
// Each webhook has its own secret, so read the trigger first and
// validate against the matching key.
const peeked = JSON.parse(rawBody) as { triggerType?: string }
const secret =
peeked.triggerType === 'ecomm_order_changed'
? process.env.WEBFLOW_SECRET_ORDER_CHANGED!
: process.env.WEBFLOW_SECRET_NEW_ORDER!
// Validate signature before processing anything.
const isValid = await verifyWebflowSignature(secret, timestamp, rawBody, signature)
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const event = JSON.parse(rawBody) as {
triggerType: string
payload: {
orderId: string
status: string
customerInfo?: {
fullName?: string
email?: string
}
customData?: { name?: string; textInput?: string }[]
purchasedItems?: { productName: string; count: number }[]
}
}
const { triggerType, payload } = event
// Webflow's order payload carries only name and email on customerInfo.
// A phone number collected through a checkout "Additional info" field
// arrives in the customData array, keyed by the field label you set.
const phone = payload.customData?.find((f) =>
f.name?.toLowerCase().includes('phone')
)?.textInput
// Only send SMS for new orders and status changes.
// Skip if no phone number on the order.
if (
(triggerType !== 'ecomm_new_order' && triggerType !== 'ecomm_order_changed') ||
!phone
) {
return NextResponse.json({ ok: true, skipped: true })
}
let message: string
if (triggerType === 'ecomm_new_order') {
const name = payload.customerInfo?.fullName?.split(' ')[0] ?? 'there'
message = `Hi ${name}, your order #${payload.orderId} is confirmed. We'll text you when it ships.`
} else {
// ecomm_order_changed
const statusMap: Record<string, string> = {
fulfilled: 'has shipped',
refunded: 'has been refunded',
disputed: 'has a dispute open',
}
const statusText = statusMap[payload.status]
// ecomm_order_changed fires on ANY change to the order, including
// edits to fields Webflow itself never sets, like the tracking
// number. Only text the customer for statuses worth a text, or
// editing a note will send "your order is now unfulfilled".
if (!statusText) {
return NextResponse.json({ ok: true, skipped: true })
}
message = `Order #${payload.orderId} update: your order ${statusText}. Questions? Reply to this message.`
}
await sendOrderSMS(phone, message)
return NextResponse.json({ ok: true })
}
The request.text() call reads the raw body before any parsing. This matters for signature validation: once you parse the body with request.json(), the raw bytes are gone, and you can't reconstruct the original string Webflow signed.
4. Register the Webflow e-commerce webhook
Register webhooks via the API to receive the `x-webflow-signature` header for validation; webhooks set up through the no-code dashboard don't send that header. A webhook created with a site token after April 14, 2025, returns its own `secretKey`, while a webhook created through an OAuth app uses the app's client secret as the signing key.
I always create webhooks via the API to keep the signing setup consistent.
Send a POST request to the Webflow Webhooks endpoint, once for new orders and once for order status changes:
# Register webhook for new orders
curl -X POST "https://api.webflow.com/v2/sites/YOUR_SITE_ID/webhooks" \
-H "Authorization: Bearer YOUR_SITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"triggerType": "ecomm_new_order",
"url": "https://your-app.webflow.io/api/webhooks/order"
}'
# Register webhook for order status changes
curl -X POST "https://api.webflow.com/v2/sites/YOUR_SITE_ID/webhooks" \
-H "Authorization: Bearer YOUR_SITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"triggerType": "ecomm_order_changed",
"url": "https://your-app.webflow.io/api/webhooks/order"
}'
Both webhook responses include a secretKey field, and they are not the same value. Webflow issues each webhook its own secret key, so store both and pick the right one per trigger:
WEBFLOW_SECRET_NEW_ORDER=secret_from_the_first_response
WEBFLOW_SECRET_ORDER_CHANGED=secret_from_the_second_response
Getting this configuration right is critical, as any misconfiguration leads to silent failures. Store one secret for both webhooks, and every event from the other one fails signature validation, returns 401, and gets retried three times before Webflow deactivates that webhook and emails you about it. Confirmations keep working while shipping notifications quietly stop, or the reverse.
Your Site ID is in Site settings > General. Your site token is in Site settings > Apps & integrations > API access > Generate API token, with write access for Sites (`sites:write`).
5. Test the Twilio SMS order notification end-to-end
Webflow's payment settings live in Settings> Ecommerce > Payment, and they connect to Stripe or PayPal for live processing. There is no documented sandbox toggle there, so plan your test around that rather than looking for one: place a real order on the published store and refund it, or set up a zero-total product so checkout completes without a charge.
Whichever route you take, within a few seconds the ecomm_new_order webhook fires, the Route Handler validates and processes it, and your Twilio-verified number receives the SMS.
To debug locally, use ngrok to expose your local server:
ngrok http 3000
Copy the ngrok HTTPS URL and use it as the webhook destination when registering. Update WEBFLOW_WEBHOOK_SECRET with the secretKey from that registration response.
If the SMS doesn't arrive, check the Twilio Console at Monitor > Logs > Messaging. Every send attempt is logged there with status and any error codes.
Watch the Webflow side too. Any non-200 response counts as a failure, and Webflow retries three more times before giving up. Repeated failures deactivate the webhook, and you get an email about it, so a wrong signing secret doesn't just drop one message; it eventually turns the integration off.
What breaks the order status SMS in Webflow?
Most order status SMS failures trace back to one of three things: the integration leans on the Twilio Node SDK instead of `fetch`, the webhook signature check is misconfigured, or the order never carried a phone number to text. Each one tends to fail quietly, so it helps to recognize the symptom behind each.
These three issues account for most failures in this integration.
SMS sends succeed locally but fail on Webflow Cloud
The Twilio Node.js SDK is the most common culprit. It depends on Node's HTTP and socket APIs, which the Workers runtime handles differently from a Node server. Webflow Cloud does enable Node.js compatibility by default, and Cloudflare's support for these modules has improved over time, so treat this as a likely cause to rule out rather than a guaranteed failure.
If you see a build or runtime error like `Dynamic require of "node:net" is not supported` (or a similar unsupported-module error) in your Cloud logs, the SDK pulled in a Node dependency the runtime can't bundle. Replace the SDK with the direct `fetch` pattern from Step 3.
Our guide to two-factor SMS verification hits the same wall from a different direction and uses the same fix.
Webhook signature validation fails
The three most common causes: the WEBFLOW_WEBHOOK_SECRET doesn't match the secretKey from the webhook registration response; the raw body was parsed before validation (use request.text(), not request.json()); or the timestamp is too old because your server clock is significantly out of sync.
Log the computedHex and providedSignature values side by side to narrow it down.
The customer phone number is missing from the payload
Webflow's standard order payload includes only the customer's name and email, so a phone number appears only if your checkout collects it.
In the Webflow Designer, open the Checkout page and drag an Additional Info element from the Add panel onto the canvas. There is no built-in phone toggle: add your own labeled input, and the label you choose becomes the key the value arrives under in the order's `customData` array. Make the field required if SMS confirmation is your primary notification method.
One step people miss: add the element to both the Checkout and the Checkout (PayPal) pages. If you miss the second, orders paid through PayPal arrive with no phone number, which looks like an intermittent bug rather than a missing element.
Build more order status SMS notifications
The Route Handler in this guide handles the two most important order events: placement and status change. Webflow's `ecomm_order_changed` payload includes the full updated order object, so you can extend the `statusMap` with any status Webflow sends (`pending`, `unfulfilled`, `dispute-lost`) and route each to a different SMS template.
For stores with high order volume, consider adding a Webflow Cloud KV Store rate-limit layer before the Twilio call to avoid sending duplicate SMS messages during rapid-fire webhook retries (Webflow retries failed webhooks up to 3 times at 10-minute intervals).
Explore Webflow + Twilio to see the bigger picture of what the two platforms do together, from order and shipping alerts to verification and two-way messaging.
Frequently asked questions
Can I use the Twilio Node.js SDK instead of fetch?
No, I don't recommend it on Webflow Cloud. The Twilio SDK is built around Node's HTTP client and socket transport, which the Workers runtime doesn't map cleanly, so it can fail to bundle or throw at deploy time even when it works in local dev. The `fetch`-based approach in this guide is functionally identical (same REST API, same auth) and runs reliably on the Workers runtime.
What phone number format does Webflow store for customers?
Webflow stores the phone number exactly as the customer types it during checkout. If your SMS fails with a Twilio "invalid phone number" error, the customer likely entered a local format (e.g., 555-1234) without a country code. Add checkout instructions asking for the full international format, or use a library like libphonenumber-js in the Route Handler to parse and normalize the number before sending.
How do I stop sending SMS to customers who don't want them?
Add an opt-in checkbox as an Additional Info field on the Webflow checkout. It arrives in the order's `customData` array as a checkbox value, so in the Route Handler you can check that consent value before calling `sendOrderSMS`. Twilio also handles opt-outs on its end, though more narrowly than people assume.
It processes standard keywords like STOP, UNSUBSCRIBE and CANCEL automatically for toll-free and long code numbers, only for single-word messages and only against the most recent number that messaged that person. If you send from more than one number, you have to propagate the opt-out yourself.
Do the two webhooks share a signing secret?
No. Webflow issues each webhook created with a site token its own secretKey, so register both, store both, and select the right one based on the triggerType in the payload before validating. Sharing one secret across both means every event from the other webhook fails validation, and after enough failures Webflow deactivates it.
Can I send different SMS messages for different order statuses?
Yes. The ecomm_order_changed webhook payload includes a status field. Extend the statusMap object in the Route Handler with any status string you want to handle. Statuses not in the map fall back to the generic "your order is now [status]" template, so no handler crashes if Webflow adds a new status in the future.




