
How to Import CSV Contacts Into HubSpot
The import screen inside a HubSpot portal takes a .csv, .xlsx or .xls file with one sheet and fewer than 1,000 columns. On a paid tier that file reaches 512 MB and 1,048,576 rows. A POST to /crm/v3/imports takes the same file from a server you run, with a columnMappings entry for every column in it. Three of those imports run at once and the next one waits in a DEFERRED state. Both of those paths belong to the portal owner, who holds the credentials and the file. Your customer stands outside that portal with a badge-scan lead list from booth H-214.
Their export arrives looking like this.
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| 1 | badge_id | work_email | company | job_title | lead_rating | marketing_opt_in |
| 2 | BX-4471 | p.raman@Northwind-Robotics.COM | Northwind Robotics | Head of Automation | Hot | Yes |
| 3 | BX-4472 | t.lindqvist@haldenmarine.se | Halden Marine | Procurement Lead | A | No |
| 4 | BX-4473 | a.okafor@bluecrest.foods | Bluecrest Foods | Plant Engineer | 1 | Yes |
| 5 | BX-4474 | i.duarte@vantorsystems.pt | Vantor Systems | Automation Buyer | rating_warm | Y |
| 209 rows not shown | ||||||
| 215 | BX-4684 | s.mikkelsen@fjordline.dk | Fjordline Pumps | Maintenance Manager | Warm | No |
1badge_id,work_email,company,job_title,lead_rating,marketing_opt_in2BX-4471,p.raman@Northwind-Robotics.COM ,Northwind Robotics,Head of Automation,Hot,Yes3BX-4472,t.lindqvist@haldenmarine.se,Halden Marine,Procurement Lead,A,No4BX-4473,a.okafor@bluecrest.foods,Bluecrest Foods,Plant Engineer,1,Yes5BX-4474,i.duarte@vantorsystems.pt,Vantor Systems,Automation Buyer,rating_warm,Y⋮209 rows not shown215BX-4684,s.mikkelsen@fjordline.dk,Fjordline Pumps,Maintenance Manager,Warm,NoThe property names on the other side are email, company, jobtitle and three the exhibitor created. The lead_rating column carries three conventions for one rating, because three people at the booth scanned badges their own way. The Vantor Systems row already holds rating_warm, pasted out of the portal by somebody who knew where to look. The opt-in column says Yes, No and Y. HubSpot calls the property behind it a single checkbox, for values that are true or false. The first email trails a space and shouts its domain.
The exhibitor hands that lead list to the importer inside your app. Updog Importer opens it in the browser, lines its headers up with your schema, and shows them every value it read. Your onComplete handler receives the rows. The handler posts them in batches to an endpoint you own. The endpoint holds the private app token and calls the batch upsert. HubSpot writes the contacts.
No Updog server stands between that booth and that portal.
The value behind the label
Lead rating is an enumeration property, which is HubSpot's word for a dropdown, a radio select or a set of checkboxes. Each option inside one carries two strings, a label and an internal value. HubSpot's instructions for adding one say that "by default, the internal value will be the same as the label". Somebody can type a different value into that field, and once it is saved "you won't be able to change this". So the string a person reads in the portal and the string the API takes are two separate things.
That gap is why the option list comes off the account itself.
import { Client } from "@hubspot/api-client";
const hubspot = new Client({ accessToken: process.env.HUBSPOT_PRIVATE_APP_TOKEN,});
export async function leadRatingOptions() { const { results } = await hubspot.crm.properties.coreApi.getAll("contacts"); const rating = results.find((property) => property.name === "lead_rating");
return (rating?.options ?? []) .filter((option) => !option.hidden) .map((option) => ({ value: option.value, label: option.label }));}The call needs the crm.schemas.contacts.read scope, and it answers with that portal's own vocabulary.
[ { "value": "rating_hot", "label": "Hot" }, { "value": "rating_warm", "label": "Warm" }, { "value": "rating_cold", "label": "Cold" }]The same call against a second exhibitor answers with three different strings under the same three labels. Reading the property is also how you confirm the spelling of every other field, because the name on each result is the string the API takes.
A column schema built from the portal
The columns array is what the person reads while they work, so it gets built from the options that call returned.
import type { DataEditorColumn } from "@updog/data-editor";
type RatingOption = { value: string; label: string };
export const buildColumns = (ratings: RatingOption[]): DataEditorColumn[] => { const values = ratings.map((option) => option.value); const labels = Object.fromEntries( ratings.map((option) => [option.value, option.label]), );
return [ { id: "badgeId", title: "Badge ID", size: 130, validators: [{ type: "required" }, { type: "unique" }], }, { id: "email", title: "Work email", size: 240, transformer: (value) => typeof value === "string" ? value.toLowerCase() : value, validators: [ { type: "required" }, { type: "email" }, { type: "unique" }, ], }, { id: "company", title: "Company", size: 190 }, { id: "jobTitle", title: "Job title", size: 190 }, { id: "leadRating", title: "Lead rating", size: 150, editor: { type: "select", options: values, enableCustomValue: false }, formatter: (value) => labels[value] ?? value, validators: [{ type: "required" }, { type: "oneOf", values }], }, { id: "marketingOptIn", title: "Marketing opt-in", size: 160, editor: { type: "select", options: ["true", "false"], enableCustomValue: false, }, validators: [{ type: "oneOf", values: ["true", "false"] }], }, ];};The select editor stores each option string as both the stored value and the display label. So options carries the internal values, and the formatter puts the readable half back on the screen without touching the data. Setting enableCustomValue to false closes the enum, so nobody invents a fourth rating inside the grid. The oneOf validator holds the same list and flags a cell that lands outside it. An unmatched select value is dropped on import, which is what the required validator on the rating catches.
The email transformer lowercases the value, so Northwind-Robotics.COM and its lowercase twin count as one address under the unique check. The trailing space costs nothing, because the parser trims every cell before the grid sees it.
The matching props and the key go on the mount.
<DataEditor<Lead> apiKey="your-license-key" variant="uploader" open={open} onClose={closeImporter} columns={columns} primaryKey="email" synonyms={{ values: { rating_hot: ["a"], rating_warm: ["b"], rating_cold: ["c"], }, }} onValueMatch={matchScannerCodes} onComplete={onComplete}/>primaryKey set to email names the identity HubSpot itself uses, since its contacts guide calls email address "the primary unique identifier to avoid duplicate contacts". The headers need no help. Matching lowercases a header and drops spaces and underscores before it scores, so work_email reaches email and job_title reaches jobTitle on their own. Every snippet here is React, and the web component build takes the same props. For the install and the modal wiring underneath this snippet, see how to import a CSV file into a React app. synonyms holds two tables that stay apart, one feeding column matching and one feeding value matching, and only the value half has work here.
The values the matcher cannot reach
Updog scores every imported value against every option and keeps the best one that reaches 60. An exact match scores 100, a synonym 90, one string containing the other 80, and half the words shared 70.
rating_warm is already an internal value, so it lands at 100 and nothing has to happen. Yes, Y and No reach true and false at 90, because the built-in table already pairs yes with y and true. Hot reaches rating_hot at 70, since the two share the word hot out of two words. A and 1 reach nothing. A single letter and a single digit share no word with rating_hot, and the edit distance between them sits far past what the matcher allows.
Those two values take different props, because they are different problems.
A letter grade is a convention you know in advance. Every badge scanner the platform ships writes A, B and C. The alias is static, so it belongs in synonyms. Each key is the canonical option value and the array lists what people type. Your entries merge with the built-in table as a union per key, so the yes and no pairs above survive.
A digit means whatever the exhibitor decided. One booth writes 1 for the hottest lead and the next writes 1 for the coldest. That answer lives in the exhibitor's own record, so your code fetches it while the wizard is open.
type ValueMatchInput = { importedValues: string[]; options: string[] };
const matchScannerCodes = async ( valuesToMatch: Record<string, ValueMatchInput>,) => { const rating = valuesToMatch.leadRating; if (!rating) return {};
const url = "/api/exhibitors/" + exhibitorId + "/scanner-codes"; const codebook: Record<string, string> = await fetch(url).then((response) => response.json(), );
const matched: Record<string, string> = {}; for (const value of rating.importedValues) { const option = codebook[value]; if (option) matched[value] = option; }
return { leadRating: matched };};onValueMatch fires once for the whole upload, when the person first reaches the value-matching step, with every select column's imported values and allowed options. Return a map of column ID to imported-value pairs. Anything you leave out falls back to Updog's own matching, and a value set to null stays deliberately unmatched. When the call throws or runs past 30 seconds, the SDK uses built-in matching instead, so a slow codebook service leaves the import with Updog's own answer.
Whatever the person fixes by hand comes back on the result as learnedSynonyms, ready to store and feed back through synonyms next time. Saving those fixes against the exhibitor who made them is what how to remember CSV import mappings between uploads walks through.
The handler that posts in hundreds
When the exhibitor submits, Updog Importer hands your handler every lead grouped by source, each row flagged isNew, isChanged, isDeleted and isValid. Rows nobody touched stay out. Three scanners at one booth send three files into a single import, each arriving as its own source entry, so the handler flattens before it slices.
import type { DataEditorResult } from "@updog/data-editor";
const BATCH_SIZE = 100;
const onComplete = useCallback(async (result: DataEditorResult<Lead>) => { await saveSynonyms(result.learnedSynonyms);
const rows = result.sources .flatMap((source) => source.rows) .filter((entry) => entry.isValid && !entry.isDeleted) .map((entry) => entry.row);
for (let start = 0; start < rows.length; start += BATCH_SIZE) { const response = await fetch("/api/leads/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows: rows.slice(start, start + BATCH_SIZE) }), });
if (!response.ok) { const failure = await response.json(); throw new Error(failure.message); } }}, []);The batch size comes from HubSpot. Its contacts guide states it in one line, "Batch operations are limited to 100 records at a time." Four thousand leads is forty calls.
A private app on a Professional subscription gets 190 calls every 10 seconds and 625,000 a day across the whole account. Enterprise keeps the 190 and raises the day to 1,000,000, while Free and Starter sit at 100 and 250,000. Past the limit every call comes back 429, and policyName in the body names which limit was hit. HubSpot's advice for the ten-second one is to throttle the requests your app is making to stay under it.
Throw when a batch of leads fails. Updog waits on your handler and empties the grid as soon as it resolves. A handler that swallows the failure and returns looks like success, and the leads disappear off the screen unwritten. A spinner in the confirm dialog covers the whole round trip, which is the second reason those batches stay small. Throwing leaves every lead, every mapping and every hand fix in place, so the exhibitor submits again on data still in front of them.
Every piece of the result you plan to keep gets copied while the handler runs. After the promise resolves the editor drops its rows, its sources, its history and its learned synonyms, which is why saveSynonyms runs on the first line.
The endpoint that holds the token
The endpoint is where the token lives and where the browser stops.
import { Hono } from "hono";import { Client } from "@hubspot/api-client";
const hubspot = new Client({ accessToken: process.env.HUBSPOT_PRIVATE_APP_TOKEN, numberOfApiCallRetries: 3,});
export const app = new Hono();
app.post("/api/leads/import", async (c) => { const session = await verifySession(c.req.header("cookie")); if (!session) { return c.json({ message: "Not signed in" }, 401); }
const { rows } = await c.req.json(); const inputs = rows.map((row) => ({ id: row.email, idProperty: "email", properties: { email: row.email, company: row.company, jobtitle: row.jobTitle, badge_id: row.badgeId, lead_rating: row.leadRating, marketing_opt_in: row.marketingOptIn, }, }));
const written = await hubspot.crm.contacts.batchApi.upsert({ inputs });
if ("numErrors" in written && written.numErrors) { return c.json( { message: written.errors?.[0]?.message ?? "Part of the batch failed", errors: written.errors, }, 502, ); }
return c.json({ written: written.results.length });});verifySession stands in for your own server-side check. The token is a private app access token, created from the Legacy apps page under Development in the portal, and it travels in the Authorization header as a bearer token. It carries the scopes you ticked when you made the app, so this endpoint needs crm.objects.contacts.write for the upsert alongside the schema scope the property call used. A portal holds up to 20 private apps, and a token rotates with a seven-day overlap or expires on the spot.
HubSpot watches for that token leaving your control. Its authentication documentation says HubSpot uses GitHub's secret scanning to find tokens exposed in public repositories. "Any detected tokens will automatically be deactivated", and an email and an in-app notice follow.
idProperty set to email turns the call into a create or an update per record. HubSpot's contacts guide adds one consequence worth reading twice, that "Partial upserts are not supported when using email as the idProperty for contacts". Every input therefore carries the whole property set, which is what the mapping above builds. A custom property flagged as a unique identifier takes partial upserts, at the cost of a second identity to keep in step with the grid.
A partial success inside one response
The batch call answers 200 when every record landed, and 207 when some of them did not.
{ "status": "COMPLETE", "startedAt": "2026-08-18T09:14:02.113Z", "completedAt": "2026-08-18T09:14:02.470Z", "numErrors": 1, "results": [ { "id": "701551", "new": false, "properties": { "email": "t.lindqvist@haldenmarine.se" } } ], "errors": [ { "status": "error", "category": "VALIDATION_ERROR", "message": "...", "context": {} } ]}Both of those resolve. The client's own type says so, since upsert() returns BatchResponseSimplePublicUpsertObjectWithErrors | BatchResponseSimplePublicUpsertObject, and only a response outside the 2xx range throws an ApiException carrying code, body and headers. So the endpoint reads numErrors before it answers, and an unread numErrors is a half-write that looks clean.
Each entry in errors carries category, message and context. HubSpot adds that every field in an error response "should all be treated as optional in any error parsing", so read them defensively and pass the message up. Records that already landed stay landed, because an upsert against the same idProperty writes the same values a second time.
The client retries some failures on its own. numberOfApiCallRetries accepts a number from 0 to 6, and on a 429 whose body names TEN_SECONDLY_ROLLING the client waits ten seconds times the retry number before trying again.
The part Updog leaves to you
Updog Importer integrates with nobody. There is no HubSpot connector, no destination list, no webhook and no server of ours. onComplete hands your code an object, and the endpoint in the middle is work you do.
HubSpot already ships its own import for the other case. The portal's import screen takes a .csv, .xlsx or .xls file. A paid tier allows 512 MB and 1,048,576 rows a file. Free Tools allows 20 MB and 500,000 rows a day. POST /crm/v3/imports does the same job from your server with a columnMappings array. Those two paths are shorter whenever the file is already in the portal owner's hands.
One line on HubSpot's own troubleshooting page marks the edge of that path. A value matching no option raises Invalid enumeration option, and "The affected records were still imported, but the enumeration property does not contain a value". The contact lands and the rating stays blank. Everything above exists so the mismatch gets settled in front of the person who created it. Client-side and server-side CSV import weighs the browser route against the server route for a job like this one.
The chain you built
You read one property off the destination account, built a schema from what it returned, added two matching props, wrote a handler that posts in hundreds, and stood up one endpoint holding one token. The lead list never leaves the machine that opened it. The rows travel from your own front end to your own endpoint, and from there into the exhibitor's portal, and the only party you added to the chain is yourself. Point the same setup at a React CSV importer page or a plain modal and the middle stays the same.
That exhibitor will send another list after the next conference. By then the codes are stored, the ratings map themselves, and the leads reach the portal carrying the words the portal reads.