Developer Guide

Send SMS OTP / 2FA Codes via the WebSMS API

A practical, end-to-end walk-through with working examples in PHP, Node.js, Python and Go - grab a single file or the full bundle.

The WebSMS Connexus API exposes a dedicated /sms/otp endpoint for verification codes - cheaper than a regular SMS and pre-formatted for the WebOTP API so codes can autofill on supported browsers. This post walks through the full flow: get a token, refresh it before it expires, send a code (let us generate one or supply your own), verify what the user typed on your server, and listen for delivery reports and replies via webhook.

Download examples: every bundle includes the API client, webhook handler, README and a runnable end-to-end demo (phone form → SMS → verify with WebOTP autofill). Pick your stack:

1. Create an API key

Head to Members Area → API Keys and create a new key. You'll get a client_id (cid_...) and a client_secret (csk_...). The secret is shown once; copy it into your secrets store immediately. The keys themselves don't expire (unless you set an expiry date), but the access tokens you exchange them for do.

While you're there, also set the Webhook URL on the key. A single URL receives both incoming replies and delivery reports - we'll wire it up in Step 6.

2. Get an access token

Exchange your client_id + client_secret for a bearer token at POST /api/connexus/auth/token. The response contains an access_token and expires_in (seconds, currently 86400 = 24h).

curl -X POST https://websms.co.nz/api/connexus/auth/token \
  -d "client_id=cid_abc123..." \
  -d "client_secret=csk_xyz789..."

# {"access_token":"wst_...","token_type":"Bearer","expires_in":86400}
require 'WebSMSClient.php';
$client = new WebSMSClient(getenv('WEBSMS_CLIENT_ID'), getenv('WEBSMS_CLIENT_SECRET'));
$token  = $client->getAccessToken();   // cached + auto-refreshed

3. Refresh tokens before they expire

