> ## Documentation Index
> Fetch the complete documentation index at: https://docs.unifygtm.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Request data with the Bulk API

> Use the Bulk API to export large datasets from Unify.

## Overview

Use the Bulk API to request large datasets from Unify without waiting
for a long-running HTTP request to finish. The Bulk API is asynchronous: you
create a query job, poll the job until it is finished, and then page through the
materialized results.

Use the Bulk API when you need to export or sync many records. For small operational reads, use the standard API for
the resource instead.

<Info>
  The Bulk API is for requesting data from Unify. To create or update records in
  Unify, see [Send records via API](/developers/guides/send-data/overview).
</Info>

## Supported resources

The Bulk API currently supports these resources:

| Resource                  | Create a query job                               | Fetch results                                                    |
| :------------------------ | :----------------------------------------------- | :--------------------------------------------------------------- |
| Object records            | `POST /data/v1/objects/{object_name}/query-jobs` | `GET /data/v1/objects/{object_name}/query-jobs/{job_id}/results` |
| Events                    | `POST /data/v1/events/query-jobs`                | `GET /data/v1/events/query-jobs/{job_id}/results`                |
| Sequence enrollments      | `POST /sequences/v1/enrollments/query-jobs`      | `GET /sequences/v1/enrollments/query-jobs/{job_id}/results`      |
| Sequence enrollment steps | `POST /sequences/v1/enrollment-steps/query-jobs` | `GET /sequences/v1/enrollment-steps/query-jobs/{job_id}/results` |
| Tasks                     | `POST /tasks/v1/tasks/query-jobs`                | `GET /tasks/v1/tasks/query-jobs/{job_id}/results`                |

For `object_name`, use a [standard object](/developers/api/data/standard-objects)
such as `company` or `person`, or the API name of a custom object.

<Note>
  Task support in the Bulk API is part of the [Tasks
  API](/developers/api/tasks/overview) public beta and may change based on
  feedback.
</Note>

Each resource exposes the same set of job-management endpoints under its
`query-jobs` path:

```http theme={null}
POST   {base}/query-jobs                      Create a query job
GET    {base}/query-jobs                       List query jobs
GET    {base}/query-jobs/{job_id}              Get a single job
POST   {base}/query-jobs/{job_id}/cancel       Cancel an in-progress job
GET    {base}/query-jobs/{job_id}/results      Fetch results
```

The `{base}` for each resource is:

| Resource                  | Base path                        |
| :------------------------ | :------------------------------- |
| Object records            | `/data/v1/objects/{object_name}` |
| Events                    | `/data/v1/events`                |
| Sequence enrollments      | `/sequences/v1/enrollments`      |
| Sequence enrollment steps | `/sequences/v1/enrollment-steps` |
| Tasks                     | `/tasks/v1/tasks`                |

## Authentication

