Skip to main content
Version: next

Operations & troubleshooting

Running the sync day to day: the heartbeat, disconnecting, why signed requests don't blindly retry, and how to run it on a schedule.

Heartbeat — status()​

const status = await client.status();
console.log(status.providerStatus, status.entityCounts, status.lastSession);
if (status.freshness && !status.freshness.botReady) {
console.warn('catalog is stale:', status.freshness.stalenessHealth);
}

A signed GET that bumps the connection pulse and returns the connection status, per-kind entity counts, the last sync session summary and the server's freshness block (StatusResult). Throttle to at most once every ~5 minutes — it's a heartbeat, not a poll loop.

status.freshness is a FreshnessStatus (import type { FreshnessStatus } from '@monoverse/voicebot-node'), absent when the server sends no freshness block:

  • stalenessHealth (string) — 'ok', 'warn' or 'critical'; 'critical' if the server leaves it out. 'warn' means the catalog is older than the connection's freshness window, 'critical' that it passed the server's critical threshold.
  • botReady (boolean) — a successful sync exists and stalenessHealth is 'ok'.
  • lastSuccessfulSyncAt (string, ISO-8601) and stalenessSeconds (number), both optional — when the last successful sync finished, and how long ago.
  • freshnessWindowSeconds (number, optional) — the connection's freshness window.
  • consecutiveErrorCount (number) — sync errors in a row.
  • indexingPending (number) — always 0 for now: /status does not report an indexing count yet, so botReady does not wait for indexing.

Disconnect — unpair()​

await client.unpair();

Tells the backend to disconnect, then wipes the locally stored credentials — even if the remote call fails, so you can always re-pair. Run it before pairing a different tenant.

Retry semantics​

This is the rule that trips people up: a signed request is never blindly retried.

PathSigned?Retry policy
pairnoup to 3 attempts on 429/5xx/connection errors (no nonce spent)
snapshot upload (PUT)no (single-use URL)not retried
sendEvents / finalizeyesup to 3 resends by default after a 429, a 5xx or a connection error, each one re-signed
init / status / unpairyesone attempt by default

A signed request carries a single-use nonce the backend consumes the moment it arrives — before it can return a 429/5xx. A blind retry would replay a spent nonce and be rejected as a replay. So every resend is a resign-and-resend: a fresh X-VoiceBot-Timestamp and X-VoiceBot-Nonce, the same Idempotency-Key. sendEvents and finalize carry a stable key — each batch's batch_id, and for finalize(syncId, idempotencyKey = randomUUID()) one key per call, generated if you pass none — so the server replays a resend instead of applying it twice. That is why those two resend by default and initSnapshot does not.

The number of resends is resolved per call, and the first match wins:

  1. the call's own value — client.sendEvents(ops, { resignRetries: N }); only sendEvents takes one, and 0 means a single attempt;
  2. the constructor's value — new VoiceBotClient({ …, resignRetries: N }) sets every signed call, sendEvents and finalize included;
  3. the operation's default — 3 for sendEvents and finalize, 0 for initSnapshot, status and unpair.
await client.sendEvents(ops, { resignRetries: 0 }); // one attempt for this call only

A 429's Retry-After is honoured up to 60 s; that ceiling is fixed. Without Retry-After the wait backs off exponentially — retry: { baseMs: 250, maxMs: 5000 } by default: 250 ms, doubling, capped at 5 s — plus up to 0.1 s of jitter. A single sendEvents call can therefore block for minutes; see Running on a schedule.

Logic errors don't throw​

