Paginate search results

Try two pages, then collect more results when you need them.

Continue the search from Find companies with the Search API. Start with a two-page sample. Once that works, use the optional full traversal example when you need to collect further results.

Before you start: use the same provisioned key in CRUNCHBASE_API_KEY. The query finds SaaS organizations headquartered in San Francisco, California. The preceding guide explains the two filter UUIDs. cURL examples use bash/zsh; the Python example also works on Windows.

How pagination works

API 4.0 uses keyset pagination: each request continues from an entity UUID rather than a page number. Compared with page-number pagination, this avoids relying on shifting page offsets.

Page 1 → copy the last result’s UUID → send it as after_id → page 2.

Keep query, field_ids, order (if supplied), and limit unchanged during the traversal. These examples retain the preceding guide's request, including limit: 3 and no explicit order. If you need a particular sort order, choose it before the first page and retain it throughout; see Search API basics.

1. Request the first page

Start without either cursor. This is the same two-filter request from the Search walkthrough:

The cURL examples use --include to display response headers before the JSON body. Check the status line for 200 (for example, HTTP/2 200); the response excerpts below show only the JSON body.

curl --include --request POST \
  'https://api.crunchbase.com/v4/data/searches/organizations' \
  --header "X-cb-user-key: $CRUNCHBASE_API_KEY" \
  --header 'Content-Type: application/json' \
  --header 'Accept: application/json' \
  --data-raw '{
  "field_ids": [
    "identifier",
    "short_description",
    "categories",
    "location_identifiers"
  ],
  "query": [
    {
      "type": "predicate",
      "field_id": "categories",
      "operator_id": "includes",
      "values": [
        "5c4e6926-5ff7-b188-0892-c8eb036c5ace"
      ]
    },
    {
      "type": "predicate",
      "field_id": "location_identifiers",
      "operator_id": "includes",
      "values": [
        "528f5e3c-90d1-1111-5d1c-2e4ff979d58e"
      ]
    }
  ],
  "limit": 3
}'

Check: HTTP 200 with an entities array. Find the last item in the full response, not the last item in a shortened documentation excerpt.

In one verified run, the first page contained these results in this order. Live results can change:

PositionOrganizationentities[i].uuid
0OpenAIcf2c678c-b81a-80c3-10d1-9c5e76448e51
1Sierra4f26fd0b-9827-4ac6-b81b-695d0be91124
2Motiveb3e2ba9b-92aa-347e-c323-39c6dab35b56

The cursor for the next request is entities[entities.length - 1].uuid in JavaScript, or entities[-1]["uuid"] in Python. Check that the array is nonempty before selecting it. This is the last raw result’s UUID, even if that result is a duplicate you have already processed.

2. Request the next page

Add only after_id to the request body. The command below uses the last UUID from the table; replace that value with the last UUID from your own first-page response if different.

curl --include --request POST \
  'https://api.crunchbase.com/v4/data/searches/organizations' \
  --header "X-cb-user-key: $CRUNCHBASE_API_KEY" \
  --header 'Content-Type: application/json' \
  --header 'Accept: application/json' \
  --data-raw '{
  "field_ids": [
    "identifier",
    "short_description",
    "categories",
    "location_identifiers"
  ],
  "query": [
    {
      "type": "predicate",
      "field_id": "categories",
      "operator_id": "includes",
      "values": [
        "5c4e6926-5ff7-b188-0892-c8eb036c5ace"
      ]
    },
    {
      "type": "predicate",
      "field_id": "location_identifiers",
      "operator_id": "includes",
      "values": [
        "528f5e3c-90d1-1111-5d1c-2e4ff979d58e"
      ]
    }
  ],
  "limit": 3,
  "after_id": "b3e2ba9b-92aa-347e-c323-39c6dab35b56"
}'

Check: HTTP 200 with an entities array. In the same verified traversal, page 2 began with Blacksmith (c7943682-cdf6-4c0d-b0df-4380e315fde4). You should validate the response structure and cursor progression, not expect the same company or order every time.

