Skip to main content
Version: v1

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 Monoverse 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).
  • PATH — request.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.