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.
On this page
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.
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-refreshedconst { WebSMSClient } = require('./websms-client');
const client = new WebSMSClient(process.env.WEBSMS_CLIENT_ID, process.env.WEBSMS_CLIENT_SECRET);
const token = await client.getAccessToken(); // cached + auto-refreshedfrom websms_client import WebSMSClient
import os
client = WebSMSClient(os.environ['WEBSMS_CLIENT_ID'], os.environ['WEBSMS_CLIENT_SECRET'])
token = client.get_access_token() # cached + auto-refreshedimport websms "example.com/websms-otp"
c := websms.New(os.Getenv("WEBSMS_CLIENT_ID"), os.Getenv("WEBSMS_CLIENT_SECRET"))
token, err := c.AccessToken() // cached + auto-refreshed3. 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.
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.php — download just this file.
// WebSMS Connexus API client for OTP / 2FA flows.
//
// Handles:
// - OAuth-style client credentials -> bearer token exchange
// - In-memory token cache with auto-refresh before expiry
// - Sending an OTP (auto-generated code OR your own 4-8 digit code)
// - Server-side OTP verification with TTL + attempt tracking
//
// Node 18+ (uses the global fetch API, no third-party deps required).
//
// const c = new WebSMSClient(process.env.WEBSMS_CLIENT_ID, process.env.WEBSMS_CLIENT_SECRET);
// await c.sendOTP('6421234567', 'MyApp');
// await c.verifyOTP('6421234567', userInput);
const BASE_URL = 'https://websms.co.nz/api/connexus';
const REFRESH_SKEW_MS = 5 * 60 * 1000; // refresh 5 min before actual expiry
class WebSMSClient {
constructor(clientId, clientSecret) {
if (!clientId || !clientSecret) throw new Error('clientId and clientSecret are required');
this.clientId = clientId;
this.clientSecret = clientSecret;
this.token = null; // { access_token, expires_at }
this.otpStore = new Map(); // to -> { code, expires_at, attempts }
}
async getAccessToken() {
if (this.token && this.token.expires_at - Date.now() > REFRESH_SKEW_MS) {
return this.token.access_token;
}
return this.refreshToken();
}
async refreshToken() {
const data = await this.#post('/auth/token', {
client_id: this.clientId,
client_secret: this.clientSecret,
}, false);
if (!data.access_token) {
throw new Error('Token endpoint did not return access_token: ' + JSON.stringify(data));
}
this.token = {
access_token: data.access_token,
expires_at: Date.now() + ((data.expires_in || 86400) * 1000),
};
return this.token.access_token;
}
/**
* Send an OTP. If `code` is null, WebSMS auto-generates a 6-digit code and
* returns it; either way the issued code is stored locally for verifyOTP().
*
* @param {string} to E.164 mobile without leading '+'
* @param {string} company Brand shown in message body
* @param {string|null} code Optional 4-8 digit numeric code you supply
* @param {string|null} comment Optional trailing text ('Valid for 5 minutes.')
* @param {number} 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.
*/
async sendOTP(to, company, code = null, comment = null, ttlSeconds = 300, webOtpDomain = null) {
const params = { to, msgCompany: company };
if (code != null) params.msgCode = String(code);
if (comment != null) params.msgComment = comment;
if (webOtpDomain != null) params.webOtpDomain = webOtpDomain;
const resp = await this.#post('/sms/otp', params, true);
if (resp.status !== 'success' || !resp.code) {
throw new Error('OTP send failed: ' + JSON.stringify(resp));
}
this.otpStore.set(to, {
code: String(resp.code),
expires_at: Date.now() + ttlSeconds * 1000,
attempts: 0,
});
return resp;
}
/**
* Verify a code submitted by the user. Single-shot - the record is consumed
* on success. Capped at 5 attempts to prevent brute force.
*/
verifyOTP(to, userInput) {
const rec = this.otpStore.get(to);
if (!rec) return false;
if (rec.expires_at < Date.now()) { this.otpStore.delete(to); return false; }
if (rec.attempts >= 5) { this.otpStore.delete(to); return false; }
rec.attempts++;
if (timingSafeEqual(rec.code, String(userInput))) {
this.otpStore.delete(to);
return true;
}
return false;
}
async #post(path, params, authenticated) {
const headers = { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' };
if (authenticated) {
headers['Authorization'] = 'Bearer ' + (await this.getAccessToken());
}
const res = await fetch(BASE_URL + path, {
method: 'POST',
headers,
body: new URLSearchParams(params).toString(),
});
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { throw new Error(`Non-JSON response from ${path} (${res.status}): ${text}`); }
// Token revoked early? Refresh once and retry.
if (res.status === 401 && authenticated) {
this.token = null;
await this.refreshToken();
return this.#post(path, params, true);
}
return data;
}
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
module.exports = { WebSMSClient };
Source: websms-client.js — download just this file.
"""WebSMS Connexus API client for OTP / 2FA flows.
Handles:
- OAuth-style client credentials -> bearer token exchange
- In-process token cache with auto-refresh before expiry
- Sending an OTP (auto-generated 6-digit OR your own 4-8 digit code)
- Server-side OTP verification with TTL + attempt tracking
Python 3.8+, requires `requests`.
from websms_client import WebSMSClient
c = WebSMSClient(os.environ['WEBSMS_CLIENT_ID'], os.environ['WEBSMS_CLIENT_SECRET'])
c.send_otp('6421234567', 'MyApp')
c.verify_otp('6421234567', user_input)
"""
from __future__ import annotations
import hmac
import threading
import time
from typing import Dict, Optional
import requests
BASE_URL = 'https://websms.co.nz/api/connexus'
REFRESH_SKEW = 300 # refresh 5 min before actual expiry
class WebSMSClient:
def __init__(self, client_id: str, client_secret: str) -> None:
if not client_id or not client_secret:
raise ValueError('client_id and client_secret are required')
self._client_id = client_id
self._client_secret = client_secret
self._token: Optional[Dict] = None # {'access_token', 'expires_at'}
self._otp_store: Dict[str, Dict] = {} # to -> {code, expires_at, attempts}
self._lock = threading.Lock()
# --- Auth ----------------------------------------------------------------
def get_access_token(self) -> str:
with self._lock:
if self._token and self._token['expires_at'] > time.time() + REFRESH_SKEW:
return self._token['access_token']
return self.refresh_token()
def refresh_token(self) -> str:
data = self._post('/auth/token', {
'client_id': self._client_id,
'client_secret': self._client_secret,
}, authenticated=False)
if not data.get('access_token'):
raise RuntimeError(f'Token endpoint did not return access_token: {data}')
with self._lock:
self._token = {
'access_token': data['access_token'],
'expires_at': time.time() + int(data.get('expires_in', 86400)),
}
return data['access_token']
# --- OTP -----------------------------------------------------------------
def send_otp(
self,
to: str,
company: str,
code: Optional[str] = None,
comment: Optional[str] = None,
ttl_seconds: int = 300,
web_otp_domain: Optional[str] = None,
) -> Dict:
"""Send an OTP. If `code` is None, WebSMS auto-generates one and returns it.
Either way the issued code is stored locally for verify_otp() to compare.
Pass `web_otp_domain` (a bare hostname like 'app.example.com') to have
WebSMS append the WebOTP autofill marker ('@host #code') as the SMS's
last line so Chrome/Safari can offer one-tap autofill on your origin.
"""
params = {'to': to, 'msgCompany': company}
if code is not None:
params['msgCode'] = str(code)
if comment is not None:
params['msgComment'] = comment
if web_otp_domain is not None:
params['webOtpDomain'] = web_otp_domain
resp = self._post('/sms/otp', params, authenticated=True)
if resp.get('status') != 'success' or not resp.get('code'):
raise RuntimeError(f'OTP send failed: {resp}')
with self._lock:
self._otp_store[to] = {
'code': str(resp['code']),
'expires_at': time.time() + ttl_seconds,
'attempts': 0,
}
return resp
def verify_otp(self, to: str, user_input: str) -> bool:
"""Single-shot verify - the record is consumed on success.
Caps at 5 attempts to prevent brute force."""
with self._lock:
rec = self._otp_store.get(to)
if not rec:
return False
if rec['expires_at'] < time.time() or rec['attempts'] >= 5:
self._otp_store.pop(to, None)
return False
rec['attempts'] += 1
if hmac.compare_digest(rec['code'], str(user_input)):
self._otp_store.pop(to, None)
return True
return False
# --- HTTP ----------------------------------------------------------------
def _post(self, path: str, params: Dict, authenticated: bool) -> Dict:
headers = {'Accept': 'application/json'}
if authenticated:
headers['Authorization'] = 'Bearer ' + self.get_access_token()
r = requests.post(BASE_URL + path, data=params, headers=headers, timeout=10)
# Token revoked early? Refresh once and retry.
if r.status_code == 401 and authenticated:
with self._lock:
self._token = None
self.refresh_token()
return self._post(path, params, authenticated=True)
try:
return r.json()
except ValueError as e:
raise RuntimeError(f'Non-JSON response from {path} ({r.status_code}): {r.text}') from e
Source: websms_client.py — download just this file.
// Package websms is a small Connexus API client for OTP / 2FA flows.
//
// It handles:
// - OAuth-style client credentials -> bearer token exchange
// - Token cache + auto-refresh before expiry
// - Sending an OTP (auto-generated 6-digit OR your own 4-8 digit code)
// - Server-side OTP verification with TTL + attempt tracking
//
// No third-party deps - net/http + std library only.
package websms
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
BaseURL = "https://websms.co.nz/api/connexus"
refreshSkew = 5 * time.Minute
httpTimeout = 10 * time.Second
maxAttempts = 5
)
// Client is safe for concurrent use.
type Client struct {
clientID, clientSecret string
http *http.Client
mu sync.Mutex
token string
tokenExp time.Time
otpStore map[string]*otpRecord
}
type otpRecord struct {
code string
expires time.Time
attempts int
}
type tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// OTPResponse mirrors the /sms/otp success body.
type OTPResponse struct {
Status string `json:"status"`
MessageID string `json:"message_id"`
To string `json:"to"`
From string `json:"from"`
Code string `json:"code"`
Parts int `json:"parts"`
}
func New(clientID, clientSecret string) *Client {
return &Client{
clientID: clientID,
clientSecret: clientSecret,
http: &http.Client{Timeout: httpTimeout},
otpStore: make(map[string]*otpRecord),
}
}
// AccessToken returns a valid bearer token, refreshing transparently.
func (c *Client) AccessToken() (string, error) {
c.mu.Lock()
if c.token != "" && time.Until(c.tokenExp) > refreshSkew {
t := c.token
c.mu.Unlock()
return t, nil
}
c.mu.Unlock()
return c.RefreshToken()
}
// RefreshToken forces a fresh token regardless of cache state.
func (c *Client) RefreshToken() (string, error) {
body := url.Values{
"client_id": {c.clientID},
"client_secret": {c.clientSecret},
}
var t tokenResp
if err := c.post("/auth/token", body, false, &t); err != nil {
return "", fmt.Errorf("refresh token: %w", err)
}
if t.AccessToken == "" {
return "", errors.New("token endpoint did not return access_token")
}
exp := t.ExpiresIn
if exp == 0 {
exp = 86400
}
c.mu.Lock()
c.token = t.AccessToken
c.tokenExp = time.Now().Add(time.Duration(exp) * time.Second)
c.mu.Unlock()
return t.AccessToken, nil
}
// SendOTP sends a verification code. If code == "", WebSMS auto-generates one
// and returns it; either way the issued code is stored locally for VerifyOTP().
//
// webOtpDomain is optional - pass a bare hostname (e.g. "app.example.com") to
// have WebSMS append the WebOTP autofill marker ("@host #code") as the SMS's
// last line so Chrome/Safari can offer one-tap autofill on your origin.
func (c *Client) SendOTP(to, company, code, comment, webOtpDomain string, ttl time.Duration) (*OTPResponse, error) {
if ttl <= 0 {
ttl = 5 * time.Minute
}
body := url.Values{
"to": {to},
"msgCompany": {company},
}
if code != "" {
body.Set("msgCode", code)
}
if comment != "" {
body.Set("msgComment", comment)
}
if webOtpDomain != "" {
body.Set("webOtpDomain", webOtpDomain)
}
var resp OTPResponse
if err := c.post("/sms/otp", body, true, &resp); err != nil {
return nil, err
}
if resp.Status != "success" || resp.Code == "" {
return nil, fmt.Errorf("otp send failed: %+v", resp)
}
c.mu.Lock()
c.otpStore[to] = &otpRecord{
code: resp.Code,
expires: time.Now().Add(ttl),
}
c.mu.Unlock()
return &resp, nil
}
// VerifyOTP is single-shot - the record is consumed on success. Caps at 5 attempts.
func (c *Client) VerifyOTP(to, userInput string) bool {
c.mu.Lock()
defer c.mu.Unlock()
rec, ok := c.otpStore[to]
if !ok {
return false
}
if time.Now().After(rec.expires) || rec.attempts >= maxAttempts {
delete(c.otpStore, to)
return false
}
rec.attempts++
if subtle.ConstantTimeCompare([]byte(rec.code), []byte(userInput)) == 1 {
delete(c.otpStore, to)
return true
}
return false
}
func (c *Client) post(path string, body url.Values, authenticated bool, out interface{}) error {
req, err := http.NewRequest("POST", BaseURL+path, strings.NewReader(body.Encode()))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if authenticated {
token, err := c.AccessToken()
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
// Token revoked early? Refresh once and retry.
if res.StatusCode == http.StatusUnauthorized && authenticated {
c.mu.Lock()
c.token = ""
c.mu.Unlock()
if _, err := c.RefreshToken(); err != nil {
return err
}
return c.post(path, body, true, out)
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("non-JSON response from %s (%d): %s", path, res.StatusCode, raw)
}
return nil
}
Source: websms.go — download 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.
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');// auto-generated
await client.sendOTP('6421234567', 'MyApp', null, 'Valid for 5 minutes.');
// or supply your own
await client.sendOTP('6421234567', 'MyApp', '482174');# auto-generated
client.send_otp('6421234567', 'MyApp', comment='Valid for 5 minutes.')
# or supply your own
client.send_otp('6421234567', 'MyApp', code='482174')// auto-generated
c.SendOTP("6421234567", "MyApp", "", "Valid for 5 minutes.", 5*time.Minute)
// or supply your own
c.SendOTP("6421234567", "MyApp", "482174", "", 5*time.Minute)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
}if (client.verifyOTP(phone, req.body.code)) {
// success
} else {
// wrong / expired / too many attempts
}if client.verify_otp(phone, request.form['code']):
... # success
else:
... # wrong / expired / too many attemptsif c.VerifyOTP(phone, userInput) {
// success
} 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.php — download just this file.
// WebSMS Connexus webhook receiver - handles MO (replies) + DLR (delivery reports).
// Node 18+, Express. Configure the URL in Members Area -> API Keys.
//
// npm i express
// node webhook.js
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook', (req, res) => {
const data = req.body || {};
switch (data.type) {
case 'mo': {
// Incoming SMS reply - persist + dispatch to your business logic
const { from, body } = data;
console.log(`[websms-mo] ${from} -> ${body}`);
break;
}
case 'dlr': {
// Delivery report
// statusCode 1=DELIVRD, 2=UNDELIV, 4=QUEUED/expired, 8=ACCEPTD, 16=UNDELIV/rejected
const { messageId, status, statusCode } = data;
console.log(`[websms-dlr] ${messageId} status=${status} code=${statusCode}`);
break;
}
default:
console.warn('[websms] unknown webhook type:', data.type);
}
// Always respond 200 within 5s; failed webhooks are retried up to 3 times.
res.json({ ok: true });
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`webhook listening on :${port}`));
Source: webhook.js — download just this file.
"""WebSMS Connexus webhook receiver - handles MO (replies) + DLR (delivery reports).
Flask app. Configure the URL in Members Area -> API Keys.
pip install flask
python webhook.py # listens on :3000
Always responds 200 within 5s; failed webhooks are retried up to 3 times.
"""
import logging
import os
from flask import Flask, jsonify, request
app = Flask(__name__)
log = logging.getLogger('websms-webhook')
logging.basicConfig(level=logging.INFO)
@app.post('/webhook')
def webhook():
data = request.get_json(silent=True) or {}
kind = data.get('type')
if kind == 'mo':
# Incoming SMS reply
log.info('mo: %s -> %s', data.get('from'), data.get('body'))
# TODO: persist + dispatch to your business logic
elif kind == 'dlr':
# Delivery report
# statusCode 1=DELIVRD, 2=UNDELIV, 4=QUEUED/expired, 8=ACCEPTD, 16=UNDELIV/rejected
log.info('dlr: %s status=%s code=%s',
data.get('messageId'), data.get('status'), data.get('statusCode'))
# TODO: update the status of the original send
else:
log.warning('unknown webhook type: %s', kind)
return jsonify(ok=True)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 3000)))
Source: webhook.py — download just this file.
// WebSMS Connexus webhook receiver - handles MO (replies) + DLR (delivery reports).
// One URL handles both - the JSON `type` field tells you which event.
//
// go run .
//
// Configure the webhook URL in Members Area -> API Keys.
package websms
import (
"encoding/json"
"log"
"net/http"
)
type webhookPayload struct {
Type string `json:"type"`
MessageID string `json:"messageId"`
From string `json:"from"`
To string `json:"to"`
Body string `json:"body"`
Timestamp int64 `json:"timestamp"`
Status string `json:"status"`
StatusCode int `json:"statusCode"`
Details json.RawMessage `json:"details"`
}
// WebhookHandler can be wired into any http.ServeMux:
//
// mux := http.NewServeMux()
// mux.HandleFunc("/webhook", websms.WebhookHandler)
// http.ListenAndServe(":3000", mux)
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
var p webhookPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
switch p.Type {
case "mo":
// Incoming SMS reply
log.Printf("mo: %s -> %s", p.From, p.Body)
// TODO: persist + dispatch to your business logic
case "dlr":
// Delivery report
// statusCode 1=DELIVRD, 2=UNDELIV, 4=QUEUED/expired, 8=ACCEPTD, 16=UNDELIV/rejected
log.Printf("dlr: %s status=%s code=%d", p.MessageID, p.Status, p.StatusCode)
// TODO: update the status of the original send
default:
log.Printf("unknown webhook type: %q", p.Type)
}
// Always respond 200 within 5s; failed webhooks are retried up to 3 times.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}
Source: webhook.go — download 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.