How to sync a system to HubSpot when it only exports a CSV
A system that only exports a CSV can still sync to HubSpot reliably. The file is ingested from SFTP, S3 or email, validated against an expected schema, and upserted into HubSpot keyed on a stable external ID. Rows that fail validation go to an exception report rather than failing the batch.
By HubReven
Every few weeks someone tells us their system "cannot integrate" with HubSpot. When we ask what it can do, the answer is almost always the same: it drops a file somewhere on a schedule.
That is an integration. It is a worse interface than a REST API, but it is a real one, and treating it as an interface rather than as a chore is the difference between a pipeline and a person doing data entry every Monday.
Why file exports get dismissed
The dismissal is usually based on experience with HubSpot's built-in CSV import, which is a genuinely good tool for a one-off migration and a genuinely bad foundation for a recurring sync. It has no schema validation, no scheduling, no idempotency guarantee, and when something goes wrong it tells you a number of rows failed without telling you which ones or why.
So the import gets done by hand. Someone downloads the file, opens it in Excel, fixes the dates, deletes the rows that errored last time, and imports it. That person is now your integration layer, and they are on holiday next week.
The architecture
A file-based pipeline needs five things. None of them are exotic; all of them are skipped in the manual version.
1. Ingestion
Pick up the file from wherever it lands: SFTP, an S3 bucket, a shared drive, or an inbox. The pipeline polls on a schedule and records what it has already processed, so a file that gets re-dropped does not get double-processed.
The single most common production failure here is not a corrupt file, it is a file that never arrives. If the source system's export job dies quietly, a pipeline with no absence detection just keeps reporting success on yesterday's data. Alert on the absence of a file, not only on errors in one.
2. Schema validation
Before a single row is sent to HubSpot, check the file is the shape you agreed:
const OrderRow = z.object({
external_order_id: z.string().min(1),
customer_email: z.email(),
order_total: z.coerce.number().nonnegative(),
ordered_at: z.iso.datetime(),
status: z.enum(['pending', 'shipped', 'cancelled']),
})
const parsed = rows.map((row, index) => ({
index,
result: OrderRow.safeParse(row),
}))
const valid = parsed.filter((r) => r.result.success)
const invalid = parsed.filter((r) => !r.result.success)Source systems change their exports without telling anyone. A column gets renamed, a date format changes, someone adds a currency symbol. Validation turns that from silent data corruption into a loud, specific failure.
3. Upsert on a stable key
This is the part that determines whether you end up with duplicates.
Do not match on email address. People change jobs, share inboxes, and typo their own addresses.
Store the source system's own identifier in a dedicated HubSpot property, a unique-value text
property such as external_order_id, and upsert against it:
await hubspot.crm.objects.batchApi.upsert('orders', {
inputs: valid.map(({ result }) => ({
idProperty: 'external_order_id',
id: result.data.external_order_id,
properties: {
external_order_id: result.data.external_order_id,
order_total: String(result.data.order_total),
ordered_at: result.data.ordered_at,
order_status: result.data.status,
},
})),
})Because the write is an upsert against a stable key, the operation is idempotent. Replaying yesterday's file cannot create duplicates. That single property is what makes the whole pipeline safe to retry, and retrying is the main thing you want to be able to do at 2am.
4. Row-level exception reporting
A batch of 14,000 rows where four are malformed should import 13,996 rows and quarantine four. It should not fail, and it should not silently drop them.
The exception report is the deliverable that makes the pipeline trustworthy:
exceptions
row 2,391 invalid date "00/00/0000"
row 7,004 missing required company_id
row 9,887 currency "USD " → trailing space
row 12,455 duplicate external_order_id
Four rows, each with a reason someone in operations can act on without opening a ticket. In our experience a healthy pipeline settles at a handful of exceptions a week, and the pattern in them tells you which upstream process needs fixing.
5. Alerting
Slack or email on: file did not arrive, schema validation failed outright, error rate above a threshold, or the HubSpot API returned sustained failures. You want to hear it from the pipeline, not from a sales rep wondering why an account looks empty.
What about rate limits?
HubSpot's CRM API allows a certain number of requests per ten seconds depending on your
subscription, and batch endpoints accept up to 100 records per call. A 14,000-row file is 140
batch calls, which is comfortably within limits if you use the batch endpoints and back off on
429 responses.
The naive version, one API call per row, is 14,000 calls, and that is where people conclude that "HubSpot cannot handle this volume". HubSpot handles it fine. The loop is the problem.
When this is the wrong answer
If the source system has a usable API and a marketplace app that syncs the right objects in the right direction, use it. Custom middleware is code you have to maintain, and the correct amount of code to write is the least that solves the problem.
Build the pipeline when the export is genuinely the only interface, when the marketplace app is one-directional and you need two-way, or when business logic has to sit between the two systems.
What it costs
A single-source file pipeline is typically two to three weeks of work and lands in the $7,500-$15,000 range depending on volume, the number of objects and how much cleanup the source needs. It permanently removes a recurring manual task, which is usually the easiest business case anyone has ever had to make.
Get the next one