Installation & pairing
From zero to a paired connection. You will not push any data here — that happens in Syncing.
Requirements
- Python 3.11+.
- A full-entropy 32-byte
encryption_key— generate one withopenssl rand -hex 32and keep it in your secret manager.
1. Install
The Python SDK is not yet published to PyPI — pip install monoverse-voicebot does not work yet.
Until it ships, install from source.
git clone https://github.com/alyokhin-n/monoverse-voicebot.git
pip install -e monoverse-voicebot/packages/voicebot-python
Depends on httpx, pydantic, and cryptography. Ships py.typed; mypy --strict clean.
2. Construct the client
import os
from monoverse_voicebot import VoiceBotClient
client = VoiceBotClient(
base_url="https://api.monoverse.tech",
site_url="https://shop.example.com",
encryption_key=os.environ["VOICEBOT_ENCRYPTION_KEY"],
credentials_file="./.voicebot-credentials.enc", # omit for in-memory only
)
VoiceBotClient is a context manager — use with VoiceBotClient(...) as client: to close the underlying
httpx.Client cleanly, or call client.close() yourself.
| Argument | Purpose |
|---|---|
base_url | Ingest base URL. Must be https:// (localhost allowed for dev). |
site_url | Sent as X-VoiceBot-Site-Url on every request; used as the default pairing site. |
encryption_key | Encrypts the stored shared secret at rest (AES-256-GCM). |
credentials_file | Path for the encrypted credential envelope. Omit to keep credentials in memory only. |
base_url 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
from voicebot import client
result = client.pair("VB-ABCD-1234")
print("paired tenant", result.tenant_id, "ingest", result.ingest_url)
pair() performs the unauthenticated handshake, mints your tenant id and shared secret,
stores them encrypted, and returns a secret-free summary (tenant_id, ingest_url, optional
account). The shared secret is never returned to the caller.
pair() stores the secret encrypted (AES-256-GCM, keyed by your encryption_key) and the Credentials
object redacts it from repr() and str(). 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.pathonly: no host, no query string.TIMESTAMP— the exact string sent inX-VoiceBot-Timestamp(Unix epoch seconds, UTC). Do not reformat it.NONCE— the exact string sent inX-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 constante3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
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.
POST
/api/v1/ingest/init
1747396800
aGVsbG8td29ybGQtYTFiMmMz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
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.
| Header | Value | Notes |
|---|---|---|
X-VoiceBot-Tenant-Id | UUID | Returned by /pair. Identifies the tenant whose secret signed the request. |
X-VoiceBot-Timestamp | integer (Unix epoch seconds, UTC) | Replay window is ±300s of server time. |
X-VoiceBot-Nonce | base64url of 16 random bytes | Single-use; tracked for 600s per tenant. Fresh value per request. |
X-VoiceBot-Signature | 64-char lowercase hex | HMAC_SHA256 of the canonical string. |
X-VoiceBot-Protocol-Version | 2 | Always the integer 2 on the wire. Required on every request including /pair. |
X-VoiceBot-Plugin-Version | semver string | Producer version, e.g. 2.1.0. Telemetry only. |
X-VoiceBot-Site-Url | URL | Origin of the sending site. Heartbeat upserts the site row from it. |
Content-Type | application/json or application/x-ndjson | Per endpoint; NDJSON upload may be gzipped. |
Idempotency-Key | UUID | Optional on /events; required on full-snapshot /finalize. Response cached 24h. |
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 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 arguments:
| You pass | Store used | Where the secret lives |
|---|---|---|
credentials_file + encryption_key | FileCredentialStore | AES-GCM envelope on disk |
encryption_key only | MemoryCredentialStore | AES-GCM envelope in process memory |
credential_store | your implementation | wherever you put it (e.g. a KMS-backed store) |
Use a full-entropy 32-byte key. A 32-byte value, 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.