Bulk import
POST /api/v1/events/bulk loads up to 5000 records per request as one job,
built for day-0 loads and migrations rather than the real-time path of
POST /api/v1/events. This page covers the three
things a large load needs: sending it, following it row by row, and checking
afterwards that HubSpot holds what you sent.
A bulk import writes straight to HubSpot: it does not run your event flow
manifest. There are no field_mappings, no when filters, no transforms, no
associations and no plugin steps. Every key of an entity is written as a
HubSpot property under that exact name, so send HubSpot’s internal property
names. If your data needs mapping, send it through the
real-time path instead (the entities shape takes
up to 200 records per request).
All endpoints on this page authenticate with the connector API key in the
x-api-key header, like the rest of the Event API.
A job belongs to the connector whose key created it: reading it with another
connector’s key answers 404 job_not_found.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/events/bulk | Start a bulk import job |
GET | /api/v1/events/bulk/{job_id}/status | Job status and counters |
GET | /api/v1/events/bulk/{job_id}/rows | Per-row outcomes, paged |
POST | /api/v1/events/bulk/{job_id}/rows/{reference}/replay | Reprocess one failed row |
POST | /api/v1/events/bulk/verify | Start a verification job against HubSpot |
GET | /api/v1/events/bulk/verify/{job_id}/status | Verification status and counters |
GET | /api/v1/events/bulk/verify/{job_id}/rows | Per-record verification outcomes, paged |
Start a job
curl -X POST https://app.plugsync.com/api/v1/events/bulk \
-H "x-api-key: ps_live_..." \
-H "content-type: application/json" \
-d '{
"entity_type": "contact",
"external_id_property": "erp_customer_id",
"idempotency_key": "initial-load-2026-10-part-1",
"entities": [
{"erp_customer_id": "C-1", "email": "a@example.com", "firstname": "Ada"},
{"erp_customer_id": "C-2", "email": "b@example.com", "firstname": "Bo"}
]
}'Request body
| Field | Type | Required | Notes |
|---|---|---|---|
entity_type | string | yes | Which object the rows are, and the key the identity mapping of each row is stored under. How it becomes a HubSpot object type: entity_type and custom objects. |
hs_object_type | string | no | The HubSpot object type to write, overriding whatever entity_type resolves to. It never changes the identity mapping key, which stays entity_type. |
external_id_property | string | yes | The key, in each entity, that holds the record’s external id. It identifies the row (see Rows) and is written to HubSpot like every other key, so the property must exist on the object. |
storeId | string | no | Multi-brand routing: the identity mappings of the job are stored under this store. Absent, the connector’s default store is used. Unlike the real-time path, the x-store-id header is not read here. |
idempotency_key | string (max 128) | no | Makes the request safe to resend, see Idempotency. |
entities | array of objects | yes | 1 to 5000 records. Each is a flat object of HubSpot property names and values, including external_id_property. |
The whole request body may not exceed 10 MiB. Larger loads (for example
40,000 rows) go in consecutive requests of up to 5000 rows each, each with
its own idempotency_key.
For each row, an external id that already has an identity mapping (under the
same entity_type and store) updates the mapped HubSpot record; otherwise a
record is created and mapped. The identity mappings written here are the same
ones the real-time path reads, so events sent later for the same external id
update these records instead of duplicating them.
entity_type and custom objects
The HubSpot object type a job writes is resolved, in order:
hs_object_type, when the request sets it.- The
object_typeof thehubspotschema of the entity named exactlyentity_typein the connector’s published config. - For the standard types, the HubSpot plural:
contact→contacts,company→companies,deal→deals,ticket→tickets,product→products,line_item→line_items,conversation→conversations. entity_typeitself, unchanged.
For a custom object HubSpot only accepts the fully qualified name
(p<hubId>_<name>, for example p12345678_invoices) or the object type id
(2-XXXXXXX, for example 2-1234567), never the display name. Sending
"entity_type": "invoices" with no config entity of that name reaches
HubSpot as invoices and every row fails with HubSpot’s
Unable to infer object type error. Two ways to get it right:
- Use the entity name from your config (
"entity_type": "invoice"when the config declares aninvoiceentity with"object_type": "p12345678_invoices"). The rows land under the same identity key the real-time path uses for that entity, which is what you want when events will keep the records up to date afterwards. - Set
hs_object_typeexplicitly, for a connector with no published config or to bypass the resolution for a one-off job. Keepentity_typeequal to the entity name your flows use if they will later update these records.
HubSpot lists the fully qualified names and object type ids of a portal’s
custom objects at GET /crm/v3/schemas.
Response
202 Accepted:
{
"job_id": "3f6c1e0a-6a43-4c55-9a51-0f1d7b8f2a10",
"status": "queued",
"entities_count": 2,
"chunks_total": 1
}The rows are processed in chunks of 100 (chunks_total is the request’s row
count divided by 100, rounded up), after the response.
Idempotency
Resending a request with the same idempotency_key on the same connector
returns the job the key first created, with its current status, and starts
nothing new. A request that reuses a key with a different number of
entities is rejected with 409 and "detail": "body_mismatch". Only the
entity count is compared, so use a new key for every distinct part of a
load.
A job whose chunks could not be queued ends failed (see
Errors), and resending with the same key returns that same failed
job. Retry with a new key.
Quota and rate limits
- Monthly event quota: checked upfront for the whole request. If the
rows of the request do not fit in what the organization has left this
month, the request is rejected with
429and the samequota_exceededdetail as the real-time path, and nothing is queued. Each row that succeeds counts as one event, when its chunk completes; failed rows are not counted. - Per-second rate limit: every call on this page (starts, status polls,
row pages, replays, verifications) counts against the organization’s
per-second limit, reported in the
X-RateLimit-*response headers. Poll/statusevery few seconds, not in a tight loop.
Errors
| Status | detail | Meaning |
|---|---|---|
401 | Missing, malformed or revoked API key. | |
409 | body_mismatch | idempotency_key reused with a different entity count. |
422 | payload_too_large: ... | The body exceeds 10 MiB; the message carries the measured size. |
422 | field errors | Body validation failed: a missing field, an empty entities array, more than 5000 entities, an idempotency_key over 128 characters. |
423 | {"error": "org_suspended"} or {"error": "org_closing"} | The organization’s syncs are suspended, or it is being closed. Nothing was created. |
429 | Per-second rate limit or monthly quota, see above. | |
502 | enqueue_failed | Only part of the job’s chunks could be queued. The job exists and is failed, its last_error says how many chunks were queued; the queued ones may still be processed. Check /rows, then send the missing rows again with a new idempotency_key. |
503 | {"error": "event_ingestion_unavailable"} | A transient platform failure while queueing the job: the job exists and is failed. Chunks are queued in groups of up to 10, so on a large job the failure can come after the first chunks were already queued, and those may still be processed. Check /rows before resending the missing rows with a new idempotency_key, with backoff. GET /api/health/data-plane says when ingestion is back. |
A paused connector does not reject the request: the job is accepted and its rows wait, see Held jobs.
Follow a job
Status
curl https://app.plugsync.com/api/v1/events/bulk/{job_id}/status \
-H "x-api-key: ps_live_..."{
"job_id": "3f6c1e0a-6a43-4c55-9a51-0f1d7b8f2a10",
"status": "partial_failure",
"entities_total": 5000,
"entities_succeeded": 4987,
"entities_failed": 13,
"entities_skipped": 0,
"chunks_total": 50,
"chunks_processed": 50,
"started_at": "2026-10-14T09:12:03Z",
"completed_at": "2026-10-14T09:15:41Z",
"last_error": "chunk 37: 2 entities failed",
"warning": null
}| Field | Meaning |
|---|---|
status | See the table below. |
entities_total | Rows in the request. |
entities_succeeded, entities_failed, entities_skipped | Rows per outcome so far, counted from the row ledger. An external id that appears more than once in a request has a single row, carrying its last outcome, so these can add up to less than entities_total once the job is over. entities_skipped stays 0 for a job started on this endpoint. |
chunks_total, chunks_processed | Progress in chunks of 100 rows. |
started_at | When the first chunk was processed. null while no chunk has been picked up yet. |
completed_at | When the last chunk was processed. null until the job is terminal. |
last_error | The last error reported for the job, if any. The per-row causes are in /rows. |
warning | Set when status alone does not tell the job is progressing normally, see Held jobs and A job that does not start. Advisory: it never changes status. |
Job statuses:
status | Terminal | Meaning |
|---|---|---|
queued | no | Accepted; no chunk has been processed yet. |
running | no | At least one chunk processed, others pending. |
completed | yes | Every chunk processed and no row failed. |
partial_failure | yes | Every chunk processed and at least one row failed, including when all of them did. The failed rows are in /rows?status=failed and can be replayed. |
failed | yes | The job’s chunks could not be queued at creation (502 or 503 above); last_error says why. Rows that fail during processing make the job partial_failure, not failed. |
Poll until the status is terminal. A replay of one
row recomputes the counters and sets the job to partial_failure or
completed, whatever its status was before, failed included.
Held jobs
While the connector is paused or in error, the organization’s syncs are
suspended, or an open circuit breaker
can block the HubSpot object type the job writes, the job’s rows wait
without being written or counted. /status keeps the job queued or
running and sets warning to the cause, for example:
"warning": "Chunks are held: the connector is paused. It resumes on its own once the connector is active again."For a breaker, the warning names each breaker that can hold the job, the object types its flow writes (or that it holds every object type, when opened by fatal errors) and when it can next be probed. The job does not record its object type, so the list includes every breaker that could apply.
Held rows resume on their own once the cause clears, not necessarily in
their original order. A row is held for at most 3 days of uninterrupted
blocking: past that it ends failed with error_category held_expired,
without anything written, and can be replayed. A
drain on the connector
likewise ends its held rows failed with error_category drained. Rows
of a job that is not held are written as usual during a drain.
A job that does not start
A job normally leaves queued within seconds. When it is still queued
with started_at at null more than 10 minutes after it was created,
and it is not held, warning says that no chunk has been picked up yet:
"warning": "Job queued for 742s and no chunk has been picked up yet. It keeps its place and runs once processing starts, so there is no need to resend it. If it is still queued in a few minutes, contact support@plugsync.com with job_id 6f1c2a9e-4b7d-4e2a-9c1f-3d8e5b0a7c21."That is not a job waiting its turn. Keep polling for a few more minutes:
the job keeps its place and runs once processing starts, so resending it
is not needed. If it is still queued, contact support@plugsync.com with
the job_id.
The warning covers a job that never starts. A running job whose
chunks_processed stops moving across several polls gets no warning:
report it to support the same way.
Rows
curl "https://app.plugsync.com/api/v1/events/bulk/{job_id}/rows?status=failed&limit=200&offset=0" \
-H "x-api-key: ps_live_..."{
"job_id": "3f6c1e0a-6a43-4c55-9a51-0f1d7b8f2a10",
"rows": [
{
"reference": "C-1042",
"status": "failed",
"error_category": "conflict",
"error_message": "Contact already exists. Existing ID: 51234"
},
{
"reference": "row:1877",
"status": "failed",
"error_category": "mapping",
"error_message": "mapping_failed: no external id"
}
],
"total": 13,
"limit": 200,
"offset": 0,
"has_more": false
}Query parameters:
| Parameter | Default | Notes |
|---|---|---|
status | all rows | succeeded, failed or skipped. Any other value answers 422. |
limit | 200 | 1 to 1000. |
offset | 0 | Rows to skip. |
Rows come in processing order. total is the number of rows matching the
filter, and has_more is true while offset plus the rows returned is
below total: request the next page with offset increased by limit.
A row appears once its chunk has been processed: there is no pending row status, and a row not yet processed simply is not listed. Row statuses:
status | Meaning |
|---|---|
succeeded | Written to HubSpot. |
failed | Not written; error_category and error_message say why. |
skipped | Not written because the flow chose not to. Only rows of a job started by a file source in flows emission can be skipped. |
reference is the row’s stable handle: its external id, or row:<index>
(the 0-based position in the request’s entities array) when the row has no
external id or one longer than 300 characters. The same external id sent
twice in a request is one row.
error_category
error_message is the readable cause, usually HubSpot’s own answer.
error_category groups it:
error_category | Cause | What to do |
|---|---|---|
mapping | The row has no value under external_id_property. | Add the external id and replay. |
validation | The external id is not usable: it contains # or is longer than 256 bytes in UTF-8. | Fix the id and replay. |
conflict | HubSpot answered 409, typically a unique property (such as an email) already held by another record. | Resolve the duplicate in the data or in HubSpot, then replay. |
upstream_failure | HubSpot rejected the write: an unknown property, an invalid value, a wrong object type, or the whole chunk refused with 401 / 403 (an expired or revoked HubSpot authorization, a missing scope). Also a row skipped without any write, because the HubSpot record mapped to its external id was deleted, or because that external id has been superseded by another one for the same record. | Read error_message, fix the row, the portal or the HubSpot connection, replay. |
credential | No HubSpot connection could be resolved for the connector: none is connected, several are and none is bound to the connector, or the connection has no access token. | Connect (or bind) HubSpot on the connector, then replay. |
held_expired | The row was held more than 3 days (see Held jobs). Nothing was written. | Replay once the connector syncs again. |
drained | A drain discarded the row while it was held. Nothing was written. | Replay once the drain is off. |
Other values are HubSpot’s own error category for that record, passed
through as HubSpot returned it (for example VALIDATION_ERROR). The field is
null for a succeeded or skipped row, and can be null on a failed row
when HubSpot gave no category. Transient HubSpot failures (rate limits,
5xx) are not row outcomes: the chunk is retried automatically and the rows
report the outcome of the retry.
Replay a failed row
curl -X POST "https://app.plugsync.com/api/v1/events/bulk/{job_id}/rows/C-1042/replay" \
-H "x-api-key: ps_live_..." \
-H "content-type: application/json" \
-d '{
"entity": {"erp_customer_id": "C-1042", "email": "c.1042@example.com"},
"entity_type": "contact",
"external_id_property": "erp_customer_id"
}'Reprocesses one failed row with corrected data, through the same path as the
original job, and records the outcome on the same row (the reference in
the path), even if the correction changes the external id. The job’s
counters and status follow.
| Field | Required | Notes |
|---|---|---|
entity | yes | The corrected row. |
entity_type, external_id_property | for a job started on this endpoint | As in the original request. There is no hs_object_type: the object type is resolved from entity_type as in entity_type and custom objects, so for a custom object pass the config entity name or the fully qualified name. |
source_name, event_type | for a job started by a file source in flows emission | The file source and event type the row came from; the row runs through that flow. |
202 Accepted with {"job_id": "...", "reference": "C-1042", "status": "queued", "warning": null}.
For a row of a job started on this endpoint, warning is set when the
replay is accepted but held (connector paused or in error, syncs suspended,
an open breaker on its object type): it is queued, not lost, and runs once
the cause clears. For a row of a file source job in flows emission,
warning is always null: the row runs through its flow, whose
circuit breakers apply as for any
event. Check the outcome with /rows.
A replay needs one event left in the monthly quota (429 quota_exceeded
otherwise), and counts as one event only if the row then succeeds. Other
errors:
| Status | detail | Meaning |
|---|---|---|
404 | job_not_found, row_not_found | No such job on this connector, or no row with that reference (URL-encode it). |
409 | job_not_terminal: ... | The job is still queued or running: replay once it is terminal. |
409 | row_not_replayable: ... | Only a failed row can be replayed. |
422 | entity_type and external_id_property are required ... or source_name and event_type are required ... | The routing fields the job needs are missing. |
422 | payload_too_large: ... | The body exceeds 10 MiB. |
423 | org_suspended, org_closing | The organization’s syncs are suspended, or it is being closed. |
503 | {"error": "event_ingestion_unavailable"} | The replay could not be queued, nothing changed on the row. Retry with backoff. |
Verify a load against HubSpot
POST /api/v1/events/bulk/verify compares a set of records with what HubSpot
holds now, record by record and property by property. It only reads
HubSpot: it writes nothing and does not count against the monthly event
quota (it does count against the per-second rate limit, like every call).
plugsync does not keep the rows you sent, so you send them again, all of
them or a subset.
curl -X POST https://app.plugsync.com/api/v1/events/bulk/verify \
-H "x-api-key: ps_live_..." \
-H "content-type: application/json" \
-d '{
"entity_type": "contact",
"external_id_property": "erp_customer_id",
"source_batch_id": "3f6c1e0a-6a43-4c55-9a51-0f1d7b8f2a10",
"entities": [
{"erp_customer_id": "C-1", "email": "a@example.com", "firstname": "Ada"}
]
}'| Field | Type | Required | Notes |
|---|---|---|---|
entity_type | string | yes | The identity mapping key the records were written under: the same entity_type as the bulk import, or the config entity name for records your flows wrote. The HubSpot object type is the one recorded with each mapping, so custom objects need no extra field. |
external_id_property | string | yes | The key holding each record’s external id. It is used to find the record and is not compared. |
storeId | string | no | The store the mappings were written under. Absent, the connector’s default store. |
source_batch_id | UUID | no | The job_id of the bulk import being checked, recorded for your audit trail. It must belong to the same connector (404 source_batch_not_found otherwise). The compared data is always the entities you send. |
entities | array of objects | yes | 1 to 5000 records, 10 MiB combined at most (422 otherwise). |
202 Accepted:
{
"job_id": "b2a7d4c9-1e0f-4f3a-8d6b-2c9e5a7f1b34",
"status": "queued",
"records_count": 1,
"chunks_total": 1
}Only the properties present in each record you send are compared, never the
whole HubSpot record. Values are compared after normalization: null and
"" are the same (no value), booleans compare as true / false, and
whole numbers compare by value (10, 10.0 and "10.0" are equal).
Verification status
GET /api/v1/events/bulk/verify/{job_id}/status:
{
"job_id": "b2a7d4c9-1e0f-4f3a-8d6b-2c9e5a7f1b34",
"status": "completed",
"records_total": 5000,
"records_ok": 4990,
"records_diff": 7,
"records_absent": 3,
"records_error": 0,
"chunks_total": 50,
"chunks_processed": 50,
"last_error": null
}The statuses have the same names as a bulk import’s: queued and running
(poll again), completed (every record checked), partial_failure (some
records could not be read from HubSpot: records_error of them, said in
last_error), failed (no record could be checked, for example the
connector has no usable HubSpot connection, or HubSpot read errors on every
record; last_error says which). chunks_processed moves while the job runs;
records_ok, records_diff, records_absent and records_error are filled
in when it ends. A verification that stays running while
chunks_processed no longer moves will not finish: start a new one for the
records missing from its /rows.
Verification rows
GET /api/v1/events/bulk/verify/{job_id}/rows takes the same status,
limit (default 200, max 1000) and offset parameters as the
bulk rows, and pages the same way:
{
"job_id": "b2a7d4c9-1e0f-4f3a-8d6b-2c9e5a7f1b34",
"rows": [
{
"reference": "C-7",
"status": "diff",
"differences": {
"firstname": {"expected": "Ada", "actual": "Adah"}
},
"error_message": null
}
],
"total": 7,
"limit": 200,
"offset": 0,
"has_more": false
}status | Meaning |
|---|---|
ok | Every compared property matches. |
diff | At least one property differs. differences maps each one to its normalized expected (what you sent) and actual (what HubSpot holds) values. |
absent | Nothing to compare against: no identity mapping for the external id under this entity_type and store, or the mapped HubSpot record no longer exists (deleted or archived). |
error | HubSpot could not be read for this record, even after retrying its rate limit; error_message starts with hubspot_read_failed:. Not a data mismatch: verify the record again later. |
reference follows the bulk import rule: the external id, or row:<index>
when there is none or it is longer than 300 characters. A record sent twice
is one row.
Related reference
- Event API: the real-time path, its authentication and its 429 responses.
- Reliability: retry, failures, replay: circuit breakers and drains, which can hold a bulk job.
- Data export: bulk imports and verifications are part of a connector’s export.