Skip to main content

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.
The Bulk API is currently in preview. Endpoint behavior, filters, and response shapes may change before general availability.The Bulk API is for requesting data from Unify. To create or update records in Unify, see Send records via API.

Supported resources

The Bulk API currently supports these resources:
ResourceCreate a query jobFetch results
Object recordsPOST /data/v1/objects/{object_name}/query-jobsGET /data/v1/objects/{object_name}/query-jobs/{job_id}/results
EventsPOST /data/v1/events/query-jobsGET /data/v1/events/query-jobs/{job_id}/results
Sequence enrollmentsPOST /sequences/v1/enrollments/query-jobsGET /sequences/v1/enrollments/query-jobs/{job_id}/results
Sequence enrollment stepsPOST /sequences/v1/enrollment-steps/query-jobsGET /sequences/v1/enrollment-steps/query-jobs/{job_id}/results
For object_name, use a standard object such as company or person, or the API name of a custom object. Each resource exposes the same set of job-management endpoints under its query-jobs path:
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:
ResourceBase path
Object records/data/v1/objects/{object_name}
Events/data/v1/events
Sequence enrollments/sequences/v1/enrollments
Sequence enrollment steps/sequences/v1/enrollment-steps

Authentication

The Bulk API requires a user-backed API key. Generate an API key in Settings → Developers and include it with each request:
X-Api-Key: <YOUR_API_KEY>
All examples use the main Unify API base URL:
https://api.unifygtm.com

How the Bulk API works

1

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.
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:
{
  "job_id": "<string>",
  "status": "IN_PROGRESS",
  "expires_at": "2026-05-13T12:00:00Z"
}
2

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.
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:
{
  "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
}
3

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.
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'
{
  "total": 123,
  "page": 1,
  "page_size": 1000,
  "data": [
    {
      "id": "<string>",
      "updated_at": "2026-05-12T11:58:00Z"
    }
  ]
}
4

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.

Create a query job

Create-job endpoints are resource-specific:
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
The request body depends on the resource:
  • Events and sequences 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 sequences and events

For 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:
{
  "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:
{
  "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 for the built-in attributes you can select and filter on.
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.

Filter fields

Available filter fields depend on the resource.

Sequence filters

Sequence enrollment and enrollment-step jobs share a common set of filters:
FilterDescription
updated_atRange filter on the time records were last updated.
started_atRange filter on when the enrollment or step started.
ended_atRange filter on when the enrollment or step ended.
sequenceFilter by sequence ID.
personFilter by person ID.
companyFilter by company ID.
mailboxFilter by mailbox ID or email address.
idInclude (in) or exclude (not_in) specific row IDs.
is_blocked, is_bounced, is_canceled, is_opted_out, is_paused, is_repliedBoolean state filters.
Enrollment jobs also accept status. 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.
FilterDescription
timestampRange filter on when the event occurred.
idInclude (in) or exclude (not_in) specific event IDs.
typeFilter by event type (page, track, identify).
visitorFilter by visitor ID.
sessionFilter by session ID.
companyFilter by revealed company ID.
personFilter by revealed person ID.
domainFilter by the domain on which the event occurred.

Job statuses

A job can have one of the following statuses:
StatusMeaning
IN_PROGRESSThe job has been created and results are still being prepared.
FINISHEDThe job completed successfully and results are available.
FAILEDThe job failed. Check error_code on the job metadata.
CANCELEDThe job was canceled before completion.
EXPIREDThe 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:
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
Query parameters:
ParameterDescription
limitNumber of jobs to return. Defaults to 100; maximum is 100.
cursorOpaque pagination cursor from the previous response.
statusOptional job status filter.
Example request:
curl 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs?limit=100&status=FINISHED' \
  -H 'X-Api-Key: ${UNIFY_API_KEY}'
Example response:
{
  "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:
curl -X POST 'https://api.unifygtm.com/sequences/v1/enrollments/query-jobs/<job_id>/cancel' \
  -H 'X-Api-Key: ${UNIFY_API_KEY}'
{
  "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:
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:
Accept: application/json
JSON limits:
LimitValue
Default page_size1000
Maximum page_size2000
Maximum raw payload size8 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.
Accept: application/x-ndjson
Example request:
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:
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.
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 for the built-in attributes.
{
  "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" }
    }
  }
}
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 the full set of standard scalar attributes, with unset values returned as null. The person’s own company is a { "id": ... } reference rather than an inlined record.
{
  "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": {
      "corporate_phone": null,
      "description": null,
      "do_not_contact": null,
      "domain": "unifygtm.com",
      "employee_count": 120,
      "founded": null,
      "industry": "Software",
      "lead_source": null,
      "linkedin_url": null,
      "name": "Unify",
      "status": null,
      "time_zone": null
    }
  },
  "person": {
    "object": "person",
    "id": "6741cc39-b332-45a8-a439-75b69db998b1",
    "created_at": "2026-05-01T00:00:00Z",
    "updated_at": "2026-05-12T11:58:00Z",
    "attributes": {
      "corporate_phone": null,
      "do_not_call": null,
      "do_not_email": null,
      "email": "lead@example.com",
      "email_opt_out": null,
      "eu_resident": null,
      "first_name": "Alex",
      "last_name": "Rivera",
      "lead_source": null,
      "linkedin_url": null,
      "mobile_phone": null,
      "status": null,
      "title": "VP Marketing",
      "work_phone": null,
      "company": { "id": "de885595-2d9a-4fb9-ae30-25ef18b6219b" }
    }
  }
}
Enrollment rows include status flags and embed the related sequence, mailbox, and person. reply_email_message is null until the person replies.
{
  "id": "a1b2c3d4-0000-0000-0000-000000000000",
  "updated_at": "2026-05-12T11:58:00Z",
  "status": "IN_PROGRESS",
  "is_blocked": false,
  "is_bounced": false,
  "is_canceled": false,
  "is_opted_out": false,
  "is_paused": false,
  "is_replied": false,
  "version": 1,
  "started_at": "2026-05-10T09:00:00Z",
  "ended_at": null,
  "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
    }
  },
  "reply_email_message": null
}
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.
{
  "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
}

Job states

The results endpoint validates the job state before returning data:
Job stateHTTP statusError code
IN_PROGRESS409results_not_ready
FAILED409results_failed
CANCELED409results_canceled
EXPIRED410results_expired
Page too large413results_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.
OperationRate 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.

Bulk API connector examples

Reference implementations for building your own connectors on top of the Bulk API.

What’s next

Data API reference

Review the Data API for object, attribute, and record operations.

Send records via API

Learn how to create and update records in Unify.