Calling /auth/token on every request would be wasteful and slow. Each of the clients below caches the token (in memory, or a JSON file in PHP since FPM workers don't share state) along with its expiry, and refreshes it transparently a few minutes before the real expiry to avoid races where a request lands the moment a token dies. Every client also retries once on a 401 from a protected endpoint as a final safety net.

The pattern: store access_token + expires_at = now + expires_in; refresh when expires_at - now < 5 minutes; retry once on a 401.

Here's the relevant section of the client in each language - this is the same code that ships in the download.

<?php
/**
 * WebSMS Connexus API client for OTP / 2FA flows.
 *
 * Handles:
 *   - OAuth-style client credentials -> bearer token exchange
 *   - In-memory + on-disk token cache with auto-refresh before expiry
 *   - Sending an OTP (auto-generated 6-digit code OR your own 4-8 digit code)
 *   - Server-side OTP verification with TTL + attempt tracking
 *
 * Requires PHP 7.4+ and ext-curl. No third-party dependencies.
 *
 * Usage:
 *   $client = new WebSMSClient(getenv('WEBSMS_CLIENT_ID'), getenv('WEBSMS_CLIENT_SECRET'));
 *   $r = $client->sendOTP('6421234567', 'MyApp');
 *   // ... user enters code ...
 *   if ($client->verifyOTP('6421234567', $userInput)) { ... }
 */
class WebSMSClient
{
    private const BASE_URL = 'https://websms.co.nz/api/connexus';

    /** Refresh the token this many seconds before it actually expires. */
    private const REFRESH_SKEW = 300;

    private string $clientId;
    private string $clientSecret;
    private string $tokenCachePath;
    private string $otpStorePath;

    public function __construct(string $clientId, string $clientSecret, ?string $cacheDir = null)
    {
        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
        $cacheDir = $cacheDir ?? sys_get_temp_dir();
        $this->tokenCachePath = $cacheDir . '/websms_token_' . md5($clientId) . '.json';
        $this->otpStorePath   = $cacheDir . '/websms_otp_'   . md5($clientId) . '.json';
    }

    /**
     * Get a valid access token, refreshing transparently if missing/expired/near-expiry.
     */
    public function getAccessToken(): string
    {
        $cached = $this->loadTokenCache();
        if ($cached && $cached['expires_at'] > time() + self::REFRESH_SKEW) {
            return $cached['access_token'];
        }
        return $this->refreshToken();
    }

    /**
     * Force a fresh token regardless of cache state.
     */
    public function refreshToken(): string
    {
        $resp = $this->httpPost('/auth/token', [
            'client_id'     => $this->clientId,
            'client_secret' => $this->clientSecret,
        ], false);

        if (empty($resp['access_token'])) {
            throw new RuntimeException('Token endpoint did not return access_token: ' . json_encode($resp));
        }

        $this->saveTokenCache([
            'access_token' => $resp['access_token'],
            'expires_at'   => time() + (int) ($resp['expires_in'] ?? 86400),
        ]);

        return $resp['access_token'];
    }

    /**
     * Send an OTP. If $code is null, WebSMS generates one and returns it; either way
     * the issued code is stored locally for verifyOTP() to compare against.
     *
     * @param string      $to            E.164 mobile number without the leading '+'.
     * @param string      $company       Brand shown in message body.
     * @param string|null $code          Optional 4-8 digit code you supply yourself.
     * @param string|null $comment       Optional trailing text (e.g. "Valid for 5 minutes.").
     * @param int         $ttlSeconds    How long the code is valid for verification.
     * @param string|null $webOtpDomain  Optional bare hostname (e.g. "app.example.com").
     *                                   When set, WebSMS appends the WebOTP autofill marker
     *                                   ("@host #code") as the SMS's last line so Chrome/Safari
     *                                   can offer one-tap autofill on your origin.
     */
    public function sendOTP(
        string $to,
        string $company,
        ?string $code = null,
        ?string $comment = null,
        int $ttlSeconds = 300,
        ?string $webOtpDomain = null
    ): array
    {
        $params = [
            'to'         => $to,
            'msgCompany' => $company,
        ];
        if ($code !== null)         $params['msgCode']      = $code;
        if ($comment !== null)      $params['msgComment']   = $comment;
        if ($webOtpDomain !== null) $params['webOtpDomain'] = $webOtpDomain;

        $resp = $this->httpPost('/sms/otp', $params, true);

        if (($resp['status'] ?? null) !== 'success' || empty($resp['code'])) {
            throw new RuntimeException('OTP send failed: ' . json_encode($resp));
        }

        $this->saveOTPRecord($to, $resp['code'], $ttlSeconds);
        return $resp;
    }

    /**
     * Verify a code submitted by the user. Returns true once and only once per code:
     * on success the stored record is consumed so the same code can't be reused.
     */
    public function verifyOTP(string $to, string $userInput): bool
    {
        $store = $this->loadOTPStore();
        $rec = $store[$to] ?? null;
        if (!$rec) return false;
        if ($rec['expires_at'] < time())  { unset($store[$to]); $this->saveOTPStore($store); return false; }
        if ($rec['attempts'] >= 5)        { unset($store[$to]); $this->saveOTPStore($store); return false; }

        $store[$to]['attempts']++;
        if (hash_equals((string) $rec['code'], (string) $userInput)) {
            unset($store[$to]);
            $this->saveOTPStore($store);
            return true;
        }
        $this->saveOTPStore($store);
        return false;
    }

    // --- HTTP -----------------------------------------------------------------

    private function httpPost(string $path, array $params, bool $authenticated): array
    {
        $headers = ['Accept: application/json'];
        if ($authenticated) {
            $headers[] = 'Authorization: Bearer ' . $this->getAccessToken();
        }

        $ch = curl_init(self::BASE_URL . $path);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => http_build_query($params),
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_CONNECTTIMEOUT => 5,
        ]);
        $body = curl_exec($ch);
        $http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        curl_close($ch);

        if ($body === false) throw new RuntimeException("HTTP {$path} failed: {$err}");
        $data = json_decode($body, true);
        if (!is_array($data)) throw new RuntimeException("HTTP {$path} returned non-JSON ({$http}): {$body}");

        // 401 from /sms/* with a cached token means the token was revoked early - retry once.
        if ($http === 401 && $authenticated) {
            $this->refreshToken();
            return $this->httpPost($path, $params, true);
        }
        return $data;
    }

    // --- Storage helpers (atomic JSON files) ----------------------------------
    // In production you would back these with Redis/DB so they're shared across hosts.

    private function loadTokenCache(): ?array
    {
        if (!is_file($this->tokenCachePath)) return null;
        $j = json_decode((string) file_get_contents($this->tokenCachePath), true);
        return is_array($j) ? $j : null;
    }
    private function saveTokenCache(array $data): void { $this->writeJsonAtomic($this->tokenCachePath, $data); }

    private function loadOTPStore(): array
    {
        if (!is_file($this->otpStorePath)) return [];
        $j = json_decode((string) file_get_contents($this->otpStorePath), true);
        return is_array($j) ? $j : [];
    }
    private function saveOTPStore(array $store): void { $this->writeJsonAtomic($this->otpStorePath, $store); }
    private function saveOTPRecord(string $to, string $code, int $ttl): void
    {
        $store = $this->loadOTPStore();
        $store[$to] = ['code' => $code, 'expires_at' => time() + $ttl, 'attempts' => 0];
        $this->saveOTPStore($store);
    }

    private function writeJsonAtomic(string $path, array $data): void
    {
        $tmp = $path . '.' . bin2hex(random_bytes(4));
        file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT));
        chmod($tmp, 0600);
        rename($tmp, $path);
    }
}

Source: WebSMSClient.phpdownload just this file.

