@zi2/relay-sdk
v2.0.0
Published
Enterprise SMS relay SDK with E2E encryption, provider fallback, and PCI DSS v4 compliance
Maintainers
Readme
@zi2/relay-sdk
Enterprise-grade SMS relay SDK that routes messages through physical Android devices using end-to-end encryption. Instead of paying per-message fees to cloud SMS providers, deploy your own relay devices and send SMS through their native SIM cards — with full compliance, multi-tenant isolation, and automatic provider fallback.
Key Features
- End-to-end encryption — X25519 key exchange + HKDF-SHA256 + AES-256-GCM. The server never sees plaintext message content.
- Provider fallback — Automatically fail over to Twilio, Vonage, or any cloud provider when a device goes offline.
- Multi-tenant — Organization-scoped relay devices, rate limits, and audit trails.
- 10 languages — Built-in i18n for EN, FR, ES, DE, JA, AR, ZH, PT, KO, IT.
- PCI DSS v4 + SOC 2 compliant — Encryption at rest, TLS enforcement, brute-force protection, audit logging, auto-redacting logger.
- Database-agnostic — Ships with Prisma and in-memory adapters; implement the
DatabaseAdapterinterface for any storage backend. - White-label Android app — Rebrand and deploy custom relay apps with a single config file.
Table of Contents
- Installation
- Quick Start
- Architecture
- Configuration
- Database Adapters
- Pairing Flow
- Sending SMS
- Provider Fallback
- WebSocket Protocol
- Fastify Plugin
- Error Codes
- Events
- Health Service
- Security & Compliance
- i18n
- White-Label Android App
- License
Installation
pnpm add @zi2/relay-sdkOptional peer dependencies — install only what you need:
# For PrismaAdapter (production database)
pnpm add @prisma/client
# For Fastify plugin (REST + WebSocket server)
pnpm add fastify @fastify/websocket
# For WebSocket support (Node.js server)
pnpm add wsQuick Start
import { createRelay, AesGcmEncryption } from '@zi2/relay-sdk';
import { PrismaAdapter } from '@zi2/relay-sdk/adapters/prisma';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const sdk = createRelay({
db: new PrismaAdapter(prisma),
encryption: new AesGcmEncryption(process.env.RELAY_ENCRYPTION_KEY!),
apiUrl: 'https://api.yourapp.com',
wsUrl: 'wss://api.yourapp.com/ws/relay',
});
// 1. Pair a device (returns QR code data for the Android app to scan)
const pairing = await sdk.initiatePairing('org_123', 'user_456');
// → { pairingId, serverPublicKey, pairingToken, wsUrl }
// 2. Send an SMS through the paired relay device
const result = await sdk.sendSMS({
relayId: 'relay_abc',
orgId: 'org_123',
to: '+1234567890',
body: 'Your verification code is 847291',
});
// → { messageId: 'msg_xyz', status: 'delivered' }
// 3. Check device status
const online = sdk.isRelayOnline('relay_abc'); // trueArchitecture
The SDK is organized into six layers:
┌─────────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────────┤
│ Server Layer │ Routes, Fastify plugin, WS handler │
├─────────────────────────────────────────────────────────┤
│ Core Layer │ Crypto, Queue, Pairing, Health │
├─────────────────────────────────────────────────────────┤
│ Adapter Layer │ DB, Encryption, Broadcast, Audit │
├─────────────────────────────────────────────────────────┤
│ Provider Layer │ PhoneRelay, Fallback │
├─────────────────────────────────────────────────────────┤
│ i18n Layer │ 10 locales, error translations │
└─────────────────────────────────────────────────────────┘Message flow:
Your Server Android Device Carrier
│ │ │
│ sdk.sendSMS(...) │ │
│──encrypt payload──► │ │
│ queue message │ │
│ │ │
│ ◄── WebSocket (wss://) ──► │ │
│ send_sms (encrypted) ──► │ │
│ │──native SMS──► │
│ │ │──► Recipient
│ │ ◄── delivery receipt ───│
│ ◄── sms_ack ──────────── │ │
│ ◄── delivery_receipt ─── │ │
│ │ │
│ broadcast to web UI │ │
└───────────────────────────────┘──────────────────────────┘Module Exports
| Import Path | Contents |
|---|---|
| @zi2/relay-sdk | createRelay(), types, constants, errors, crypto, adapters |
| @zi2/relay-sdk/client | Android/client-side: WebSocket client, E2E crypto, pairing, SMS sender, secure storage |
| @zi2/relay-sdk/adapters/prisma | PrismaAdapter |
| @zi2/relay-sdk/fastify | relayPlugin for Fastify |
Configuration
interface RelaySDKConfig {
/** Required — Database adapter (PrismaAdapter, MemoryAdapter, or custom) */
db: DatabaseAdapter;
/** Required — Encryption adapter for data at rest (AesGcmEncryption or custom) */
encryption: EncryptionAdapter;
/** Optional — Broadcast adapter for real-time UI updates (e.g., SSE or WebSocket push) */
broadcast?: BroadcastAdapter;
/** Optional — Audit trail adapter for PCI DSS v4 Req 10 / SOC 2 CC7.2 compliance */
audit?: AuditAdapter;
/** Optional — Logger adapter with auto-redaction of sensitive fields */
logger?: LoggerAdapter;
/** Base URL for the API server (used in QR code pairing data). Default: 'http://localhost:3000' */
apiUrl?: string;
/** WebSocket URL for device connections (included in QR code). Default: empty string (falls back to apiUrl-derived WSS URL in pairing service) */
wsUrl?: string;
/** Enforce TLS-only WebSocket connections (PCI DSS v4 Req 4.2.1). Default: true */
enforceTls?: boolean;
/** Maximum auth failures per IP before lockout (PCI DSS v4 Req 8.3.4). Default: 10 */
maxAuthFailuresPerIp?: number;
/** Overridable rate limits and timeouts (use SCREAMING_CASE keys) */
limits?: {
AUTH_TIMEOUT_MS?: number; // Default: 5000
HEARTBEAT_INTERVAL_MS?: number; // Default: 30000
PAIRING_EXPIRY_MINUTES?: number; // Default: 5
MESSAGE_EXPIRY_HOURS?: number; // Default: 24
QUEUE_DRAIN_DELAY_MS?: number; // Default: 3000
DEFAULT_SMS_TIMEOUT_MS?: number; // Default: 30000
MAX_SMS_PER_MINUTE?: number; // Default: 20
MAX_SMS_PER_HOUR?: number; // Default: 200
MAX_SMS_PER_DAY?: number; // Default: 1000
DEGRADED_THRESHOLD_MS?: number; // Default: 300000 (5 minutes)
};
}Encryption Key Generation
Generate a 256-bit encryption key for AesGcmEncryption:
openssl rand -hex 32
# → e.g. a1b2c3d4e5f6... (64 hex characters)Store it securely in your environment:
RELAY_ENCRYPTION_KEY=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2Database Adapters
PrismaAdapter (Production)
import { PrismaAdapter } from '@zi2/relay-sdk/adapters/prisma';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const db = new PrismaAdapter(prisma);Required Prisma schema models:
model PhoneRelay {
id String @id @default(uuid())
organizationId String
deviceName String
platform String
relayIdentifier String @unique
devicePublicKey String
sharedKeyEncrypted String
authTokenHash String
status String @default("active")
keyVersion Int @default(1)
lastKeyRotation DateTime @default(now())
lastSeenAt DateTime?
lastIpAddress String?
batteryLevel Int?
signalStrength Int?
totalSmsSent Int @default(0)
totalSmsFailed Int @default(0)
dailySmsSent Int @default(0)
dailyResetAt DateTime @default(now())
maxSmsPerMinute Int @default(20)
maxSmsPerHour Int @default(200)
maxSmsPerDay Int @default(1000)
pairedById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
messages RelayMessageQueue[]
}
model PhoneRelayPairing {
id String @id @default(uuid())
organizationId String
pairingToken String
serverPublicKey String
serverPrivateKeyEncrypted String
status String @default("pending")
initiatedById String
expiresAt DateTime
createdAt DateTime @default(now())
}
model RelayMessageQueue {
id String @id @default(uuid())
relayId String
organizationId String
encryptedPayload String
status String @default("pending")
attempts Int @default(0)
maxAttempts Int @default(3)
nativeMessageId String?
errorMessage String?
deliveryStatus String?
sentToDeviceAt DateTime?
ackedAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
relay PhoneRelay @relation(fields: [relayId], references: [id])
}MemoryAdapter (Testing)
import { MemoryAdapter } from '@zi2/relay-sdk';
const db = new MemoryAdapter();
// Use db.clear() to reset between testsCustom Adapter
Implement the DatabaseAdapter interface to use any storage backend:
import type { DatabaseAdapter } from '@zi2/relay-sdk';
class MyCustomAdapter implements DatabaseAdapter {
// PhoneRelay CRUD
findRelay(id: string, orgId?: string): Promise<PhoneRelayRecord | null>;
findRelayByTokenHash(id: string, tokenHash: string, statuses: string[]): Promise<PhoneRelayRecord | null>;
findRelays(orgId: string, excludeStatus?: string): Promise<PhoneRelayRecord[]>;
createRelay(data: CreateRelayInput): Promise<PhoneRelayRecord>;
updateRelay(id: string, data: Partial<PhoneRelayRecord>): Promise<void>;
updateRelayByOrg(id: string, orgId: string, data: Partial<PhoneRelayRecord>): Promise<void>;
deleteRelay(id: string): Promise<void>;
incrementRelayStat(id: string, field: 'totalSmsSent' | 'totalSmsFailed' | 'dailySmsSent', amount?: number): Promise<void>;
countRelays(orgId: string): Promise<number>;
markDegradedRelays(threshold: Date): Promise<number>;
resetDailyCounters(): Promise<number>;
// PhoneRelayPairing
findPairing(id: string): Promise<PhoneRelayPairingRecord | null>;
createPairing(data: CreatePairingInput): Promise<PhoneRelayPairingRecord>;
updatePairingStatus(id: string, status: string): Promise<void>;
deleteExpiredPairings(): Promise<number>;
// RelayMessageQueue
findMessage(id: string): Promise<RelayMessageRecord | null>;
createMessage(data: CreateMessageInput): Promise<RelayMessageRecord>;
updateMessage(id: string, data: Partial<RelayMessageRecord>): Promise<void>;
findPendingMessages(relayId: string, limit: number): Promise<RelayMessageRecord[]>;
findMessages(relayId: string, limit: number, offset: number): Promise<{ messages: RelayMessageRecord[]; total: number }>;
deleteMessagesByRelay(relayId: string): Promise<number>;
expireStaleMessages(): Promise<number>;
// Batch operations
createMessages(batch: CreateMessageInput[]): Promise<RelayMessageRecord[]>;
updateMessages(ids: string[], data: Partial<RelayMessageRecord>): Promise<number>;
}Pairing Flow
Pairing establishes a secure E2E-encrypted channel between your server and an Android device.
Step-by-step
- Server initiates pairing — generates an X25519 key pair and a one-time pairing token.
const pairing = await sdk.initiatePairing('org_123', 'user_456');
// Returns: { pairingId, serverPublicKey, pairingToken, wsUrl }Display QR code — encode the pairing data as a QR code in your web UI. The Android app scans it.
Device completes pairing — the app sends its public key and device info to the server.
const result = await sdk.completePairing({
pairingId: pairing.pairingId,
pairingToken: pairing.pairingToken,
devicePublicKey: '<device X25519 public key>',
deviceName: 'Samsung Galaxy S24',
platform: 'android',
});
// Returns: { relayId, authToken }X25519 key exchange — the server derives a shared key from its private key and the device's public key using HKDF-SHA256. Both sides now hold the same AES-256-GCM key.
Device connects via WebSocket — authenticates with the hashed auth token and begins relaying SMS.
Pairing expiry
Pairing sessions expire after 5 minutes (configurable via limits.PAIRING_EXPIRY_MINUTES). Expired sessions are cleaned up automatically by the health service.
Sending SMS
const result = await sdk.sendSMS({
relayId: 'relay_abc',
orgId: 'org_123',
to: '+1234567890',
body: 'Your verification code is 847291',
timeoutMs: 30000, // optional, default 30s
});
console.log(result.messageId); // 'msg_xyz'
console.log(result.status); // 'delivered' | 'queued' | 'sent_to_device' | 'failed'Note: When a relay is offline,
sendSMSdoes NOT throwRELAY_OFFLINE. Instead, it queues the message and returnsstatus: "queued". The message is sent automatically when the device reconnects.
Message Statuses
| Status | Description |
|---|---|
| pending | Message queued, waiting to be sent to device |
| sent_to_device | Encrypted payload delivered to device via WebSocket |
| delivered | Device confirmed SMS was sent by the carrier |
| failed | Delivery failed (device error, carrier rejection, etc.) |
| expired | Message exceeded its TTL (default: 24 hours) |
Message History
const messages = await sdk.getMessages('relay_abc', 'org_123', 50, 0);
// → { messages: [...], pagination: { total: 142, limit: 50, offset: 0 } }Rate Limits
Each relay device enforces three tiers of rate limiting (configurable per-device):
| Tier | Default | Max | |---|---|---| | Per minute | 20 | 60 | | Per hour | 200 | 1,000 | | Per day | 1,000 | 10,000 |
Provider Fallback
Use createFallbackProvider() to automatically fall back to cloud SMS providers when a relay device goes offline:
import { createRelay } from '@zi2/relay-sdk';
const sdk = createRelay({ /* ... */ });
const provider = sdk.createFallbackProvider({
primary: sdk.createProvider('relay_abc', 'org_123'),
fallbacks: [twilioProvider, vonageProvider],
isOnline: () => sdk.isRelayOnline('relay_abc'),
onFallback: (providerName) => {
console.log(`Relay offline — fell back to ${providerName}`);
},
});
// Use as a unified SMS provider
const result = await provider.sendSms('+1234567890', 'Hello from ZI2');Implementing a Fallback Provider
Any cloud provider can serve as a fallback by implementing SmsProviderAdapter:
import type { SmsProviderAdapter, SmsSendResponse } from '@zi2/relay-sdk';
class TwilioProvider implements SmsProviderAdapter {
readonly name = 'twilio';
initialize(credentials: Record<string, string>): void {
// Set up Twilio client with credentials.accountSid, credentials.authToken
}
async sendSms(to: string, body: string): Promise<SmsSendResponse> {
// Call Twilio API
return { success: true, messageId: 'SM...' };
}
async validateCredentials(credentials: Record<string, string>): Promise<boolean> {
// Verify Twilio credentials are valid
return true;
}
}WebSocket Protocol
Relay devices communicate with the server over a persistent WebSocket connection (wss://). All SMS payloads are E2E encrypted.
Message Types
| Type | Direction | Description |
|---|---|---|
| auth | Device -> Server | Authenticate with relayId and token |
| auth_ok | Server -> Device | Authentication successful |
| send_sms | Server -> Device | Encrypted SMS payload to deliver |
| sms_ack | Device -> Server | SMS send result (messageId, status, nativeMessageId, errorMessage) |
| delivery_receipt | Device -> Server | Carrier delivery confirmation (messageId, deliveryStatus) |
| status | Device -> Server | Device telemetry (batteryLevel, signalStrength) |
| rekey | Server -> Device | Initiate key rotation (new server public key) |
| rekey_ack | Device -> Server | Device's new public key for key rotation |
Connection Lifecycle
- Device opens WebSocket to
wss://your-server/ws/relay - Server starts a 5-second auth timeout
- Device sends
{ type: "auth", relayId: "...", token: "..." } - Server verifies token hash against database (timing-safe comparison)
- Server responds
{ type: "auth_ok" }and begins heartbeat pings every 30s - Server drains any pending messages from the queue
- On disconnect, server broadcasts offline status to web clients
Rate Limiting
Each WebSocket connection is rate-limited to 100 messages per 10-second window. Exceeding this closes the connection with code 4008.
Close Codes
| Code | Meaning |
|---|---|
| 4001 | Authentication timeout (5s) |
| 4002 | Message sent before authentication |
| 4003 | Missing relayId or token in auth message |
| 4004 | Invalid credentials |
| 4005 | Replaced by new connection from same device |
| 4008 | Rate limit exceeded |
| 1000 | Pong timeout — device failed to respond to heartbeat ping |
Fastify Plugin
Register the Fastify plugin to expose all relay REST endpoints and the WebSocket upgrade handler:
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
import { relayPlugin } from '@zi2/relay-sdk/fastify';
import { createRelay, AesGcmEncryption } from '@zi2/relay-sdk';
import { PrismaAdapter } from '@zi2/relay-sdk/adapters/prisma';
const app = Fastify();
const sdk = createRelay({
db: new PrismaAdapter(prisma),
encryption: new AesGcmEncryption(process.env.RELAY_ENCRYPTION_KEY!),
});
await app.register(websocket);
// Register with Fastify's prefix option
await app.register(async (instance) => {
await relayPlugin(instance, { sdk });
}, { prefix: '/phone-relay' });
await app.listen({ port: 3000 });Registered Routes
| Method | Path | Description |
|---|---|---|
| POST | /phone-relay/pair/initiate | Start a new pairing session |
| POST | /phone-relay/pair/complete | Complete device pairing |
| GET | /phone-relay/ | List all relay devices for the org |
| GET | /phone-relay/:id | Get relay device details |
| PATCH | /phone-relay/:id | Update relay settings (name, rate limits) |
| DELETE | /phone-relay/:id | Revoke a relay device |
| POST | /phone-relay/:id/test | Send a test SMS |
| GET | /phone-relay/:id/messages | List messages (paginated) |
| GET | /ws/relay | WebSocket upgrade for relay devices |
Error Codes
All errors are returned as RelayError instances with a machine-readable code, HTTP status, user-safe message, and i18n key. Error messages are intentionally generic to comply with PCI DSS v4 Req 6.2.4 (no internal details leaked).
| Code | HTTP | Message | i18n Key |
|---|---|---|---|
| RELAY_NOT_FOUND | 404 | Relay device not found | errors.relayNotFound |
| RELAY_INACTIVE | 409 | Relay is not active | errors.relayInactive |
| RELAY_OFFLINE | 503 | Relay device is offline | errors.relayOffline |
| PAIRING_NOT_FOUND | 404 | Pairing session not found | errors.pairingNotFound |
| PAIRING_EXPIRED | 410 | Pairing session has expired | errors.pairingExpired |
| PAIRING_INVALID_TOKEN | 403 | Invalid pairing token | errors.pairingInvalidToken |
| AUTH_TIMEOUT | 408 | WebSocket authentication timeout | errors.authTimeout |
| AUTH_FAILED | 401 | Invalid relay credentials | errors.authFailed |
| RATE_LIMITED | 429 | Rate limit exceeded | errors.rateLimited |
| ENCRYPTION_FAILED | 500 | Encryption operation failed | errors.encryptionFailed |
| REKEY_FAILED | 500 | Key rotation failed | errors.rekeyFailed |
| SMS_SEND_FAILED | 502 | SMS delivery failed | errors.smsSendFailed |
| SMS_TIMEOUT | 504 | SMS acknowledgement timeout | errors.smsTimeout |
Extended Error Codes (RELAY_ERRORS_EXTENDED) — These are exported separately from RELAY_ERRORS:
| Code | HTTP | Message | i18n Key |
|---|---|---|---|
| AUTH_LOCKED | 423 | Too many failed attempts. Try again later. | errors.authLocked |
| TLS_REQUIRED | 426 | TLS/SSL connection required | errors.tlsRequired |
| ORG_MISMATCH | 403 | Organization access denied | errors.orgMismatch |
Error Handling
import { RelayError, RELAY_ERRORS } from '@zi2/relay-sdk';
try {
await sdk.sendSMS({ relayId, orgId, to, body });
} catch (err) {
if (err instanceof RelayError) {
console.log(err.code); // 'RELAY_OFFLINE'
console.log(err.statusCode); // 503
console.log(err.i18nKey); // 'errors.relayOffline'
console.log(err.toJSON()); // Safe for client response (no stack trace)
}
}Events
Subscribe to real-time relay events for monitoring, alerting, or UI updates. Each subscription returns an unsubscribe function.
// Device came online
const unsub1 = sdk.onRelayOnline((relayId, orgId) => {
console.log(`Relay ${relayId} is online`);
});
// Device went offline
const unsub2 = sdk.onRelayOffline((relayId, orgId) => {
console.log(`Relay ${relayId} went offline`);
// Trigger fallback provider switch, send alert, etc.
});
// SMS delivered successfully
const unsub3 = sdk.onMessageDelivered((messageId, relayId) => {
console.log(`Message ${messageId} delivered via ${relayId}`);
});
// SMS delivery failed
const unsub4 = sdk.onMessageFailed((messageId, relayId, error) => {
console.error(`Message ${messageId} failed on ${relayId}: ${error}`);
});
// Clean up when shutting down
unsub1();
unsub2();
unsub3();
unsub4();Health Service
The health service performs periodic maintenance tasks required for compliance and reliability.
// Start automatic health checks (runs every 60 seconds)
sdk.startHealthService();
// Or run a manual check
const report = await sdk.runHealthCheck();
console.log(report);
// {
// expiredMessages: 3, — stale messages marked as expired
// degradedRelays: 1, — relays with no heartbeat for 5+ minutes
// cleanedPairings: 0 — expired pairing sessions removed
// }
// Stop the service when shutting down
sdk.stopHealthService();What the Health Service Does
| Task | Description | Compliance |
|---|---|---|
| Expire stale messages | Marks messages past their TTL as expired | Data retention |
| Mark degraded relays | Flags devices not seen for 5+ minutes as degraded | Availability monitoring |
| Clean expired pairings | Removes pending pairing sessions past their expiry | PCI DSS v4 Req 8 |
| Reset daily counters | Resets per-device daily SMS counters at midnight | Rate limit enforcement |
Security & Compliance
PCI DSS v4
| Requirement | Implementation |
|---|---|
| Req 3.4.1 — Encryption at rest | AesGcmEncryption encrypts all stored keys and message payloads. No plaintext fallback in strict mode. |
| Req 3.5.1 — Key management | X25519 key pairs generated per pairing. Shared keys encrypted at rest. Key material zero-filled after use. destroy() method for cleanup. |
| Req 3.7.1 — Key strength | Enforces 256-bit (32-byte) encryption keys at construction time. |
| Req 4.2.1 — TLS enforcement | enforceTls: true (default) rejects non-TLS WebSocket connections. |
| Req 6.2.4 — Error handling | RelayError.toJSON() returns only code + message. No stack traces, database details, or crypto internals leak to clients. |
| Req 8.3.4 — Brute-force protection | AuthLimiter locks out IPs after configurable failed auth attempts (default: 10). |
| Req 10 — Audit trail | AuditAdapter interface logs all security-relevant actions: pairing, auth, SMS, revocation, key rotation. |
| Req 11.3 — Security monitoring | Health service runs periodic checks on system integrity. |
SOC 2
| Control | Implementation |
|---|---|
| CC6.1 — Logical access | Organization-scoped queries. Auto-redacting logger strips tokens, keys, SMS body, and credentials from all log output. |
| CC7.2 — Audit trail | AuditAdapter records timestamped entries with action, org, relay, user, IP, and metadata. |
E2E Encryption Details
Key Exchange: X25519 (Curve25519 Diffie-Hellman)
Key Derivation: HKDF-SHA256 with info string "zi2-relay-e2e-v1"
Payload Cipher: AES-256-GCM (12-byte IV, 16-byte auth tag)
Token Security: SHA-256 hashing, timing-safe comparison
Key Rotation: Server-initiated rekey via WebSocket protocolAuto-Redacting Logger
The built-in ConsoleLogger (and the withRedaction() wrapper for custom loggers) automatically redacts these fields from all log output:
token, authToken, pairingToken, authTokenHash, sharedKey, sharedKeyEncrypted, privateKey, serverPrivateKey, serverPrivateKeyEncrypted, devicePublicKey, serverPublicKey, encryptedPayload, password, secret, credentials, authorization, cookie, body
import { withRedaction } from '@zi2/relay-sdk';
const safeLogger = withRedaction(myPinoLogger);
const sdk = createRelay({ logger: safeLogger, /* ... */ });i18n
The SDK ships with translations for 10 languages covering all UI strings and error messages.
import { getTranslation, getSupportedLocales, getRelayTranslations } from '@zi2/relay-sdk';
// Get a single translation
getTranslation('fr', 'relay.connected');
// → "Connect\u00e9"
getTranslation('ja', 'relay.e2eEncrypted');
// → "E2E\u6697\u53f7\u5316"
// List all supported locales
getSupportedLocales();
// → ['en', 'fr', 'es', 'de', 'ja', 'ar', 'zh', 'pt', 'ko', 'it']
// Get all translations for a locale (useful for client-side hydration)
const strings = getRelayTranslations('es');Supported Locales
| Code | Language |
|---|---|
| en | English |
| fr | French |
| es | Spanish |
| de | German |
| ja | Japanese |
| ar | Arabic |
| zh | Chinese (Simplified) |
| pt | Portuguese |
| ko | Korean |
| it | Italian |
White-Label Android App
The relay Android app can be white-labeled for custom deployments. A configuration file and build script handle rebranding.
Configuration
Create a relay.config.json in your project:
{
"appId": "com.yourcompany.smsrelay",
"appName": "YourBrand SMS Relay",
"serverUrl": "wss://api.yourcompany.com/ws/relay",
"brandColor": "#FF6600",
"notificationTitle": "YourBrand SMS Relay",
"notificationText": "Relay is active — ready to send SMS"
}| Field | Description |
|---|---|
| appId | Android application ID (reverse domain). Must be unique on Google Play. |
| appName | Display name shown on the device home screen and in settings. |
| serverUrl | WebSocket URL the app connects to. Must use wss:// in production. |
| brandColor | Primary brand color (hex). Applied to the app theme. |
| notificationTitle | Title of the persistent foreground service notification. |
| notificationText | Body text of the foreground service notification. |
Building
# Default config (./relay.config.json)
./build-whitelabel.sh
# Custom config path
./build-whitelabel.sh /path/to/client-config.jsonThe script:
- Reads the config JSON
- Generates
capacitor.config.tswith the customappIdandappName - Updates
android/app/src/main/res/values/strings.xmlwith notification strings - Runs
pnpm build(Vite production build) - Syncs the web assets to the Android project via
npx cap sync android - Builds a release APK via Gradle
- Outputs the final APK path
Requirements
- Node.js 18+
- pnpm
- Android SDK (API level 24+)
- Java 17+ (for Gradle)
jq(for JSON parsing in the build script)
Relay Management
List Devices
const relays = await sdk.listRelays('org_123');
// Returns: RelayListItem[] with isOnline status, stats, rate limitsUpdate Settings
await sdk.updateRelay('relay_abc', 'org_123', {
deviceName: 'Office Phone',
maxSmsPerDay: 500,
});Revoke a Device
await sdk.revokeRelay('relay_abc', 'org_123');
// Device is immediately disconnected and cannot reconnectRotate Encryption Keys
await sdk.rotateKeys('relay_abc', 'org_123');
// Triggers rekey protocol over WebSocket — both sides derive new shared keyLicense
Proprietary. Copyright Zenith Intelligence Technologies / ZI2 Systems. All rights reserved.
This software is licensed exclusively for use within authorized ZI2 deployments. Unauthorized copying, modification, distribution, or use of this software is strictly prohibited. Contact [email protected] for licensing inquiries.
