Cursor Pagination for Appointments

Traverse large appointment histories efficiently with a forward-only cursor.

Use GET /v3/appointments/cursor for exports, data pipelines, and backend
integrations that need to read many appointments sequentially. It avoids the growing
database cost of deep page offsets and does not calculate a total count.

Keep GET /v3/appointments when a UI needs a total count or direct access to a page.
The legacy page route remains supported, with an offset ceiling and a stricter request
budget. The deprecated /v3/appointments/filter alias should not be used for new work.

Start a traversal

The first request supplies the filters and traversal order. limit defaults to 20 and
accepts 1 through 100.

curl -G "https://v3.onsched.com/v3/appointments/cursor" \
  -H "Authorization: Bearer <token>" \
  -H "x-api-key: <company-key>" \
  --data-urlencode "resourceIds=<resource-id>" \
  --data-urlencode "from=2026-09-01" \
  --data-urlencode "to=2026-10-04" \
  --data-urlencode "sort=scheduled" \
  --data-urlencode "order=asc" \
  --data-urlencode "limit=100"

The first request accepts the same list filters as GET /v3/appointments:
locationId, resourceIds, serviceIds, customerId, status, from, and to.
Resource, service, and status values may be repeated or comma-separated. Sort is
created or scheduled; order is asc or desc.

{
  "success": true,
  "data": [
    { "id": "<appointment-id>" }
  ],
  "pageInfo": {
    "hasMore": true,
    "nextCursor": "<opaque-cursor>"
  }
}

Continue and stop

When hasMore is true, send only cursor on the next request. Do not resend filters,
sort, order, or limit; they are bound to the cursor. Stop when hasMore is false and
nextCursor is null. Never continue after that terminal response.

var uri = URI.create(apiBase + "/v3/appointments/cursor"
    + "?resourceIds=" + URLEncoder.encode(resourceId, UTF_8)
    + "&from=2026-09-01&to=2026-10-04"
    + "&sort=scheduled&order=asc&limit=100");

while (uri != null) {
  var request = HttpRequest.newBuilder(uri)
      .header("Authorization", "Bearer " + accessToken)
      .header("x-api-key", companyApiKey)
      .GET()
      .build();
  var response = client.send(request, HttpResponse.BodyHandlers.ofString());
  if (response.statusCode() == 429) {
    Thread.sleep(Long.parseLong(response.headers()
        .firstValue("Retry-After").orElse("1")) * 1000L);
    continue; // retry the same cursor
  }
  if (response.statusCode() != 200) {
    throw new IllegalStateException(response.body());
  }

  var page = objectMapper.readTree(response.body());
  persist(page.get("data"));
  var nextCursor = page.at("/pageInfo/nextCursor");
  uri = nextCursor.isNull()
      ? null
      : URI.create(apiBase + "/v3/appointments/cursor?cursor="
          + URLEncoder.encode(nextCursor.asText(), UTF_8));
}

Consistency and errors

Cursor walks read current appointment state. They are not a transactionally frozen
snapshot: appointments created, updated, cancelled, or deleted during a walk may affect
later pages. Use appointment webhooks as the authoritative source for ongoing changes;
use cursor traversal for initial or periodic reconciliation.

Cursors expire one hour after the first page and are scoped to the authenticated
Company. Invalid, expired, altered, or wrong-Company cursors return:

{
  "success": false,
  "message": "Invalid or expired appointment cursor"
}

Cursor traversal uses a more relaxed Company request budget than page pagination, but
it still shares bounded database admission. On 429, honor Retry-After and retry the
same cursor rather than starting another parallel walk.

See also


Did this page help you?