Skip to content

Page ranges

The pages option selects which pages of a document to convert. It is the parameter that determines the bill, so it has one syntax, no ambiguity, and no silent corrections.

Pages 1 to 5, page 9, and page 12 to the end
curl -sS https://api.kaho.ai/v1/convert \
-H "Authorization: Bearer $KAHO_API_KEY" \
-F "file=@annual-report.pdf" \
-F 'options={"pages":"1-5,9,12-last"};type=application/json'

Omitting pages, or sending null, converts the whole document.

The complete grammar
pages := WS? range (WS? "," WS? range)* WS?
range := endpoint | endpoint WS? "-" WS? endpoint
endpoint := number | "last"
number := [1-9] [0-9]{0,6}
WS := (" " | "\t")*

Pages are 1-based, and last is the only keyword.

ExpressionSelects
5Page 5 only
3-7Pages 3, 4, 5, 6, 7
1-5,9,12-lastPages 1–5, page 9, and page 12 to the end
lastThe final page
10-lastPage 10 to the end
1-lastThe whole document
1 - 5, 9Same as 1-5,9. Whitespace is permitted

Every rejection here is deliberate. In a parameter that determines the bill, ambiguity is unacceptable at any convenience price.

RejectedCodeWhy
-1, -5page_range_invalidA leading - is irreconcilably ambiguous between “the last page”, “up to page 5”, and a stray separator
5-page_range_invalidRedundant with the self-documenting 5-last. The error names the replacement verbatim
0page_range_zeroPages are 1-based
007page_range_invalidLeading zeros invite octal confusion
7-3page_range_reversedSilently swapping would hide a caller bug that produces a wrong bill somewhere else
last-2page_range_invalid- is the range infix; arithmetic on last re-introduces the ambiguity we just removed
[1,2,3]invalid_parameter_typeOne syntax

A selection is normalized before anything is converted, and the canonical result is echoed back to you as document.pages_selected.

  1. last resolves to the document’s page count.
  2. Ranges are expanded.
  3. The results are unioned, deduplicated and sorted ascending.
  4. Output is emitted in document order, regardless of the order you wrote the ranges in.

Each unique page is billed exactly once, regardless of how many ranges cover it.

You sendOn a 42-page documentPages selectedCredits
1-5,3-7Overlapping ranges1-77
9,3,1Out of order1,3,93
5,5,5Repeated51
40-lastlast = 4240-423
(omitted)Whole document1-4242
The canonical selection comes back in the response
{
"document": {
"page_count": 42,
"pages_selected": "1-7",
"pages_selected_count": 7
},
"usage": {
"pages_billed": 7,
"credits_charged": 7,
"line_items": [{ "kind": "page_convert", "quantity": 7, "credits": 7 }]
}
}

The two cases behave differently, and the difference is about intent.

A range that partially overshoots is clamped. Asking for 1-50 of a 30-page document gives you pages 1 to 30, a 200, and an informational page_range_clamped warning. “Give me the first fifty pages of whatever this is” is a benign idiom and we treat it as one. You are billed for the 30 pages you received.

page_range_clamped
{
"code": "page_range_clamped",
"severity": "info",
"scope": "document",
"message": "Requested pages 1-50; the document has 30. The selection was clamped to 1-30.",
"billed": true
}

A range entirely beyond the document is an error. Asking for 90-100 of a 30-page document is 422 page_range_out_of_bounds, billed 0. A total miss is never intentional, and returning an empty success would hide a bug in your caller.

LimitExceeded gives
2,048 characters in the expression422 page_range_too_complex
1,000 ranges in the expression422 page_range_too_complex
More than 200 selected pages on /v1/convert413 too_many_pages

The 200-page ceiling is on the selection, not on the document: a 5,000-page document is fine as long as you select at most 200 pages of it at a time.

Walk a large document in 200-page windows
for start in $(seq 1 200 5000); do
end=$(( start + 199 ))
curl -sS https://api.kaho.ai/v1/convert \
-H "Authorization: Bearer $KAHO_API_KEY" \
-H "Idempotency-Key: report-2026-pages-${start}-${end}" \
-H "Content-Type: application/json" \
-d "{\"source\":{\"type\":\"file_id\",\"id\":\"$FILE_ID\"},
\"options\":{\"pages\":\"${start}-${end}\"}}" \
> "part-${start}.json"
done

Note the stable idempotency key per window: a retry of any single window costs nothing extra, and the whole loop is safe to re-run.

How you express the range determines how much is held while the request runs.

What you sendCredits held
A bounded expression such as 1-5 or 1-5,9Exactly the selected count
pages: null, or an unbounded range such as 10-last200, the endpoint ceiling
Any expression, converting from an uploaded file_idExactly the selected count

An unbounded range on a freshly uploaded body means we do not know the page count until we parse, so a 200-credit hold — ten cents — is taken and released within one round trip when the request settles. You are never charged the hold; you are charged the settled figure, which is always less than or equal to it.

If ten cents of headroom matters, or you convert the same document repeatedly, upload it first:

Upload once, then every selection holds exactly
FILE_ID=$(curl -sS https://api.kaho.ai/v1/files \
-H "Authorization: Bearer $KAHO_API_KEY" \
-F "file=@annual-report.pdf" | jq -r .id)
curl -sS https://api.kaho.ai/v1/convert \
-H "Authorization: Bearer $KAHO_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source\":{\"type\":\"file_id\",\"id\":\"$FILE_ID\"},
\"options\":{\"pages\":\"12-last\"}}"

Sending pages to /v1/toc is 400 unknown_parameter, and that is the design rather than an omission. An outline is a property of a document, not of a page range, and a table of contents inferred from a slice of a document looks complete and is not.

The outline path has no page ceiling at all, because it loads no page. Only the inference path is subject to the 200-page limit, and it is whole-document or nothing.

pages always means the 1-based physical page index — the first page of the file is 1.

PDF page labels are a separate naming scheme: physical page 3 is routinely labelled "iii" and physical page 5 is routinely labelled "1". Labels appear in document.page_labels and in /v1/toc entries as page_label, and they are decoration. No label is ever accepted as input anywhere in the API, so there is no path by which the two can be confused.