For page 3, replace after_id with the last UUID from page 2. Do not reuse the first page’s cursor. Do not use the first result you chose for enrichment as the cursor unless it is also the last result on the page.

3. Know when to stop

ResultAction
Nonempty entitiesProcess the page and continue with its last UUID.
entities: []The traversal has reached its end.
A page shorter than limitStill request the next page to check for an empty array.
A full page containing exactly limit itemsStill request the next page; it may be the final nonempty page.
Missing or malformed entities, or a missing/invalid UUIDStop and report an incomplete traversal. Do not treat this as an empty page.
The next cursor was already usedStop and report an incomplete traversal rather than loop indefinitely.
A request failsRetry the same page only when appropriate; never advance its cursor on failure.

count represents the total number of matching entities, not the current page length or the number remaining after the cursor. Use the length of entities for the page size and an empty array as the stopping condition. Do not stop because a count stays unchanged or because you have collected that many records.

Try two pages in Python

This is the short learning example: make a request, copy its last UUID, and request the next page. It uses the same SaaS + San Francisco query and Python 3’s standard library.

  1. Save the code below as two_pages.py.
  2. Set CRUNCHBASE_API_KEY in the same terminal. See the Search walkthrough for bash/zsh and PowerShell setup.
  3. Run python3 two_pages.py (or python if that is your Python 3 command).

Success: two page responses followed by “Two-page sample complete.” If an empty page arrives sooner, the script reports that the end was reached. Completing this exercise does not mean you have retrieved every matching company.

Show the two-page Python example
import json
import os
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from uuid import UUID

BODY = {
    'field_ids': ['identifier', 'short_description', 'categories', 'location_identifiers'],
    'query': [
        {'type': 'predicate', 'field_id': 'categories', 'operator_id': 'includes',
         'values': ['5c4e6926-5ff7-b188-0892-c8eb036c5ace']},
        {'type': 'predicate', 'field_id': 'location_identifiers', 'operator_id': 'includes',
         'values': ['528f5e3c-90d1-1111-5d1c-2e4ff979d58e']},
    ],
    'limit': 3,
}


def fetch(body, key):
    request = Request(
        'https://api.crunchbase.com/v4/data/searches/organizations',
        data=json.dumps(body).encode(),
        headers={'X-cb-user-key': key, 'Content-Type': 'application/json',
                 'Accept': 'application/json'})
    with urlopen(request, timeout=30) as response:
        data = json.load(response)
    entities = data.get('entities') if isinstance(data, dict) else None
    if not isinstance(entities, list):
        raise ValueError('Expected an entities array.')
    for entity in entities:
        uid = entity.get('uuid') if isinstance(entity, dict) else None
        if not isinstance(uid, str):
            raise ValueError('Result missing its UUID.')
        UUID(uid)
    return entities


def main():
    key = os.environ.get('CRUNCHBASE_API_KEY', '').strip()
    if not key:
        raise ValueError('Set CRUNCHBASE_API_KEY first.')
    cursor = None
    for page_number in (1, 2):
        body = dict(BODY)
        if cursor is not None:
            body['after_id'] = cursor
        entities = fetch(body, key)
        print(json.dumps({'page': page_number, 'entities': entities}))
        if not entities:
            print('End reached: this request returned an empty page.')
            return
        next_cursor = entities[-1]['uuid']
        if next_cursor == cursor:
            raise ValueError('Repeated cursor; stop and inspect the response.')
        cursor = next_cursor
    print('Two-page sample complete. More results may remain; this is not a full export.')


if __name__ == '__main__':
    try:
        main()
    except HTTPError as error:
        print(f'Stopped: HTTP {error.code}. Check the error before retrying.', file=sys.stderr)
        sys.exit(1)
    except (URLError, TimeoutError):
        print('Stopped: network error or timeout.', file=sys.stderr)
        sys.exit(1)
    except ValueError as error:
        print(f'Stopped: {error}', file=sys.stderr)
        sys.exit(1)

