Skip to main content
Version: next

Installation & pairing

From zero to a paired connection. You will not push any data here — that happens in Syncing.

Requirements

  • Node.js 18+ (the client uses built-in node:crypto and the global fetch).
  • A full-entropy 32-byte encryptionKey — generate one with openssl rand -hex 32 and keep it in your secret manager.

1. Install

Terminal
npm install @monoverse/voicebot-node

Zero runtime dependencies — the package ships its own .d.ts and is TypeScript-strict.

2. Construct the client

voicebot.ts
import { VoiceBotClient } from '@monoverse/voicebot-node';

export const client = new VoiceBotClient({
baseUrl: 'https://api.monoverse.tech',
siteUrl: 'https://shop.example.com',
encryptionKey: process.env.VOICEBOT_ENCRYPTION_KEY!,
credentialsFile: './.voicebot-credentials.enc', // omit for in-memory only
});
OptionPurpose
baseUrlIngest base URL. Must be https:// (localhost allowed for dev).
siteUrlSent as X-VoiceBot-Site-Url on every request; used as the default pairing site.
encryptionKeyEncrypts the stored shared secret at rest (AES-256-GCM).
credentialsFilePath for the encrypted credential envelope. Omit to keep credentials in memory only.
https only

baseUrl must be https://. Plaintext http:// is rejected (an HMAC over http would leak the signature to a network attacker). localhost / 127.0.0.1 / ::1 are the only exceptions (local dev).

3. Get a pair code

In your VoiceBot cabinet, generate a one-time pair code. It looks like VB-XXXX-XXXX.

4. Pair

pair.ts
import { client } from './voicebot';

const result = await client.pair('VB-ABCD-1234');
console.log('paired tenant', result.tenantId, 'ingest', result.ingestUrl);

pair() performs the unauthenticated handshake, mints your tenant id and shared secret, stores them encrypted, and returns a secret-free summary (tenantId, ingestUrl, optional account). The shared secret is never returned to the caller.

The shared secret is never exposed

pair() stores the secret encrypted (AES-256-GCM, keyed by your encryptionKey) and the Credentials object redacts it from JSON.stringify, console.log, and toString. There is no API that reveals it. If you lose the key, re-pair.

Already paired?

The client loads stored credentials on first use. To switch tenants, unpair() first, then pair() again.

How signing works

You normally never touch the signer — every authenticated method on VoiceBotClient signs for you. This is what happens on the wire, so you can debug a 401 bad_signature.

Every authenticated ingest request is signed with HMAC-SHA256 over a canonical string. The shared secret comes from pairing (32 random bytes, base64 in the /pair response — decode to raw bytes before signing). The string-to-sign is exactly five fields joined by \n:

canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + SHA256_HEX(BODY)
signature = HEX( HMAC_SHA256(shared_secret_raw, canonical) )
  • METHOD — uppercase HTTP verb (POST, PUT, GET).
  • PATHrequest.url.path only: no host, no query string.
  • TIMESTAMP — the exact string sent in X-VoiceBot-Timestamp (Unix epoch seconds, UTC). Do not reformat it.
  • NONCE — the exact string sent in X-VoiceBot-Nonce (base64url of 16 random bytes). Do not decode it.
  • SHA256_HEX(BODY) — lowercase hex SHA-256 of the raw request body bytes you actually send. For an empty body this is the well-known constant e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

The bytes you hash must be byte-for-byte the bytes you transmit — re-serializing the JSON after signing changes the hash and the server rejects it.

Canonical string — POST /api/v1/ingest/init with an empty body
POST
/api/v1/ingest/init
1747396800
aGVsbG8td29ybGQtYTFiMmMz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Off-by-one breaks the signature

A lowercased method, an extra trailing newline, a re-encoded nonce, or hashing a re-serialized body all produce a different signature and a 401 bad_signature. Build the canonical string from the exact header strings and the exact body bytes.

Replay protection. Each request also carries a fresh timestamp and nonce. The server accepts a timestamp within ±300s of its clock (stale_timestamp otherwise) and a nonce only once per 600s window per tenant (nonce_replay otherwise). Because the nonce is consumed even on an error, do not blindly retry a signed request — see retry semantics in your endpoint reference.

The request headers

Every authenticated request carries these headers. They are the inputs to the signature, the tenant binding, and the replay window.

HeaderValueNotes
X-VoiceBot-Tenant-IdUUIDReturned by /pair. Identifies the tenant whose secret signed the request.
X-VoiceBot-Timestampinteger (Unix epoch seconds, UTC)Replay window is ±300s of server time.
X-VoiceBot-Noncebase64url of 16 random bytesSingle-use; tracked for 600s per tenant. Fresh value per request.
X-VoiceBot-Signature64-char lowercase hexHMAC_SHA256 of the canonical string.
X-VoiceBot-Protocol-Version2Always the integer 2 on the wire. Required on every request including /pair.
X-VoiceBot-Plugin-Versionsemver stringProducer version, e.g. 2.1.0. Telemetry only.
X-VoiceBot-Site-UrlURLOrigin of the sending site. Heartbeat upserts the site row from it.
Content-Typeapplication/json or application/x-ndjsonPer endpoint; NDJSON upload may be gzipped.
Idempotency-KeyUUIDOptional on /events; required on full-snapshot /finalize. Response cached 24h.
Protocol-Version foot-gun

The wire header X-VoiceBot-Protocol-Version must always be the integer 2 — a mismatch is rejected with 426 Upgrade Required. The JSON body of the /pair response currently reports "protocol_version": 1, which is a server-side field value, not the version you send. Ignore the response field for signing purposes and keep sending 2 on the wire.

The Protocol-Version foot-gun

The client always sends X-VoiceBot-Protocol-Version: 2 (the PROTOCOL_VERSION constant). The /pair response body reports protocol_version: 1 — that field is stale; never read it for signing. Sending anything but 2 on the wire is rejected with 426 Upgrade Required.

The encrypted credential store

The client persists credentials through a CredentialStore. You get the right one automatically from the constructor options:

You passStore usedWhere the secret lives
credentialsFile + encryptionKeyFileCredentialStoreAES-GCM envelope on disk (0600)
encryptionKey onlyMemoryCredentialStoreAES-GCM envelope in process memory
credentialStoreyour implementationwherever you put it (e.g. a KMS-backed store)

Use a full-entropy 32-byte key. A 32-byte Buffer, 64-char hex, or 32-byte base64 string is used directly; a plain passphrase is an explicit weaker fallback (scrypt-stretched) and must be at least 16 chars — anything shorter is rejected.

Next

  • Map your data — build canonical entities with the typed builders.
  • Sync — push the baseline snapshot, then deltas.