Pagination and syncing
List endpoints share the same cursor pagination and sync parameters, so one sync loop works for vehicles, enquiries, customers, deals and everything else.
Two lists differ: GET /v1/events is ordered by when each event happened (see Webhooks and events), and GET /v1/theme-versions is paged by number (see Website themes).
Cursor pagination
| Parameter | Description |
|---|---|
limit | Items per page, 1 to 100. Default 25. |
cursor | The meta.next_cursor from the previous page. Omit it for the first page. |
Every list response includes:
{
"data": [],
"meta": {
"next_cursor": "eyJpdiI6...",
"has_more": true
}
}
Keep requesting with cursor=<next_cursor> until has_more is false (next_cursor is then null).
Cursors are opaque. Do not build or edit them; a cursor that has been changed is refused with 422 validation_failed.
Ordering
Lists are always ordered by updated_at, then id, ascending: oldest change first. That makes the last item on the last page the most recently changed one, which is exactly what a sync job needs to remember.
Incremental sync with updated_since
Every list accepts updated_since, an ISO 8601 timestamp. Only items whose updated_at is at or after that time are returned.
curl "https://api.vehiso.com/v1/enquiries?updated_since=2026-09-27T14:00:00Z&limit=100" \
-H "Authorization: Bearer $VEHISO_API_KEY"
The comparison is inclusive, so the item you saw last on the previous run is returned again. Make your writes upserts keyed on id and that repeat is harmless; it also guarantees that nothing updated in the same second is skipped.
Deletions: include_deleted
A normal list leaves out deleted items. To mirror deletes, add include_deleted=true to any list of records that can be deleted (vehicles, enquiries, customers, deals, appointments and job cards, for example). Deleted items then appear in the same ordered stream as tombstones:
{
"id": "b7e3a1c2-9f4d-4a6e-8c21-7d5b0e9f3a64",
"deleted": true,
"deleted_at": "2026-09-27T09:15:42Z"
}
A tombstone has only these three fields. When you see "deleted": true, delete (or mark deleted) the row with that id in your copy.
Worked example: mirror to a warehouse
This job copies a dealer's customers into a warehouse table and keeps it current. Run it on a schedule (every few minutes is plenty). It stores one value between runs: the highest updated_at it has processed.
The same loop works for any list endpoint; change the path and the upsert.
- Python
- Node.js
import os
import time
import requests
API = "https://api.vehiso.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VEHISO_API_KEY']}"}
def sync_customers(db):
since = db.get_checkpoint("customers") # None on the first run
params = {"limit": 100, "include_deleted": "true"}
if since:
params["updated_since"] = since
newest = since
while True:
res = requests.get(f"{API}/customers", params=params, headers=HEADERS, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "5")))
continue
res.raise_for_status()
body = res.json()
for item in body["data"]:
if item.get("deleted"):
db.delete_customer(item["id"])
newest = max(newest or "", item["deleted_at"])
else:
db.upsert_customer(item) # keyed on item["id"]
newest = max(newest or "", item["updated_at"])
if not body["meta"]["has_more"]:
break
params["cursor"] = body["meta"]["next_cursor"]
if newest:
db.set_checkpoint("customers", newest)
const API = 'https://api.vehiso.com/v1';
const headers = {Authorization: `Bearer ${process.env.VEHISO_API_KEY}`};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function syncCustomers(db) {
const since = await db.getCheckpoint('customers'); // null on the first run
const params = new URLSearchParams({limit: '100', include_deleted: 'true'});
if (since) params.set('updated_since', since);
let newest = since;
for (;;) {
const res = await fetch(`${API}/customers?${params}`, {headers});
if (res.status === 429) {
await sleep(Number(res.headers.get('Retry-After') ?? 5) * 1000);
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.code}: ${body.message}`);
for (const item of body.data) {
if (item.deleted) {
await db.deleteCustomer(item.id);
if (!newest || item.deleted_at > newest) newest = item.deleted_at;
} else {
await db.upsertCustomer(item); // keyed on item.id
if (!newest || item.updated_at > newest) newest = item.updated_at;
}
}
if (!body.meta.has_more) break;
params.set('cursor', body.meta.next_cursor);
}
if (newest) await db.setCheckpoint('customers', newest);
}
Why this is safe:
- Ordering. Items arrive oldest change first, so if the job stops half way, the checkpoint it would have saved is still correct next time. Save the checkpoint only after a run finishes, as above, and a crash simply repeats work.
- Inclusive
updated_since. Items changed in the same second as the checkpoint are fetched again and upserted again, never missed. - String comparison. Timestamps are all UTC in the same
YYYY-MM-DDTHH:MM:SSZformat, so comparing them as strings orders them correctly. - Tombstones. Deletes arrive in the same stream, so your copy never keeps a row the dealer has deleted.
Webhooks or polling?
Polling with updated_since is the simplest reliable design and is what a warehouse wants. If you need to react within seconds, add webhooks and keep a slower poll running as a safety net.