A 429/5xx on a signed call surfaces as a thrown TransientError once its resends are used up (it's a transport failure); thrown from sendEvents, it also carries the results of the batches sent before the failing one — see Syncing → When a batch throws. But per-op logic errors (a missing required field, a bad slug, money as a float) come back inside EventsResult.errors and do not throw — read them and fix the data. See Syncing → Reading the result.

426 Upgrade Required​

The backend requires a newer sync protocol than this package speaks. There is no fallback — update the package:

Terminal
npm install @monoverse/voicebot-node@latest

Running on a schedule​

You own the cadence — the proven pattern is frequent deltas plus one periodic full. A minimal cron-driven worker:

sync-worker.ts
import { client } from './voicebot';
import { collectChangedOps, fullCatalog } from './catalog';

async function deltaRun() {
const ops = await collectChangedOps(); // since your last watermark
if (ops.length) await client.sendEvents(ops);
}

async function fullRun() {
await client.snapshot(fullCatalog());
}

// Wire these to your scheduler:
// */15 * * * * node sync-worker.js delta
// 0 3 * * * node sync-worker.js full
const mode = process.argv[2];
await (mode === 'full' ? fullRun() : deltaRun());
  • High-churn store → delta every 5 minutes.
  • Typical store → delta every 15 minutes (above).
  • Schedule the full for a low-traffic hour so the larger upload doesn't compete with peak load.
Never sync inside a request handler

A single sendEvents call can block for minutes: per batch (at most 500 ops or 5 MB) up to 3 waits of 60 s on 429s, plus up to 7 s of re-checks. snapshot() ends in finalize, which resends the same way. Call both from a worker, a cron job or CatalogScheduler, never inside a web request handler.

Don't overlap runs

A long sync that runs past its next tick must not start a second copy. Guard with your scheduler's overlap protection (or a lock), and prefer dispatching the full to a dedicated worker.

Long-running process? Use CatalogScheduler​

If your app is a daemon (an Express/Nest process, a container that stays up), the package ships a scheduler so you don't hand-code the loop — interval timers, a without-overlapping guard, and onError / onSync callbacks that keep the process alive through a failed run:

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

const scheduler = new CatalogScheduler({
full: () => client.snapshot(fullCatalog()),
delta: () => collectChangedOps().then((ops) => (ops.length ? client.sendEvents(ops) : undefined)),
runFullOnStart: true,
});
scheduler.start(); // stop() on shutdown

Defaults: delta every 15 minutes, full every 24 hours (deltaIntervalMs / fullIntervalMs to change).

The cabinet's Synchronize button doesn't reach a producer​

The Synchronize button in the Monoverse cabinet works by calling into your store over HTTP — it can only reach integrations that expose an inbound sync hook (the WordPress plugin, the Laravel package). A Node producer has no inbound endpoint, so that button does not apply to this stack: your cron/CatalogScheduler cadence is the trigger. To push immediately, run your own sync worker; the cabinet's sync coverage panel reflects the result either way.

Encrypted credential storage​

The shared secret is stored encrypted at rest (AES-256-GCM, node:crypto) and is never logged, printed, or serialized — Credentials redacts it from JSON.stringify, console.log, and toString.

  • Pass a full-entropy 32-byte encryptionKey (openssl rand -hex 32); keep it in your secret manager / env.
  • credentialsFile writes the AES-GCM envelope to disk with 0600 perms; the plaintext secret never touches disk.
  • Omit credentialsFile for an in-memory store — the secret lives encrypted even in process memory.
  • For a KMS-backed at-rest key, implement CredentialStore and pass it as credentialStore.
Rotating the encryption key

If you change encryptionKey, the stored envelope can no longer be decrypted and the client surfaces a ConfigError ("re-pair or check VOICEBOT_ENCRYPTION_KEY"). Re-pair after a key rotation.

Troubleshooting​

SymptomCause & fix
ConfigError: …must use https://baseUrl/ingest URL is plaintext http://. Switch to https:// (localhost excepted).
refusing to push an empty full snapshotYour entity source yielded zero rows. Fix the source; never force-push an empty snapshot — it would wipe the catalog.
NotPairedErrorNo stored credentials. Call pair() first, or check that encryptionKey/credentialsFile match the run that paired.
426 protocol_upgrade_requiredUpdate the package — there is no protocol fallback.
Per-op errors in the resultValidation/backend logic error. Common causes: missing required key, money as a float, category referenced by id instead of slug.
ConfigError on credential loadWrong/rotated encryptionKey. Re-pair.

Next​