This example stops on request errors and does not retry or deduplicate records. For collecting results, use the full traversal below, which adds those safeguards.

Run the pagination loop in Python

Optional: collect results beyond the exercise. You can stop reading here if your goal was to understand how the cursor connects two requests.

This Python 3 example uses only the standard library. It follows the same query, deduplicates by UUID, and writes each unique result as one JSON line. It does not make additional Entity Lookup requests.

  1. Save the code below as paginate_companies.py.
  2. Set CRUNCHBASE_API_KEY in the same terminal, as in Make your first API request.
  3. Run python3 paginate_companies.py (or python if that is your Python 3 command).

The default 10-page safety cap keeps the first run small. For this broad query, expect the demonstration to stop at that cap with an INCOMPLETE message and exit code 2. That is not the end of the dataset. The cap includes the request needed to observe an empty page.

To allow a longer traversal, choose a suitable cap, for example:

python3 paginate_companies.py --max-pages 1000

A larger cap still does not guarantee completion. Only a final end_reached status and exit code 0 indicate that the loop observed an empty page. This means the traversal ended, not that it captured a transactional snapshot.

Show the complete Python pagination example
import argparse
import json
import os
import random
import sys
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from uuid import UUID

URL = 'https://api.crunchbase.com/v4/data/searches/organizations'
QUERY = {
    'field_ids': ['identifier', 'short_description', 'categories', 'location_identifiers'],
    'query': [
        {'type': 'predicate', 'field_id': 'categories', 'operator_id': 'includes',
         'values': ['5c4e6926-5ff7-b188-0892-c8eb036c5ace']},
        {'type': 'predicate', 'field_id': 'location_identifiers', 'operator_id': 'includes',
         'values': ['528f5e3c-90d1-1111-5d1c-2e4ff979d58e']},
    ],
    'limit': 3,
}


def retry_delay(value):
    if value is None:
        return 0
    try:
        return max(0, float(value))
    except ValueError:
        try:
            date = parsedate_to_datetime(value)
            return max(0, (date - datetime.now(timezone.utc)).total_seconds())
        except (ValueError, TypeError, OverflowError):
            return 0


def fetch_page(body, key):
    # Retry this same page; never advance the cursor after a failed request.
    for attempt in range(4):
        time.sleep(0.4)  # Pace every attempt, including retries, in this process.
        req = Request(URL, data=json.dumps(body).encode(), headers={
            'X-cb-user-key': key, 'Content-Type': 'application/json',
            'Accept': 'application/json',
        })
        wait = 0
        try:
            with urlopen(req, timeout=30) as response:
                data = json.load(response)
            if not isinstance(data, dict) or not isinstance(data.get('entities'), list):
                raise ValueError('Expected a JSON object containing an entities array.')
            return data['entities']
        except HTTPError as error:
            status = error.code
            wait = retry_delay(error.headers.get('Retry-After'))
            error.close()
            if status != 429 and not 500 <= status <= 599:
                raise RuntimeError(f'HTTP {status}; correct the request or access before retrying.') from None
            reason = f'HTTP {status}'
        except (URLError, TimeoutError):
            reason = 'network error or timeout'
        if attempt == 3:
            raise RuntimeError(f'{reason}; exhausted four attempts for this page.')
        # Stop instead of retrying early when the server requests a long pause.
        if wait > 60:
            raise RuntimeError(f'{reason}; server requests a pause of {wait:.0f}s. Wait before restarting.')
        time.sleep(max(wait, 2 ** attempt + random.uniform(0, 0.5)))