The Bulk API requires a user-backed API key. Generate an API key in
[Settings → Developers](https://app.unifygtm.com/dashboard/settings/integrations/api-keys)
and include it with each request:

```http theme={null}
X-Api-Key: <YOUR_API_KEY>
```

All examples use the main Unify API base URL:

```text theme={null}
https://api.unifygtm.com
```

## How the Bulk API works

<Steps titleSize="h3">
  <Step title="Create a query job">
    Submit a query job for the resource you want to export. For sequences and
    events the request body is optional and defaults to an empty filter. For
    object records, a `query` with a `select` is required.

    ```bash theme={null}
    curl -X POST 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs' \
      -H 'X-Api-Key: ${UNIFY_API_KEY}' \
      -H 'Content-Type: application/json'
    ```

    The response includes the `job_id`, current `status`, and `expires_at` time:

    ```json theme={null}
    {
      "job_id": "<string>",
      "status": "IN_PROGRESS",
      "expires_at": "2026-05-13T12:00:00Z"
    }
    ```
  </Step>

  <Step title="Poll job metadata">
    Poll the job metadata endpoint until the job reaches a terminal status.
    Avoid tight polling loops; use a steady interval or exponential backoff. Most
    jobs finish within a few seconds.

    ```bash theme={null}
    curl 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs/<job_id>' \
      -H 'X-Api-Key: ${UNIFY_API_KEY}'
    ```

    A finished job includes `total_rows`:

    ```json theme={null}
    {
      "job_id": "<string>",
      "status": "FINISHED",
      "total_rows": 123,
      "error_code": null,
      "created_at": "2026-05-12T12:00:00Z",
      "expires_at": "2026-05-13T12:00:00Z",
      "canceled_at": null
    }
    ```
  </Step>

  <Step title="Fetch results">
    After the job status is `FINISHED`, fetch results by page. Results are
    immutable for a finished job, so each page is stable for that job.

    ```bash theme={null}
    curl 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs/<job_id>/results?page=1&page_size=1000' \
      -H 'X-Api-Key: ${UNIFY_API_KEY}' \
      -H 'Accept: application/json'
    ```

    ```json theme={null}
    {
      "total": 123,
      "page": 1,
      "page_size": 1000,
      "data": [
        {
          "id": "<string>",
          "updated_at": "2026-05-12T11:58:00Z"
        }
      ]
    }
    ```
  </Step>

  <Step title="Store a checkpoint">
    For recurring syncs, store a checkpoint such as the newest `updated_at`
    (or, for events, `timestamp`) value you processed. Use that checkpoint in the
    next query job so you only request new or changed records.
  </Step>
</Steps>

## Create a query job

Create-job endpoints are resource-specific:

```http theme={null}
POST /data/v1/objects/{object_name}/query-jobs
POST /data/v1/events/query-jobs
POST /sequences/v1/enrollments/query-jobs
POST /sequences/v1/enrollment-steps/query-jobs
POST /tasks/v1/tasks/query-jobs
```

The request body depends on the resource:

* **Events, sequences, and tasks** take an optional `filter` object that narrows
  the dataset.
* **Object records** take a required `query` object that selects which
  attributes to return and, optionally, filters and sorts the records.

### Filter tasks, sequences, and events

For tasks, sequences, and events, omit the body to export everything, or pass a
`filter` to narrow the dataset. For example, scope a request to exclude a
specific set of IDs:

```json theme={null}
{
  "filter": {
    "id": { "not_in": ["<string>", "<string>"] }
  }
}
```

### Query object records

Object record jobs take a `query` with a required `select` that lists the
attributes to return. Use `where` to filter by attribute value, `sort_by` to
order results, and `metadata` to filter by base record timestamps:

```json theme={null}
{
  "query": {
    "select": {
      "name": true,
      "domain": true,
      "record_owner": {
        "select": { "email": true }
      }
    },
    "where": {
      "status": { "equals": "active" }
    },
    "sort_by": { "field": "updated_at", "direction": "ASCENDING" },
    "metadata": {
      "updated_at": { "gte": "2026-05-01T00:00:00Z" }
    }
  }
}
```

* `select` — Required. Each key is an attribute API name on the queried object.
  Use `true` to return the attribute directly, or `{ "select": ... }` to expand a
  single-reference attribute and select attributes on the referenced object.
  Nested selects may be at most three levels deep. Selected attributes are
  sparse: a result row only includes keys for selected attributes that have a
  value, so attributes with no value are omitted rather than returned as `null`.
* `where` — Filter by one or more attribute conditions. `{ "equals": ... }`
  matches an attribute directly; a nested object filters through a
  single-reference attribute on the referenced object.
* `sort_by` — Sort by a base record field (`id`, `created_at`, or `updated_at`)
  in `ASCENDING` or `DESCENDING` order.
* `metadata` — Filter by base record timestamps (`created_at` / `updated_at`).
  Pairing a `created_at` / `updated_at` `metadata` filter with the matching
  `sort_by` enables incremental queries that page through every record changed
  since a prior checkpoint.

The `where` and `select` shape mirrors the standard object records API. See
[Standard objects and attributes](/developers/api/data/standard-objects) for the
built-in attributes you can select and filter on.

<Note>
  Timestamps in the object Bulk API are millisecond precision. Both the
  timestamps returned in results and the values you supply to `metadata` and
  `where` filters are interpreted at millisecond precision.
</Note>

## Filter fields

Available filter fields depend on the resource.

### Sequence filters

Sequence enrollment and enrollment-step jobs share a common set of filters:

| Filter                                                                               | Description                                            |
| :----------------------------------------------------------------------------------- | :----------------------------------------------------- |
| `updated_at`                                                                         | Range filter on the time records were last updated.    |
| `started_at`                                                                         | Range filter on when the enrollment or step started.   |
| `ended_at`                                                                           | Range filter on when the enrollment or step ended.     |
| `sequence`                                                                           | Filter by sequence ID.                                 |
| `person`                                                                             | Filter by person ID.                                   |
| `company`                                                                            | Filter by company ID.                                  |
| `mailbox`                                                                            | Filter by mailbox ID or email address.                 |
| `id`                                                                                 | Include (`in`) or exclude (`not_in`) specific row IDs. |
| `is_blocked`, `is_bounced`, `is_canceled`, `is_opted_out`, `is_paused`, `is_replied` | Boolean state filters.                                 |

Enrollment jobs also accept `status` and `enrolled_by_play` (include or exclude
enrollments by the ID of the play that created them). Enrollment-step jobs
additionally accept `status`, `enrollment` (filter by enrollment ID), `type`,
and `step_number`.

Range filters such as `updated_at` accept `gt`, `gte`, `lt`, and `lte` bounds.
`gt` is mutually exclusive with `gte`, and `lt` with `lte`. Set filters such as
`id`, `status`, and `type` accept `in` and/or `not_in` arrays.

### Event filters

Events are immutable, so the natural cursor is `timestamp`, which is also the
only datetime field you can filter on.

| Filter      | Description                                              |
| :---------- | :------------------------------------------------------- |
| `timestamp` | Range filter on when the event occurred.                 |
| `id`        | Include (`in`) or exclude (`not_in`) specific event IDs. |
| `type`      | Filter by event type (`page`, `track`, `identify`).      |
| `visitor`   | Filter by visitor ID.                                    |
| `session`   | Filter by session ID.                                    |
| `company`   | Filter by revealed company ID.                           |
| `person`    | Filter by revealed person ID.                            |
| `domain`    | Filter by the domain on which the event occurred.        |

### Task filters

Task jobs accept the following filters:

| Filter          | Description                                                        |
| :-------------- | :----------------------------------------------------------------- |
| `updated_at`    | Range filter on the time tasks were last updated.                  |
| `created_at`    | Range filter on when the task was created.                         |
| `due_at`        | Range filter on the task due date.                                 |
| `ended_at`      | Range filter on when the task was completed, skipped, or canceled. |
| `id`            | Include (`in`) or exclude (`not_in`) specific task IDs.            |
| `status`        | Filter by task status (`in` / `not_in`).                           |
| `type`          | Filter by task type (`in` / `not_in`).                             |
| `priority`      | Filter by task priority (`in` / `not_in`).                         |
| `assigned_user` | Filter by assigned user ID.                                        |
| `person`        | Filter by person ID.                                               |
| `company`       | Filter by company ID.                                              |

Range filters accept `gt`, `gte`, `lt`, and `lte` bounds; `gt` is mutually
exclusive with `gte`, and `lt` with `lte`. Set filters such as `id`, `status`,
`type`, and `priority` accept `in` and/or `not_in` arrays. The `assigned_user`,
`person`, and `company` filters match a single ID with `{ "id": "<uuid>" }`.

## Job statuses

A job can have one of the following statuses:

| Status        | Meaning                                                        |
| :------------ | :------------------------------------------------------------- |
| `IN_PROGRESS` | The job has been created and results are still being prepared. |
| `FINISHED`    | The job completed successfully and results are available.      |
| `FAILED`      | The job failed. Check `error_code` on the job metadata.        |
| `CANCELED`    | The job was canceled before completion.                        |
| `EXPIRED`     | The job or its results are no longer available.                |

Jobs and results are available until `expires_at`, which is currently 24 hours
after job creation. Download all required result pages before that time.

## List jobs

List jobs for a resource by sending a `GET` to its `query-jobs` path:

```http theme={null}
GET /data/v1/objects/{object_name}/query-jobs
GET /data/v1/events/query-jobs
GET /sequences/v1/enrollments/query-jobs
GET /sequences/v1/enrollment-steps/query-jobs
GET /tasks/v1/tasks/query-jobs
```

Query parameters:

| Parameter | Description                                                    |
| :-------- | :------------------------------------------------------------- |
| `limit`   | Number of jobs to return. Defaults to `100`; maximum is `100`. |
| `cursor`  | Opaque pagination cursor from the previous response.           |
| `status`  | Optional job status filter.                                    |

Example request:

```bash theme={null}
curl 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs?limit=100&status=FINISHED' \
  -H 'X-Api-Key: ${UNIFY_API_KEY}'
```

Example response:

```json theme={null}
{
  "jobs": [
    {
      "job_id": "<string>",
      "status": "FINISHED",
      "total_rows": 123,
      "error_code": null,
      "created_at": "2026-05-12T12:00:00Z",
      "expires_at": "2026-05-13T12:00:00Z",
      "canceled_at": null
    }
  ],
  "next_cursor": null
}
```

## Cancel a job

Cancel an in-progress job to stop it from being processed:

```bash theme={null}
curl -X POST 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs/<job_id>/cancel' \
  -H 'X-Api-Key: ${UNIFY_API_KEY}'
```

```json theme={null}
{
  "job_id": "<string>",
  "status": "CANCELED"
}
```

Only in-progress jobs can be canceled. Canceling a job that has already finished,
failed, or been canceled returns `409 Conflict` with `job_not_cancelable`.

## Fetch results

Fetch results for a finished job:

```http theme={null}
GET {base}/query-jobs/{job_id}/results?page=1&page_size=1000
```

Result pagination is page-based. Rows are ordered by a stable sort with a
tie-breaker, so pages remain stable for a finished job.

### JSON results

JSON is the default response format:

```http theme={null}
Accept: application/json
```

JSON limits:

| Limit                    | Value   |
| :----------------------- | :------ |
| Default `page_size`      | `1000`  |
| Maximum `page_size`      | `2000`  |
| Maximum raw payload size | `8 MiB` |

If a JSON page is too large, the request returns `413` with
`results_page_too_large`. Request a smaller `page_size` or use NDJSON.

### NDJSON results

Use NDJSON for larger streamed pages by providing the following
header in your request.

```http theme={null}
Accept: application/x-ndjson
```

Example request:

```bash theme={null}
curl -N 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs/<job_id>/results?page=1&page_size=10000' \
  -H 'X-Api-Key: ${UNIFY_API_KEY}' \
  -H 'Accept: application/x-ndjson'
```

NDJSON responses return one JSON object per line, streaming one line at a time. Pagination metadata is
returned in response headers instead of a JSON envelope:

```http theme={null}
x-total: 123
x-page: 1
x-page-size: 10000
```

The maximum NDJSON `page_size` is `10000`.

### Result shapes

Each item in `data` (or each NDJSON line) is a result row whose shape depends on
the resource. The examples below are representative — fields may be added before
general availability, so parse defensively and ignore unrecognized keys.

<AccordionGroup>
  <Accordion title="Object records">
    Object record rows use the same `object` / `id` / `attributes` envelope as the
    standard records API. `attributes` contains exactly the attributes named in your
    `select`; expanded single-reference attributes are nested as their own envelope.
    See [Standard objects and attributes](/developers/api/data/standard-objects) for
    the built-in attributes.

    ```json theme={null}
    {
      "object": "company",
      "id": "de885595-2d9a-4fb9-ae30-25ef18b6219b",
      "created_at": "2026-05-12T11:58:00Z",
      "updated_at": "2026-05-12T11:58:00Z",
      "attributes": {
        "name": "Unify",
        "domain": "unifygtm.com",
        "record_owner": {
          "object": "person",
          "id": "6741cc39-b332-45a8-a439-75b69db998b1",
          "attributes": { "email": "owner@unifygtm.com" }
        }
      }
    }
    ```
  </Accordion>

  <Accordion title="Events">
    Events are flat, denormalized rows — they are not wrapped in the
    `object` / `attributes` envelope used by object records. `type` is the
    canonical event type (`page`, `track`, or `identify`). Page context
    (`domain`, `path`, `referrer_domain`, `referrer_path`), the URL `query`, UTM
    parameters, and any custom track-event properties are merged into a single
    `properties` object; `properties` is `null` when none are present.

    The revealed `company` and `person` are embedded as nested object records and
    are `null` when the visitor is unresolved. Unlike a top-level object-records
    `select` (which returns only the attributes you ask for), an embedded record
    carries every standard scalar attribute that has a value. As elsewhere in the
    API, an embedded record's attributes are sparse: attributes with no value are
    omitted rather than returned as `null`. The person's own `company` is a
    `{ "id": ... }` reference rather than an inlined record.

    ```json theme={null}
    {
      "id": "1f3c0e2a-8b6d-4c2e-9f1a-2d4e6f8a0b1c",
      "original_event_id": "evt_abc123",
      "timestamp": "2026-05-12T11:58:00Z",
      "type": "page",
      "name": "Pricing page viewed",
      "properties": {
        "plan": "pro",
        "domain": "unifygtm.com",
        "path": "/pricing",
        "referrer_domain": "google.com",
        "referrer_path": "/",
        "utm_source": "google",
        "utm_medium": "cpc",
        "utm_campaign": "brand"
      },
      "visitor_id": "7a9c0e2a-8b6d-4c2e-9f1a-2d4e6f8a0b1c",
      "anonymous_id": "anon_abc123",
      "user_id": "user_abc123",
      "session_id": "9b2a2761-3cbe-481f-8a1e-b24859c674f8",
      "company": {
        "object": "company",
        "id": "de885595-2d9a-4fb9-ae30-25ef18b6219b",
        "created_at": "2026-05-01T00:00:00Z",
        "updated_at": "2026-05-12T11:58:00Z",
        "attributes": {
          "name": "Unify",
          "domain": "unifygtm.com",
          "employee_count": 120,
          "industry": "Software"
        }
      },
      "person": {
        "object": "person",
        "id": "6741cc39-b332-45a8-a439-75b69db998b1",
        "created_at": "2026-05-01T00:00:00Z",
        "updated_at": "2026-05-12T11:58:00Z",
        "attributes": {
          "email": "lead@example.com",
          "first_name": "Alex",
          "last_name": "Rivera",
          "title": "VP Marketing",
          "company": { "id": "de885595-2d9a-4fb9-ae30-25ef18b6219b" }
        }
      }
    }
    ```
  </Accordion>

  <Accordion title="Sequence enrollments">
    Enrollment rows include status flags and embed the related `sequence`,
    `mailbox`, and `person`. `enrolled_by_play` identifies the play that created
    the enrollment and is `null` for enrollments created manually or via the
    API. `reply_email_message` is `null` until the person replies.

    ```json theme={null}
    {
      "id": "a1b2c3d4-0000-0000-0000-000000000000",
      "updated_at": "2026-05-12T11:58:00Z",
      "status": "FINISHED",
      "is_blocked": false,
      "is_bounced": false,
      "is_canceled": false,
      "is_opted_out": false,
      "is_paused": false,
      "is_replied": true,
      "version": 1,
      "started_at": "2026-05-10T09:00:00Z",
      "ended_at": "2026-05-12T11:58:00Z",
      "sequence": { "id": "seq_123", "name": "Outbound — Q2" },
      "mailbox": {
        "id": "mbx_123",
        "email_address": "rep@unifygtm.com",
        "forward_to_email_address": null,
        "secondary_forward_to_email_address": null,
        "user": {
          "id": "usr_123",
          "email": "rep@unifygtm.com",
          "first_name": "Sam",
          "last_name": "Rep"
        },
        "secondary_forward_to_user": null
      },
      "person": {
        "id": "psn_123",
        "email": "lead@example.com",
        "first_name": "Lee",
        "last_name": "Lead",
        "linkedin_url": null,
        "company": {
          "id": "cmp_123",
          "name": "Example Inc",
          "domain": "example.com",
          "description": null,
          "linkedin_url": null
        }
      },
      "enrolled_by_play": {
        "id": "de885595-2d9a-4fb9-ae30-25ef18b6219b",
        "name": "New signup outreach"
      },
      "reply_email_message": {
        "id": "1f3c0e2a-8b6d-4c2e-9f1a-2d4e6f8a0b1c",
        "subject": "Re: Quick question",
        "classifications": [
          {
            "id": "4a8b6c0d-2e1f-4a3b-8c5d-9e7f1a2b3c4d",
            "category": "POSITIVE"
          }
        ]
      }
    }
    ```
  </Accordion>

  <Accordion title="Sequence enrollment steps">
    Step rows embed a summary of their parent `enrollment` and, for email steps,
    the sent `email_message` with open and click counts. `email_message` is `null`
    for non-email steps.

    ```json theme={null}
    {
      "id": "step_123",
      "updated_at": "2026-05-12T11:58:00Z",
      "type": "AUTOMATIC_EMAIL",
      "status": "FINISHED",
      "is_blocked": false,
      "is_bounced": false,
      "is_canceled": false,
      "is_opted_out": false,
      "is_paused": false,
      "is_replied": false,
      "sequence": { "id": "seq_123", "name": "Outbound — Q2", "version": 1 },
      "step_number": 1,
      "started_at": "2026-05-10T09:00:00Z",
      "ended_at": "2026-05-10T09:00:05Z",
      "enrollment": {
        "id": "a1b2c3d4-0000-0000-0000-000000000000",
        "mailbox": {
          "id": "mbx_123",
          "email_address": "rep@unifygtm.com",
          "forward_to_email_address": null,
          "secondary_forward_to_email_address": null,
          "user": null,
          "secondary_forward_to_user": null
        },
        "person": {
          "id": "psn_123",
          "email": "lead@example.com",
          "first_name": "Lee",
          "last_name": "Lead",
          "linkedin_url": null,
          "company": null
        }
      },
      "email_message": {
        "id": "msg_123",
        "subject": "Quick question",
        "sent_at": "2026-05-10T09:00:05Z",
        "from_email_address": "rep@unifygtm.com",
        "is_bounced": false,
        "is_opted_out": false,
        "clicks": 0,
        "opens": 2
      },
      "reply_email_message": null
    }
    ```
  </Accordion>

  <Accordion title="Tasks">
    Task rows are flat and reference related records by ID rather than embedding
    them. `assigned_user_id`, `due_at`, and `ended_at` are `null` when unset —
    for example, `ended_at` is `null` until the task is completed, skipped, or
    canceled.

    ```json theme={null}
    {
      "id": "b7f1e2c4-0000-0000-0000-000000000000",
      "type": "EMAIL",
      "status": "READY",
      "priority": "HIGH",
      "person_id": "6741cc39-b332-45a8-a439-75b69db998b1",
      "assigned_user_id": "3f2a1b8c-1111-2222-3333-444455556666",
      "due_at": "2026-05-13T12:00:00Z",
      "ended_at": null,
      "created_at": "2026-05-12T11:58:00Z",
      "updated_at": "2026-05-12T11:58:00Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Job states

The results endpoint validates the job state before returning data:

| Job state      | HTTP status | Error code               |
| :------------- | :---------- | :----------------------- |
| `IN_PROGRESS`  | `409`       | `results_not_ready`      |
| `FAILED`       | `409`       | `results_failed`         |
| `CANCELED`     | `409`       | `results_canceled`       |
| `EXPIRED`      | `410`       | `results_expired`        |
| Page too large | `413`       | `results_page_too_large` |

Only fetch results after the job status is `FINISHED`.

## Rate limiting

Bulk API rate limits are applied by operation type. Query job creation is
limited to roughly **100 jobs per day**, so create jobs only when you need a
new snapshot and use filters or checkpoints to keep each job focused.

| Operation               | Rate limit               |
| :---------------------- | :----------------------- |
| Create query jobs       | \~100 jobs per day       |
| query/list job status   | \~10 requests per second |
| list/stream job results | \~5 requests per second  |
| cancel in flight jobs   | \~1 request per second   |

Treat other job management requests, such as checking job status, listing jobs,
and canceling jobs, as low-frequency control-plane calls. When polling job
status, use a steady interval or exponential backoff instead of a tight loop.
Status checks are intended for roughly 5 requests per second, and most jobs
finish quickly enough that slower polling is usually sufficient.

When fetching results, page through data with bounded concurrency. If you receive
`429 Too Many Requests`, wait before retrying, honor the `Retry-After` header to avoid additional rate limiting.

## Best practices

* **Request only the data you need.** Use filters (and, for object records, a
  focused `select`) to keep result sets small and exports fast.
* **Prefer incremental syncs.** Use checkpoints such as `updated_at` (or
  `timestamp` for events) so recurring jobs only request new or changed records.
* **Design for retries.** Store the `job_id`, job status, and processing
  checkpoint in your system.
* **Process results idempotently.** Use stable record IDs so retrying a page
  does not create duplicate downstream records.
* **Use NDJSON for large pages.** NDJSON streams results and supports larger
  pages than JSON.
* **Download before expiration.** Jobs expire at `expires_at`; create a new job
  if you need the data again after expiration.

## Build your own connector

Want to move bulk data into your own warehouse or tool? Use our example
connectors as a starting point for building integrations on top of the Bulk API.

<Card title="Bulk API connector examples" href="https://github.com/unifygtm/bulk-api-connector-examples" icon="github" horizontal>
  Reference implementations for building your own connectors on top of the Bulk
  API.
</Card>

## What's next

<CardGroup cols={2}>
  <Card title="Data API reference" href="/developers/api/data/overview" icon="book" horizontal>
    Review the Data API for object, attribute, and record operations.
  </Card>

  <Card title="Send records via API" href="/developers/guides/send-data/overview" icon="webhook" horizontal>
    Learn how to create and update records in Unify.
  </Card>
</CardGroup>
