Cloudflare Workers and Sending SMS

Connect a Cloudflare Worker to the TNZ REST API and send SMS, Voice and Email.

Updated

Use a Cloudflare Worker to send SMS through the TNZ REST API.

  1. Your Application SMS request
  2. Cloudflare Worker Check and Submit
  3. TNZ REST API Send SMS
  4. Mobile Phone Receives the SMS

1. Get your TNZ API token

  1. Open Users in the TNZ Dashboard.
  2. Select or create an API-enabled User.
  3. Open its API tab and generate an Auth Token.
  4. Keep the token ready for step 3, where you will store it as TNZ_AUTH_TOKEN.

Your Worker uses this token to authenticate with TNZ. For full instructions, see Generating your TNZ Auth Token.

2. Create your Cloudflare Worker

2.1 Create the project

Run this command in the folder where you keep your development projects:

Terminal
cd <your-project-folder>
npm create cloudflare@latest -- tnz-worker

Select:

  1. Hello World example
  2. Worker only
  3. JavaScript
  4. Choose No when asked whether to deploy

2.2 Open the project folder

Terminal
cd tnz-worker

2.3 Add the Worker code

Replace <your-project-folder>/tnz-worker/src/index.js with this complete example:

src/index.js
View complete JavaScript (94 lines)
const TNZ_SMS_URL = "https://api.tnz.co.nz/api/v3.00/sms";
// Allow New Zealand (+64) and Australian (+61) destinations.
const ALLOWED_PREFIXES = ["+64", "+61"];
// 459 basic GSM characters fit in three concatenated SMS parts (153 each).
// This counts code points, not SMS parts: extension characters and Unicode can use more parts.
const MAX_MESSAGE_LENGTH = 459;

// Return JSON without allowing clients or intermediaries to cache the response.
const json = (body, status = 200, headers = {}) => Response.json(body, {
  status, headers: { "Cache-Control": "no-store", ...headers }
});
const error = (message, status) => json({ error: message }, status);

// Normalise the NZ/AU formats in the TNZ mobile-number formatting guide.
// Always send international format to TNZ, independent of account formatting settings.
const normaliseMobile = (value) => {
  if (typeof value !== "string") return null;
  // Remove common formatting, but leave letters and other invalid characters to fail validation.
  let number = value.replace(/[\s()\-]/g, "");
  if (number.startsWith("00")) number = "+" + number.slice(2);
  else if (/^(64|61)\d+$/.test(number)) number = "+" + number;
  else if (/^02\d+$/.test(number)) number = "+64" + number.slice(1);
  else if (/^04\d+$/.test(number)) number = "+61" + number.slice(1);
  else if (/^2\d+$/.test(number)) number = "+64" + number;
  else if (/^4\d+$/.test(number)) number = "+61" + number;
  // Check international syntax. Country restrictions are applied after normalisation.
  return /^\+[1-9]\d{7,14}$/.test(number) ? number : null;
};

