> Reverse Contact API documentation. This is the Markdown copy of https://docs.reversecontact.com/endpoints/job-rows
> Every page is listed at https://docs.reversecontact.com/llms.txt, every endpoint with its credit cost and status at https://docs.reversecontact.com/catalog.json

# Read job results by page

> Read the results of a Bulk enrichment job a page at a time, in the order sent, from the start of the run, with a cursor that never skips an item, for free.

- Endpoint: `GET https://api.reversecontact.com/v2/jobs/:jobId/rows`
- Credits: Free, also at a zero balance. Each page counts toward the rate limits.
- Answer: synchronous.
- Available in: REST API, MCP tool `get_job_rows`, TypeScript and Python SDKs, CLI command `rc bulk rows`.
- Last verified: 2026-11-04

## Introduction

The Read job results by page endpoint returns the results of a job you [created](https://docs.reversecontact.com/endpoints/bulk-enrich-persons) as JSON, a page at a time, in the order you sent the items. Each item has the same fields as a line of the [NDJSON results file](https://docs.reversecontact.com/endpoints/job-results#ndjson): your own fields and `externalId`, what happened, what it cost, and the person found.

You can read it from the start of the run: there is no need to wait for the job to settle. While the job runs, a page stops at the first item not finished yet, so a cursor never skips an item and every item you read is final. When the next item is still running, the page is empty, `nextCursor` stays set and the response carries a `Retry-After` header: read the same cursor again after that many seconds. `nextCursor` is `null` once nothing more will come. A cursor keeps working after the job settles, so you can stop reading and pick up where you left off.

A cancelled job still finishes the items it had started, within the hour: a page waits for them the same way. While the job is paused, the page stays empty until it runs again.

This endpoint is **free** (0 credits), also at a zero balance. Each page counts toward your rate limits like any other call, and while the job runs, its items take up to 80% of your per-minute limit: read with `limit=100`, at the `Retry-After` pace. The TypeScript and Python SDKs and `rc bulk rows --all` wait out a `429` on their own. The key needs Person Enrichment or Contact, the permissions that read an `enrich_persons` job.

## Authorization

| Name     | Type   | Required | Description                                                                                         |
| -------- | ------ | -------- | --------------------------------------------------------------------------------------------------- |
| `apikey` | string | Yes      | Your API key from the developer dashboard. Pass it via `Authorization: Bearer YOUR_API_KEY` header. |

## Request

The route is `GET /v2/jobs/:jobId/rows`.

### Path parameters

| Name    | Type   | Required | Description                |
| ------- | ------ | -------- | -------------------------- |
| `jobId` | string | Yes      | The `id` of the job (UUID) |

### Query Parameters

| Name     | Type    | Required | Description                                                                                  |
| -------- | ------- | -------- | -------------------------------------------------------------------------------------------- |
| `cursor` | string  | No       | `nextCursor` of the previous page, up to 512 characters. Without it, the page starts at the first item. |
| `limit`  | integer | No       | Items per page, 1 to 100. Defaults to 100.                                                   |

A page can hold fewer items than `limit`: it stops at the first item not finished, and before 2 MB of JSON.

## Response structure

| Field     | Type    | Description                                      |
| --------- | ------- | ------------------------------------------------ |
| `success` | boolean | `true` if the request was processed successfully |
| `error`   | `null`  | Always `null` on success                         |

**data** (JobRowsPage): One page of results

| Field        | Type           | Description                                                                 |
| ------------ | -------------- | --------------------------------------------------------------------------- |
| `items`      | array          | The items of this page, in the order sent, see below                        |
| `nextCursor` | string \| null | Pass it as `cursor` to read on. `null` once nothing more will come.         |
| `status`     | string         | The job status when the page was read, as in [Get a job](https://docs.reversecontact.com/endpoints/job) |

**items** (JobResultLine): One item of the job

| Field             | Type           | Description                                                                                                                                                  |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `row`             | number         | Position of the item in the job, from 1                                                                                                                      |
| `input`           | object         | The item as you sent it, `externalId` included, empty fields left out                                                                                       |
| `status`          | string         | What happened to the item, as the `rc_status` column: see [Download job results](https://docs.reversecontact.com/endpoints/job-results#response-structure)                            |
| `message`         | string         | The same in a sentence, with the credits the item cost                                                                                                       |
| `creditsConsumed` | number         | Credits this item cost                                                                                                                                       |
| `person`          | object \| null | The person found, in the shape of [Enrich profile](https://docs.reversecontact.com/endpoints/enrich-person#response-structure). `null` when no one was found or the job did not ask for the profile. |
| `isFullProfile`   | boolean        | `true` when `person` is the full profile                                                                                                                     |
| `linkedinUrl`     | string         | Without `profile` in the job: the Social profile URL the finders searched from                                                                              |
| `contactEmail`    | object         | With `contact_email`: `status` (`found`, `not_found`, `error` or `skipped`, `null` when the finder did not run), `value`, and `personalValue` with `emailType: "any"` |
| `contactPhone`    | object         | With `contact_phone`: `status` (`found`, `not_found`, `error` or `skipped`, `null` when the finder did not run), `value` and `type`                         |

`quotas` and `metadata` are the blocks of every V2 response: see [Usage](https://docs.reversecontact.com/endpoints/usage#response-structure).

## Errors

| Status | Code                             | Meaning                                                                                                                                       |
| ------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `JOB_INVALID_INPUT`              | `cursor` is not a position of this job, such as a cursor of another job                                                                       |
| `404`  | `JOB_NOT_FOUND`                  | No `enrich_persons` job with this id in your workspace, or one your key may not read. A List job or a Search export has no results here.     |
| `409`  | `JOB_RESULTS_FORMAT_UNAVAILABLE` | The results of this job were stored before this endpoint existed: download them as CSV with [Download job results](https://docs.reversecontact.com/endpoints/job-results) |
| `422`  | `VALIDATION_ERROR`               | `jobId` is not a UUID, `limit` is not an integer from 1 to 100, or `cursor` is longer than 512 characters                                     |
| `429`  | `JOB_ROWS_BUSY`                  | Too many pages are being read at once: wait `error.details.retryAfter` seconds, given in the `Retry-After` header too, then read again        |

A `429` can also come from your rate limits: see [Rate limits](https://docs.reversecontact.com/guides/rate-limits).

## Code examples

**Code Examples**

```bash [cURL]
curl "https://api.reversecontact.com/v2/jobs/7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60/rows?limit=100" \
  -H "Authorization: Bearer YOUR_API_KEY"
```
```javascript [JavaScript]
const jobId = "7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60";
const headers = { "Authorization": "Bearer YOUR_API_KEY" };
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

let cursor = null;
do {
  const query = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) });
  const response = await fetch(`https://api.reversecontact.com/v2/jobs/${jobId}/rows?${query}`, { headers });
  if (response.status === 429) {
    await sleep(Number(response.headers.get("retry-after") ?? 2));
    continue;
  }
  const { data } = await response.json();
  for (const item of data.items) console.log(item.row, item.input.externalId, item.status);
  if (data.items.length === 0 && data.nextCursor) {
    if (data.status === "paused") break;
    await sleep(Number(response.headers.get("retry-after") ?? 2));
  }
  cursor = data.nextCursor;
} while (cursor);
```
```python [Python]
import time

import requests

job_id = "7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60"
headers = {"Authorization": "Bearer YOUR_API_KEY"}

cursor = None
while True:
    params = {"limit": 100, **({"cursor": cursor} if cursor else {})}
    response = requests.get(
        f"https://api.reversecontact.com/v2/jobs/{job_id}/rows", headers=headers, params=params
    )
    if response.status_code == 429:
        time.sleep(int(response.headers.get("Retry-After", 2)))
        continue
    data = response.json()["data"]
    for item in data["items"]:
        print(item["row"], item["input"].get("externalId"), item["status"])
    if not data["items"] and data["nextCursor"]:
        if data["status"] == "paused":
            break
        time.sleep(int(response.headers.get("Retry-After", 2)))
    cursor = data["nextCursor"]
    if cursor is None:
        break
```

## Example response

The example cuts `person` after its first fields: a page carries the whole profile.

**Example Response**

```json [200 - A page]
{
  "success": true,
  "data": {
    "items": [
      {
        "row": 1,
        "input": { "linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001" },
        "status": "enriched",
        "message": "Profile and email found (4 credits)",
        "creditsConsumed": 4,
        "person": {
          "id": "prs_9f8e7d6c5b4a",
          "firstName": "Jane",
          "lastName": "Doe",
          "linkedinUrl": "https://www.social.com/in/janedoe",
          "headline": "VP of Marketing @ Acme Corp"
        },
        "isFullProfile": true,
        "contactEmail": { "status": "found", "value": "jane.doe@acme.com", "personalValue": null }
      },
      {
        "row": 2,
        "input": { "firstName": "John", "lastName": "Smith", "companyDomain": "acme.com", "externalId": "crm-002" },
        "status": "partial",
        "message": "Profile found, no email (2 credits)",
        "creditsConsumed": 2,
        "person": {
          "id": "prs_1a2b3c4d5e6f",
          "firstName": "John",
          "lastName": "Smith",
          "linkedinUrl": "https://www.social.com/in/john-smith",
          "headline": "Head of Sales at Acme"
        },
        "isFullProfile": true,
        "contactEmail": { "status": "not_found", "value": null, "personalValue": null }
      }
    ],
    "nextCursor": "eyJ2IjoxLCJyb3ciOjN9",
    "status": "running"
  },
  "error": null,
  "quotas": {
    "creditsConsumed": 0,
    "workspace": {
      "id": "ws_abc123",
      "credits": { "total": 10000, "used": 4531, "left": 5469 }
    }
  },
  "metadata": {
    "requestId": "41f7ce39-14eb-4f81-a499-bcbd97d437ed",
    "executionTimeMs": 42
  }
}
```
```json [200 - Next item still running]
{
  "success": true,
  "data": {
    "items": [],
    "nextCursor": "eyJ2IjoxLCJyb3ciOjN9",
    "status": "running"
  },
  "error": null,
  "quotas": {
    "creditsConsumed": 0,
    "workspace": {
      "id": "ws_abc123",
      "credits": { "total": 10000, "used": 4531, "left": 5469 }
    }
  },
  "metadata": {
    "requestId": "6f4a5cba-ef9e-470b-ab60-821d4781673c",
    "executionTimeMs": 18
  }
}
```
```json [429 - Busy]
{
  "success": false,
  "data": null,
  "error": {
    "code": "JOB_ROWS_BUSY",
    "message": "Too many result pages read at once: retry in 2 seconds",
    "details": { "retryAfter": 2 }
  },
  "quotas": {
    "creditsConsumed": 0,
    "workspace": {
      "id": "ws_abc123",
      "credits": { "total": 10000, "used": 4531, "left": 5469 }
    }
  },
  "metadata": {
    "requestId": "def7c408-4e1c-4b83-8858-8e87de88925a",
    "executionTimeMs": 3
  }
}
```
