@siymo/otp-sdk
v2.1.0
Published
Umbrella package for the Siymo OTP SDKs. Re-exports @siymo/otp-server (server-side); use @siymo/otp-browser directly in the browser.
Maintainers
Readme
siymo-otp-sdk
TypeScript SDK for the REST and WebSocket contract exposed by siymo-otp-service.
This repository ships two purpose-built SDKs plus a shared core. Pick the
one that matches where your code runs — never use the legacy
SiymoOtpClient in a browser, since it is not designed to keep your API key
out of the bundle.
| Package | Where it runs | Holds API key? | Use it for |
|---|---|---|---|
| @siymo/otp-server | Your backend (Node) | Yes | Initiating sessions, verifying OTPs, confirming verificationTokens, account/history endpoints |
| @siymo/otp-browser | The end-user's browser | No | Subscribing to /ws/otp, rendering QR codes, awaiting otp.verified |
| @siymo/otp-core | Either | n/a | Shared types, error classes, WebSocket frame parser |
The umbrella package @siymo/otp-sdk (this repo's root package.json)
re-exports @siymo/otp-server for backward compatibility with code that
imported siymo-otp-sdk. Browser code MUST import @siymo/otp-browser
directly; importing the umbrella in a browser will trigger a runtime warning
when an API key is detected.
End-to-end flow (inbound SMS)
This is the canonical browser-SDK use case. The customer's browser shows a QR
code; the user scans it; their SMS app pre-fills with a base64-encoded OTP
and the service number; once delivered, the service emits otp.verified over
the WebSocket with a short-lived verificationToken JWT.
// 1. Customer's backend (Node) — uses @siymo/otp-server
import { SiymoOtpServer } from '@siymo/otp-server';
const otp = new SiymoOtpServer({
apiKey: process.env.SIYMO_OTP_API_KEY!, // siymo_<48-hex>
baseUrl: 'https://otp.siymo.com',
});
// POST /api/start — called by the browser
app.post('/api/start', async (req, res) => {
const session = await otp.inbound.sms.initiate({
phone: req.body.phone,
qrCode: true,
});
// Stash the phone we requested so we can compare on confirm.
req.session.pendingPhone = req.body.phone;
res.json({
sessionId: session.sessionId,
clientToken: session.clientToken,
qrCodeImage: session.qrCodeImage,
});
});
// POST /api/confirm — called by the browser when the WS reports verified
app.post('/api/confirm', async (req, res) => {
const result = await otp.inbound.confirm({
verificationToken: req.body.verificationToken,
expectedPhone: req.session.pendingPhone, // throws on mismatch
});
await db.users.update(req.user.id, { phoneVerified: true, phone: result.phone });
res.sendStatus(204);
});// 2. End-user's browser — uses @siymo/otp-browser
import { SiymoOtpBrowser } from '@siymo/otp-browser';
const { sessionId, clientToken, qrCodeImage } = await fetch('/api/start', {
method: 'POST',
body: JSON.stringify({ phone: '+998901234567' }),
headers: { 'content-type': 'application/json' },
}).then((r) => r.json());
const client = new SiymoOtpBrowser({
baseUrl: 'https://otp.siymo.com',
sessionId,
clientToken,
});
client.renderQr('#qr', qrCodeImage);
const verified = await client.waitForConfirmation();
await fetch('/api/confirm', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ verificationToken: verified.data.verificationToken }),
});The browser SDK never sees the API key, never calls /otp/initiate/*
directly, and only forwards the short-lived verificationToken JWT back to
the customer backend. The backend re-verifies the JWT signature server-to-
server via POST /otp/confirm and only then trusts the verified state.
Outbound flows
These use traditional "enter the code" UIs and don't need the browser SDK.
The user types the OTP into the customer's frontend; the customer backend
calls verify on their behalf.
// Customer backend
const session = await otp.outbound.sms.initiate({
phone: '+998901234567',
language: 'uz',
});
// Send `session.sessionId` to the frontend along with whatever UI prompts the user.
const result = await otp.outbound.verify({
sessionId: incomingSessionId,
otp: '482031',
});
if (result.success) {
// verified
}If you'd rather wait for verification rather than long-poll, use
otp.outbound.wait({ sessionId }) — it parks the request server-side until
the session resolves (TTL up to 10 minutes).
Migration from the previous single-package SDK
If your code today does:
import { SiymoOtpClient } from 'siymo-otp-sdk';
const client = new SiymoOtpClient({
baseUrl: 'https://otp.siymo.com',
defaultHeaders: { Authorization: `Bearer ${process.env.SIYMO_OTP_API_KEY}` },
});
await client.initiateInboundSms({ phone, qrCode: true });…it still works (the legacy SiymoOtpClient is preserved in this repo's
src/), but is deprecated. Migrate to:
// Backend
import { SiymoOtpServer } from '@siymo/otp-server';
const otp = new SiymoOtpServer({
apiKey: process.env.SIYMO_OTP_API_KEY!,
baseUrl: 'https://otp.siymo.com',
});
await otp.inbound.sms.initiate({ phone, qrCode: true });The legacy client emits a console.warn when it detects an API key passed
via defaultHeaders while running inside a browser-like environment
(window/self defined). API keys must never leave your backend.
What's new in this version
- Per-session
clientToken: everyinitiate*response now includes aclientToken. The browser SDK requires it on/ws/otpupgrade. Plain text only travels backend → browser; the OTP service stores its SHA-256. - Stateless
verificationTokenJWT: emitted inotp.verified, valid for ~60 seconds, signed withOTP_VERIFICATION_JWT_SECRET. Forward it from the browser to your backend; your backend confirms it viaPOST /otp/confirm(@siymo/otp-server'sinbound.confirm). - Phone-free WebSocket payloads:
otp.attempt,otp.locked, andotp.verifiedno longer include the user's phone number — the canonical phone is delivered to your backend by the JWT confirm round-trip.
Notes
- The SDK uses the service's existing WebSocket contract at
/ws/otp?sessionId=<uuid>&token=<clientToken>. - The long-poll endpoint
/otp/wait?sessionId=<uuid>requires Bearer auth and is therefore only callable from@siymo/otp-server. The browser SDK can reach it through a customer-controlled proxy (waitForConfirmationLongPoll). - Inbound call and SMS initiation requests accept an optional
qrCode: trueflag. When set, the response includesqrCodeImageas a data URL. - The WebSocket stream can emit
otp.subscribed,otp.attempt,otp.locked,otp.verified, andotp.expired. - In modern Node runtimes such as Node
22.x,WebSocketis available globally. To override it, passwebSocketFactoryto the browser SDK. waitForConfirmation()keeps waiting throughotp.attemptevents, forwards WebSocket callbacks likeonAttempt, and rejects onotp.lockedorotp.expired.- Verification endpoints intentionally return
200 OKfor both success and logical verification failures. Inspect typed response fields likeverified,message, andtriesLeft.