export default {
  async fetch(request, env) {
    // Accept only POST requests to the SMS endpoint.
    if (new URL(request.url).pathname !== "/send-sms") return error("Not found", 404);
    if (request.method !== "POST") return json({ error: "Use POST" }, 405, { Allow: "POST" });

    // Both credentials are Cloudflare secrets. Authenticate the caller with the Worker key.
    if (!env.TNZ_AUTH_TOKEN || !env.WORKER_API_KEY) return error("Missing secrets", 503);
    if (request.headers.get("Authorization") !== `Bearer ${env.WORKER_API_KEY}`) {
      return error("Unauthorized", 401);
    }

    // Require a JSON object and return useful errors for malformed input.
    const type = (request.headers.get("Content-Type") || "").split(";")[0].trim().toLowerCase();
    if (type !== "application/json") return error("Use application/json", 415);
    let input;
    try { input = await request.json(); }
    catch { return error("Invalid JSON", 400); }
    if (!input || typeof input !== "object" || Array.isArray(input)) return error("Use a JSON object", 400);

    // Send live SMS by default. Callers can set test: true for a test-only request.
    const { to, message, test = false } = input;

    // Normalise the destination, then check message length and the test flag.
    const destination = normaliseMobile(to);
    if (!destination) return error("Use a valid NZ/AU mobile or international number", 400);
    if (typeof message !== "string" || !message.trim() || [...message].length > MAX_MESSAGE_LENGTH) {
      return error("Use a nonblank message of at most 459 characters", 400);
    }
    if (typeof test !== "boolean") return error("test must be true or false", 400);

    // Restrict country prefixes. This does not verify that a number is an active mobile.
    if (!ALLOWED_PREFIXES.some(prefix => destination.startsWith(prefix))) return error("Destination not permitted", 403);

    try {
      // Submit to TNZ using the TNZ token, which stays inside the Worker.
      const upstream = await fetch(TNZ_SMS_URL, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${env.TNZ_AUTH_TOKEN}`,
          "Content-Type": "application/json", Accept: "application/json"
        },
        // Include Mode only for test requests. Omit it for live submissions.
        body: JSON.stringify({ Destination: destination, Message: message, ...(test ? { Mode: "Test" } : {}) }),
        // Stop waiting after 15 seconds. A timeout does not prove that sending failed.
        signal: AbortSignal.timeout(15000)
      });
      // Surface upstream failures without exposing credentials or the response body.
      if (!upstream.ok) {
        return json({ error: "TNZ error; check before retrying", upstreamStatus: upstream.status }, 502);
      }
      // Require the expected success response and a message ID for later tracking.
      const data = await upstream.json();
      if (upstream.status !== 200 || typeof data?.MessageID !== "string" || !data.MessageID) {
        return error("Unexpected TNZ response; check before retrying", 502);
      }
      // Acceptance confirms submission, not delivery to the handset.
      return json({ accepted: true, test, messageId: data.MessageID });
    } catch {
      // A network or response error can leave the sending outcome unknown.
      // Check the outcome before retrying to avoid duplicate messages.
      return error("TNZ outcome unknown; do not automatically retry", 502);
    }
  }
};

Sending Mode

The Worker accepts POST /send-sms requests and sends live SMS by default.

To check a request without sending an SMS, include "test": true in the JSON body.

Mobile Prefixes Allowed

  • ALLOWED_PREFIXES limits this example to New Zealand (+64) and Australia (+61).
  • This is an application restriction, not a list of all countries TNZ supports.
  • It does not verify that a number is an active mobile.

The Worker converts these formats to international format before checking the allowed prefixes and submitting to TNZ:

  • New Zealand: 02…, 2…, +64…, 0064… or 64….
  • Australia: 04…, 4…, +61…, 0061… or 61….

Spaces, dashes and brackets are removed.

See mobile number formatting for SMS for examples.

SMS Length

  • MAX_MESSAGE_LENGTH limits messages to 459 characters, equivalent to three SMS parts when using basic GSM characters.
  • GSM extension characters and Unicode can require more parts.
  • See message length and costs for details.

3. Configure your credentials

3.1 Deploy the Worker

Deploy the code from the project folder:

Terminal
cd <your-project-folder>/tnz-worker/
npx wrangler deploy

Sign in if prompted. Copy the deployed Worker URL for step 4.

Responses before credentials are ready

A POST /send-sms request returns 503 until both secrets are configured. Opening that path in a browser sends GET and returns 405.

3.2 Generate your Worker API key

Use your own securely generated random secret as WORKER_API_KEY, or run the command below locally to generate one:

Terminal
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

Save this value securely for step 4. Use the same key in your Worker and the server-side application that calls it. Keep it out of public browser JavaScript.

3.3 Store both secrets

Run these commands one at a time:

Terminal
npx wrangler secret put TNZ_AUTH_TOKEN
npx wrangler secret put WORKER_API_KEY
  1. Run the TNZ_AUTH_TOKEN command and paste your TNZ Dashboard token at the prompt.
  2. Run the WORKER_API_KEY command and paste the key you generated in step 3.2.

Wrangler deploys each secret change.

Alternatively, add secrets in the Cloudflare dashboard
  1. Open Workers & Pages, select your Worker, then open Settings.
  2. Under Variables and Secrets, select Add.
  3. Choose Secret, enter TNZ_AUTH_TOKEN as the name, and paste your TNZ token.
  4. Add WORKER_API_KEY as another secret with your chosen key.
  5. Select Deploy. Check that both secret names appear.

4. Send your first SMS

Follow these steps in one Bash terminal to send a live SMS through your Worker.

Live messages may incur charges. Use your own mobile number or a recipient who has agreed to the test. Check their opt-out status.

4.1 Load your Worker API key

Open a private Bash terminal and run:

Terminal
read -r -s -p "Worker API key: " TNZ_WORKER_API_KEY
printf "\n"

At the Worker API key: prompt, paste your WORKER_API_KEY from step 3 and press Enter. Your input stays hidden and out of shell history.

Keep this terminal open for the remaining steps. Use your Worker key here. Your TNZ_AUTH_TOKEN stays in the Worker.

4.2 Prepare the SMS command

Copy the command below into a text editor. Replace:

  • https://YOUR-WORKER.workers.dev with your deployed Worker URL from Wrangler’s output or your Worker’s domain settings. Keep /send-sms at the end.
  • YOUR_MOBILE_NUMBER with your NZ or AU mobile number, inside the double quotes. Use local 02…/04… or international +64…/+61… format.
Terminal
curl -X POST "https://YOUR-WORKER.workers.dev/send-sms" \
  -H "Authorization: Bearer $TNZ_WORKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"YOUR_MOBILE_NUMBER","message":"Hello from Cloudflare Workers"}'

Leave $TNZ_WORKER_API_KEY unchanged. Keep the example message so you can recognise it on your phone.

4.3 Send the request

Paste your edited command into the same Bash terminal and press Enter. This sends a live SMS request.

How the command works
  • POST submits the request to your Worker’s /send-sms endpoint.
  • The Authorization header reads your key from the temporary terminal variable TNZ_WORKER_API_KEY. This variable holds your existing key.
  • Content-Type: application/json tells the Worker how to read the to and message fields sent by -d.
  • The Worker checks your key and request, then submits the SMS to TNZ using its stored TNZ_AUTH_TOKEN.

4.4 Check the response and your phone

Look for a response like this in your terminal:

Example response
{
  "accepted": true,
  "test": false,
  "messageId": "1000000a-f002-4007-b00a-d00000000002"
}
  • accepted: true means TNZ accepted the submission. It does not confirm handset delivery.
  • test: false means this was a live request.
  • Save your actual messageId for tracking. TNZ returns this identifier as MessageID. The ID above is an example.

Check your phone for the message. If it does not arrive, use the ID to check delivery.

For errors, use the troubleshooting table. For a timeout or unclear result, follow the retry checks before sending again.

4.5 Clear the key from your terminal

When you finish, run unset TNZ_WORKER_API_KEY to remove the temporary variable. This leaves the secret in your Worker unchanged.

Keep keys out of shared commands, screenshots and documentation.

After a successful send, see next steps for application events and scheduled messages.

5. Next steps: use SMS in your application

Once your curl test works, use the same SMS Worker in one of these guides:

Follow-on guide

Application Events

Connect your application Worker through a service binding and send an SMS when a chosen event occurs.

Read the guide
Follow-on guide

Scheduled Messages

Use a Cloudflare Cron Trigger to call your SMS Worker on a daily schedule.

Read the guide

Additional Functionality

ControlAction
Credentials and permissionsUse a dedicated TNZ API User. Restrict access and rotate both credentials. Never send either key to a browser.
Fraud and usageAdd server-side rate limits, approved recipient rules and usage alerts. A country-prefix check does not validate a mobile number or stop abuse from a stolen key. If a service returns a rate-limit response, follow that service’s retry policy.
Message length and costsSet request-size and message-length limits. Long messages, Unicode and emojis can increase SMS parts and costs, even within the example’s character cap. Check SMS encoding and multipart messages and review character conversion before enabling it.
Duplicate sendsStore a caller request ID and processing state. Prevent simultaneous requests from sending the same message twice. Decide how to check requests whose outcome is unclear.
Consent and opt-outsBefore sending, check that you have permission to message the recipient and that they have not opted out.
LogsRecord request IDs, timestamps, TNZ MessageID, status and latency. Exclude credentials, message text and recipient numbers.
Handle retries carefullyThe example waits up to 15 seconds and never retries automatically. A timeout or unreadable response can leave the outcome unknown. Check the message status before retrying. If there is no message ID or the result remains unclear, contact TNZ with the timestamp and any returned status. Do not resend until you know the outcome.
Track deliveryRecord submission and delivery separately. Use GET /sms/{MessageID} or configure and validate result webhooks. Poll at most once per second and stop after six hours without a result. The sample’s /send-sms route is not a webhook receiver.

Add tracking, templates or scheduling

The SMS endpoint reference covers additional fields:

  • Reference labels a request but does not prevent duplicate sends. SubAccount and Department support reporting.
  • TemplateID selects a template. If you also supply Message, that text takes priority.
  • SendTime schedules a message in the local time set by Timezone. Do not assume UTC.
  • Before adding FromNumber, check destination-country rules.

Add storage or queues

  • Cloudflare D1 can hold request state.
  • Cloudflare Queues supports background processing.
  • Before adding retries, make sure simultaneous requests cannot send the same message twice.
  • Decide how you will check requests whose outcome is unclear.

Send Voice or Email messages

  • See the text-to-speech endpoint (/tts) or Email endpoint (/email).
  • Each channel needs its own payload, validation and result handling. You may re-use the same TNZ Auth Token.
  • Review its test behavior before adapting this example.

Troubleshooting

ProblemWhat to check
404 or 405Use your deployed Worker URL, POST and the exact path /send-sms. A browser address-bar request uses GET.
401Use WORKER_API_KEY in the caller’s Bearer header, not the TNZ token.
503Check that both secrets are deployed to this Worker.
400Read the response’s error field. Send a JSON object with a supported NZ/AU or international to number and a nonblank message of at most 459 characters. If supplied, test must be the boolean true or false.
403Check that the recipient’s country prefix appears in ALLOWED_PREFIXES. If you change the list, save the file and run npx wrangler deploy again.
415Set the Content-Type header to application/json.
502If upstreamStatus is present, check the TNZ API error. If TNZ returns 401, check the TNZ token. If the outcome is unclear, follow the retry checks.
Accepted, but no SMSCheck test mode first. For live sends, use messageId to check the delivery result.

Further help

  • Use the TNZ REST API documentation to extend your integration.
  • If a TNZ request fails, contact TNZ with its timestamp, status and MessageID if available.
  • Leave credentials and message content out of your enquiry.

Topics:   Developers SMS

Start your free trial today!

Ready to revolutionise your messaging?
Connect with people anywhere - boost engagement, responses and conversations.

25 SMS Credits over 14-days. No obligation. No credit card required.