Skip to Content

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.

MethodPathPurpose
POST/api/v1/events/bulkStart a bulk import job
GET/api/v1/events/bulk/{job_id}/statusJob status and counters
GET/api/v1/events/bulk/{job_id}/rowsPer-row outcomes, paged
POST/api/v1/events/bulk/{job_id}/rows/{reference}/replayReprocess one failed row
POST/api/v1/events/bulk/verifyStart a verification job against HubSpot
GET/api/v1/events/bulk/verify/{job_id}/statusVerification status and counters
GET/api/v1/events/bulk/verify/{job_id}/rowsPer-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

FieldTypeRequiredNotes
entity_typestringyesWhich 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_typestringnoThe HubSpot object type to write, overriding whatever entity_type resolves to. It never changes the identity mapping key, which stays entity_type.
external_id_propertystringyesThe 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.
storeIdstringnoMulti-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_keystring (max 128)noMakes the request safe to resend, see Idempotency.
entitiesarray of objectsyes1 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:

  1. hs_object_type, when the request sets it.
  2. The object_type of the hubspot schema of the entity named exactly entity_type in the connector’s published config.
  3. For the standard types, the HubSpot plural: contact → contacts, company → companies, deal → deals, ticket → tickets, product → products, line_item → line_items, conversation → conversations.
  4. entity_type itself, 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 an invoice entity 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_type explicitly, for a connector with no published config or to bypass the resolution for a one-off job. Keep entity_type equal 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 429 and the same quota_exceeded detail 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 /status every few seconds, not in a tight loop.

Errors

StatusdetailMeaning
401Missing, malformed or revoked API key.
409body_mismatchidempotency_key reused with a different entity count.
422payload_too_large: ...The body exceeds 10 MiB; the message carries the measured size.
422field errorsBody 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.
429Per-second rate limit or monthly quota, see above.
502enqueue_failedOnly 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 }
FieldMeaning
statusSee the table below.
entities_totalRows in the request.
entities_succeeded, entities_failed, entities_skippedRows 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_processedProgress in chunks of 100 rows.
started_atWhen the first chunk was processed. null while no chunk has been picked up yet.
completed_atWhen the last chunk was processed. null until the job is terminal.
last_errorThe last error reported for the job, if any. The per-row causes are in /rows.
warningSet 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:

statusTerminalMeaning
queuednoAccepted; no chunk has been processed yet.
runningnoAt least one chunk processed, others pending.
completedyesEvery chunk processed and no row failed.
partial_failureyesEvery 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.
failedyesThe 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:

ParameterDefaultNotes
statusall rowssucceeded, failed or skipped. Any other value answers 422.
limit2001 to 1000.
offset0Rows 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:

statusMeaning
succeededWritten to HubSpot.
failedNot written; error_category and error_message say why.
skippedNot 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_categoryCauseWhat to do
mappingThe row has no value under external_id_property.Add the external id and replay.
validationThe external id is not usable: it contains # or is longer than 256 bytes in UTF-8.Fix the id and replay.
conflictHubSpot 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_failureHubSpot 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.
credentialNo 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_expiredThe row was held more than 3 days (see Held jobs). Nothing was written.Replay once the connector syncs again.
drainedA 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.

FieldRequiredNotes
entityyesThe corrected row.
entity_type, external_id_propertyfor a job started on this endpointAs 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_typefor a job started by a file source in flows emissionThe 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:

StatusdetailMeaning
404job_not_found, row_not_foundNo such job on this connector, or no row with that reference (URL-encode it).
409job_not_terminal: ...The job is still queued or running: replay once it is terminal.
409row_not_replayable: ...Only a failed row can be replayed.
422entity_type and external_id_property are required ... or source_name and event_type are required ...The routing fields the job needs are missing.
422payload_too_large: ...The body exceeds 10 MiB.
423org_suspended, org_closingThe 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"} ] }'
FieldTypeRequiredNotes
entity_typestringyesThe 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_propertystringyesThe key holding each record’s external id. It is used to find the record and is not compared.
storeIdstringnoThe store the mappings were written under. Absent, the connector’s default store.
source_batch_idUUIDnoThe 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.
entitiesarray of objectsyes1 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 }
statusMeaning
okEvery compared property matches.
diffAt least one property differs. differences maps each one to its normalized expected (what you sent) and actual (what HubSpot holds) values.
absentNothing 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).
errorHubSpot 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.

Last updated on