API documentation

OTP API

Generate, deliver and verify one-time passcodes — with request signing so codes cannot be replayed.

OTP endpoints are signed. In addition to x-api-key, every request must carry X-Timestamp and X-Signature headers (see below). Requests older than 60 seconds are rejected.

Signing a request

  1. timestamp = current time in milliseconds since epoch (e.g. 1760000000000).
  2. body = the exact JSON string you send (compact, no extra whitespace, same key order).
  3. signature = hex( HMAC-SHA256( secret, apiKey + body + timestamp ) ). Your secret is the API secret issued with your key; if you were not given one, use the API key itself as the secret.
sign.js (Node)
import crypto from 'node:crypto';

export function signedHeaders(apiKey, apiSecret, bodyObj) {
  const body = JSON.stringify(bodyObj);          // send THIS exact string
  const ts   = Date.now().toString();
  const sig  = crypto.createHmac('sha256', apiSecret).update(apiKey + body + ts).digest('hex');
  return { body, headers: { 'x-api-key': apiKey, 'X-Timestamp': ts, 'X-Signature': sig, 'Content-Type': 'application/json' } };
}
sign.php
function tian_signed_headers(string $apiKey, string $apiSecret, array $bodyArr): array {
  $body = json_encode($bodyArr, JSON_UNESCAPED_SLASHES); // send THIS exact string
  $ts   = (string) round(microtime(true) * 1000);
  $sig  = hash_hmac('sha256', $apiKey . $body . $ts, $apiSecret);
  return [$body, ["x-api-key: $apiKey", "X-Timestamp: $ts", "X-Signature: $sig", "Content-Type: application/json"]];
}

POST /otp/send

Generates a code, stores it against to + purpose and delivers it by SMS. Requires the otp.send scope.

tostring, requiredRecipient number
purposestring, optionalContext label such as login, signup, momo_withdrawal. Default verification. Must match on verify.
client_idstring, optionalYour own session/device identifier (or send header X-Client-Id). Must match on verify when used.
200 OK
{ "success": true, "status": "sent", "message": "OTP sent successfully", "data": { "expires_at": "2026-10-09T09:19:02.000Z" } }

To prevent phone-number enumeration this endpoint always answers status: "sent", even if the number is invalid or you are out of credit. Check your balance separately.

POST /otp/verify

Checks the code the user typed. Requires the otp.verify scope.

tostring, requiredSame number used in /otp/send
otpstring, requiredCode entered by the user
purposestring, optionalSame purpose as on send
client_idstring, optionalSame client id as on send
200 OK
{ "success": true, "message": "OTP verified successfully", "data": { "verified": true } }
400 Bad Request
{ "success": false, "message": "Invalid or expired OTP" }

End-to-end example (Node)

otp-flow.js
const BASE = 'https://tiansms.com/api/v1';
const KEY = process.env.TIAN_KEY, SECRET = process.env.TIAN_SECRET;

async function call(path, bodyObj) {
  const { body, headers } = signedHeaders(KEY, SECRET, bodyObj);
  const res = await fetch(BASE + path, { method: 'POST', headers, body });
  return res.json();
}

await call('/otp/send',   { to: '+233241234567', purpose: 'login', client_id: sessionId });
// ...user types the code...
const r = await call('/otp/verify', { to: '+233241234567', otp: userInput, purpose: 'login', client_id: sessionId });
if (r.success) grantAccess();

Tips

  • Codes expire after 5 minutes by default; expiry is returned in expires_at.
  • Use a distinct purpose per flow so a login code cannot be used to confirm a payment.
  • Rate-limit your own verify attempts (e.g. 5 per code) to stop brute force.