def traverse(fetch, max_pages, emit):
    seen_ids, seen_cursors = set(), set()
    cursor = None
    duplicates = 0
    for page_number in range(1, max_pages + 1):
        body = dict(QUERY)
        if cursor is not None:
            body['after_id'] = cursor
        entities = fetch(body)
        if not isinstance(entities, list):
            raise ValueError('Expected an entities array, not a missing or null value.')
        if not entities:
            return {'status': 'end_reached', 'pages_requested': page_number,
                    'unique_entities': len(seen_ids), 'duplicates_skipped': duplicates}
        # Validate the whole page before emitting any of its records.
        ids = []
        for entity in entities:
            uid = entity.get('uuid') if isinstance(entity, dict) else None
            if not isinstance(uid, str):
                raise ValueError('Result missing its UUID; cursor cannot be trusted.')
            UUID(uid)
            ids.append(uid)
        next_cursor = ids[-1]  # Last raw result, even if already seen.
        if next_cursor in seen_cursors:
            raise RuntimeError('Repeated cursor; stopped to prevent an infinite loop.')
        for uid, entity in zip(ids, entities):
            if uid in seen_ids:
                duplicates += 1
                continue
            emit(entity)
            seen_ids.add(uid)
        seen_cursors.add(next_cursor)
        cursor = next_cursor
    raise RuntimeError(f'Reached max-pages={max_pages} before an empty page; output is partial.')


def main():
    parser = argparse.ArgumentParser(description='Page through SaaS organizations in San Francisco.')
    parser.add_argument('--max-pages', type=int, default=10,
                        help='Safety cap including the final empty-page request (default: 10).')
    args = parser.parse_args()
    if args.max_pages < 1:
        parser.error('--max-pages must be positive')
    key = os.environ.get('CRUNCHBASE_API_KEY', '').strip()
    if not key:
        raise ValueError('Set CRUNCHBASE_API_KEY to your provisioned key first.')
    summary = traverse(lambda body: fetch_page(body, key), args.max_pages,
                       lambda entity: print(json.dumps(entity), flush=True))
    print(json.dumps(summary), file=sys.stderr)


if __name__ == '__main__':
    try:
        main()
    except (ValueError, RuntimeError) as error:
        print(f'INCOMPLETE: {error} Any records already written are partial.', file=sys.stderr)
        sys.exit(2)

Records go to standard output; the completion summary or error goes to standard error. You can redirect records to a file:

python3 paginate_companies.py --max-pages 1000 > companies.jsonl

Use a fresh output filename for each run if you need to retain earlier output. If the process stops early, any records already written are partial. Check the exit status and final message before treating the file as a completed traversal. This teaching example restarts from page 1; it does not persist checkpoints or resume an interrupted run.

The summary reports pages_requested, unique_entities, and duplicates_skipped. Deduplication retains the first occurrence of each UUID. For a synchronization job, decide whether later occurrences should instead update your stored record. The example keeps UUIDs and cursors in memory; use a persistent store for large traversals.

Retries and pacing

The optional script sends one request at a time, spaces every attempt by at least 0.4 seconds, and allows four attempts per page for HTTP 429, HTTP 5xx, or network failures. It keeps the same cursor while retrying and honors Retry-After. A requested pause longer than 60 seconds stops this teaching script instead of retrying early.

These are this example’s safeguards, not shared controls for other workers using your key. See request limits and retries for the canonical limits and policy.

Return to a previous page

For backward pagination, set before_id to the UUID of the first result on the current page. Remove after_id, and keep the query, fields, order, and limit unchanged. Never provide both cursor parameters together.

This navigates backward through the live result set. It does not guarantee that a previously viewed page will contain exactly the same records after data changes. If your UI must redisplay the exact page a user saw, retain that page in your application.

Account for changing data

Pagination requests do not share a transactional snapshot. Records can move into or out of the matching or ordered result set between calls. Duplicates and omissions are possible, including when a sort field changes during traversal.

Deduplicating by UUID prevents repeated processing of the same entity; it cannot recover omitted records or guarantee every matching entity is returned exactly once. Reconcile or repeat synchronization as needed when coverage of changing data matters. Preserve the query, traversal timestamps, completion status, and any errors alongside your results.

If your legacy agreement includes exports, see Legacy exports for available formats and collections. Check your contractual access and the fields provided before choosing an export in place of a Search traversal.

Next steps


Did this page help you?