Endpoints
Eight endpoints under /api/v1/ingest. Two are unsigned (pairing), one is signed by the single-use
upload URL rather than HMAC (upload), and the rest are HMAC-signed per
Authentication & HMAC.
| Endpoint | Method | Auth | Idempotency |
|---|---|---|---|
/pair | POST | None (TLS only) | Pair code is single-use |
/pair-by-key | POST | Domain proof (TLS only) | A fresh challenge per call |
/init | POST | HMAC | — |
/upload/{sync_id} | PUT | Single-use URL | Overwrites by key |
/finalize | POST | HMAC | Idempotency-Key (required for full snapshots) |
/events | POST | HMAC | batch_id + optional Idempotency-Key |
/status | GET | HMAC | Always safe |
/unpair | POST | HMAC | Idempotent |
POST /pair-by-key
Pairs with the store's publishable key (pk_…) instead of a pair code. A pk_ is printed in every
page your shop serves, so the key alone proves nothing: the call carries a pair_nonce — 32 to 128
characters your producer generates and keeps for the length of the call.
{
"public_key": "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"pair_nonce": "<64 hex characters>",
"site_url": "https://shop.example.com",
"provider_id": "laravel"
}
Before it answers, the API sends one GET over HTTPS to an origin of the key's bound domain with a
fresh challenge query parameter — /voicebot/pair-challenge for Laravel,
/?rest_route=/voicebot-sync/v1/pair-challenge for WooCommerce. Your producer answers
{"proof": "<hex>"}, where proof is the hex HMAC-SHA256 of challenge keyed by pair_nonce. It
follows no redirect and reads no private address. A correct proof returns the same body as /pair.
| Status | error.code | Meaning |
|---|---|---|
| 401 | invalid_key | The key is unknown or inactive |
| 403 | domain_mismatch | site_url is not the key's bound domain |
| 403 | domain_unproven | The challenge route did not answer with the right proof |
| 409 | key_has_no_domain | The key has no bound domain or site yet |
Full sync — three steps
A snapshot is init → upload → finalize.
POST /init
Opens a sync session and returns an upload target. sync_type is "full" (the snapshot path) or
"partial"; expected_counts is a hint for dashboard progress, not validation.
{ "sync_type": "full", "expected_counts": { "product": 245, "category": 18, "page": 32 } }
{
"sync_id": "a7b1c4e2-19f0-4d6f-87aa-c00ffee12345",
"upload_url": "https://api.monoverse.tech/api/v1/ingest/upload/a7b1c4e2-...",
"object_key": "ingest/03d7d7e6-.../a7b1c4e2-....ndjson.gz",
"format": "ndjson_gzip",
"expires_at": "2026-05-16T11:00:00Z",
"max_size_bytes": 209715200
}
If a snapshot is already uploading or dispatching, init returns 409 conflict_full_sync_in_progress.
PUT /upload/{sync_id}
Stream the NDJSON body (one entity per line), optionally Content-Encoding: gzip, to
the returned upload_url. No HMAC here — the URL is single-use and ingest-scoped (in production
it's an S3 signed PUT). Max body is max_size_bytes (200 MB); over that is 413. Success is
204 No Content.
POST /finalize
Closes the session and schedules processing. Idempotency-Key is required for full-snapshot
finalize — a retry with the same key returns the cached response and does no extra work.
{ "sync_id": "a7b1c4e2-..." }
// →
{ "sync_id": "a7b1c4e2-...", "status": "dispatching", "dispatch_eta_seconds": 60 }
After finalize the backend parses each NDJSON line, validates it, and writes the entities and their projections. Per-line failures are dead-lettered, not fatal.
Incremental — POST /events
Small signed batches of upserts and deletes, 1–500 operations per batch. The server sets no body-size
cap; the official SDKs split batches at 5 MB. batch_id is your dedup id — see
Idempotency for exactly-once for what a resend returns.
{
"batch_id": "8e3a1b9c-...",
"events": [
{
"type": "upsert",
"entity_kind": "product",
"external_id": "wc:product:iphone-15-pro-256gb",
"lang": "uk_UA",
"payload": {
"sku": "APL-IP15P-256",
"name": "Apple iPhone 15 Pro 256GB",
"slug": "iphone-15-pro-256gb",
"product_type": "variable",
"price_amount": 52999,
"regular_price_amount": 59999,
"currency": "UAH",
"description": "Флагман Apple з чіпом A18 Pro та титановим корпусом.",
"stock_status": "instock",
"stock_quantity": 14,
"permalink": "https://shop.example.com/product/iphone-15-pro-256gb/",
"categories": ["smartphones", "apple"],
"tags": ["new", "premium", "5g"],
"attributes": [
{ "name": "Колір", "slug": "pa_color", "options": ["Blue Titanium", "White Titanium"], "variation": true }
],
"variations": [
{ "external_id": "wc:variation:12345", "sku": "APL-IP15P-256-BLU",
"price_amount": 52999, "stock_status": "instock", "attributes": { "pa_color": "Blue Titanium" } }
],
"images": [
"https://cdn.example.com/iphone-15-pro-blue-1.jpg",
"https://cdn.example.com/iphone-15-pro-blue-2.jpg"
]
}
},
{ "type": "delete", "entity_kind": "product", "external_id": "wc:product:obsolete" }
]
}
{
"processed": 1,
"errors": [],
"idempotent_replay": false
}
The upsert lowers the price of the product shown in Entities and sends that product whole. How the server applies a batch:
- An upsert replaces the whole entity. A field you leave out does not keep its old value, so a
change to one field still sends every field,
langandtranslation_ofincluded — see An upsert replaces the whole entity. - Operations apply in the order sent. An upsert then a delete of the same
external_idleaves it deleted; a delete then an upsert leaves it live. processedcounts the upserts applied. Deletes are not counted.errors[].indexis the operation's position inevents[], counting from 0 and counting deletes.- A delete needs
external_id. A delete withoutexternal_id, or with an emptyentity_kind, is not applied; it comes back inerrorslike this, for an operation at position 2:{ "index": 2, "entity_kind": "product", "external_id": null, "error": "delete requires entity_kind and external_id" }. - To hide a product that exists but is not for sale — a draft, for example — send a
deletefor it. A later upsert of the sameexternal_idbrings it back. site_clockanddeleted_atare ignored. The server accepts both fields and reads neither.
Each operation's payload follows the same per-kind shape as a snapshot line — see
Entities.
GET /status
Read-only heartbeat. Returns the connection status, the last sync session summary, and per-kind entity
counts. The producer may also send X-VoiceBot-Site-Url, X-VoiceBot-WP-Version,
X-VoiceBot-WC-Version, X-VoiceBot-PHP-Version so the dashboard reflects the live site. Throttle to
at most once every ~5 minutes.
POST /unpair
Disconnects the provider connection and invalidates the secret. Idempotent — a second call returns
affected: 0. Run it before re-pairing a different tenant.
{ "ok": true, "affected": 1 }
Rate limits
| Scope | Limit |
|---|---|
/pair + /pair-by-key (combined) | 10 / min per IP |
init + upload + finalize (combined) | 6 / min per tenant |
/events | 60 / min per tenant |
| Full snapshots | 4 / hour per tenant |
A 429 returns Retry-After. Note that signed 429s are not safe to blind-retry — see
Errors.
Next
- Entities — what goes in each NDJSON line and
/eventspayload.