Skip to main content
Version: v1

Operations & troubleshooting

Running the sync day to day: the commands, their exit codes, what cron should alert on, and how the package recovers from failures without losing data.

voicebot:sync

voicebot:sync — flags at a glance
php artisan voicebot:sync # incremental delta (the cron path)
php artisan voicebot:sync --full # complete snapshot (first run, then nightly)
php artisan voicebot:sync --dry-run # compute + print what WOULD be sent; push nothing
php artisan voicebot:sync --since="2026-06-01" # delta from a specific instant (catch-up)
php artisan voicebot:sync --queue # dispatch to a queue worker and return immediately
FlagEffect
--fullStream a full snapshot instead of a delta. Cannot be combined with --since.
--since=<datetime>Delta only — override the stored watermark (any strtotime/ISO-8601 value).
--dry-runCompute and print counts; push nothing. Cannot be combined with --queue.
--queueDispatch the sync to a worker (queue from VOICEBOT_SYNC_QUEUE) and return.

Full snapshot

php artisan voicebot:sync --full

Streams a gzip-NDJSON snapshot of every enabled kind, opens an upload session, PUTs the file, and finalizes. On success the per-kind watermark is advanced to the snapshot start, so the next delta only carries later changes.

Full snapshot: 1298 records.
product 1240
category 58
sync_id: 7b2e…

Delta

php artisan voicebot:sync

Per kind, streams rows changed since the watermark as /events batches (capped by op count and bytes), then advances the watermark only if every batch for that kind succeeded.

Delta: 37 ops in 1 batch(es).

Queueing large syncs

--queue dispatches the work to a queue worker and returns immediately — ideal under ->withoutOverlapping() so the scheduled tick doesn't block. Requires a running queue worker. It cannot be combined with --dry-run.

Exit codes

The command's exit code is cron-friendly — wire your monitor to it.

CodeConstantMeaningWhat to do
0successSync completed (or --dry-run finished).Nothing.
1transientNetwork/5xx failure, or a delta that dead-lettered ops.Alert. Retry next run.
2configNot paired, empty snapshot refused, bad model, bad --since.Fix config. A retry won't help.
What cron should alert on

Alert on 1 (transient / partial failure) and 2 (config error). 2 means the next scheduled run will fail the same way until you fix the configuration — page on it. 1 is usually self-healing on the next run, but a sustained streak of 1 (e.g. dead-letters piling up) deserves attention.

A delta that parked any ops in the dead-letter table exits 1 even though the rest pushed — it's a partial failure, surfaced so your monitor notices.

voicebot:doctor

php artisan voicebot:doctor
php artisan voicebot:doctor --samples=3 # inspect more sample rows per kind

A read-only pre-flight: verifies pairing, backend reachability, and — per enabled kind — a resolvable source, a non-empty map, and the backend-required payload keys on a sampled mapped row. Emits one sample record per kind and pushes nothing. Exits 0 on all-clear, 1 if anything failed. Run it after any mapping change. (Output example in Installation.)

voicebot:unpair

php artisan voicebot:unpair

Tells the backend to disconnect, then wipes the locally stored tenant id + shared secret. The local credentials are cleared even if the remote call fails (it exits 1 in that case), so you can always re-pair. Run this before pairing a different tenant.

Reliability model

Watermark / delta semantics

The watermark is per entity kind (voicebot_sync_state). A delta selects rows whose updated_at is strictly greater than the stored watermark, then advances the watermark to the instant the run started — not to the max updated_at of the rows sent.

Why run-start: a row written during the run carries an updated_at later than the start instant. Advancing to max-updated_at would skip it forever; advancing to run-start re-includes it next run. That's at-least-once delivery, made safe by the fact that upserts are idempotent. The watermark only advances when every batch for the kind succeeded — a failed kind's window is retried in full next run.

--since overrides the stored watermark for a one-off catch-up (it does not change the stored value).

Dead-letter table & bounded attempts

A delta batch that fails permanently (a non-retryable backend error) is never silently dropped — its ops are parked in voicebot_dead_letter. Re-failures of the same op identity (entity_kind + external_id + op_type) bump an attempts counter instead of duplicating rows. Once attempts reaches sync.dead_letter_max_attempts (default 5, env VOICEBOT_DEAD_LETTER_MAX_ATTEMPTS) the row is flagged exhausted for manual inspection/replay rather than being re-parked forever.

Inspect parked ops directly:

Inspect the dead-letter table
SELECT entity_kind, external_id, op_type, attempts, exhausted, error, failed_at
FROM voicebot_dead_letter
ORDER BY failed_at DESC;

A non-empty, growing dead-letter table means a delta keeps failing — read error, fix the mapping or the data, and the next sync that re-touches those rows will re-attempt them.

Why signed calls don't retry on 5xx

Signed requests carry a single-use nonce that the backend consumes the moment it receives the request — before it can return a 429/5xx. A blind retry would replay a spent nonce and be rejected as a replay. So signed calls retry on connection errors only; the unsigned pair handshake and the no-HMAC snapshot upload do retry on 429/5xx with backoff that honours Retry-After. This is why a transient backend error on a delta dead-letters the batch (exit 1) rather than retrying inline.

Troubleshooting

Not paired. Run voicebot:pair <code> first. (exit 2)

No stored connection. Run php artisan voicebot:pair VB-XXXX-XXXX (see Installation). If you just switched tenants, you must unpair then pair.

refusing to push an empty full snapshot (exit 2)

A --full produced zero rows across all enabled kinds. Pushing it would arm the server tombstone guard to wipe the catalog, so it's refused. Almost always a mapping/config error: a wrong model, an enabled kind with no data, or a query that returns nothing. Run voicebot:doctor — a kind showing 0 rows is the culprit.

The empty-snapshot guard protects your live catalog

A full snapshot tells the backend "this is everything" — so the server tombstones anything not in it. An accidental empty snapshot would therefore wipe the catalog. The client refuses to send one; never work around this by forcing the push — fix the mapping so the snapshot has rows.

--since is not a valid date/time (exit 2)

The --since value didn't parse. Use ISO-8601 (2026-06-01 or 2026-06-01T12:00:00Z) or any value strtotime accepts.

VoiceBot base/ingest URL must use https:// (exit 2)

The configured URL is plaintext http://. An HMAC over http would leak the signature; switch to https://. localhost / 127.0.0.1 / ::1 are the only exceptions (local dev).

protocol_upgrade_required (HTTP 426)

The backend requires a newer sync protocol version than this package speaks. Upgrade the package — there is no fallback; older protocol versions are refused outright:

Terminal
composer update monoverse/voicebot-laravel-sync
No protocol fallback — keep the package current

The sync protocol is versioned and the backend rejects out-of-date producers with HTTP 426 rather than silently degrading. If a deploy starts failing with protocol_upgrade_required, the fix is always to update the Composer package; there is no compatibility shim.

Delta reports dead-lettered ops (exit 1)

Some ops failed validation or hit a backend error and were parked. Query voicebot_dead_letter, read each error, and fix the mapping or data. Common causes: a required payload key is missing/empty (see Data reference), money sent as a float instead of minor units, or a category referenced by id instead of slug.

Backend unreachable or rejected in doctor

Check VOICEBOT_BASE_URL and network egress from the app server. If pairing is fine but status fails, the connection may have been disabled in the cabinet (the billing kill-switch revokes ingest when the provider connection isn't connected).

Sample row missing required payload key(s) in doctor

The mapped record for that kind doesn't carry the backend-required field (e.g. product without name, page without title). Add the missing key to the kind's map. The exact requirement per kind is in Data reference.