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 andstalenessHealthis'ok'.lastSuccessfulSyncAt(string, ISO-8601) andstalenessSeconds(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) — always0for now:/statusdoes not report an indexing count yet, sobotReadydoes 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.
| Path | Signed? | Retry policy |
|---|---|---|
pair | no | up to 3 attempts on 429/5xx/connection errors (no nonce spent) |
snapshot upload (PUT) | no (single-use URL) | not retried |
sendEvents / finalize | yes | up to 3 resends by default after a 429, a 5xx or a connection error, each one re-signed |
init / status / unpair | yes | one 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:
- the call's own value —
client.sendEvents(ops, { resignRetries: N }); onlysendEventstakes one, and0means a single attempt; - the constructor's value —
new VoiceBotClient({ …, resignRetries: N })sets every signed call,sendEventsandfinalizeincluded; - the operation's default — 3 for
sendEventsandfinalize, 0 forinitSnapshot,statusandunpair.
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:
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:
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.
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.
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:
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. credentialsFilewrites the AES-GCM envelope to disk with0600perms; the plaintext secret never touches disk.- Omit
credentialsFilefor an in-memory store — the secret lives encrypted even in process memory. - For a KMS-backed at-rest key, implement
CredentialStoreand pass it ascredentialStore.
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
| Symptom | Cause & fix |
|---|---|
ConfigError: …must use https:// | baseUrl/ingest URL is plaintext http://. Switch to https:// (localhost excepted). |
refusing to push an empty full snapshot | Your entity source yielded zero rows. Fix the source; never force-push an empty snapshot — it would wipe the catalog. |
NotPairedError | No stored credentials. Call pair() first, or check that encryptionKey/credentialsFile match the run that paired. |
426 protocol_upgrade_required | Update the package — there is no protocol fallback. |
Per-op errors in the result | Validation/backend logic error. Common causes: missing required key, money as a float, category referenced by id instead of slug. |
ConfigError on credential load | Wrong/rotated encryptionKey. Re-pair. |
Next
- Ingest Protocol Reference → Errors — the full status table and the no-retry-on-signed rationale.