4. Send an OTP

POST /api/connexus/sms/otp sends a verification code. By default we generate a 6-digit code for you and return it in the response body - store it, we don't. If you'd rather control the code yourself (handy when you want the same code delivered through multiple channels, or you've already generated one upstream), pass msgCode with a 4-8 digit numeric value.

Auto-generated code

curl -X POST https://websms.co.nz/api/connexus/sms/otp \
  -H "Authorization: Bearer wst_..." \
  -d "to=6421234567" \
  -d "msgCompany=MyApp" \
  -d "msgComment=Valid for 5 minutes."

Your own code

curl -X POST https://websms.co.nz/api/connexus/sms/otp \
  -H "Authorization: Bearer wst_..." \
  -d "to=6421234567" \
  -d "msgCompany=MyApp" \
  -d "msgCode=482174"

Both produce a message body of the form "482174 is your MyApp verification code. Valid for 5 minutes." To enable one-tap browser autofill on Chrome/Safari, also pass webOtpDomain=example.com and we'll append the WebOTP marker (@example.com #482174) as the last line of the SMS.

Always store the returned code server-side, keyed by phone number, with a short TTL (5-10 minutes). Don't echo it back to the browser, and don't store it in a cookie. The clients below do this for you.
// auto-generated 6-digit code
$client->sendOTP('6421234567', 'MyApp', null, 'Valid for 5 minutes.');

// or supply your own (4-8 digits, numeric)
$client->sendOTP('6421234567', 'MyApp', '482174');

5. Verify the code on your server

When the user submits the code they received, compare it to what you stored in Step 4. Three things matter:

  • Use a constant-time comparison (hash_equals / crypto.timingSafeEqual / hmac.compare_digest / subtle.ConstantTimeCompare) to avoid timing oracles.
  • Cap attempts - the example clients allow 5 tries before invalidating the code.
  • Single-shot: on a successful match, delete the stored record so the same code can't be reused.
if ($client->verifyOTP($phone, $_POST['code'])) {
    // success - mark phone verified, issue session, etc.
} else {
    // wrong / expired / too many attempts
}

6. Receive delivery reports & replies via webhook

Set the webhook URL on your API key in Members Area → API Keys. A single URL receives both incoming replies (type: "mo") and delivery reports (type: "dlr") - branch on the type field. Your endpoint must return 200 within 5 seconds; failures are retried up to 3 times.

MO (incoming reply)

{
  "type": "mo",
  "messageId": "MO456",
  "from": "+6421234567",
  "to": "shortcode",
  "body": "Reply text",
  "timestamp": 1234567890
}

DLR (delivery report)

{
  "type": "dlr",
  "messageId": "MSG123",
  "status": "DELIVRD",
  "statusCode": 1,
  "timestamp": 1234567890
}

statusCode: 1 DELIVRD, 2 UNDELIV, 4 QUEUED/expired, 8 ACCEPTD, 16 UNDELIV/rejected.

<?php
/**
 * WebSMS Connexus webhook receiver.
 *
 * One URL handles both incoming SMS replies (type=mo) and delivery reports
 * (type=dlr). Configure the URL in Members Area -> API Keys.
 *
 * Always respond 200 within 5s; failed webhooks are retried up to 3 times.
 */

$raw  = file_get_contents('php://input');
$data = json_decode($raw, true);

if (!is_array($data) || !isset($data['type'])) {
    http_response_code(400);
    echo json_encode(['error' => 'invalid payload']);
    exit;
}

switch ($data['type']) {
    case 'mo':
        // Incoming SMS reply.
        // Example fields: messageId, from (E.164), to (your shortcode), body, timestamp, encoding, network
        $from = $data['from'] ?? '';
        $body = $data['body'] ?? '';
        error_log("[websms-mo] {$from} -> {$body}");
        // TODO: persist to your DB / dispatch to your business logic
        break;

    case 'dlr':
        // Delivery report.
        // statusCode 1=DELIVRD, 2=UNDELIV, 4=QUEUED/expired, 8=ACCEPTD, 16=UNDELIV/rejected
        $messageId = $data['messageId'] ?? '';
        $status    = $data['status']    ?? '';
        $code      = $data['statusCode'] ?? null;
        error_log("[websms-dlr] {$messageId} status={$status} code={$code}");
        // TODO: update the status of the original send in your DB
        break;

    default:
        // Unknown type - acknowledge so we don't get retried, but log it.
        error_log('[websms] unknown webhook type: ' . $data['type']);
}

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['ok' => true]);

Source: webhook.phpdownload just this file.

Download the example

Each bundle contains the client, webhook receiver, README and an .env.example. Bundles are generated on demand from the same source files rendered above, so they always match what's on this page.

Related

Ready to ship 2FA?

Create an API key, drop in the bundle, send your first verification code in minutes.